add ElementPicker component

This commit is contained in:
Justin Edmund 2026-01-04 14:22:37 -08:00
parent 62c225a6e7
commit ac82066aab
3 changed files with 289 additions and 0 deletions

View 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}

View file

@ -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>

View file

@ -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;
}