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">
|
<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};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue