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:
parent
0fbdd24491
commit
5edb225d2d
15 changed files with 1715 additions and 168 deletions
|
|
@ -23,6 +23,8 @@ export interface SearchFilters {
|
||||||
proficiency2?: number[]
|
proficiency2?: number[]
|
||||||
subaura?: boolean
|
subaura?: boolean
|
||||||
extra?: boolean
|
extra?: boolean
|
||||||
|
// Series filter (by slug) - works for weapons, summons, and characters
|
||||||
|
series?: string[]
|
||||||
// Character-specific filters
|
// Character-specific filters
|
||||||
season?: number[]
|
season?: number[]
|
||||||
characterSeries?: number[]
|
characterSeries?: number[]
|
||||||
|
|
@ -100,6 +102,9 @@ function buildSearchParams(
|
||||||
if (filters.extra !== undefined) {
|
if (filters.extra !== undefined) {
|
||||||
apiFilters.extra = filters.extra
|
apiFilters.extra = filters.extra
|
||||||
}
|
}
|
||||||
|
if (filters.series && filters.series.length > 0) {
|
||||||
|
apiFilters.series = filters.series
|
||||||
|
}
|
||||||
// Character-specific filters
|
// Character-specific filters
|
||||||
if (filters.season && filters.season.length > 0) {
|
if (filters.season && filters.season.length > 0) {
|
||||||
apiFilters.season = filters.season
|
apiFilters.season = filters.season
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,15 @@
|
||||||
import type { SearchResult } from '$lib/api/adapters/search.adapter'
|
import type { SearchResult } from '$lib/api/adapters/search.adapter'
|
||||||
import { searchQueries, type SearchFilters } from '$lib/api/queries/search.queries'
|
import { searchQueries, type SearchFilters } from '$lib/api/queries/search.queries'
|
||||||
import { collectionQueries } from '$lib/api/queries/collection.queries'
|
import { collectionQueries } from '$lib/api/queries/collection.queries'
|
||||||
|
import { entityQueries } from '$lib/api/queries/entity.queries'
|
||||||
import Button from '../ui/Button.svelte'
|
import Button from '../ui/Button.svelte'
|
||||||
|
import Select from '../ui/Select.svelte'
|
||||||
import Icon from '../Icon.svelte'
|
import Icon from '../Icon.svelte'
|
||||||
|
import Input from '../ui/Input.svelte'
|
||||||
import CharacterTags from '$lib/components/tags/CharacterTags.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 { useInfiniteLoader } from '$lib/stores/loaderState.svelte'
|
||||||
import { getCharacterImage, getWeaponImage, getSummonImage } from '$lib/features/database/detail/image'
|
import { getCharacterImage, getWeaponImage, getSummonImage } from '$lib/features/database/detail/image'
|
||||||
import type { AddItemResult, SearchMode } from '$lib/types/api/search'
|
import type { AddItemResult, SearchMode } from '$lib/types/api/search'
|
||||||
|
|
@ -41,12 +47,12 @@
|
||||||
let elementFilters = $state<number[]>([])
|
let elementFilters = $state<number[]>([])
|
||||||
let rarityFilters = $state<number[]>([])
|
let rarityFilters = $state<number[]>([])
|
||||||
let proficiencyFilters = $state<number[]>([])
|
let proficiencyFilters = $state<number[]>([])
|
||||||
|
let seriesFilter = $state<string | undefined>(undefined)
|
||||||
|
|
||||||
// Search mode state (only available when authUserId is provided)
|
// Search mode state (only available when authUserId is provided)
|
||||||
let searchMode = $state<SearchMode>('all')
|
let searchMode = $state<SearchMode>('all')
|
||||||
|
|
||||||
// Refs
|
// Refs
|
||||||
let searchInput: HTMLInputElement
|
|
||||||
let sentinelEl = $state<HTMLElement>()
|
let sentinelEl = $state<HTMLElement>()
|
||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
|
|
@ -60,25 +66,7 @@
|
||||||
{ value: 6, label: 'Light', color: 'var(--light-bg)' }
|
{ 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
|
// Debounce search query changes
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const query = searchQuery
|
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
|
// Build filters object for query
|
||||||
// Use requiredProficiencies for mainhand selection if set, otherwise use user-selected filters
|
// Use requiredProficiencies for mainhand selection if set, otherwise use user-selected filters
|
||||||
const effectiveProficiencies = $derived(
|
const effectiveProficiencies = $derived(
|
||||||
|
|
@ -107,7 +117,8 @@
|
||||||
const filters = $derived<SearchFilters>({
|
const filters = $derived<SearchFilters>({
|
||||||
element: elementFilters.length > 0 ? elementFilters : undefined,
|
element: elementFilters.length > 0 ? elementFilters : undefined,
|
||||||
rarity: rarityFilters.length > 0 ? rarityFilters : 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
|
// Helper to map collection items to search result format with collectionId
|
||||||
|
|
@ -271,41 +282,26 @@
|
||||||
searchResults.length === 0 && !activeQuery.isLoading && !activeQuery.isError
|
searchResults.length === 0 && !activeQuery.isLoading && !activeQuery.isError
|
||||||
)
|
)
|
||||||
|
|
||||||
// Focus search input on mount
|
|
||||||
$effect(() => {
|
|
||||||
if (searchInput) {
|
|
||||||
searchInput.focus()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function handleItemClick(item: AddItemResult) {
|
function handleItemClick(item: AddItemResult) {
|
||||||
if (canAddMore) {
|
if (canAddMore) {
|
||||||
onAddItems([item])
|
onAddItems([item])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleElementFilter(element: number) {
|
function handleElementChange(value: number | number[]) {
|
||||||
if (elementFilters.includes(element)) {
|
elementFilters = Array.isArray(value) ? value : value !== undefined ? [value] : []
|
||||||
elementFilters = elementFilters.filter(e => e !== element)
|
|
||||||
} else {
|
|
||||||
elementFilters = [...elementFilters, element]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleRarityFilter(rarity: number) {
|
function handleRarityChange(value: number | number[]) {
|
||||||
if (rarityFilters.includes(rarity)) {
|
rarityFilters = Array.isArray(value) ? value : value !== undefined ? [value] : []
|
||||||
rarityFilters = rarityFilters.filter(r => r !== rarity)
|
|
||||||
} else {
|
|
||||||
rarityFilters = [...rarityFilters, rarity]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleProficiencyFilter(prof: number) {
|
function handleSeriesChange(value: string | undefined) {
|
||||||
if (proficiencyFilters.includes(prof)) {
|
seriesFilter = value
|
||||||
proficiencyFilters = proficiencyFilters.filter(p => p !== prof)
|
}
|
||||||
} else {
|
|
||||||
proficiencyFilters = [...proficiencyFilters, prof]
|
function handleProficiencyChange(value: number | number[]) {
|
||||||
}
|
proficiencyFilters = Array.isArray(value) ? value : value !== undefined ? [value] : []
|
||||||
}
|
}
|
||||||
|
|
||||||
function getImageUrl(item: AddItemResult): string {
|
function getImageUrl(item: AddItemResult): string {
|
||||||
|
|
@ -333,12 +329,13 @@
|
||||||
|
|
||||||
<div class="search-content">
|
<div class="search-content">
|
||||||
<div class="search-section">
|
<div class="search-section">
|
||||||
<input
|
<Input
|
||||||
bind:this={searchInput}
|
|
||||||
bind:value={searchQuery}
|
bind:value={searchQuery}
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search by name..."
|
placeholder="Search by name..."
|
||||||
aria-label="Search"
|
leftIcon="search"
|
||||||
|
contained
|
||||||
|
fullWidth
|
||||||
class="search-input"
|
class="search-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -363,59 +360,78 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="filters-section">
|
<div class="filters-section">
|
||||||
<!-- Element filters -->
|
<!-- Rarity and Element filters (side by side) -->
|
||||||
<div class="filter-group">
|
<div class="filter-row">
|
||||||
<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}
|
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<label class="filter-label">Proficiency</label>
|
<div class="filter-header">
|
||||||
<div class="filter-buttons proficiency-grid">
|
<label class="filter-label">Rarity</label>
|
||||||
{#each proficiencies as prof}
|
{#if rarityFilters.length > 0}
|
||||||
<button
|
<a href="#" class="clear-link" onclick={(e) => { e.preventDefault(); rarityFilters = [] }}>Clear</a>
|
||||||
class="filter-btn prof-btn"
|
{/if}
|
||||||
class:active={proficiencyFilters.includes(prof.value)}
|
|
||||||
onclick={() => toggleProficiencyFilter(prof.value)}
|
|
||||||
aria-pressed={proficiencyFilters.includes(prof.value)}
|
|
||||||
>
|
|
||||||
{prof.label}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
{/if}
|
{/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>
|
</div>
|
||||||
|
|
||||||
<!-- Results -->
|
<!-- Results -->
|
||||||
|
|
@ -517,34 +533,18 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-section {
|
.search-section {
|
||||||
padding: 0 0 $unit-2x 0;
|
padding: 0 $unit-2x $unit-2x $unit-2x;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
||||||
.search-input {
|
:global(.search-input) {
|
||||||
width: 100%;
|
border-radius: $card-corner;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.mode-toggle {
|
.mode-toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: $unit-half;
|
gap: $unit-half;
|
||||||
padding-bottom: $unit-2x;
|
padding: 0 $unit-2x $unit-2x $unit-2x;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
||||||
.mode-btn {
|
.mode-btn {
|
||||||
|
|
@ -573,26 +573,49 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.filters-section {
|
.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);
|
border-bottom: 1px solid var(--border-primary);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
||||||
.filter-group {
|
.filter-row {
|
||||||
margin-bottom: calc($unit * 1.5);
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $unit;
|
||||||
|
}
|
||||||
|
|
||||||
&:last-child {
|
.filter-group {
|
||||||
margin-bottom: 0;
|
display: flex;
|
||||||
}
|
flex-direction: column;
|
||||||
|
gap: $unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 $unit-half;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-label {
|
.filter-label {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: $font-tiny;
|
font-size: $font-small;
|
||||||
font-weight: $bold;
|
font-weight: $bold;
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--text-secondary);
|
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 {
|
.filter-buttons {
|
||||||
|
|
@ -627,19 +650,13 @@
|
||||||
color: white;
|
color: white;
|
||||||
border-color: var(--accent-blue);
|
border-color: var(--accent-blue);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.element-btn.active {
|
|
||||||
background: var(--element-color);
|
|
||||||
border-color: var(--element-color);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.results-section {
|
.results-section {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: $unit-2x 0;
|
padding: $unit-2x;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|
||||||
.loading,
|
.loading,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
interface Props extends HTMLInputAttributes {
|
interface Props extends HTMLInputAttributes {
|
||||||
variant?: 'default' | 'contained' | 'duration' | 'number' | 'range'
|
variant?: 'default' | 'contained' | 'duration' | 'number' | 'range'
|
||||||
contained?: boolean
|
contained?: boolean
|
||||||
|
size?: 'small' | 'medium' | 'large'
|
||||||
error?: string
|
error?: string
|
||||||
label?: string
|
label?: string
|
||||||
leftIcon?: string
|
leftIcon?: string
|
||||||
|
|
@ -29,6 +30,7 @@
|
||||||
let {
|
let {
|
||||||
variant = 'default',
|
variant = 'default',
|
||||||
contained = false,
|
contained = false,
|
||||||
|
size = 'medium',
|
||||||
error,
|
error,
|
||||||
label,
|
label,
|
||||||
leftIcon,
|
leftIcon,
|
||||||
|
|
@ -73,7 +75,9 @@
|
||||||
const showCounter = $derived(
|
const showCounter = $derived(
|
||||||
counter !== undefined || (charsRemaining !== undefined && charsRemaining <= 5)
|
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() {
|
function handleClear() {
|
||||||
value = ''
|
value = ''
|
||||||
|
|
@ -87,6 +91,7 @@
|
||||||
const inputClasses = $derived(
|
const inputClasses = $derived(
|
||||||
[
|
[
|
||||||
'input',
|
'input',
|
||||||
|
size,
|
||||||
(variant === 'contained' || contained) && 'contained',
|
(variant === 'contained' || contained) && 'contained',
|
||||||
variant === 'duration' && 'duration',
|
variant === 'duration' && 'duration',
|
||||||
variant === 'number' && 'number',
|
variant === 'number' && 'number',
|
||||||
|
|
@ -100,7 +105,6 @@
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ')
|
.join(' ')
|
||||||
)
|
)
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<fieldset class={fieldsetClasses}>
|
<fieldset class={fieldsetClasses}>
|
||||||
|
|
@ -244,14 +248,9 @@
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
display: block;
|
display: block;
|
||||||
font-family: var(--font-family);
|
font-family: var(--font-family);
|
||||||
font-size: $font-regular;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@include smooth-transition($duration-quick, background-color);
|
@include smooth-transition($duration-quick, background-color);
|
||||||
|
|
||||||
&:not(.wrapper) {
|
|
||||||
padding: calc($unit * 1.25) $unit-2x;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.fullHeight {
|
&.fullHeight {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
@ -292,12 +291,9 @@
|
||||||
input {
|
input {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border-radius: $input-corner;
|
border-radius: $input-corner;
|
||||||
// border: 2px solid transparent;
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
padding: calc($unit * 1.75) $unit-2x;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-size: $font-regular;
|
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
@include smooth-transition($duration-quick, border-color);
|
@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 {
|
input {
|
||||||
border: 2px solid transparent;
|
border: 2px solid transparent;
|
||||||
}
|
}
|
||||||
|
|
@ -471,6 +447,94 @@
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
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
|
// Direct input element styles
|
||||||
|
|
@ -483,9 +547,7 @@
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
display: block;
|
display: block;
|
||||||
font-family: var(--font-family);
|
font-family: var(--font-family);
|
||||||
font-size: $font-regular;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: calc($unit * 1.5) $unit-2x;
|
|
||||||
@include smooth-transition($duration-quick, background-color);
|
@include smooth-transition($duration-quick, background-color);
|
||||||
|
|
||||||
&[type='number']::-webkit-inner-spin-button {
|
&[type='number']::-webkit-inner-spin-button {
|
||||||
|
|
@ -547,6 +609,25 @@
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
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
|
// Placeholder styles
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
label: string
|
label: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
color?: string
|
color?: string
|
||||||
|
image?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -19,6 +20,7 @@
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
size?: 'small' | 'medium' | 'large'
|
size?: 'small' | 'medium' | 'large'
|
||||||
contained?: boolean
|
contained?: boolean
|
||||||
|
fullWidth?: boolean
|
||||||
class?: string
|
class?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -30,6 +32,7 @@
|
||||||
disabled = false,
|
disabled = false,
|
||||||
size = 'small',
|
size = 'small',
|
||||||
contained = false,
|
contained = false,
|
||||||
|
fullWidth = false,
|
||||||
class: className = ''
|
class: className = ''
|
||||||
}: Props = $props()
|
}: Props = $props()
|
||||||
|
|
||||||
|
|
@ -44,6 +47,11 @@
|
||||||
// Convert value array to string array for Bits UI
|
// Convert value array to string array for Bits UI
|
||||||
const stringValue = $derived(value.map((v) => String(v)))
|
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
|
// Get selected labels for display
|
||||||
const selectedLabels = $derived(() => {
|
const selectedLabels = $derived(() => {
|
||||||
if (value.length === 0) return null
|
if (value.length === 0) return null
|
||||||
|
|
@ -58,6 +66,7 @@
|
||||||
'multi-select',
|
'multi-select',
|
||||||
size,
|
size,
|
||||||
contained && 'contained',
|
contained && 'contained',
|
||||||
|
fullWidth && 'full',
|
||||||
disabled && 'disabled',
|
disabled && 'disabled',
|
||||||
value.length > 0 && 'has-value',
|
value.length > 0 && 'has-value',
|
||||||
className
|
className
|
||||||
|
|
@ -84,6 +93,11 @@
|
||||||
items={stringOptions}
|
items={stringOptions}
|
||||||
>
|
>
|
||||||
<SelectPrimitive.Trigger class={selectClasses} data-placeholder={value.length === 0}>
|
<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>
|
<span class="text">{selectedLabels() || placeholder}</span>
|
||||||
<Icon name="chevron-down-small" size={14} class="chevron" />
|
<Icon name="chevron-down-small" size={14} class="chevron" />
|
||||||
</SelectPrimitive.Trigger>
|
</SelectPrimitive.Trigger>
|
||||||
|
|
@ -99,7 +113,11 @@
|
||||||
style={option.color ? `--option-color: ${option.color}` : ''}
|
style={option.color ? `--option-color: ${option.color}` : ''}
|
||||||
>
|
>
|
||||||
{#snippet children({ selected })}
|
{#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">
|
<span class="indicator">
|
||||||
<Icon name="check" size={12} class="check-icon {selected ? 'visible' : ''}" />
|
<Icon name="check" size={12} class="check-icon {selected ? 'visible' : ''}" />
|
||||||
|
|
@ -166,6 +184,20 @@
|
||||||
color: var(--text-secondary);
|
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) {
|
:global(.chevron) {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
|
|
@ -195,6 +227,11 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Variant: full width
|
||||||
|
:global([data-select-trigger].multi-select.full) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
// Dropdown content
|
// Dropdown content
|
||||||
:global([data-select-content].multi-content) {
|
:global([data-select-content].multi-content) {
|
||||||
background: var(--dialog-bg);
|
background: var(--dialog-bg);
|
||||||
|
|
@ -254,6 +291,13 @@
|
||||||
border-bottom-right-radius: $item-corner;
|
border-bottom-right-radius: $item-corner;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.item-image {
|
||||||
|
width: $unit-3x;
|
||||||
|
height: $unit-3x;
|
||||||
|
flex-shrink: 0;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
:global(.check-icon) {
|
:global(.check-icon) {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity $duration-quick ease;
|
transition: opacity $duration-quick ease;
|
||||||
|
|
|
||||||
130
src/lib/components/ui/element-picker/ElementPicker.svelte
Normal file
130
src/lib/components/ui/element-picker/ElementPicker.svelte
Normal 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}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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}
|
||||||
|
|
@ -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>
|
||||||
126
src/lib/components/ui/rarity-picker/RarityPicker.svelte
Normal file
126
src/lib/components/ui/rarity-picker/RarityPicker.svelte
Normal 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}
|
||||||
302
src/lib/components/ui/rarity-picker/RarityPickerSegmented.svelte
Normal file
302
src/lib/components/ui/rarity-picker/RarityPickerSegmented.svelte
Normal 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>
|
||||||
|
|
@ -64,4 +64,14 @@ export function getOppositeElement(element?: number): number | undefined {
|
||||||
if (element === undefined || element === null) return undefined
|
if (element === undefined || element === null) return undefined
|
||||||
const elementData = ELEMENTS[element]
|
const elementData = ELEMENTS[element]
|
||||||
return elementData?.opposite_id
|
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`
|
||||||
}
|
}
|
||||||
|
|
@ -32,3 +32,9 @@ export function getProficiencyOptions() {
|
||||||
label
|
label
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getProficiencyImage(proficiency: number): string {
|
||||||
|
const label = PROFICIENCY_LABELS[proficiency]
|
||||||
|
if (!label || label === 'None') return ''
|
||||||
|
return `/images/proficiencies/${label.toLowerCase()}.png`
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,4 +34,10 @@ export function getRarityClass(rarity: number): string {
|
||||||
default:
|
default:
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRarityImage(rarity: number): string {
|
||||||
|
const label = RARITY_LABELS[rarity]
|
||||||
|
if (!label) return ''
|
||||||
|
return `/images/rarity/${label.toLowerCase()}.png`
|
||||||
}
|
}
|
||||||
|
|
@ -94,6 +94,7 @@ $wind-text-20: #006a45;
|
||||||
$wind-text-30: #1dc688;
|
$wind-text-30: #1dc688;
|
||||||
$wind-bg-00: #30c372;
|
$wind-bg-00: #30c372;
|
||||||
$wind-bg-10: #3ee489;
|
$wind-bg-10: #3ee489;
|
||||||
|
$wind-bg-15: #86f2bb;
|
||||||
$wind-bg-20: #cdffed;
|
$wind-bg-20: #cdffed;
|
||||||
|
|
||||||
$fire-text-00: #3f0202;
|
$fire-text-00: #3f0202;
|
||||||
|
|
@ -102,6 +103,7 @@ $fire-text-20: #6e0000;
|
||||||
$fire-text-30: #ec5c5c;
|
$fire-text-30: #ec5c5c;
|
||||||
$fire-bg-00: #e05555;
|
$fire-bg-00: #e05555;
|
||||||
$fire-bg-10: #fa6d6d;
|
$fire-bg-10: #fa6d6d;
|
||||||
|
$fire-bg-15: #fd9d9d;
|
||||||
$fire-bg-20: #ffcdcd;
|
$fire-bg-20: #ffcdcd;
|
||||||
|
|
||||||
$water-text-00: #03263b;
|
$water-text-00: #03263b;
|
||||||
|
|
@ -110,6 +112,7 @@ $water-text-20: #00639c;
|
||||||
$water-text-30: #5cb7ec;
|
$water-text-30: #5cb7ec;
|
||||||
$water-bg-00: #4aabe3;
|
$water-bg-00: #4aabe3;
|
||||||
$water-bg-10: #6cc9ff;
|
$water-bg-10: #6cc9ff;
|
||||||
|
$water-bg-15: #9ddbff;
|
||||||
$water-bg-20: #cdedff;
|
$water-bg-20: #cdedff;
|
||||||
|
|
||||||
$earth-text-00: #321602;
|
$earth-text-00: #321602;
|
||||||
|
|
@ -119,6 +122,7 @@ $earth-text-20: #8e3c0b;
|
||||||
$earth-text-30: #ec985c;
|
$earth-text-30: #ec985c;
|
||||||
$earth-bg-00: #df8849;
|
$earth-bg-00: #df8849;
|
||||||
$earth-bg-10: #fd9f5b;
|
$earth-bg-10: #fd9f5b;
|
||||||
|
$earth-bg-15: #fec194;
|
||||||
$earth-bg-20: #ffe2cd;
|
$earth-bg-20: #ffe2cd;
|
||||||
|
|
||||||
$light-text-00: #3d3700;
|
$light-text-00: #3d3700;
|
||||||
|
|
@ -127,6 +131,7 @@ $light-text-20: #715100;
|
||||||
$light-text-30: #c59c0c;
|
$light-text-30: #c59c0c;
|
||||||
$light-bg-00: #cab91c;
|
$light-bg-00: #cab91c;
|
||||||
$light-bg-10: #e8d633;
|
$light-bg-10: #e8d633;
|
||||||
|
$light-bg-15: #f4e880;
|
||||||
$light-bg-20: #fffacd;
|
$light-bg-20: #fffacd;
|
||||||
|
|
||||||
$dark-text-00: #23002f;
|
$dark-text-00: #23002f;
|
||||||
|
|
@ -135,6 +140,7 @@ $dark-text-20: #560075;
|
||||||
$dark-text-30: #c65cec;
|
$dark-text-30: #c65cec;
|
||||||
$dark-bg-00: #ba63d8;
|
$dark-bg-00: #ba63d8;
|
||||||
$dark-bg-10: #de7bff;
|
$dark-bg-10: #de7bff;
|
||||||
|
$dark-bg-15: #e8a4ff;
|
||||||
$dark-bg-20: #f2cdff;
|
$dark-bg-20: #f2cdff;
|
||||||
|
|
||||||
$transparent--stroke--light: rgba(0, 0, 0, 0.9);
|
$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--hover--dark: $grey-70;
|
||||||
$segmented--control--background--segment--text--checked--dark: $grey-90;
|
$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
|
// Color Definitions: Element / Wind
|
||||||
$wind--bg--light: $wind-bg-10;
|
$wind--bg--light: $wind-bg-10;
|
||||||
$wind--bg--dark: $wind-bg-10;
|
$wind--bg--dark: $wind-bg-10;
|
||||||
|
|
|
||||||
|
|
@ -229,6 +229,10 @@
|
||||||
--segmented-control-background-segment-text-hover: #{colors.$segmented--control--background--segment--text--hover--light};
|
--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};
|
--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
|
// Light - Extra Weapons
|
||||||
--extra-purple-bg: #{colors.$extra--purple--bg--light};
|
--extra-purple-bg: #{colors.$extra--purple--bg--light};
|
||||||
--extra-purple-card-bg: #{colors.$extra--purple--card--bg--light};
|
--extra-purple-card-bg: #{colors.$extra--purple--card--bg--light};
|
||||||
|
|
@ -297,6 +301,15 @@
|
||||||
--light-nav-selected-bg: #{colors.$light-bg-20};
|
--light-nav-selected-bg: #{colors.$light-bg-20};
|
||||||
--dark-nav-selected-bg: #{colors.$dark-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)
|
// Item detail backgrounds (same colors as nav selected)
|
||||||
--null-item-detail-bg: #{colors.$grey-85};
|
--null-item-detail-bg: #{colors.$grey-85};
|
||||||
--wind-item-detail-bg: #{colors.$wind-bg-20};
|
--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-hover: #{colors.$segmented--control--background--segment--text--hover--dark};
|
||||||
--segmented-control-background-segment-text-checked: #{colors.$segmented--control--background--segment--text--checked--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
|
// Dark - Extra Weapons
|
||||||
--extra-purple-bg: #{colors.$extra--purple--bg--dark};
|
--extra-purple-bg: #{colors.$extra--purple--bg--dark};
|
||||||
--extra-purple-card-bg: #{colors.$extra--purple--card--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};
|
--light-nav-selected-bg: #{colors.$light-bg-20};
|
||||||
--dark-nav-selected-bg: #{colors.$dark-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)
|
// Item detail backgrounds (same colors as nav selected)
|
||||||
--null-item-detail-bg: #{colors.$grey-85};
|
--null-item-detail-bg: #{colors.$grey-85};
|
||||||
--wind-item-detail-bg: #{colors.$wind-bg-20};
|
--wind-item-detail-bg: #{colors.$wind-bg-20};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue