style CharacterTypeahead to match Select, use granblueId as value
This commit is contained in:
parent
3d75c22f3b
commit
ba17a17224
1 changed files with 128 additions and 30 deletions
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
<script lang="ts">
|
||||
import Svelecte from 'svelecte'
|
||||
import Icon from '../Icon.svelte'
|
||||
import { searchAdapter, type SearchResult } from '$lib/api/adapters/search.adapter'
|
||||
|
||||
interface CharacterOption {
|
||||
|
|
@ -13,10 +14,12 @@
|
|||
}
|
||||
|
||||
interface Props {
|
||||
/** Selected character ID */
|
||||
/** Selected character granblue ID (e.g. "3040581000") */
|
||||
value?: string | null
|
||||
/** Initial character data for display (when loading existing value) */
|
||||
initialCharacter?: { id: string; name: string; granblueId: string } | null
|
||||
/** Callback when value changes */
|
||||
onValueChange?: (characterId: string | null) => void
|
||||
onValueChange?: (granblueId: string | null) => void
|
||||
/** Placeholder text */
|
||||
placeholder?: string
|
||||
/** Disabled state */
|
||||
|
|
@ -27,29 +30,66 @@
|
|||
clearable?: boolean
|
||||
/** Minimum characters before search */
|
||||
minQuery?: number
|
||||
/** Use contained styling (for use inside containers) */
|
||||
contained?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
value = $bindable(null),
|
||||
initialCharacter = null,
|
||||
onValueChange,
|
||||
placeholder = 'Search characters...',
|
||||
disabled = false,
|
||||
size = 'medium',
|
||||
clearable = true,
|
||||
minQuery = 2
|
||||
minQuery = 2,
|
||||
contained = false
|
||||
}: Props = $props()
|
||||
|
||||
let options = $state<CharacterOption[]>([])
|
||||
// Initialize selectedOption from initialCharacter if provided
|
||||
function initializeFromCharacter() {
|
||||
if (initialCharacter && value) {
|
||||
return {
|
||||
id: initialCharacter.id,
|
||||
label: initialCharacter.name,
|
||||
granblueId: initialCharacter.granblueId
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
let searchResults = $state<CharacterOption[]>([])
|
||||
let selectedOption = $state<CharacterOption | null>(initializeFromCharacter())
|
||||
let isLoading = $state(false)
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// Update selectedOption when initialCharacter changes or value is cleared
|
||||
$effect(() => {
|
||||
if (!value) {
|
||||
selectedOption = null
|
||||
} else if (initialCharacter && !selectedOption) {
|
||||
selectedOption = initializeFromCharacter()
|
||||
}
|
||||
})
|
||||
|
||||
// Combine search results with the selected option so Svelecte can always find it
|
||||
const options = $derived.by(() => {
|
||||
const selected = selectedOption
|
||||
if (selected && !searchResults.find((o) => o.granblueId === selected.granblueId)) {
|
||||
return [selected, ...searchResults]
|
||||
}
|
||||
return searchResults
|
||||
})
|
||||
|
||||
const typeaheadClasses = $derived(
|
||||
['character-typeahead', size, disabled && 'disabled'].filter(Boolean).join(' ')
|
||||
['character-typeahead', size, contained && 'contained', disabled && 'disabled']
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
)
|
||||
|
||||
async function searchCharacters(query: string) {
|
||||
if (query.length < minQuery) {
|
||||
options = []
|
||||
searchResults = []
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -61,7 +101,7 @@
|
|||
locale: 'en'
|
||||
})
|
||||
|
||||
options = response.results.map((result: SearchResult) => ({
|
||||
searchResults = response.results.map((result: SearchResult) => ({
|
||||
id: result.id,
|
||||
label: result.name?.en || result.name?.ja || result.granblueId,
|
||||
granblueId: result.granblueId,
|
||||
|
|
@ -69,7 +109,7 @@
|
|||
}))
|
||||
} catch (error) {
|
||||
console.error('Character search error:', error)
|
||||
options = []
|
||||
searchResults = []
|
||||
} finally {
|
||||
isLoading = false
|
||||
}
|
||||
|
|
@ -90,35 +130,30 @@
|
|||
}
|
||||
|
||||
function handleChange(selected: CharacterOption | null) {
|
||||
const newValue = selected?.id || null
|
||||
const newValue = selected?.granblueId || null
|
||||
value = newValue
|
||||
selectedOption = selected
|
||||
onValueChange?.(newValue)
|
||||
}
|
||||
|
||||
// Compute current value for Svelecte display
|
||||
const displayValue = $derived.by(() => {
|
||||
if (!value) return null
|
||||
const found = options.find((o) => o.id === value)
|
||||
if (found) return found
|
||||
// If we have a value but it's not in options, show placeholder
|
||||
return { id: value, label: value, granblueId: '' } as CharacterOption
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class={typeaheadClasses} oninput={handleInput}>
|
||||
<Svelecte
|
||||
{options}
|
||||
value={displayValue}
|
||||
value={value}
|
||||
labelField="label"
|
||||
valueField="id"
|
||||
valueField="granblueId"
|
||||
searchable={true}
|
||||
{placeholder}
|
||||
{disabled}
|
||||
{clearable}
|
||||
onChange={handleChange}
|
||||
keepSelectionInList={false}
|
||||
/>
|
||||
>
|
||||
{#snippet toggleIcon(dropdownShow)}
|
||||
<Icon name="chevron-down-small" size={14} class="chevron" />
|
||||
{/snippet}
|
||||
</Svelecte>
|
||||
{#if isLoading}
|
||||
<span class="loading-indicator">...</span>
|
||||
{/if}
|
||||
|
|
@ -129,19 +164,21 @@
|
|||
@use '$src/themes/colors' as *;
|
||||
@use '$src/themes/typography' as *;
|
||||
@use '$src/themes/layout' as *;
|
||||
@use '$src/themes/mixins' as *;
|
||||
@use '$src/themes/effects' as *;
|
||||
|
||||
.character-typeahead {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
// Svelecte overrides
|
||||
// Svelecte CSS variable overrides
|
||||
--sv-bg: var(--input-bg);
|
||||
--sv-border-color: transparent;
|
||||
--sv-border: 2px solid var(--sv-border-color);
|
||||
--sv-active-border: 2px solid #{$blue};
|
||||
--sv-border: 1px solid var(--sv-border-color);
|
||||
--sv-active-border: 1px solid #{$blue};
|
||||
--sv-active-outline: none;
|
||||
--sv-border-radius: #{$input-corner};
|
||||
--sv-min-height: calc(#{$unit} * 5.5);
|
||||
--sv-min-height: #{$unit-4x};
|
||||
--sv-placeholder-color: var(--text-tertiary);
|
||||
--sv-color: var(--text-primary);
|
||||
|
||||
|
|
@ -154,27 +191,88 @@
|
|||
--sv-item-active-bg: var(--option-bg-hover);
|
||||
--sv-item-selected-bg: var(--option-bg-hover);
|
||||
|
||||
--sv-icon-color: var(--text-secondary);
|
||||
--sv-icon-color: var(--text-tertiary);
|
||||
--sv-icon-hover-color: var(--text-primary);
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// Target Svelecte control for hover states
|
||||
:global(.sv-control) {
|
||||
padding: calc($unit-half + 1px) $unit calc($unit-half + 1px) $unit-half;
|
||||
@include smooth-transition($duration-quick, background-color, border-color);
|
||||
}
|
||||
|
||||
&:hover:not(.disabled) :global(.sv-control) {
|
||||
background-color: var(--input-bg-hover);
|
||||
}
|
||||
|
||||
// Contained variant
|
||||
&.contained {
|
||||
--sv-bg: var(--select-contained-bg);
|
||||
|
||||
&:hover:not(.disabled) :global(.sv-control) {
|
||||
background-color: var(--select-contained-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
// Style the dropdown
|
||||
:global(.sv_dropdown) {
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
max-height: 40vh;
|
||||
z-index: 102;
|
||||
}
|
||||
|
||||
// Style dropdown items
|
||||
:global(.sv-item) {
|
||||
border-radius: $item-corner-small;
|
||||
padding: $unit $unit-2x;
|
||||
gap: $unit;
|
||||
@include smooth-transition($duration-quick, background-color);
|
||||
}
|
||||
|
||||
// Style the input text
|
||||
:global(.sv-input--text) {
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
|
||||
// Style the indicator buttons
|
||||
:global(.sv-btn-indicator) {
|
||||
color: var(--text-tertiary);
|
||||
@include smooth-transition($duration-quick, color);
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
// Style our custom chevron icon
|
||||
:global(.chevron) {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
// Hide the separator bar between buttons
|
||||
:global(.sv-btn-separator) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Size variants
|
||||
.character-typeahead.small {
|
||||
--sv-min-height: calc(#{$unit} * 3.5);
|
||||
--sv-min-height: #{$unit-3x};
|
||||
--sv-font-size: #{$font-small};
|
||||
}
|
||||
|
||||
.character-typeahead.medium {
|
||||
--sv-min-height: calc(#{$unit} * 5.5);
|
||||
--sv-min-height: #{$unit-4x};
|
||||
--sv-font-size: #{$font-regular};
|
||||
}
|
||||
|
||||
.character-typeahead.large {
|
||||
--sv-min-height: calc(#{$unit} * 6.5);
|
||||
--sv-min-height: calc(#{$unit} * 6);
|
||||
--sv-font-size: #{$font-large};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue