implement visual party segmented control
This commit is contained in:
parent
ad2e04623f
commit
fed7b5ae50
6 changed files with 249 additions and 2 deletions
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "召喚石"
|
||||
}
|
||||
|
|
|
|||
89
src/lib/components/party/PartySegmentedControl.svelte
Normal file
89
src/lib/components/party/PartySegmentedControl.svelte
Normal 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>
|
||||
42
src/lib/components/ui/segmented-control/RepSegment.svelte
Normal file
42
src/lib/components/ui/segmented-control/RepSegment.svelte
Normal 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>
|
||||
|
|
@ -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
27
src/lib/types/enums.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue