The aria-live region previously lived inside {#if items.length === 0} so
it remounted whenever items transitioned between empty and populated —
VoiceOver in particular swallows announcements from freshly-mounted live
regions, and the "N persons found" announcement was missing entirely on
the populated branch. Move the live region above the conditional so the
element persists, and announce a localized "1 person found" / "N persons
found" count on the populated branch. The visible empty-state <p> stays
as a visual cue (no aria-live). Leonie #3 on PR #629 round 3.
Adds person_mention_results_count_singular / _plural in de/en/es.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
271 lines
9.7 KiB
Svelte
271 lines
9.7 KiB
Svelte
<script lang="ts">
|
|
import type { components } from '$lib/generated/api';
|
|
// eslint-disable-next-line boundaries/dependencies -- mention dropdown needs person date formatting; extract to shared if it becomes reusable
|
|
import { formatLifeDateRange } from '$lib/person/personLifeDates';
|
|
import { untrack } from 'svelte';
|
|
import { m } from '$lib/paraglide/messages.js';
|
|
// Layered defence cap on the @mention search query length (CWE-400
|
|
// amplification). The <input maxlength> attribute below caps direct
|
|
// user edits, but the editor-mirror path (Tiptap contenteditable -> mirror
|
|
// $effect -> searchQuery) is not covered by `maxlength` since the
|
|
// contenteditable has no such enforcement. Clipping at the mirror keeps
|
|
// the cap honest from both paths. Tracked server-side separately.
|
|
// Nora #1 on PR #629. Hoisted to mentionConstants.ts so the host editor
|
|
// (PersonMentionEditor) can clip the inserted displayName to the same cap
|
|
// — see Felix #3 on PR #629.
|
|
import { MAX_QUERY_LENGTH } from './mentionConstants';
|
|
|
|
type Person = components['schemas']['Person'];
|
|
|
|
// The dropdown receives a single reactive state object. PersonMentionEditor
|
|
// mutates fields on this object (model.items = ..., etc.) and Svelte's $state
|
|
// proxy reactivity propagates the change here. This is the supported way to
|
|
// update an imperatively-mounted Svelte 5 component — `mount` does not return
|
|
// settable prop accessors.
|
|
type DropdownState = {
|
|
items: Person[];
|
|
command: (item: Person) => void;
|
|
clientRect: (() => DOMRect | null) | null;
|
|
};
|
|
|
|
let {
|
|
model,
|
|
editorQuery = '',
|
|
onSearch = () => {}
|
|
}: {
|
|
model: DropdownState;
|
|
/** Text typed after `@` in the host editor. Mirrors into the search input
|
|
* until the user takes manual ownership by typing into the input itself. */
|
|
editorQuery?: string;
|
|
onSearch?: (query: string) => void;
|
|
} = $props();
|
|
|
|
let searchQuery = $state(untrack(() => editorQuery.slice(0, MAX_QUERY_LENGTH)));
|
|
let userHasEdited = $state(false);
|
|
|
|
// Mirror the editor's typed text until the user takes ownership.
|
|
//
|
|
// Why `$state + $effect` (not `$derived`): `searchQuery` is also written by
|
|
// `bind:value` on the <input> below, so it needs to be a mutable `$state`.
|
|
// A `$derived` would be read-only and would clobber direct user edits on
|
|
// every editor keystroke. The `userHasEdited` latch pins ownership once the
|
|
// user types into the input. Felix #1 on PR #629.
|
|
$effect(() => {
|
|
if (!userHasEdited) {
|
|
searchQuery = editorQuery.slice(0, MAX_QUERY_LENGTH);
|
|
}
|
|
});
|
|
|
|
// Fire onSearch whenever the effective query changes — covers both the
|
|
// editor mirror and direct input edits. This is the only place onSearch
|
|
// fires; when the dropdown is unmounted, the effect is disposed and no
|
|
// further fetches occur.
|
|
$effect(() => {
|
|
onSearch(searchQuery);
|
|
});
|
|
|
|
// highlightedIndex must be both writable (keyboard handler mutates it) and
|
|
// reset when `items` changes (so it never points past the end of a new list).
|
|
// A pure $derived is read-only and cannot serve both needs, so $state + $effect
|
|
// is the correct pattern here. The autofixer suggestion to use $derived does not
|
|
// apply: the mutation in onKeyDown is not a derivation.
|
|
let highlightedIndex = $state(0);
|
|
|
|
$effect(() => {
|
|
// Read model.items to subscribe; reset index whenever the list is replaced.
|
|
void model.items;
|
|
highlightedIndex = 0;
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Positioning — flip strategy: open upward when there is not enough room
|
|
// below the cursor to show the dropdown without clipping the viewport.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type Position = {
|
|
top: string | null;
|
|
bottom: string | null;
|
|
left: string;
|
|
};
|
|
|
|
const DROPDOWN_CLEARANCE_PX = 200;
|
|
|
|
const position = $derived.by<Position>(() => {
|
|
const cr = model.clientRect;
|
|
if (!cr) return { top: '0px', bottom: null, left: '0px' };
|
|
const rect = cr();
|
|
if (!rect) return { top: '0px', bottom: null, left: '0px' };
|
|
|
|
// Some editors report a caret DOMRect with zero width; fall back to rect.x.
|
|
const left = `${rect.width === 0 ? rect.x : rect.left}px`;
|
|
|
|
if (window.innerHeight - rect.bottom < DROPDOWN_CLEARANCE_PX) {
|
|
// Not enough space below — anchor bottom of dropdown to top of caret.
|
|
return {
|
|
top: null,
|
|
bottom: `${window.innerHeight - rect.top}px`,
|
|
left
|
|
};
|
|
}
|
|
|
|
return { top: `${rect.bottom}px`, bottom: null, left };
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Keyboard handler — exported so Tiptap's render() can forward events.
|
|
// Returns true when the event is consumed (prevents the editor's default).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function onKeyDown(event: KeyboardEvent): boolean {
|
|
const len = model.items.length;
|
|
if (event.key === 'ArrowDown') {
|
|
highlightedIndex = (highlightedIndex + 1) % Math.max(len, 1);
|
|
return true;
|
|
}
|
|
|
|
if (event.key === 'ArrowUp') {
|
|
highlightedIndex = (highlightedIndex - 1 + Math.max(len, 1)) % Math.max(len, 1);
|
|
return true;
|
|
}
|
|
|
|
if (event.key === 'Enter') {
|
|
const selected = model.items[highlightedIndex];
|
|
if (selected) {
|
|
model.command(selected);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Escape: let the suggestion plugin handle it (return false = not consumed).
|
|
return false;
|
|
}
|
|
|
|
function selectItem(item: Person) {
|
|
model.command(item);
|
|
}
|
|
</script>
|
|
|
|
<!--
|
|
Mounted imperatively to document.body by the Tiptap suggestion plugin.
|
|
Positioned absolutely relative to the viewport using inline styles derived
|
|
from the Tiptap clientRect() callback.
|
|
|
|
SECURITY: This component receives pre-filtered Person[] items from the
|
|
parent — it does NOT fetch. The parent's fetch relies on the SvelteKit Vite
|
|
proxy injecting the auth_token cookie as the Authorization header.
|
|
Mounted in transcribe mode behind WRITE_ALL — never reachable to
|
|
unauthenticated users.
|
|
-->
|
|
<div
|
|
class="fixed z-50 w-72 max-w-[calc(100vw-1rem)] overflow-hidden rounded-sm border border-line bg-surface shadow-lg"
|
|
role="listbox"
|
|
aria-label={m.person_mention_btn_label()}
|
|
style:top={position.top}
|
|
style:bottom={position.bottom}
|
|
style:left={position.left}
|
|
>
|
|
<div class="border-b border-line px-3 py-2">
|
|
<label class="sr-only" for="mention-search">{m.person_mention_search_label()}</label>
|
|
<div class="flex items-center gap-2">
|
|
<svg
|
|
aria-hidden="true"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
class="h-5 w-5 shrink-0 text-ink-2"
|
|
>
|
|
<circle cx="11" cy="11" r="7" />
|
|
<path d="m20 20-3.5-3.5" stroke-linecap="round" />
|
|
</svg>
|
|
<input
|
|
id="mention-search"
|
|
type="search"
|
|
data-test-search-input
|
|
maxlength={MAX_QUERY_LENGTH}
|
|
class="min-h-[44px] w-full bg-transparent font-sans text-base text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-inset"
|
|
placeholder={m.person_mention_search_prompt()}
|
|
bind:value={searchQuery}
|
|
oninput={() => {
|
|
userHasEdited = true;
|
|
}}
|
|
onmousedown={(e) => e.stopPropagation()}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<!--
|
|
Persistent aria-live region — lives ABOVE the conditional branches so the
|
|
element never unmounts when items transition between empty and populated.
|
|
VoiceOver in particular swallows announcements from freshly-mounted live
|
|
regions, and the previous (conditional-inside) markup silently dropped
|
|
the "N persons found" announcement when results populated. Leonie #3 on
|
|
PR #629 round 3.
|
|
-->
|
|
<p class="sr-only" aria-live="polite">
|
|
{#if model.items.length === 0}
|
|
{searchQuery.trim() === ''
|
|
? m.person_mention_search_prompt()
|
|
: m.person_mention_popup_empty()}
|
|
{:else if model.items.length === 1}
|
|
{m.person_mention_results_count_singular()}
|
|
{:else}
|
|
{m.person_mention_results_count_plural({ count: model.items.length })}
|
|
{/if}
|
|
</p>
|
|
{#if model.items.length === 0}
|
|
<!--
|
|
Visible empty-state copy — visual-only. The persistent sr-only <p>
|
|
above is the announcer. Leonie #3 on PR #629 round 3.
|
|
-->
|
|
<p class="px-3 py-2.5 font-sans text-sm text-ink-3">
|
|
{searchQuery.trim() === ''
|
|
? m.person_mention_search_prompt()
|
|
: m.person_mention_popup_empty()}
|
|
</p>
|
|
<!--
|
|
Empty-state escape hatch — without it the transcriber has to close
|
|
the dropdown, navigate to /persons/new, come back, and re-type the
|
|
query. target=_blank keeps the document and editor state intact;
|
|
rel=noopener prevents reverse-tabnabbing on the new tab. Leonie #5621.
|
|
-->
|
|
<a
|
|
href="/persons/new"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="flex min-h-[44px] items-center gap-2 border-t border-line px-3 py-2.5 font-sans text-sm font-medium text-brand-navy hover:bg-canvas focus:bg-canvas focus:outline-none"
|
|
onmousedown={(e) => e.preventDefault()}
|
|
>
|
|
{m.person_mention_create_new()}
|
|
<span aria-hidden="true">→</span>
|
|
</a>
|
|
{:else}
|
|
{#each model.items as person, i (person.id)}
|
|
<div
|
|
class={[
|
|
'flex min-h-[44px] cursor-pointer flex-col gap-1 px-3 py-2.5 text-left hover:bg-canvas',
|
|
// brand-mint ring (≈2.5:1 on white) fails WCAG 1.4.11 Non-Text
|
|
// Contrast for a meaningful keyboard-highlight indicator. brand-navy
|
|
// gives ~14.5:1 against the bg-brand-mint/20 row. Leonie #5621.
|
|
i === highlightedIndex && 'bg-brand-mint/20 ring-2 ring-brand-navy ring-inset'
|
|
]}
|
|
role="option"
|
|
aria-selected={i === highlightedIndex}
|
|
data-test-person-id={person.id}
|
|
tabindex="-1"
|
|
onmousedown={(e) => {
|
|
// Prevent blur on the editor before the selection fires.
|
|
e.preventDefault();
|
|
selectItem(person);
|
|
}}
|
|
>
|
|
<span class="truncate font-serif text-base text-ink">{person.displayName}</span>
|
|
{#if formatLifeDateRange(person.birthYear, person.deathYear)}
|
|
<span class="truncate font-sans text-xs text-ink-3">
|
|
{formatLifeDateRange(person.birthYear, person.deathYear)}
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
{/if}
|
|
</div>
|