Add picker components and refine search filters (#454)

## Summary
- Add Element, Rarity, and Proficiency picker components
- Refactor SearchContent filter layout with inline clear links
- Add theme-aware CSS variables for picker hover/selected states
- Add series filter support to search queries

## New Components
- **ElementPicker** - segmented/dropdown with element-specific colors
- **RarityPicker** - segmented/dropdown for R/SR/SSR
- **ProficiencyPicker** - segmented/dropdown for weapon types

## Picker Features
- Single and multi-select modes
- Auto-responsive: segmented on desktop, dropdown on mobile
- Manual mode override via `mode` prop
- Optional "Any" element for ElementPicker
- Contained/default style variants
- Tooltips on hover in segmented mode

## SearchContent Changes
- Rarity and Element filters on same row with space-between
- Clear links in filter headers (not buttons)
- Flexbox layout with consistent gaps
- Series dropdown filter
This commit is contained in:
Justin Edmund 2026-01-04 20:56:04 -08:00 committed by GitHub
parent 0fbdd24491
commit 5edb225d2d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1715 additions and 168 deletions

View file

@ -23,6 +23,8 @@ export interface SearchFilters {
proficiency2?: number[]
subaura?: boolean
extra?: boolean
// Series filter (by slug) - works for weapons, summons, and characters
series?: string[]
// Character-specific filters
season?: number[]
characterSeries?: number[]
@ -100,6 +102,9 @@ function buildSearchParams(
if (filters.extra !== undefined) {
apiFilters.extra = filters.extra
}
if (filters.series && filters.series.length > 0) {
apiFilters.series = filters.series
}
// Character-specific filters
if (filters.season && filters.season.length > 0) {
apiFilters.season = filters.season

View file

@ -6,9 +6,15 @@
import type { SearchResult } from '$lib/api/adapters/search.adapter'
import { searchQueries, type SearchFilters } from '$lib/api/queries/search.queries'
import { collectionQueries } from '$lib/api/queries/collection.queries'
import { entityQueries } from '$lib/api/queries/entity.queries'
import Button from '../ui/Button.svelte'
import Select from '../ui/Select.svelte'
import Icon from '../Icon.svelte'
import Input from '../ui/Input.svelte'
import CharacterTags from '$lib/components/tags/CharacterTags.svelte'
import ElementPicker from '../ui/element-picker/ElementPicker.svelte'
import RarityPicker from '../ui/rarity-picker/RarityPicker.svelte'
import ProficiencyPicker from '../ui/proficiency-picker/ProficiencyPicker.svelte'
import { useInfiniteLoader } from '$lib/stores/loaderState.svelte'
import { getCharacterImage, getWeaponImage, getSummonImage } from '$lib/features/database/detail/image'
import type { AddItemResult, SearchMode } from '$lib/types/api/search'
@ -41,12 +47,12 @@
let elementFilters = $state<number[]>([])
let rarityFilters = $state<number[]>([])
let proficiencyFilters = $state<number[]>([])
let seriesFilter = $state<string | undefined>(undefined)
// Search mode state (only available when authUserId is provided)
let searchMode = $state<SearchMode>('all')
// Refs
let searchInput: HTMLInputElement
let sentinelEl = $state<HTMLElement>()
// Constants
@ -60,25 +66,7 @@
{ value: 6, label: 'Light', color: 'var(--light-bg)' }
]
const rarities = [
{ value: 1, label: 'R' },
{ value: 2, label: 'SR' },
{ value: 3, label: 'SSR' }
]
const proficiencies = [
{ value: 1, label: 'Sabre' },
{ value: 2, label: 'Dagger' },
{ value: 3, label: 'Spear' },
{ value: 4, label: 'Axe' },
{ value: 5, label: 'Staff' },
{ value: 6, label: 'Gun' },
{ value: 7, label: 'Melee' },
{ value: 8, label: 'Bow' },
{ value: 9, label: 'Harp' },
{ value: 10, label: 'Katana' }
]
// Debounce search query changes
$effect(() => {
const query = searchQuery
@ -98,6 +86,28 @@
}
})
// Series query - fetch list based on current type
const seriesQuery = createQuery(() => {
switch (type) {
case 'weapon':
return entityQueries.weaponSeriesList()
case 'character':
return entityQueries.characterSeriesList()
case 'summon':
return entityQueries.summonSeriesList()
}
})
// Build series options for dropdown (use ID for API filtering)
const seriesOptions = $derived.by(() => {
const data = seriesQuery.data
if (!data) return []
return data.map((s) => ({
value: s.id,
label: s.name.en
}))
})
// Build filters object for query
// Use requiredProficiencies for mainhand selection if set, otherwise use user-selected filters
const effectiveProficiencies = $derived(
@ -107,7 +117,8 @@
const filters = $derived<SearchFilters>({
element: elementFilters.length > 0 ? elementFilters : undefined,
rarity: rarityFilters.length > 0 ? rarityFilters : undefined,
proficiency: type === 'weapon' && effectiveProficiencies ? effectiveProficiencies : undefined
proficiency: type === 'weapon' && effectiveProficiencies ? effectiveProficiencies : undefined,
series: seriesFilter ? [seriesFilter] : undefined
})
// Helper to map collection items to search result format with collectionId
@ -271,41 +282,26 @@
searchResults.length === 0 && !activeQuery.isLoading && !activeQuery.isError
)
// Focus search input on mount
$effect(() => {
if (searchInput) {
searchInput.focus()
}
})
function handleItemClick(item: AddItemResult) {
if (canAddMore) {
onAddItems([item])
}
}
function toggleElementFilter(element: number) {
if (elementFilters.includes(element)) {
elementFilters = elementFilters.filter(e => e !== element)
} else {
elementFilters = [...elementFilters, element]
}
function handleElementChange(value: number | number[]) {
elementFilters = Array.isArray(value) ? value : value !== undefined ? [value] : []
}
function toggleRarityFilter(rarity: number) {
if (rarityFilters.includes(rarity)) {
rarityFilters = rarityFilters.filter(r => r !== rarity)
} else {
rarityFilters = [...rarityFilters, rarity]
}
function handleRarityChange(value: number | number[]) {
rarityFilters = Array.isArray(value) ? value : value !== undefined ? [value] : []
}
function toggleProficiencyFilter(prof: number) {
if (proficiencyFilters.includes(prof)) {
proficiencyFilters = proficiencyFilters.filter(p => p !== prof)
} else {
proficiencyFilters = [...proficiencyFilters, prof]
}
function handleSeriesChange(value: string | undefined) {
seriesFilter = value
}
function handleProficiencyChange(value: number | number[]) {
proficiencyFilters = Array.isArray(value) ? value : value !== undefined ? [value] : []
}
function getImageUrl(item: AddItemResult): string {
@ -333,12 +329,13 @@
<div class="search-content">
<div class="search-section">
<input
bind:this={searchInput}
<Input
bind:value={searchQuery}
type="text"
placeholder="Search by name..."
aria-label="Search"
leftIcon="search"
contained
fullWidth
class="search-input"
/>
</div>
@ -363,59 +360,78 @@
{/if}
<div class="filters-section">
<!-- Element filters -->
<div class="filter-group">
<label class="filter-label">Element</label>
<div class="filter-buttons">
{#each elements as element}
<button
class="filter-btn element-btn"
class:active={elementFilters.includes(element.value)}
style:--element-color={element.color}
onclick={() => toggleElementFilter(element.value)}
aria-pressed={elementFilters.includes(element.value)}
>
{element.label}
</button>
{/each}
</div>
</div>
<!-- Rarity filters -->
<div class="filter-group">
<label class="filter-label">Rarity</label>
<div class="filter-buttons">
{#each rarities as rarity}
<button
class="filter-btn rarity-btn"
class:active={rarityFilters.includes(rarity.value)}
onclick={() => toggleRarityFilter(rarity.value)}
aria-pressed={rarityFilters.includes(rarity.value)}
>
{rarity.label}
</button>
{/each}
</div>
</div>
<!-- Proficiency filters (weapons only, hidden when required proficiencies set for mainhand) -->
{#if type === 'weapon' && !requiredProficiencies}
<!-- Rarity and Element filters (side by side) -->
<div class="filter-row">
<div class="filter-group">
<label class="filter-label">Proficiency</label>
<div class="filter-buttons proficiency-grid">
{#each proficiencies as prof}
<button
class="filter-btn prof-btn"
class:active={proficiencyFilters.includes(prof.value)}
onclick={() => toggleProficiencyFilter(prof.value)}
aria-pressed={proficiencyFilters.includes(prof.value)}
>
{prof.label}
</button>
{/each}
<div class="filter-header">
<label class="filter-label">Rarity</label>
{#if rarityFilters.length > 0}
<a href="#" class="clear-link" onclick={(e) => { e.preventDefault(); rarityFilters = [] }}>Clear</a>
{/if}
</div>
<RarityPicker
value={rarityFilters}
onValueChange={handleRarityChange}
multiple={true}
contained={true}
size="small"
/>
</div>
<div class="filter-group">
<div class="filter-header">
<label class="filter-label">Element</label>
{#if elementFilters.length > 0}
<a href="#" class="clear-link" onclick={(e) => { e.preventDefault(); elementFilters = [] }}>Clear</a>
{/if}
</div>
<ElementPicker
value={elementFilters}
onValueChange={handleElementChange}
multiple={true}
includeAny={true}
contained={true}
size="small"
/>
</div>
</div>
<!-- Proficiency filters (weapons and characters, hidden when required proficiencies set for mainhand) -->
{#if (type === 'weapon' || type === 'character') && !requiredProficiencies}
<div class="filter-group">
<div class="filter-header">
<label class="filter-label">Proficiency</label>
{#if proficiencyFilters.length > 0}
<a href="#" class="clear-link" onclick={(e) => { e.preventDefault(); proficiencyFilters = [] }}>Clear</a>
{/if}
</div>
<ProficiencyPicker
value={proficiencyFilters}
onValueChange={handleProficiencyChange}
multiple={true}
contained={true}
size="small"
/>
</div>
{/if}
<!-- Series filter -->
<div class="filter-group">
<div class="filter-header">
<label class="filter-label">Series</label>
{#if seriesFilter}
<a href="#" class="clear-link" onclick={(e) => { e.preventDefault(); seriesFilter = undefined }}>Clear</a>
{/if}
</div>
<Select
options={seriesOptions}
value={seriesFilter}
onValueChange={handleSeriesChange}
placeholder="All series"
contained={true}
fullWidth={true}
/>
</div>
</div>
<!-- Results -->
@ -517,34 +533,18 @@
}
.search-section {
padding: 0 0 $unit-2x 0;
padding: 0 $unit-2x $unit-2x $unit-2x;
flex-shrink: 0;
.search-input {
width: 100%;
padding: $unit calc($unit * 1.5);
border: 1px solid var(--border-primary);
border-radius: $input-corner;
font-size: $font-regular;
background: var(--bg-secondary);
color: var(--text-primary);
&:focus {
outline: none;
border-color: var(--accent-blue);
box-shadow: 0 0 0 2px var(--accent-blue-alpha);
}
&::placeholder {
color: var(--text-tertiary);
}
:global(.search-input) {
border-radius: $card-corner;
}
}
.mode-toggle {
display: flex;
gap: $unit-half;
padding-bottom: $unit-2x;
padding: 0 $unit-2x $unit-2x $unit-2x;
flex-shrink: 0;
.mode-btn {
@ -573,26 +573,49 @@
}
.filters-section {
padding-bottom: $unit-2x;
display: flex;
flex-direction: column;
gap: calc($unit * 1.5);
padding: 0 $unit-2x $unit-2x $unit-2x;
border-bottom: 1px solid var(--border-primary);
flex-shrink: 0;
.filter-group {
margin-bottom: calc($unit * 1.5);
.filter-row {
display: flex;
justify-content: space-between;
gap: $unit;
}
&:last-child {
margin-bottom: 0;
}
.filter-group {
display: flex;
flex-direction: column;
gap: $unit;
}
.filter-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 $unit-half;
}
.filter-label {
display: block;
font-size: $font-tiny;
font-size: $font-small;
font-weight: $bold;
text-transform: uppercase;
color: var(--text-secondary);
margin-bottom: $unit;
letter-spacing: 0.5px;
}
.clear-link {
font-size: $font-small;
color: var(--text-secondary);
text-decoration: none;
cursor: pointer;
transition: 0.15s color ease-out;
&:hover {
color: var(--text-primary);
}
}
.filter-buttons {
@ -627,19 +650,13 @@
color: white;
border-color: var(--accent-blue);
}
&.element-btn.active {
background: var(--element-color);
border-color: var(--element-color);
color: white;
}
}
}
.results-section {
flex: 1;
overflow-y: auto;
padding: $unit-2x 0;
padding: $unit-2x;
min-height: 0;
.loading,

View file

@ -6,6 +6,7 @@
interface Props extends HTMLInputAttributes {
variant?: 'default' | 'contained' | 'duration' | 'number' | 'range'
contained?: boolean
size?: 'small' | 'medium' | 'large'
error?: string
label?: string
leftIcon?: string
@ -29,6 +30,7 @@
let {
variant = 'default',
contained = false,
size = 'medium',
error,
label,
leftIcon,
@ -73,7 +75,9 @@
const showCounter = $derived(
counter !== undefined || (charsRemaining !== undefined && charsRemaining <= 5)
)
const hasWrapper = $derived(accessory || leftIcon || rightIcon || clearable || maxLength !== undefined || validationIcon)
const hasWrapper = $derived(
accessory || leftIcon || rightIcon || clearable || maxLength !== undefined || validationIcon
)
function handleClear() {
value = ''
@ -87,6 +91,7 @@
const inputClasses = $derived(
[
'input',
size,
(variant === 'contained' || contained) && 'contained',
variant === 'duration' && 'duration',
variant === 'number' && 'number',
@ -100,7 +105,6 @@
.filter(Boolean)
.join(' ')
)
</script>
<fieldset class={fieldsetClasses}>
@ -244,14 +248,9 @@
color: var(--text-primary);
display: block;
font-family: var(--font-family);
font-size: $font-regular;
width: 100%;
@include smooth-transition($duration-quick, background-color);
&:not(.wrapper) {
padding: calc($unit * 1.25) $unit-2x;
}
&.fullHeight {
height: 100%;
}
@ -292,12 +291,9 @@
input {
background: transparent;
border-radius: $input-corner;
// border: 2px solid transparent;
box-sizing: border-box;
color: var(--text-primary);
padding: calc($unit * 1.75) $unit-2x;
width: 100%;
font-size: $font-regular;
font-family: inherit;
@include smooth-transition($duration-quick, border-color);
@ -381,26 +377,6 @@
}
}
&:has(.iconLeft) input {
padding-left: $unit-5x;
}
&:has(.iconRight) input {
padding-right: $unit-5x;
}
&:has(.validationIcon) input {
padding-right: $unit-5x;
}
&:has(.clearButton) input {
padding-right: $unit-5x;
}
&:has(.counter) input {
padding-right: $unit-8x;
}
input {
border: 2px solid transparent;
}
@ -471,6 +447,94 @@
opacity: 0.5;
cursor: not-allowed;
}
// Size variants
&.small {
font-size: $font-small;
min-height: $unit-3x;
&:not(.wrapper) {
padding: $unit-half $unit;
}
&.wrapper input,
&.accessory input {
padding: $unit-half $unit;
font-size: $font-small;
}
&:has(.iconLeft) input {
padding-left: $unit-4x;
}
&:has(.iconRight) input,
&:has(.validationIcon) input,
&:has(.clearButton) input {
padding-right: $unit-4x;
}
&:has(.counter) input {
padding-right: $unit-6x;
}
}
&.medium {
font-size: $font-regular;
min-height: $unit-4x;
&:not(.wrapper) {
padding: $unit calc($unit * 1.5);
}
&.wrapper input,
&.accessory input {
padding: $unit calc($unit * 1.5);
font-size: $font-regular;
}
&:has(.iconLeft) input {
padding-left: $unit-5x;
}
&:has(.iconRight) input,
&:has(.validationIcon) input,
&:has(.clearButton) input {
padding-right: $unit-5x;
}
&:has(.counter) input {
padding-right: $unit-8x;
}
}
&.large {
font-size: $font-large;
min-height: calc($unit * 6);
&:not(.wrapper) {
padding: $unit-2x $unit-3x;
}
&.wrapper input,
&.accessory input {
padding: $unit-2x $unit-3x;
font-size: $font-large;
}
&:has(.iconLeft) input {
padding-left: $unit-6x;
}
&:has(.iconRight) input,
&:has(.validationIcon) input,
&:has(.clearButton) input {
padding-right: $unit-6x;
}
&:has(.counter) input {
padding-right: $unit-10x;
}
}
}
// Direct input element styles
@ -483,9 +547,7 @@
color: var(--text-primary);
display: block;
font-family: var(--font-family);
font-size: $font-regular;
width: 100%;
padding: calc($unit * 1.5) $unit-2x;
@include smooth-transition($duration-quick, background-color);
&[type='number']::-webkit-inner-spin-button {
@ -547,6 +609,25 @@
opacity: 0.5;
cursor: not-allowed;
}
// Size variants
&.small {
padding: $unit-half $unit;
font-size: $font-small;
min-height: $unit-3x;
}
&.medium {
padding: $unit calc($unit * 1.5);
font-size: $font-regular;
min-height: $unit-4x;
}
&.large {
padding: $unit-2x $unit-3x;
font-size: $font-large;
min-height: calc($unit * 6);
}
}
// Placeholder styles

View file

@ -9,6 +9,7 @@
label: string
disabled?: boolean
color?: string
image?: string
}
interface Props {
@ -19,6 +20,7 @@
disabled?: boolean
size?: 'small' | 'medium' | 'large'
contained?: boolean
fullWidth?: boolean
class?: string
}
@ -30,6 +32,7 @@
disabled = false,
size = 'small',
contained = false,
fullWidth = false,
class: className = ''
}: Props = $props()
@ -44,6 +47,11 @@
// Convert value array to string array for Bits UI
const stringValue = $derived(value.map((v) => String(v)))
// Get first selected option for display (image/color)
const firstSelectedOption = $derived(
value.length > 0 ? options.find((opt) => opt.value === value[0]) : undefined
)
// Get selected labels for display
const selectedLabels = $derived(() => {
if (value.length === 0) return null
@ -58,6 +66,7 @@
'multi-select',
size,
contained && 'contained',
fullWidth && 'full',
disabled && 'disabled',
value.length > 0 && 'has-value',
className
@ -84,6 +93,11 @@
items={stringOptions}
>
<SelectPrimitive.Trigger class={selectClasses} data-placeholder={value.length === 0}>
{#if firstSelectedOption?.image}
<img src={firstSelectedOption.image} alt="" class="trigger-image" />
{:else if firstSelectedOption?.color}
<span class="trigger-color-dot" style="background-color: {firstSelectedOption.color}"></span>
{/if}
<span class="text">{selectedLabels() || placeholder}</span>
<Icon name="chevron-down-small" size={14} class="chevron" />
</SelectPrimitive.Trigger>
@ -99,7 +113,11 @@
style={option.color ? `--option-color: ${option.color}` : ''}
>
{#snippet children({ selected })}
<span class="label" class:has-color={!!option.color} class:selected>{option.label}</span
{#if option.image}
<img src={option.image} alt="" class="item-image" />
{/if}
<span class="label" class:has-color={!!option.color && !option.image} class:selected
>{option.label}</span
>
<span class="indicator">
<Icon name="check" size={12} class="check-icon {selected ? 'visible' : ''}" />
@ -166,6 +184,20 @@
color: var(--text-secondary);
}
.trigger-image {
width: $unit-3x;
height: $unit-3x;
flex-shrink: 0;
object-fit: contain;
}
.trigger-color-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
:global(.chevron) {
flex-shrink: 0;
color: var(--text-tertiary);
@ -195,6 +227,11 @@
}
}
// Variant: full width
:global([data-select-trigger].multi-select.full) {
width: 100%;
}
// Dropdown content
:global([data-select-content].multi-content) {
background: var(--dialog-bg);
@ -254,6 +291,13 @@
border-bottom-right-radius: $item-corner;
}
.item-image {
width: $unit-3x;
height: $unit-3x;
flex-shrink: 0;
object-fit: contain;
}
:global(.check-icon) {
opacity: 0;
transition: opacity $duration-quick ease;

View file

@ -0,0 +1,130 @@
<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'
showClear?: boolean
disabled?: boolean
class?: string
}
let {
value = $bindable(),
onValueChange,
multiple = false,
includeAny = false,
mode = 'auto',
contained = false,
size = 'medium',
showClear = false,
disabled = false,
class: className = ''
}: Props = $props()
// Map size to segmented control size (small stays small, medium/large become regular)
const segmentedSize = $derived(size === 'small' ? 'small' : 'regular')
// 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="medium"
{contained}
disabled={disabled}
placeholder="Select elements..."
fullWidth={true}
class={className}
/>
{:else}
<Select
{options}
value={typeof value === 'number' ? value : undefined}
onValueChange={handleSingleChange}
size="medium"
{contained}
disabled={disabled}
placeholder="Select element"
fullWidth={true}
class={className}
/>
{/if}
{:else}
<ElementPickerSegmented
{value}
onValueChange={handleSegmentedChange}
{multiple}
{includeAny}
{contained}
{showClear}
size={segmentedSize}
disabled={disabled}
class={className}
/>
{/if}

View file

@ -0,0 +1,350 @@
<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'
// 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
size?: 'small' | 'regular'
showClear?: boolean
class?: string
}
let {
value = $bindable(),
onValueChange,
multiple = false,
includeAny = false,
contained = false,
disabled = false,
size = 'small',
showClear = false,
class: className = ''
}: Props = $props()
// Check if any elements are selected
const hasSelection = $derived.by(() => {
if (multiple) {
const arr = Array.isArray(value) ? value : value !== undefined ? [value] : []
return arr.length > 0
}
return value !== undefined
})
function handleClear() {
if (multiple) {
value = []
onValueChange?.([])
} else {
value = undefined
onValueChange?.(undefined as any)
}
}
// 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(
['container', contained && 'contained', size === 'regular' ? 'regular' : 'small', className]
.filter(Boolean)
.join(' ')
)
</script>
{#if showClear}
<div class="wrapper">
<div class={containerClasses}>
{#if multiple}
<ToggleGroup.Root
type="multiple"
value={stringValue as string[]}
onValueChange={handleMultipleChange}
class="element-group"
{disabled}
>
{#each elements as element}
<Tooltip content={getLabel(element)}>
{#snippet children()}
<ToggleGroup.Item
value={String(element)}
class="element-item"
{disabled}
>
<img
src={getElementImage(element)}
alt={getLabel(element)}
class="element-image"
/>
</ToggleGroup.Item>
{/snippet}
</Tooltip>
{/each}
</ToggleGroup.Root>
{:else}
<ToggleGroup.Root
type="single"
value={stringValue as string | undefined}
onValueChange={handleSingleChange}
class="element-group"
{disabled}
>
{#each elements as element}
<Tooltip content={getLabel(element)}>
{#snippet children()}
<ToggleGroup.Item
value={String(element)}
class="element-item"
{disabled}
>
<img
src={getElementImage(element)}
alt={getLabel(element)}
class="element-image"
/>
</ToggleGroup.Item>
{/snippet}
</Tooltip>
{/each}
</ToggleGroup.Root>
{/if}
</div>
{#if hasSelection}
<button type="button" class="clearButton" onclick={handleClear}> Clear </button>
{/if}
</div>
{:else}
<div class={containerClasses}>
{#if multiple}
<ToggleGroup.Root
type="multiple"
value={stringValue as string[]}
onValueChange={handleMultipleChange}
class="element-group"
{disabled}
>
{#each elements as element}
<Tooltip content={getLabel(element)}>
{#snippet children()}
<ToggleGroup.Item
value={String(element)}
class="element-item"
{disabled}
>
<img src={getElementImage(element)} alt={getLabel(element)} class="element-image" />
</ToggleGroup.Item>
{/snippet}
</Tooltip>
{/each}
</ToggleGroup.Root>
{:else}
<ToggleGroup.Root
type="single"
value={stringValue as string | undefined}
onValueChange={handleSingleChange}
class="element-group"
{disabled}
>
{#each elements as element}
<Tooltip content={getLabel(element)}>
{#snippet children()}
<ToggleGroup.Item
value={String(element)}
class="element-item"
{disabled}
>
<img src={getElementImage(element)} alt={getLabel(element)} class="element-image" />
</ToggleGroup.Item>
{/snippet}
</Tooltip>
{/each}
</ToggleGroup.Root>
{/if}
</div>
{/if}
<style lang="scss">
@use '$src/themes/spacing' as *;
@use '$src/themes/layout' as *;
@use '$src/themes/effects' as *;
@use '$src/themes/colors' as *;
@use '$src/themes/typography' as *;
.wrapper {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: $unit;
}
.container {
display: inline-flex;
border-radius: $full-corner;
padding: $unit-half;
&.contained {
background-color: var(--segmented-control-background-bg);
}
}
:global(.element-group) {
display: flex;
gap: $unit-quarter;
align-items: center;
}
:global(.element-item) {
all: unset;
cursor: pointer;
border-radius: $full-corner;
padding: $unit-half;
@include smooth-transition($duration-quick, background-color, opacity);
}
:global(.element-item:focus-visible) {
@include focus-ring($blue);
}
:global(.element-item:disabled) {
opacity: 0.5;
cursor: not-allowed;
}
// Element-specific hover and selected states
:global(.element-item[data-value='0']:hover:not(:disabled)) {
background-color: var(--null-nav-hover-bg);
}
:global(.element-item[data-value='0'][data-state='on']) {
background-color: var(--null-nav-selected-bg);
}
:global(.element-item[data-value='1']:hover:not(:disabled)) {
background-color: var(--wind-nav-hover-bg);
}
:global(.element-item[data-value='1'][data-state='on']) {
background-color: var(--wind-nav-selected-bg);
}
:global(.element-item[data-value='2']:hover:not(:disabled)) {
background-color: var(--fire-nav-hover-bg);
}
:global(.element-item[data-value='2'][data-state='on']) {
background-color: var(--fire-nav-selected-bg);
}
:global(.element-item[data-value='3']:hover:not(:disabled)) {
background-color: var(--water-nav-hover-bg);
}
:global(.element-item[data-value='3'][data-state='on']) {
background-color: var(--water-nav-selected-bg);
}
:global(.element-item[data-value='4']:hover:not(:disabled)) {
background-color: var(--earth-nav-hover-bg);
}
:global(.element-item[data-value='4'][data-state='on']) {
background-color: var(--earth-nav-selected-bg);
}
:global(.element-item[data-value='5']:hover:not(:disabled)) {
background-color: var(--dark-nav-hover-bg);
}
:global(.element-item[data-value='5'][data-state='on']) {
background-color: var(--dark-nav-selected-bg);
}
:global(.element-item[data-value='6']:hover:not(:disabled)) {
background-color: var(--light-nav-hover-bg);
}
:global(.element-item[data-value='6'][data-state='on']) {
background-color: var(--light-nav-selected-bg);
}
:global(.element-image) {
display: block;
}
// Size variants
.small {
:global(.element-item) {
padding: $unit-half;
}
:global(.element-image) {
width: calc($unit * 3.25);
height: calc($unit * 3.25);
}
}
.regular {
:global(.element-item) {
padding: $unit-half;
}
:global(.element-image) {
width: $unit-4x;
height: $unit-4x;
}
}
.clearButton {
all: unset;
cursor: pointer;
font-size: $font-small;
color: var(--text-secondary);
padding: $unit-half $unit;
border-radius: $input-corner;
@include smooth-transition($duration-quick, background-color, color);
&:hover {
background-color: var(--option-bg-hover);
color: var(--text-primary);
}
&:focus-visible {
@include focus-ring($blue);
}
}
</style>

View file

@ -0,0 +1,126 @@
<svelte:options runes={true} />
<script lang="ts">
import Select from '../Select.svelte'
import MultiSelect from '../MultiSelect.svelte'
import ProficiencyPickerSegmented from './ProficiencyPickerSegmented.svelte'
import { PROFICIENCY_LABELS, getProficiencyImage } from '$lib/utils/proficiency'
// Proficiency display order for dropdown: Sabre, Dagger, Spear, Axe, Staff, Gun, Melee, Bow, Harp, Katana
const PROFICIENCY_DISPLAY_ORDER = [1, 2, 4, 3, 6, 9, 7, 5, 8, 10]
interface Props {
value?: number | number[]
onValueChange?: (value: number | number[]) => void
multiple?: boolean
mode?: 'auto' | 'segmented' | 'dropdown'
contained?: boolean
size?: 'small' | 'medium' | 'large'
showClear?: boolean
disabled?: boolean
class?: string
}
let {
value = $bindable(),
onValueChange,
multiple = false,
mode = 'auto',
contained = false,
size = 'medium',
showClear = false,
disabled = false,
class: className = ''
}: Props = $props()
// Map size to segmented control size (small stays small, medium/large become regular)
const segmentedSize = $derived(size === 'small' ? 'small' : 'regular')
// 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 proficiency options for Select/MultiSelect
const options = $derived.by(() => {
return PROFICIENCY_DISPLAY_ORDER.map((proficiency) => ({
value: proficiency,
label: PROFICIENCY_LABELS[proficiency] ?? 'Unknown',
image: getProficiencyImage(proficiency)
}))
})
// 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="medium"
{contained}
disabled={disabled}
placeholder="Select proficiencies..."
fullWidth={true}
class={className}
/>
{:else}
<Select
{options}
value={typeof value === 'number' ? value : undefined}
onValueChange={handleSingleChange}
size="medium"
{contained}
disabled={disabled}
placeholder="Select proficiency"
fullWidth={true}
class={className}
/>
{/if}
{:else}
<ProficiencyPickerSegmented
{value}
onValueChange={handleSegmentedChange}
{multiple}
{contained}
{showClear}
size={segmentedSize}
disabled={disabled}
class={className}
/>
{/if}

View file

@ -0,0 +1,305 @@
<svelte:options runes={true} />
<script lang="ts">
import { ToggleGroup } from 'bits-ui'
import Tooltip from '../Tooltip.svelte'
import { PROFICIENCY_LABELS, getProficiencyImage } from '$lib/utils/proficiency'
// Proficiency display order: Sabre, Dagger, Spear, Axe, Staff, Gun, Melee, Bow, Harp, Katana
// Using values from PROFICIENCY_LABELS: 1=Sabre, 2=Dagger, 3=Axe, 4=Spear, 5=Bow, 6=Staff, 7=Melee, 8=Harp, 9=Gun, 10=Katana
const PROFICIENCY_DISPLAY_ORDER = [1, 2, 4, 3, 6, 9, 7, 5, 8, 10]
interface Props {
value?: number | number[]
onValueChange?: (value: number | number[]) => void
multiple?: boolean
contained?: boolean
disabled?: boolean
size?: 'small' | 'regular'
showClear?: boolean
class?: string
}
let {
value = $bindable(),
onValueChange,
multiple = false,
contained = false,
disabled = false,
size = 'small',
showClear = false,
class: className = ''
}: Props = $props()
// Check if any proficiencies are selected
const hasSelection = $derived.by(() => {
if (multiple) {
const arr = Array.isArray(value) ? value : value !== undefined ? [value] : []
return arr.length > 0
}
return value !== undefined
})
function handleClear() {
if (multiple) {
value = []
onValueChange?.([])
} else {
value = undefined
onValueChange?.(undefined as any)
}
}
// Get label for proficiency
function getLabel(proficiency: number): string {
return PROFICIENCY_LABELS[proficiency] ?? '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(
['container', contained && 'contained', size === 'regular' ? 'regular' : 'small', className]
.filter(Boolean)
.join(' ')
)
</script>
{#if showClear}
<div class="wrapper">
<div class={containerClasses}>
{#if multiple}
<ToggleGroup.Root
type="multiple"
value={stringValue as string[]}
onValueChange={handleMultipleChange}
class="proficiency-group"
{disabled}
>
{#each PROFICIENCY_DISPLAY_ORDER as proficiency}
<Tooltip content={getLabel(proficiency)}>
{#snippet children()}
<ToggleGroup.Item
value={String(proficiency)}
class="proficiency-item"
{disabled}
>
<img
src={getProficiencyImage(proficiency)}
alt={getLabel(proficiency)}
class="proficiency-image"
/>
</ToggleGroup.Item>
{/snippet}
</Tooltip>
{/each}
</ToggleGroup.Root>
{:else}
<ToggleGroup.Root
type="single"
value={stringValue as string | undefined}
onValueChange={handleSingleChange}
class="proficiency-group"
{disabled}
>
{#each PROFICIENCY_DISPLAY_ORDER as proficiency}
<Tooltip content={getLabel(proficiency)}>
{#snippet children()}
<ToggleGroup.Item
value={String(proficiency)}
class="proficiency-item"
{disabled}
>
<img
src={getProficiencyImage(proficiency)}
alt={getLabel(proficiency)}
class="proficiency-image"
/>
</ToggleGroup.Item>
{/snippet}
</Tooltip>
{/each}
</ToggleGroup.Root>
{/if}
</div>
{#if hasSelection}
<button type="button" class="clearButton" onclick={handleClear}> Clear </button>
{/if}
</div>
{:else}
<div class={containerClasses}>
{#if multiple}
<ToggleGroup.Root
type="multiple"
value={stringValue as string[]}
onValueChange={handleMultipleChange}
class="proficiency-group"
{disabled}
>
{#each PROFICIENCY_DISPLAY_ORDER as proficiency}
<Tooltip content={getLabel(proficiency)}>
{#snippet children()}
<ToggleGroup.Item
value={String(proficiency)}
class="proficiency-item"
{disabled}
>
<img src={getProficiencyImage(proficiency)} alt={getLabel(proficiency)} class="proficiency-image" />
</ToggleGroup.Item>
{/snippet}
</Tooltip>
{/each}
</ToggleGroup.Root>
{:else}
<ToggleGroup.Root
type="single"
value={stringValue as string | undefined}
onValueChange={handleSingleChange}
class="proficiency-group"
{disabled}
>
{#each PROFICIENCY_DISPLAY_ORDER as proficiency}
<Tooltip content={getLabel(proficiency)}>
{#snippet children()}
<ToggleGroup.Item
value={String(proficiency)}
class="proficiency-item"
{disabled}
>
<img src={getProficiencyImage(proficiency)} alt={getLabel(proficiency)} class="proficiency-image" />
</ToggleGroup.Item>
{/snippet}
</Tooltip>
{/each}
</ToggleGroup.Root>
{/if}
</div>
{/if}
<style lang="scss">
@use '$src/themes/spacing' as *;
@use '$src/themes/layout' as *;
@use '$src/themes/effects' as *;
@use '$src/themes/colors' as *;
@use '$src/themes/typography' as *;
.wrapper {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: $unit;
}
.container {
display: flex;
width: 100%;
border-radius: $full-corner;
padding: $unit-half;
&.contained {
background-color: var(--segmented-control-background-bg);
}
}
:global(.proficiency-group) {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
:global(.proficiency-item) {
all: unset;
cursor: pointer;
border-radius: $full-corner;
padding: $unit-half;
@include smooth-transition($duration-quick, background-color, opacity);
}
:global(.proficiency-item:focus-visible) {
@include focus-ring($blue);
}
:global(.proficiency-item:disabled) {
opacity: 0.5;
cursor: not-allowed;
}
// Simple hover and selected states with gray background
:global(.proficiency-item:hover:not(:disabled)) {
background-color: var(--picker-item-bg-hover);
}
:global(.proficiency-item[data-state='on']) {
background-color: var(--picker-item-bg-selected);
}
:global(.proficiency-image) {
display: block;
}
// Size variants
.small {
:global(.proficiency-item) {
padding: $unit-half;
}
:global(.proficiency-image) {
width: calc($unit * 3.25);
height: calc($unit * 3.25);
}
}
.regular {
:global(.proficiency-item) {
padding: $unit-half;
}
:global(.proficiency-image) {
width: $unit-4x;
height: $unit-4x;
}
}
.clearButton {
all: unset;
cursor: pointer;
font-size: $font-small;
color: var(--text-secondary);
padding: $unit-half $unit;
border-radius: $input-corner;
@include smooth-transition($duration-quick, background-color, color);
&:hover {
background-color: var(--option-bg-hover);
color: var(--text-primary);
}
&:focus-visible {
@include focus-ring($blue);
}
}
</style>

View file

@ -0,0 +1,126 @@
<svelte:options runes={true} />
<script lang="ts">
import Select from '../Select.svelte'
import MultiSelect from '../MultiSelect.svelte'
import RarityPickerSegmented from './RarityPickerSegmented.svelte'
import { RARITY_LABELS, getRarityImage } from '$lib/utils/rarity'
// Rarity display order: R(1) → SR(2) → SSR(3)
const RARITY_DISPLAY_ORDER = [1, 2, 3]
interface Props {
value?: number | number[]
onValueChange?: (value: number | number[]) => void
multiple?: boolean
mode?: 'auto' | 'segmented' | 'dropdown'
contained?: boolean
size?: 'small' | 'medium' | 'large'
showClear?: boolean
disabled?: boolean
class?: string
}
let {
value = $bindable(),
onValueChange,
multiple = false,
mode = 'auto',
contained = false,
size = 'medium',
showClear = false,
disabled = false,
class: className = ''
}: Props = $props()
// Map size to segmented control size (small stays small, medium/large become regular)
const segmentedSize = $derived(size === 'small' ? 'small' : 'regular')
// 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 rarity options for Select/MultiSelect
const options = $derived.by(() => {
return RARITY_DISPLAY_ORDER.map((rarity) => ({
value: rarity,
label: RARITY_LABELS[rarity] ?? 'Unknown',
image: getRarityImage(rarity)
}))
})
// 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="medium"
{contained}
disabled={disabled}
placeholder="Select rarities..."
fullWidth={true}
class={className}
/>
{:else}
<Select
{options}
value={typeof value === 'number' ? value : undefined}
onValueChange={handleSingleChange}
size="medium"
{contained}
disabled={disabled}
placeholder="Select rarity"
fullWidth={true}
class={className}
/>
{/if}
{:else}
<RarityPickerSegmented
{value}
onValueChange={handleSegmentedChange}
{multiple}
{contained}
{showClear}
size={segmentedSize}
disabled={disabled}
class={className}
/>
{/if}

View file

@ -0,0 +1,302 @@
<svelte:options runes={true} />
<script lang="ts">
import { ToggleGroup } from 'bits-ui'
import Tooltip from '../Tooltip.svelte'
import { RARITY_LABELS, getRarityImage } from '$lib/utils/rarity'
// Rarity display order: R(1) → SR(2) → SSR(3)
const RARITY_DISPLAY_ORDER = [1, 2, 3]
interface Props {
value?: number | number[]
onValueChange?: (value: number | number[]) => void
multiple?: boolean
contained?: boolean
disabled?: boolean
size?: 'small' | 'regular'
showClear?: boolean
class?: string
}
let {
value = $bindable(),
onValueChange,
multiple = false,
contained = false,
disabled = false,
size = 'small',
showClear = false,
class: className = ''
}: Props = $props()
// Check if any rarities are selected
const hasSelection = $derived.by(() => {
if (multiple) {
const arr = Array.isArray(value) ? value : value !== undefined ? [value] : []
return arr.length > 0
}
return value !== undefined
})
function handleClear() {
if (multiple) {
value = []
onValueChange?.([])
} else {
value = undefined
onValueChange?.(undefined as any)
}
}
// Get label for rarity
function getLabel(rarity: number): string {
return RARITY_LABELS[rarity] ?? '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(
['container', contained && 'contained', size === 'regular' ? 'regular' : 'small', className]
.filter(Boolean)
.join(' ')
)
</script>
{#if showClear}
<div class="wrapper">
<div class={containerClasses}>
{#if multiple}
<ToggleGroup.Root
type="multiple"
value={stringValue as string[]}
onValueChange={handleMultipleChange}
class="rarity-group"
{disabled}
>
{#each RARITY_DISPLAY_ORDER as rarity}
<Tooltip content={getLabel(rarity)}>
{#snippet children()}
<ToggleGroup.Item
value={String(rarity)}
class="rarity-item"
{disabled}
>
<img
src={getRarityImage(rarity)}
alt={getLabel(rarity)}
class="rarity-image"
/>
</ToggleGroup.Item>
{/snippet}
</Tooltip>
{/each}
</ToggleGroup.Root>
{:else}
<ToggleGroup.Root
type="single"
value={stringValue as string | undefined}
onValueChange={handleSingleChange}
class="rarity-group"
{disabled}
>
{#each RARITY_DISPLAY_ORDER as rarity}
<Tooltip content={getLabel(rarity)}>
{#snippet children()}
<ToggleGroup.Item
value={String(rarity)}
class="rarity-item"
{disabled}
>
<img
src={getRarityImage(rarity)}
alt={getLabel(rarity)}
class="rarity-image"
/>
</ToggleGroup.Item>
{/snippet}
</Tooltip>
{/each}
</ToggleGroup.Root>
{/if}
</div>
{#if hasSelection}
<button type="button" class="clearButton" onclick={handleClear}> Clear </button>
{/if}
</div>
{:else}
<div class={containerClasses}>
{#if multiple}
<ToggleGroup.Root
type="multiple"
value={stringValue as string[]}
onValueChange={handleMultipleChange}
class="rarity-group"
{disabled}
>
{#each RARITY_DISPLAY_ORDER as rarity}
<Tooltip content={getLabel(rarity)}>
{#snippet children()}
<ToggleGroup.Item
value={String(rarity)}
class="rarity-item"
{disabled}
>
<img src={getRarityImage(rarity)} alt={getLabel(rarity)} class="rarity-image" />
</ToggleGroup.Item>
{/snippet}
</Tooltip>
{/each}
</ToggleGroup.Root>
{:else}
<ToggleGroup.Root
type="single"
value={stringValue as string | undefined}
onValueChange={handleSingleChange}
class="rarity-group"
{disabled}
>
{#each RARITY_DISPLAY_ORDER as rarity}
<Tooltip content={getLabel(rarity)}>
{#snippet children()}
<ToggleGroup.Item
value={String(rarity)}
class="rarity-item"
{disabled}
>
<img src={getRarityImage(rarity)} alt={getLabel(rarity)} class="rarity-image" />
</ToggleGroup.Item>
{/snippet}
</Tooltip>
{/each}
</ToggleGroup.Root>
{/if}
</div>
{/if}
<style lang="scss">
@use '$src/themes/spacing' as *;
@use '$src/themes/layout' as *;
@use '$src/themes/effects' as *;
@use '$src/themes/colors' as *;
@use '$src/themes/typography' as *;
.wrapper {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: $unit;
}
.container {
display: inline-flex;
border-radius: $full-corner;
padding: $unit-half;
&.contained {
background-color: var(--segmented-control-background-bg);
}
}
:global(.rarity-group) {
display: flex;
gap: $unit-quarter;
align-items: center;
}
:global(.rarity-item) {
all: unset;
cursor: pointer;
border-radius: $full-corner;
padding: $unit-half;
@include smooth-transition($duration-quick, background-color, opacity);
}
:global(.rarity-item:focus-visible) {
@include focus-ring($blue);
}
:global(.rarity-item:disabled) {
opacity: 0.5;
cursor: not-allowed;
}
// Simple hover and selected states with gray background
:global(.rarity-item:hover:not(:disabled)) {
background-color: var(--picker-item-bg-hover);
}
:global(.rarity-item[data-state='on']) {
background-color: var(--picker-item-bg-selected);
}
:global(.rarity-image) {
display: block;
}
// Size variants
.small {
:global(.rarity-item) {
padding: $unit-half;
}
:global(.rarity-image) {
width: calc($unit * 3.25);
height: calc($unit * 3.25);
}
}
.regular {
:global(.rarity-item) {
padding: $unit-half;
}
:global(.rarity-image) {
width: $unit-4x;
height: $unit-4x;
}
}
.clearButton {
all: unset;
cursor: pointer;
font-size: $font-small;
color: var(--text-secondary);
padding: $unit-half $unit;
border-radius: $input-corner;
@include smooth-transition($duration-quick, background-color, color);
&:hover {
background-color: var(--option-bg-hover);
color: var(--text-primary);
}
&:focus-visible {
@include focus-ring($blue);
}
}
</style>

View file

@ -64,4 +64,14 @@ export function getOppositeElement(element?: number): number | undefined {
if (element === undefined || element === null) return undefined
const elementData = ELEMENTS[element]
return elementData?.opposite_id
}
/**
* Get the path to the element image from /images/elements/
* Used by ElementPicker component
*/
export function getElementImage(element?: number): string {
if (element === undefined || element === null) return ''
const label = ELEMENT_LABELS[element]?.toLowerCase() ?? 'null'
return `/images/elements/${label}.png`
}

View file

@ -32,3 +32,9 @@ export function getProficiencyOptions() {
label
}))
}
export function getProficiencyImage(proficiency: number): string {
const label = PROFICIENCY_LABELS[proficiency]
if (!label || label === 'None') return ''
return `/images/proficiencies/${label.toLowerCase()}.png`
}

View file

@ -34,4 +34,10 @@ export function getRarityClass(rarity: number): string {
default:
return ''
}
}
export function getRarityImage(rarity: number): string {
const label = RARITY_LABELS[rarity]
if (!label) return ''
return `/images/rarity/${label.toLowerCase()}.png`
}

View file

@ -94,6 +94,7 @@ $wind-text-20: #006a45;
$wind-text-30: #1dc688;
$wind-bg-00: #30c372;
$wind-bg-10: #3ee489;
$wind-bg-15: #86f2bb;
$wind-bg-20: #cdffed;
$fire-text-00: #3f0202;
@ -102,6 +103,7 @@ $fire-text-20: #6e0000;
$fire-text-30: #ec5c5c;
$fire-bg-00: #e05555;
$fire-bg-10: #fa6d6d;
$fire-bg-15: #fd9d9d;
$fire-bg-20: #ffcdcd;
$water-text-00: #03263b;
@ -110,6 +112,7 @@ $water-text-20: #00639c;
$water-text-30: #5cb7ec;
$water-bg-00: #4aabe3;
$water-bg-10: #6cc9ff;
$water-bg-15: #9ddbff;
$water-bg-20: #cdedff;
$earth-text-00: #321602;
@ -119,6 +122,7 @@ $earth-text-20: #8e3c0b;
$earth-text-30: #ec985c;
$earth-bg-00: #df8849;
$earth-bg-10: #fd9f5b;
$earth-bg-15: #fec194;
$earth-bg-20: #ffe2cd;
$light-text-00: #3d3700;
@ -127,6 +131,7 @@ $light-text-20: #715100;
$light-text-30: #c59c0c;
$light-bg-00: #cab91c;
$light-bg-10: #e8d633;
$light-bg-15: #f4e880;
$light-bg-20: #fffacd;
$dark-text-00: #23002f;
@ -135,6 +140,7 @@ $dark-text-20: #560075;
$dark-text-30: #c65cec;
$dark-bg-00: #ba63d8;
$dark-bg-10: #de7bff;
$dark-bg-15: #e8a4ff;
$dark-bg-20: #f2cdff;
$transparent--stroke--light: rgba(0, 0, 0, 0.9);
@ -528,6 +534,13 @@ $segmented--control--background--segment--text--dark: $grey-60;
$segmented--control--background--segment--text--hover--dark: $grey-70;
$segmented--control--background--segment--text--checked--dark: $grey-90;
// Color Definitions: Picker (icon-based pickers like Element, Rarity, Proficiency)
// Uses darker backgrounds than segmented controls for better visibility
$picker--item--bg--hover--light: $grey-70;
$picker--item--bg--hover--dark: $grey-40;
$picker--item--bg--selected--light: $grey-75;
$picker--item--bg--selected--dark: $grey-45;
// Color Definitions: Element / Wind
$wind--bg--light: $wind-bg-10;
$wind--bg--dark: $wind-bg-10;

View file

@ -229,6 +229,10 @@
--segmented-control-background-segment-text-hover: #{colors.$segmented--control--background--segment--text--hover--light};
--segmented-control-background-segment-text-checked: #{colors.$segmented--control--background--segment--text--checked--light};
// Light - Picker (icon-based pickers)
--picker-item-bg-hover: #{colors.$picker--item--bg--hover--light};
--picker-item-bg-selected: #{colors.$picker--item--bg--selected--light};
// Light - Extra Weapons
--extra-purple-bg: #{colors.$extra--purple--bg--light};
--extra-purple-card-bg: #{colors.$extra--purple--card--bg--light};
@ -297,6 +301,15 @@
--light-nav-selected-bg: #{colors.$light-bg-20};
--dark-nav-selected-bg: #{colors.$dark-bg-20};
// Light - Element navigation hover background (between bg and nav-selected)
--null-nav-hover-bg: #{colors.$grey-80};
--wind-nav-hover-bg: #{colors.$wind-bg-15};
--fire-nav-hover-bg: #{colors.$fire-bg-15};
--water-nav-hover-bg: #{colors.$water-bg-15};
--earth-nav-hover-bg: #{colors.$earth-bg-15};
--light-nav-hover-bg: #{colors.$light-bg-15};
--dark-nav-hover-bg: #{colors.$dark-bg-15};
// Item detail backgrounds (same colors as nav selected)
--null-item-detail-bg: #{colors.$grey-85};
--wind-item-detail-bg: #{colors.$wind-bg-20};
@ -598,6 +611,10 @@ html[data-theme='dark'] {
--segmented-control-background-segment-text-hover: #{colors.$segmented--control--background--segment--text--hover--dark};
--segmented-control-background-segment-text-checked: #{colors.$segmented--control--background--segment--text--checked--dark};
// Dark - Picker (icon-based pickers)
--picker-item-bg-hover: #{colors.$picker--item--bg--hover--dark};
--picker-item-bg-selected: #{colors.$picker--item--bg--selected--dark};
// Dark - Extra Weapons
--extra-purple-bg: #{colors.$extra--purple--bg--dark};
--extra-purple-card-bg: #{colors.$extra--purple--card--bg--dark};
@ -666,6 +683,15 @@ html[data-theme='dark'] {
--light-nav-selected-bg: #{colors.$light-bg-20};
--dark-nav-selected-bg: #{colors.$dark-bg-20};
// Dark - Element navigation hover background (same as light theme)
--null-nav-hover-bg: #{colors.$grey-80};
--wind-nav-hover-bg: #{colors.$wind-bg-15};
--fire-nav-hover-bg: #{colors.$fire-bg-15};
--water-nav-hover-bg: #{colors.$water-bg-15};
--earth-nav-hover-bg: #{colors.$earth-bg-15};
--light-nav-hover-bg: #{colors.$light-bg-15};
--dark-nav-hover-bg: #{colors.$dark-bg-15};
// Item detail backgrounds (same colors as nav selected)
--null-item-detail-bg: #{colors.$grey-85};
--wind-item-detail-bg: #{colors.$wind-bg-20};