implement visual party segmented control

This commit is contained in:
Justin Edmund 2025-09-16 20:08:57 -07:00
parent ad2e04623f
commit fed7b5ae50
6 changed files with 249 additions and 2 deletions

View file

@ -8,5 +8,9 @@
"nav_settings": "Settings",
"nav_logout": "Log out",
"nav_account_aria": "Your account",
"nav_menu_button": "Menu"
"nav_menu_button": "Menu",
"party_segmented_control_characters": "Characters",
"party_segmented_control_weapons": "Weapons",
"party_segmented_control_summons": "Summons"
}

View file

@ -8,5 +8,9 @@
"nav_settings": "設定",
"nav_logout": "ログアウト",
"nav_account_aria": "アカウント",
"nav_menu_button": "メニュー"
"nav_menu_button": "メニュー",
"party_segmented_control_characters": "キャラ",
"party_segmented_control_weapons": "武器",
"party_segmented_control_summons": "召喚石"
}

View file

@ -0,0 +1,89 @@
<!-- PartySegmentedControl Component -->
<script lang="ts">
import { getContext } from 'svelte'
import type { Party, GridCharacter, GridWeapon, GridSummon } from '$lib/types/api/party'
import { GridType } from '$lib/types/enums'
import SegmentedControl from '$lib/components/ui/segmented-control/SegmentedControl.svelte'
import RepSegment from '$lib/components/ui/segmented-control/RepSegment.svelte'
import CharacterRep from '$lib/components/reps/CharacterRep.svelte'
import WeaponRep from '$lib/components/reps/WeaponRep.svelte'
import SummonRep from '$lib/components/reps/SummonRep.svelte'
import * as m from '$lib/paraglide/messages'
interface Props {
selectedTab?: GridType
onTabChange?: (tab: GridType) => void
party: Party
class?: string
}
let {
selectedTab = GridType.Character,
onTabChange,
party,
class: className
}: Props = $props()
// Handle value changes
let value = $state(selectedTab)
$effect(() => {
value = selectedTab
})
function handleValueChange(newValue: string) {
value = newValue as GridType
onTabChange?.(newValue as GridType)
}
// Get user gender from context if available
// This would typically come from auth/account state
const accountContext = getContext<any>('account')
const userGender = $derived(accountContext?.user?.gender ?? 0)
</script>
<nav class={className}>
<SegmentedControl
bind:value
onValueChange={handleValueChange}
gap={true}
grow={true}
>
<RepSegment
value={GridType.Character}
label={m.party_segmented_control_characters()}
selected={value === GridType.Character}
>
<CharacterRep
jobId={party.job?.id}
element={party.element}
gender={userGender}
characters={party.characters}
/>
</RepSegment>
<RepSegment
value={GridType.Weapon}
label={m.party_segmented_control_weapons()}
selected={value === GridType.Weapon}
>
<WeaponRep weapons={party.weapons} />
</RepSegment>
<RepSegment
value={GridType.Summon}
label={m.party_segmented_control_summons()}
selected={value === GridType.Summon}
>
<SummonRep summons={party.summons} />
</RepSegment>
</SegmentedControl>
</nav>
<style lang="scss">
nav {
width: 100%;
margin-bottom: 1rem;
}
</style>

View file

@ -0,0 +1,42 @@
<!-- RepSegment Component - A segment with visual content and label -->
<svelte:options runes={true} />
<script lang="ts">
import { RadioGroup as RadioGroupPrimitive } from 'bits-ui'
import styles from './rep-segment.module.scss'
import type { HTMLButtonAttributes } from 'svelte/elements'
interface Props extends Omit<HTMLButtonAttributes, 'value'> {
value: string
label: string
class?: string
selected?: boolean
}
let {
value,
label,
class: className,
selected = false,
children: content,
...restProps
}: Props = $props()
</script>
<RadioGroupPrimitive.Item
{value}
class={`${styles.repSegment} ${selected ? styles.selected : ''} ${className || ''}`}
{...restProps}
>
{#snippet children({ checked })}
{#if checked}
<div class={styles.indicator}></div>
{/if}
<div class={styles.wrapper}>
<div class={styles.content}>
{@render content?.()}
</div>
<div class={styles.label}>{label}</div>
</div>
{/snippet}
</RadioGroupPrimitive.Item>

View file

@ -0,0 +1,81 @@
@use '$src/themes/spacing' as *;
@use '$src/themes/layout' as *;
@use '$src/themes/colors' as *;
@use '$src/themes/typography' as *;
.repSegment {
position: relative;
flex: 1;
min-width: 0;
padding: 0;
background: transparent;
border: none;
border-radius: $item-corner;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: $grey-80; // Solid gray for hover state
}
&:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
&.selected .wrapper,
&:hover .wrapper {
opacity: 1;
}
&:hover .label {
color: var(--text-primary);
}
}
.indicator {
position: absolute;
inset: 0;
background: $grey-80;
border-radius: $item-corner;
opacity: 0;
transition: opacity 0.2s ease;
[data-state='checked'] & {
opacity: 1;
}
}
.wrapper {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: $unit;
padding: $unit;
opacity: 0.7;
transition: opacity 0.2s ease;
[data-state='checked'] & {
opacity: 1;
}
}
.content {
width: 100%;
min-height: 60px;
display: flex;
align-items: center;
justify-content: center;
}
.label {
font-size: $font-small;
font-weight: $medium;
color: var(--text-secondary);
white-space: nowrap;
[data-state='checked'] & {
color: var(--text-primary);
}
}

27
src/lib/types/enums.ts Normal file
View file

@ -0,0 +1,27 @@
export enum GridType {
Character = 'character',
Weapon = 'weapon',
Summon = 'summon'
}
export enum TeamElement {
Any = 0,
Wind = 1,
Fire = 2,
Water = 3,
Earth = 4,
Dark = 5,
Light = 6
}
export function getElementClass(element: number): string | null {
switch (element) {
case TeamElement.Wind: return 'wind'
case TeamElement.Fire: return 'fire'
case TeamElement.Water: return 'water'
case TeamElement.Earth: return 'earth'
case TeamElement.Dark: return 'dark'
case TeamElement.Light: return 'light'
default: return null
}
}