refactor: improve type definitions and event handling in CharacterSearchInput component
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user