refactor: improve type definitions and event handling in CharacterSearchInput component

This commit is contained in:
2026-03-14 17:18:58 +01:00
parent 31308ef126
commit 9485d9841c
3 changed files with 90 additions and 68 deletions

View File

@@ -1,16 +1,23 @@
<script lang="ts"> <script lang="ts">
import type { CharacterWithRelations } from '$lib/server/daily-character';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { createEventDispatcher } from 'svelte';
export let characters: any[]; let {
export let selectedCharacters: any[]; characters,
selectedCharacters,
onSelect
}: {
characters: CharacterWithRelations[];
selectedCharacters: CharacterWithRelations[];
onSelect: (character: CharacterWithRelations) => void;
} = $props();
const dispatch = createEventDispatcher(); const state = $state({
searchInput: '',
let searchInput = ''; highlightedIndex: 0,
let highlightedIndex = 0; dropdownContainer: null as HTMLDivElement | null,
let dropdownContainer: HTMLDivElement; searchContainer: null as HTMLDivElement | null
let searchContainer: HTMLDivElement; });
function normalizeSearchText(value: string): string { function normalizeSearchText(value: string): string {
return value return value
@@ -28,52 +35,67 @@
}; };
}); });
$: filteredCharacters = characters.filter(char => { const filteredCharacters = $derived.by(() => {
const searchTerm = normalizeSearchText(searchInput); const searchTerm = normalizeSearchText(state.searchInput);
const nameMatches = normalizeSearchText(char.name).includes(searchTerm);
let epithetsMatches = false; return characters.filter((char) => {
if (char.epithets) { const nameMatches = normalizeSearchText(char.name).includes(searchTerm);
try {
const parsedEpithets = typeof char.epithets === 'string'
? JSON.parse(char.epithets)
: char.epithets;
if (Array.isArray(parsedEpithets)) { let epithetsMatches = false;
epithetsMatches = parsedEpithets.some((epithet: string) => if (char.epithets) {
normalizeSearchText(epithet).includes(searchTerm) try {
); const parsedEpithets =
} else if (typeof parsedEpithets === 'string') { typeof char.epithets === 'string' ? JSON.parse(char.epithets) : char.epithets;
epithetsMatches = normalizeSearchText(parsedEpithets).includes(searchTerm);
if (Array.isArray(parsedEpithets)) {
epithetsMatches = parsedEpithets.some((epithet: string) =>
normalizeSearchText(epithet).includes(searchTerm)
);
} else if (typeof parsedEpithets === 'string') {
epithetsMatches = normalizeSearchText(parsedEpithets).includes(searchTerm);
}
} catch {
epithetsMatches = normalizeSearchText(String(char.epithets)).includes(searchTerm);
} }
} catch {
epithetsMatches = normalizeSearchText(String(char.epithets)).includes(searchTerm);
} }
}
return (nameMatches || epithetsMatches) && return (nameMatches || epithetsMatches) &&
!selectedCharacters.some(selected => selected.id === char.id); !selectedCharacters.some((selected) => selected.id === char.id);
});
}); });
// Reset highlighted index when filtered list changes // Reset highlighted index when filtered list changes.
$: if (filteredCharacters) { $effect(() => {
highlightedIndex = 0; const nextFilteredCharacters = filteredCharacters;
} if (!nextFilteredCharacters) {
return;
// Scroll highlighted item into view
$: if (dropdownContainer && highlightedIndex >= 0) {
const highlightedButton = dropdownContainer.querySelector(
`button:nth-child(${highlightedIndex + 1})`
) as HTMLElement;
if (highlightedButton) {
highlightedButton.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
} }
} state.highlightedIndex = 0;
});
function selectCharacter(character: any) { // Scroll highlighted item into view.
dispatch('select', character); $effect(() => {
searchInput = ''; const nextFilteredCharacters = filteredCharacters;
highlightedIndex = 0;
if (!state.dropdownContainer || state.highlightedIndex < 0) {
return;
}
if (state.highlightedIndex >= nextFilteredCharacters.length) {
return;
}
const highlightedButton = state.dropdownContainer.querySelector(
`button:nth-child(${state.highlightedIndex + 1})`
) as HTMLElement | null;
highlightedButton?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
});
function selectCharacter(character: CharacterWithRelations) {
onSelect(character);
state.searchInput = '';
state.highlightedIndex = 0;
} }
function handleKeydown(event: KeyboardEvent) { function handleKeydown(event: KeyboardEvent) {
@@ -82,16 +104,19 @@
switch (event.key) { switch (event.key) {
case 'ArrowDown': case 'ArrowDown':
event.preventDefault(); event.preventDefault();
highlightedIndex = Math.min(highlightedIndex + 1, filteredCharacters.length - 1); state.highlightedIndex = Math.min(
state.highlightedIndex + 1,
filteredCharacters.length - 1
);
break; break;
case 'ArrowUp': case 'ArrowUp':
event.preventDefault(); event.preventDefault();
highlightedIndex = Math.max(highlightedIndex - 1, 0); state.highlightedIndex = Math.max(state.highlightedIndex - 1, 0);
break; break;
case 'Enter': case 'Enter':
event.preventDefault(); event.preventDefault();
if (filteredCharacters[highlightedIndex]) { if (filteredCharacters[state.highlightedIndex]) {
selectCharacter(filteredCharacters[highlightedIndex]); selectCharacter(filteredCharacters[state.highlightedIndex]);
} }
break; break;
} }
@@ -99,16 +124,15 @@
function submitGuess() { function submitGuess() {
if (filteredCharacters.length === 0) return; if (filteredCharacters.length === 0) return;
const characterToSelect = const characterToSelect = filteredCharacters[state.highlightedIndex] ?? filteredCharacters[0];
filteredCharacters[highlightedIndex] ?? filteredCharacters[0];
if (characterToSelect) { if (characterToSelect) {
selectCharacter(characterToSelect); selectCharacter(characterToSelect);
} }
} }
function handleClickOutside(event: MouseEvent) { function handleClickOutside(event: MouseEvent) {
if (searchContainer && !searchContainer.contains(event.target as Node)) { if (state.searchContainer && !state.searchContainer.contains(event.target as Node)) {
searchInput = ''; state.searchInput = '';
} }
} }
</script> </script>
@@ -116,21 +140,21 @@
<div class="rounded-3xl border border-white/10 bg-white/5 p-6 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur z-10"> <div class="rounded-3xl border border-white/10 bg-white/5 p-6 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur z-10">
<h2 class="text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">Entrer une supposition</h2> <h2 class="text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">Entrer une supposition</h2>
<div class="mt-4 flex flex-col gap-3 sm:flex-row"> <div class="mt-4 flex flex-col gap-3 sm:flex-row">
<div bind:this={searchContainer} class="relative w-full"> <div bind:this={state.searchContainer} class="relative w-full">
<input <input
bind:value={searchInput} bind:value={state.searchInput}
class="w-full rounded-full border border-amber-200/30 bg-slate-900/60 px-5 py-3 text-sm text-slate-100 placeholder:text-slate-400 focus:border-amber-200/70 focus:outline-none" class="w-full rounded-full border border-amber-200/30 bg-slate-900/60 px-5 py-3 text-sm text-slate-100 placeholder:text-slate-400 focus:border-amber-200/70 focus:outline-none"
placeholder="Nom du personnage" placeholder="Nom du personnage"
type="text" type="text"
onkeydown={handleKeydown} onkeydown={handleKeydown}
/> />
{#if searchInput.length > 0 && filteredCharacters.length > 0} {#if state.searchInput.length > 0 && filteredCharacters.length > 0}
<div bind:this={dropdownContainer} class="absolute top-full left-0 right-0 mt-2 max-h-96 overflow-y-auto rounded-2xl border border-amber-200/30 bg-slate-900/90 backdrop-blur z-10"> <div bind:this={state.dropdownContainer} class="absolute top-full left-0 right-0 mt-2 max-h-96 overflow-y-auto rounded-2xl border border-amber-200/30 bg-slate-900/90 backdrop-blur z-10">
{#each filteredCharacters as character, index (character.id)} {#each filteredCharacters as character, index (character.id)}
<button <button
class="w-full px-5 py-4 text-left text-base text-slate-100 transition border-b border-slate-800/50 last:border-b-0 flex items-center gap-4 {index === highlightedIndex ? 'bg-slate-700' : 'hover:bg-slate-800/70'}" class="w-full px-5 py-4 text-left text-base text-slate-100 transition border-b border-slate-800/50 last:border-b-0 flex items-center gap-4 {index === state.highlightedIndex ? 'bg-slate-700' : 'hover:bg-slate-800/70'}"
type="button" type="button"
onmouseenter={() => highlightedIndex = index} onmouseenter={() => (state.highlightedIndex = index)}
onclick={() => selectCharacter(character)} onclick={() => selectCharacter(character)}
> >
{#if character.pictureUrl} {#if character.pictureUrl}
@@ -166,7 +190,7 @@
<button <button
type="button" type="button"
onclick={submitGuess} onclick={submitGuess}
disabled={searchInput.length === 0 || filteredCharacters.length === 0} disabled={state.searchInput.length === 0 || filteredCharacters.length === 0}
class="rounded-full bg-amber-300 px-6 py-3 text-sm font-semibold text-slate-900 transition hover:bg-amber-200 disabled:cursor-not-allowed disabled:opacity-50" class="rounded-full bg-amber-300 px-6 py-3 text-sm font-semibold text-slate-900 transition hover:bg-amber-200 disabled:cursor-not-allowed disabled:opacity-50"
> >
Valider Valider

View File

@@ -147,8 +147,7 @@
$: columnVisibility = data.columnVisibility || {}; $: columnVisibility = data.columnVisibility || {};
$: hasWon = selectedCharacters.some(char => char.id === dailyCharacter.id); $: hasWon = selectedCharacters.some(char => char.id === dailyCharacter.id);
function handleCharacterSelect(event: CustomEvent) { function handleCharacterSelect(character: CharacterWithRelations) {
const character = event.detail;
selectCharacter(character); selectCharacter(character);
} }
@@ -306,7 +305,7 @@
<CharacterSearchInput <CharacterSearchInput
{characters} {characters}
{selectedCharacters} {selectedCharacters}
on:select={handleCharacterSelect} onSelect={handleCharacterSelect}
/> />
{/if} {/if}
</section> </section>

View File

@@ -299,8 +299,7 @@
selectedCharacters = []; selectedCharacters = [];
} }
function handleCharacterSelect(event: CustomEvent) { function handleCharacterSelect(character: CharacterWithRelations) {
const character = event.detail;
selectCharacter(character); selectCharacter(character);
} }
@@ -619,7 +618,7 @@
<CharacterSearchInput <CharacterSearchInput
{characters} {characters}
{selectedCharacters} {selectedCharacters}
on:select={handleCharacterSelect} onSelect={handleCharacterSelect}
/> />
{/if} {/if}
{:else} {:else}