add ElementPicker component
This commit is contained in:
parent
62c225a6e7
commit
ac82066aab
3 changed files with 289 additions and 0 deletions
121
src/lib/components/ui/element-picker/ElementPicker.svelte
Normal file
121
src/lib/components/ui/element-picker/ElementPicker.svelte
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
<svelte:options runes={true} />
|
||||
|
||||
<script lang="ts">
|
||||
import Select from '../Select.svelte'
|
||||
import MultiSelect from '../MultiSelect.svelte'
|
||||
import ElementPickerSegmented from './ElementPickerSegmented.svelte'
|
||||
import { ELEMENT_LABELS, getElementImage } from '$lib/utils/element'
|
||||
|
||||
// Element display order: Fire(2) → Water(3) → Earth(4) → Wind(1) → Light(6) → Dark(5)
|
||||
const ELEMENT_DISPLAY_ORDER = [2, 3, 4, 1, 6, 5]
|
||||
|
||||
interface Props {
|
||||
value?: number | number[]
|
||||
onValueChange?: (value: number | number[]) => void
|
||||
multiple?: boolean
|
||||
includeAny?: boolean
|
||||
mode?: 'auto' | 'segmented' | 'dropdown'
|
||||
contained?: boolean
|
||||
size?: 'small' | 'medium' | 'large'
|
||||
disabled?: boolean
|
||||
class?: string
|
||||
}
|
||||
|
||||
let {
|
||||
value = $bindable(),
|
||||
onValueChange,
|
||||
multiple = false,
|
||||
includeAny = false,
|
||||
mode = 'auto',
|
||||
contained = false,
|
||||
size = 'medium',
|
||||
disabled = false,
|
||||
class: className = ''
|
||||
}: Props = $props()
|
||||
|
||||
// Responsive detection for auto mode
|
||||
let isMobile = $state(false)
|
||||
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const mq = window.matchMedia('(max-width: 640px)')
|
||||
isMobile = mq.matches
|
||||
|
||||
const handler = (e: MediaQueryListEvent) => {
|
||||
isMobile = e.matches
|
||||
}
|
||||
|
||||
mq.addEventListener('change', handler)
|
||||
return () => mq.removeEventListener('change', handler)
|
||||
})
|
||||
|
||||
// Determine if we should use dropdown mode
|
||||
const shouldUseDropdown = $derived(mode === 'dropdown' || (mode === 'auto' && isMobile))
|
||||
|
||||
// Build element options for Select/MultiSelect
|
||||
const options = $derived.by(() => {
|
||||
const order = includeAny ? [0, ...ELEMENT_DISPLAY_ORDER] : ELEMENT_DISPLAY_ORDER
|
||||
return order.map((element) => ({
|
||||
value: element,
|
||||
label: element === 0 ? 'Any' : (ELEMENT_LABELS[element] ?? 'Unknown'),
|
||||
image: getElementImage(element)
|
||||
}))
|
||||
})
|
||||
|
||||
// Handle value changes for single-select dropdown
|
||||
function handleSingleChange(newValue: number | undefined) {
|
||||
if (newValue !== undefined) {
|
||||
value = newValue
|
||||
onValueChange?.(newValue)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle value changes for multi-select dropdown
|
||||
function handleMultipleChange(newValue: number[]) {
|
||||
value = newValue
|
||||
onValueChange?.(newValue)
|
||||
}
|
||||
|
||||
// Handle value changes for segmented control
|
||||
function handleSegmentedChange(newValue: number | number[]) {
|
||||
value = newValue
|
||||
onValueChange?.(newValue)
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if shouldUseDropdown}
|
||||
{#if multiple}
|
||||
<MultiSelect
|
||||
{options}
|
||||
value={Array.isArray(value) ? value : value !== undefined ? [value] : []}
|
||||
onValueChange={handleMultipleChange}
|
||||
{size}
|
||||
{contained}
|
||||
disabled={disabled}
|
||||
placeholder="Select elements..."
|
||||
class={className}
|
||||
/>
|
||||
{:else}
|
||||
<Select
|
||||
{options}
|
||||
value={typeof value === 'number' ? value : undefined}
|
||||
onValueChange={handleSingleChange}
|
||||
{size}
|
||||
{contained}
|
||||
disabled={disabled}
|
||||
placeholder="Select element"
|
||||
class={className}
|
||||
/>
|
||||
{/if}
|
||||
{:else}
|
||||
<ElementPickerSegmented
|
||||
{value}
|
||||
onValueChange={handleSegmentedChange}
|
||||
{multiple}
|
||||
{includeAny}
|
||||
{contained}
|
||||
disabled={disabled}
|
||||
class={className}
|
||||
/>
|
||||
{/if}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
<svelte:options runes={true} />
|
||||
|
||||
<script lang="ts">
|
||||
import { ToggleGroup } from 'bits-ui'
|
||||
import Tooltip from '../Tooltip.svelte'
|
||||
import { ELEMENT_LABELS, getElementImage } from '$lib/utils/element'
|
||||
import styles from './element-picker.module.scss'
|
||||
|
||||
// Element display order: Fire(2) → Water(3) → Earth(4) → Wind(1) → Light(6) → Dark(5)
|
||||
const ELEMENT_DISPLAY_ORDER = [2, 3, 4, 1, 6, 5]
|
||||
|
||||
interface Props {
|
||||
value?: number | number[]
|
||||
onValueChange?: (value: number | number[]) => void
|
||||
multiple?: boolean
|
||||
includeAny?: boolean
|
||||
contained?: boolean
|
||||
disabled?: boolean
|
||||
class?: string
|
||||
}
|
||||
|
||||
let {
|
||||
value = $bindable(),
|
||||
onValueChange,
|
||||
multiple = false,
|
||||
includeAny = false,
|
||||
contained = false,
|
||||
disabled = false,
|
||||
class: className = ''
|
||||
}: Props = $props()
|
||||
|
||||
// Build element list based on includeAny prop
|
||||
const elements = $derived(includeAny ? [0, ...ELEMENT_DISPLAY_ORDER] : ELEMENT_DISPLAY_ORDER)
|
||||
|
||||
// Get label for element (use "Any" for element 0 instead of "Null")
|
||||
function getLabel(element: number): string {
|
||||
if (element === 0) return 'Any'
|
||||
return ELEMENT_LABELS[element] ?? 'Unknown'
|
||||
}
|
||||
|
||||
// Convert value to string format for ToggleGroup
|
||||
const stringValue = $derived.by(() => {
|
||||
if (multiple) {
|
||||
const arr = Array.isArray(value) ? value : value !== undefined ? [value] : []
|
||||
return arr.map(String)
|
||||
} else {
|
||||
return value !== undefined ? String(value) : undefined
|
||||
}
|
||||
})
|
||||
|
||||
// Handle value changes from ToggleGroup
|
||||
function handleSingleChange(newValue: string | undefined) {
|
||||
if (newValue !== undefined) {
|
||||
const numValue = Number(newValue)
|
||||
value = numValue
|
||||
onValueChange?.(numValue)
|
||||
}
|
||||
}
|
||||
|
||||
function handleMultipleChange(newValue: string[]) {
|
||||
const numValues = newValue.map(Number)
|
||||
value = numValues
|
||||
onValueChange?.(numValues)
|
||||
}
|
||||
|
||||
const containerClasses = $derived(
|
||||
[styles.container, contained && styles.contained, className].filter(Boolean).join(' ')
|
||||
)
|
||||
</script>
|
||||
|
||||
<div class={containerClasses}>
|
||||
{#if multiple}
|
||||
<ToggleGroup.Root
|
||||
type="multiple"
|
||||
value={stringValue as string[]}
|
||||
onValueChange={handleMultipleChange}
|
||||
class={styles.group}
|
||||
{disabled}
|
||||
>
|
||||
{#each elements as element}
|
||||
<Tooltip content={getLabel(element)}>
|
||||
{#snippet children()}
|
||||
<ToggleGroup.Item value={String(element)} class={styles.item} {disabled}>
|
||||
<img
|
||||
src={getElementImage(element)}
|
||||
alt={getLabel(element)}
|
||||
class={styles.image}
|
||||
/>
|
||||
</ToggleGroup.Item>
|
||||
{/snippet}
|
||||
</Tooltip>
|
||||
{/each}
|
||||
</ToggleGroup.Root>
|
||||
{:else}
|
||||
<ToggleGroup.Root
|
||||
type="single"
|
||||
value={stringValue as string | undefined}
|
||||
onValueChange={handleSingleChange}
|
||||
class={styles.group}
|
||||
{disabled}
|
||||
>
|
||||
{#each elements as element}
|
||||
<Tooltip content={getLabel(element)}>
|
||||
{#snippet children()}
|
||||
<ToggleGroup.Item value={String(element)} class={styles.item} {disabled}>
|
||||
<img
|
||||
src={getElementImage(element)}
|
||||
alt={getLabel(element)}
|
||||
class={styles.image}
|
||||
/>
|
||||
</ToggleGroup.Item>
|
||||
{/snippet}
|
||||
</Tooltip>
|
||||
{/each}
|
||||
</ToggleGroup.Root>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
@use '$src/themes/spacing' as *;
|
||||
@use '$src/themes/layout' as *;
|
||||
@use '$src/themes/effects' as *;
|
||||
@use '$src/themes/colors' as *;
|
||||
|
||||
.container {
|
||||
display: inline-flex;
|
||||
border-radius: $full-corner;
|
||||
padding: $unit-half;
|
||||
|
||||
&.contained {
|
||||
background-color: var(--segmented-control-background-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.group {
|
||||
display: flex;
|
||||
gap: $unit-half;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.item {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
border-radius: $full-corner;
|
||||
padding: $unit-half;
|
||||
@include smooth-transition($duration-quick, background-color, opacity);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--option-bg-hover);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
@include focus-ring($blue);
|
||||
}
|
||||
|
||||
&[data-state='on'] {
|
||||
background-color: var(--accent-subtle-bg);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.image {
|
||||
width: $unit-3x;
|
||||
height: $unit-3x;
|
||||
display: block;
|
||||
}
|
||||
Loading…
Reference in a new issue