feat: Remplacer le champ UUID par une recherche autocomplete pour la liaison parent-élève

L'ajout d'un parent à un élève nécessitait de connaître et coller
manuellement l'UUID du compte parent, ce qui était source d'erreurs
et très peu ergonomique pour les administrateurs.

Le nouveau composant ParentSearchInput offre une recherche par nom/email
avec autocomplétion (debounce 300ms, navigation clavier, ARIA combobox).
Les parents déjà liés sont exclus des résultats, et la sélection se
réinitialise proprement quand l'admin retape dans le champ.
This commit is contained in:
2026-03-12 00:41:41 +01:00
parent 8c70ed1324
commit 8f83dafb7a
6 changed files with 861 additions and 45 deletions

View File

@@ -0,0 +1,336 @@
<script lang="ts">
import { getApiBaseUrl } from '$lib/api/config';
import { authenticatedFetch } from '$lib/auth';
interface ParentResult {
id: string;
firstName: string;
lastName: string;
email: string;
}
let {
onSelect,
onClear,
excludeIds = [],
placeholder = 'Rechercher un parent par nom ou email...'
}: {
onSelect: (parent: ParentResult) => void;
onClear?: () => void;
excludeIds?: string[];
placeholder?: string;
} = $props();
let query = $state('');
let results = $state<ParentResult[]>([]);
let isOpen = $state(false);
let isLoading = $state(false);
let activeIndex = $state(-1);
let selectedLabel = $state('');
let timeoutId: ReturnType<typeof setTimeout> | null = null;
let abortController: AbortController | null = null;
let containerEl: HTMLDivElement | undefined = $state();
let inputEl: HTMLInputElement | undefined = $state();
const listboxId = 'parent-search-listbox';
$effect(() => {
return () => {
if (timeoutId !== null) clearTimeout(timeoutId);
abortController?.abort();
};
});
async function searchParents(searchQuery: string) {
if (searchQuery.length < 2) {
results = [];
isOpen = false;
return;
}
abortController?.abort();
abortController = new AbortController();
try {
isLoading = true;
const apiUrl = getApiBaseUrl();
const params = new URLSearchParams({
role: 'ROLE_PARENT',
search: searchQuery,
itemsPerPage: '10'
});
const response = await authenticatedFetch(
`${apiUrl}/users?${params.toString()}`,
{ signal: abortController.signal }
);
if (!response.ok) return;
const data = await response.json();
const members = data['hydra:member'] ?? data['member'] ?? [];
const mapped = members.map((u: Record<string, string>) => ({
id: u['id'] ?? '',
firstName: u['firstName'] ?? '',
lastName: u['lastName'] ?? '',
email: u['email'] ?? ''
}));
results = excludeIds.length > 0
? mapped.filter((p: ParentResult) => !excludeIds.includes(p.id))
: mapped;
isOpen = true;
activeIndex = -1;
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') return;
results = [];
} finally {
isLoading = false;
}
}
function handleInput(event: Event) {
const target = event.target as HTMLInputElement;
query = target.value;
if (selectedLabel !== '') {
selectedLabel = '';
onClear?.();
}
if (timeoutId !== null) clearTimeout(timeoutId);
timeoutId = globalThis.setTimeout(() => {
searchParents(query);
}, 300);
}
function formatParentLabel(parent: ParentResult): string {
const name = `${parent.firstName} ${parent.lastName}`.trim();
return name || parent.email;
}
function selectParent(parent: ParentResult) {
selectedLabel = formatParentLabel(parent);
query = selectedLabel;
isOpen = false;
results = [];
activeIndex = -1;
onSelect(parent);
}
function handleKeydown(event: KeyboardEvent) {
if (!isOpen || results.length === 0) {
if (event.key === 'Escape') {
isOpen = false;
}
return;
}
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
activeIndex = (activeIndex + 1) % results.length;
break;
case 'ArrowUp':
event.preventDefault();
activeIndex = activeIndex <= 0 ? results.length - 1 : activeIndex - 1;
break;
case 'Enter': {
event.preventDefault();
const selected = results[activeIndex];
if (activeIndex >= 0 && selected) {
selectParent(selected);
}
break;
}
case 'Escape':
isOpen = false;
activeIndex = -1;
break;
}
}
function handleClickOutside(event: MouseEvent) {
const target = event.target;
if (containerEl && target instanceof HTMLElement && !containerEl.contains(target)) {
isOpen = false;
activeIndex = -1;
}
}
function optionId(index: number): string {
return `${listboxId}-option-${index}`;
}
export function clear() {
query = '';
selectedLabel = '';
results = [];
isOpen = false;
activeIndex = -1;
}
</script>
<svelte:document onclick={handleClickOutside} />
<div class="parent-search" bind:this={containerEl}>
<label for="parent-search-input" class="sr-only">{placeholder}</label>
<div class="input-wrapper">
<svg class="search-icon" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clip-rule="evenodd" />
</svg>
<input
bind:this={inputEl}
id="parent-search-input"
type="text"
role="combobox"
autocomplete="off"
aria-expanded={isOpen}
aria-controls={listboxId}
aria-activedescendant={activeIndex >= 0 ? optionId(activeIndex) : undefined}
aria-label={placeholder}
{placeholder}
value={query}
oninput={handleInput}
onkeydown={handleKeydown}
/>
{#if isLoading}
<span class="loading-indicator" aria-hidden="true">...</span>
{/if}
</div>
{#if isOpen}
<ul id={listboxId} role="listbox" class="dropdown" aria-label="Résultats de recherche parents">
{#if results.length === 0}
<li class="no-results" role="presentation">Aucun parent trouvé</li>
{:else}
{#each results as parent, index (parent.id)}
<li
id={optionId(index)}
role="option"
aria-selected={index === activeIndex}
class="option"
class:active={index === activeIndex}
onclick={() => selectParent(parent)}
onmouseenter={() => { activeIndex = index; }}
>
<span class="option-name">{formatParentLabel(parent)}</span>
{#if parent.firstName || parent.lastName}
<span class="option-email">{parent.email}</span>
{/if}
</li>
{/each}
{/if}
</ul>
{/if}
</div>
<style>
.parent-search {
position: relative;
}
.input-wrapper {
position: relative;
display: flex;
align-items: center;
}
.search-icon {
position: absolute;
left: 0.75rem;
width: 1.125rem;
height: 1.125rem;
color: #9ca3af;
pointer-events: none;
}
input {
width: 100%;
padding: 0.625rem 2.5rem 0.625rem 2.25rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 0.875rem;
color: #374151;
background: white;
transition: border-color 0.15s, box-shadow 0.15s;
}
input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
input::placeholder {
color: #9ca3af;
}
.loading-indicator {
position: absolute;
right: 0.75rem;
color: #9ca3af;
font-size: 0.75rem;
}
.dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 0.25rem;
background: white;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
max-height: 15rem;
overflow-y: auto;
z-index: 10;
list-style: none;
padding: 0.25rem 0;
margin: 0.25rem 0 0;
}
.option {
padding: 0.5rem 0.75rem;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 0.125rem;
transition: background 0.1s;
}
.option:hover,
.option.active {
background: #eff6ff;
}
.option-name {
font-size: 0.875rem;
font-weight: 500;
color: #1f2937;
}
.option-email {
font-size: 0.75rem;
color: #6b7280;
}
.no-results {
padding: 0.75rem;
text-align: center;
color: #6b7280;
font-size: 0.875rem;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
</style>