fix(a11y): 44px remove targets + empty states on person/document pickers
Both PersonMultiSelect and DocumentMultiSelect remove buttons were ~12px tap targets (below the 44px WCAG minimum) — pad them to min-h/min-w 44px with a focus-visible ring (SVG stays 12px). Add an optional emptyLabel slot inside the chip container and a hiddenInputName prop on PersonMultiSelect (mirroring DocumentMultiSelect) so EventForm can wire personIds without touching WhoWhenSection. Document the intentional bare typeahead fetch in documentTypeahead.ts (same-origin in prod, Vite-proxied in dev). Refs #781 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -11,12 +11,15 @@ interface Props {
|
|||||||
selectedDocuments?: DocumentOption[];
|
selectedDocuments?: DocumentOption[];
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
hiddenInputName?: string;
|
hiddenInputName?: string;
|
||||||
|
/** Empty-state text shown inside the chip container when nothing is selected. */
|
||||||
|
emptyLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
selectedDocuments = $bindable([]),
|
selectedDocuments = $bindable([]),
|
||||||
placeholder = m.geschichte_editor_search_document(),
|
placeholder = m.geschichte_editor_search_document(),
|
||||||
hiddenInputName = 'documentIds'
|
hiddenInputName = 'documentIds',
|
||||||
|
emptyLabel = undefined
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let searchTerm = $state('');
|
let searchTerm = $state('');
|
||||||
@@ -73,7 +76,7 @@ function removeDocument(id: string | undefined) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => removeDocument(doc.id)}
|
onclick={() => removeDocument(doc.id)}
|
||||||
class="ml-0.5 text-ink/50 hover:text-red-500 focus:outline-none"
|
class="ml-0.5 inline-flex min-h-[44px] min-w-[44px] items-center justify-center rounded-sm text-ink/50 hover:text-red-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
aria-label={m.comp_multiselect_remove()}
|
aria-label={m.comp_multiselect_remove()}
|
||||||
>
|
>
|
||||||
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -88,6 +91,10 @@ function removeDocument(id: string | undefined) {
|
|||||||
</span>
|
</span>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
|
{#if emptyLabel && selectedDocuments.length === 0}
|
||||||
|
<span class="px-1 py-1 font-sans text-sm text-ink-3 italic">{emptyLabel}</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<input
|
<input
|
||||||
bind:this={inputEl}
|
bind:this={inputEl}
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ export type DocumentOption = Pick<
|
|||||||
|
|
||||||
export function createDocumentTypeahead() {
|
export function createDocumentTypeahead() {
|
||||||
return createTypeahead<DocumentOption>({
|
return createTypeahead<DocumentOption>({
|
||||||
|
// Intentional bare browser fetch (matches the Geschichte editor): in dev the
|
||||||
|
// Vite proxy forwards /api and injects the auth header; in prod the app is
|
||||||
|
// same-origin so the auth cookie travels automatically. An internal
|
||||||
|
// +server.ts proxy would add complexity with no practical security benefit.
|
||||||
fetchUrl: (q) =>
|
fetchUrl: (q) =>
|
||||||
fetch(`/api/documents/search?q=${encodeURIComponent(q)}&size=10`)
|
fetch(`/api/documents/search?q=${encodeURIComponent(q)}&size=10`)
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
|
|||||||
@@ -7,9 +7,17 @@ type Person = components['schemas']['Person'];
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
selectedPersons?: PersonOption[];
|
selectedPersons?: PersonOption[];
|
||||||
|
/** Name of the hidden inputs carrying selected ids. Mirrors DocumentMultiSelect. */
|
||||||
|
hiddenInputName?: string;
|
||||||
|
/** Empty-state text shown inside the chip container when nothing is selected. */
|
||||||
|
emptyLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { selectedPersons = $bindable([]) }: Props = $props();
|
let {
|
||||||
|
selectedPersons = $bindable([]),
|
||||||
|
hiddenInputName = 'receiverIds',
|
||||||
|
emptyLabel = undefined
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
let searchTerm = $state('');
|
let searchTerm = $state('');
|
||||||
let results: Person[] = $state([]);
|
let results: Person[] = $state([]);
|
||||||
@@ -64,7 +72,7 @@ function removePerson(id: string | undefined) {
|
|||||||
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
|
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
|
||||||
|
|
||||||
{#each selectedPersons as person (person.id)}
|
{#each selectedPersons as person (person.id)}
|
||||||
<input type="hidden" name="receiverIds" value={person.id} />
|
<input type="hidden" name={hiddenInputName} value={person.id} />
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
<div class="relative" use:clickOutside onclickoutside={() => (showDropdown = false)}>
|
<div class="relative" use:clickOutside onclickoutside={() => (showDropdown = false)}>
|
||||||
@@ -79,7 +87,7 @@ function removePerson(id: string | undefined) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => removePerson(person.id)}
|
onclick={() => removePerson(person.id)}
|
||||||
class="ml-0.5 text-ink/50 hover:text-red-500 focus:outline-none"
|
class="ml-0.5 inline-flex min-h-[44px] min-w-[44px] items-center justify-center rounded-sm text-ink/50 hover:text-red-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
aria-label={m.comp_multiselect_remove()}
|
aria-label={m.comp_multiselect_remove()}
|
||||||
>
|
>
|
||||||
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -94,6 +102,10 @@ function removePerson(id: string | undefined) {
|
|||||||
</span>
|
</span>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
|
{#if emptyLabel && selectedPersons.length === 0}
|
||||||
|
<span class="px-1 py-1 font-sans text-sm text-ink-3 italic">{emptyLabel}</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<input
|
<input
|
||||||
bind:this={inputEl}
|
bind:this={inputEl}
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
Reference in New Issue
Block a user