style CharacterTypeahead to match Select, use granblueId as value

This commit is contained in:
Justin Edmund 2025-12-15 11:03:06 -08:00
parent 3d75c22f3b
commit ba17a17224

View file

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