fix CharacterTypeahead initial value display on page load

derive options from initialCharacter directly instead of using
effect-based initialization which had race conditions with SSR
This commit is contained in:
Justin Edmund 2025-12-15 11:16:13 -08:00
parent 80bcbd59db
commit 4d7d2c563e

View file

@ -5,6 +5,7 @@
import Svelecte from 'svelecte' import Svelecte from 'svelecte'
import Icon from '../Icon.svelte' 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'
import { getCharacterImage } from '$lib/utils/images'
interface CharacterOption { interface CharacterOption {
id: string id: string
@ -46,39 +47,42 @@
contained = false contained = false
}: Props = $props() }: Props = $props()
// Initialize selectedOption from initialCharacter if provided let searchResults = $state<CharacterOption[]>([])
function initializeFromCharacter() { // Only used when user selects something NEW (different from initialCharacter)
if (initialCharacter && value) { let userSelectedOption = $state<CharacterOption | null>(null)
return { let isLoading = $state(false)
let searchTimeout: ReturnType<typeof setTimeout> | null = null
// Clear userSelectedOption when value is cleared
$effect(() => {
if (!value) {
userSelectedOption = null
}
})
// Derive options: include initialCharacter or userSelectedOption so Svelecte can find the value
const options = $derived.by(() => {
const results = [...searchResults]
// If user selected something new, prioritize that
const userSelected = userSelectedOption
if (userSelected && !results.find((o) => o.granblueId === userSelected.granblueId)) {
return [userSelected, ...results]
}
// Otherwise, include initialCharacter if we have a value matching it
if (value && initialCharacter && initialCharacter.granblueId === value) {
const initOption: CharacterOption = {
id: initialCharacter.id, id: initialCharacter.id,
label: initialCharacter.name, label: initialCharacter.name,
granblueId: initialCharacter.granblueId granblueId: initialCharacter.granblueId
} }
if (!results.find((o) => o.granblueId === initOption.granblueId)) {
return [initOption, ...results]
}
} }
return null
}
let searchResults = $state<CharacterOption[]>([]) return results
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( const typeaheadClasses = $derived(
@ -132,7 +136,12 @@
function handleChange(selected: CharacterOption | null) { function handleChange(selected: CharacterOption | null) {
const newValue = selected?.granblueId || null const newValue = selected?.granblueId || null
value = newValue value = newValue
selectedOption = selected // Only track as userSelectedOption if it's different from initialCharacter
if (selected && initialCharacter && selected.granblueId === initialCharacter.granblueId) {
userSelectedOption = null // Use initialCharacter instead
} else {
userSelectedOption = selected
}
onValueChange?.(newValue) onValueChange?.(newValue)
} }
</script> </script>
@ -153,6 +162,30 @@
{#snippet toggleIcon(dropdownShow)} {#snippet toggleIcon(dropdownShow)}
<Icon name="chevron-down-small" size={14} class="chevron" /> <Icon name="chevron-down-small" size={14} class="chevron" />
{/snippet} {/snippet}
{#snippet option(opt)}
{@const char = opt as CharacterOption}
<div class="option-item">
<img
src={getCharacterImage(char.granblueId, 'square', '01')}
alt=""
class="option-image"
/>
<span class="option-label">{char.label}</span>
</div>
{/snippet}
{#snippet selection(sel)}
{@const char = (sel as CharacterOption[])[0]}
{#if char}
<div class="selection-item">
<img
src={getCharacterImage(char.granblueId, 'square', '01')}
alt=""
class="selection-image"
/>
<span class="selection-label">{char.label}</span>
</div>
{/if}
{/snippet}
</Svelecte> </Svelecte>
{#if isLoading} {#if isLoading}
<span class="loading-indicator">...</span> <span class="loading-indicator">...</span>
@ -258,6 +291,47 @@
:global(.sv-btn-separator) { :global(.sv-btn-separator) {
display: none; display: none;
} }
// Custom option item styling
.option-item {
display: flex;
align-items: center;
gap: $unit;
}
.option-image {
width: 24px;
height: 24px;
border-radius: $item-corner-small;
flex-shrink: 0;
}
.option-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
// Custom selection item styling (shown in input when value selected)
.selection-item {
display: flex;
align-items: center;
gap: $unit-half;
}
.selection-image {
width: 20px;
height: 20px;
border-radius: $item-corner-small;
flex-shrink: 0;
}
.selection-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
} }
// Size variants // Size variants