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"> <script lang="ts">
import Svelecte from 'svelecte' import Svelecte from 'svelecte'
import Icon from '../Icon.svelte'
import { searchAdapter, type SearchResult } from '$lib/api/adapters/search.adapter' import { searchAdapter, type SearchResult } from '$lib/api/adapters/search.adapter'
interface CharacterOption { interface CharacterOption {
@ -13,10 +14,12 @@
} }
interface Props { interface Props {
/** Selected character ID */ /** Selected character granblue ID (e.g. "3040581000") */
value?: string | null value?: string | null
/** Initial character data for display (when loading existing value) */
initialCharacter?: { id: string; name: string; granblueId: string } | null
/** Callback when value changes */ /** Callback when value changes */
onValueChange?: (characterId: string | null) => void onValueChange?: (granblueId: string | null) => void
/** Placeholder text */ /** Placeholder text */
placeholder?: string placeholder?: string
/** Disabled state */ /** Disabled state */
@ -27,29 +30,66 @@
clearable?: boolean clearable?: boolean
/** Minimum characters before search */ /** Minimum characters before search */
minQuery?: number minQuery?: number
/** Use contained styling (for use inside containers) */
contained?: boolean
} }
let { let {
value = $bindable(null), value = $bindable(null),
initialCharacter = null,
onValueChange, onValueChange,
placeholder = 'Search characters...', placeholder = 'Search characters...',
disabled = false, disabled = false,
size = 'medium', size = 'medium',
clearable = true, clearable = true,
minQuery = 2 minQuery = 2,
contained = false
}: Props = $props() }: 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 isLoading = $state(false)
let searchTimeout: ReturnType<typeof setTimeout> | null = null 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( 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) { async function searchCharacters(query: string) {
if (query.length < minQuery) { if (query.length < minQuery) {
options = [] searchResults = []
return return
} }
@ -61,7 +101,7 @@
locale: 'en' locale: 'en'
}) })
options = response.results.map((result: SearchResult) => ({ searchResults = response.results.map((result: SearchResult) => ({
id: result.id, id: result.id,
label: result.name?.en || result.name?.ja || result.granblueId, label: result.name?.en || result.name?.ja || result.granblueId,
granblueId: result.granblueId, granblueId: result.granblueId,
@ -69,7 +109,7 @@
})) }))
} catch (error) { } catch (error) {
console.error('Character search error:', error) console.error('Character search error:', error)
options = [] searchResults = []
} finally { } finally {
isLoading = false isLoading = false
} }
@ -90,35 +130,30 @@
} }
function handleChange(selected: CharacterOption | null) { function handleChange(selected: CharacterOption | null) {
const newValue = selected?.id || null const newValue = selected?.granblueId || null
value = newValue value = newValue
selectedOption = selected
onValueChange?.(newValue) 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> </script>
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class={typeaheadClasses} oninput={handleInput}> <div class={typeaheadClasses} oninput={handleInput}>
<Svelecte <Svelecte
{options} {options}
value={displayValue} value={value}
labelField="label" labelField="label"
valueField="id" valueField="granblueId"
searchable={true} searchable={true}
{placeholder} {placeholder}
{disabled} {disabled}
{clearable} {clearable}
onChange={handleChange} onChange={handleChange}
keepSelectionInList={false} >
/> {#snippet toggleIcon(dropdownShow)}
<Icon name="chevron-down-small" size={14} class="chevron" />
{/snippet}
</Svelecte>
{#if isLoading} {#if isLoading}
<span class="loading-indicator">...</span> <span class="loading-indicator">...</span>
{/if} {/if}
@ -129,19 +164,21 @@
@use '$src/themes/colors' as *; @use '$src/themes/colors' as *;
@use '$src/themes/typography' as *; @use '$src/themes/typography' as *;
@use '$src/themes/layout' as *; @use '$src/themes/layout' as *;
@use '$src/themes/mixins' as *;
@use '$src/themes/effects' as *;
.character-typeahead { .character-typeahead {
position: relative; position: relative;
width: 100%; width: 100%;
// Svelecte overrides // Svelecte CSS variable overrides
--sv-bg: var(--input-bg); --sv-bg: var(--input-bg);
--sv-border-color: transparent; --sv-border-color: transparent;
--sv-border: 2px solid var(--sv-border-color); --sv-border: 1px solid var(--sv-border-color);
--sv-active-border: 2px solid #{$blue}; --sv-active-border: 1px solid #{$blue};
--sv-active-outline: none; --sv-active-outline: none;
--sv-border-radius: #{$input-corner}; --sv-border-radius: #{$input-corner};
--sv-min-height: calc(#{$unit} * 5.5); --sv-min-height: #{$unit-4x};
--sv-placeholder-color: var(--text-tertiary); --sv-placeholder-color: var(--text-tertiary);
--sv-color: var(--text-primary); --sv-color: var(--text-primary);
@ -154,27 +191,88 @@
--sv-item-active-bg: var(--option-bg-hover); --sv-item-active-bg: var(--option-bg-hover);
--sv-item-selected-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); --sv-icon-hover-color: var(--text-primary);
&.disabled { &.disabled {
opacity: 0.5; opacity: 0.5;
pointer-events: none; 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 { .character-typeahead.small {
--sv-min-height: calc(#{$unit} * 3.5); --sv-min-height: #{$unit-3x};
--sv-font-size: #{$font-small}; --sv-font-size: #{$font-small};
} }
.character-typeahead.medium { .character-typeahead.medium {
--sv-min-height: calc(#{$unit} * 5.5); --sv-min-height: #{$unit-4x};
--sv-font-size: #{$font-regular}; --sv-font-size: #{$font-regular};
} }
.character-typeahead.large { .character-typeahead.large {
--sv-min-height: calc(#{$unit} * 6.5); --sv-min-height: calc(#{$unit} * 6);
--sv-font-size: #{$font-large}; --sv-font-size: #{$font-large};
} }