dorfteich/apps/web/src/editor/WikilinkAutocomplete.tsx
Claude Fable 5 057992faaf #164: ARIA-Semantik — Editorfläche, Autocomplete-Listboxen, Sidebar, Toolbar
Die Editorfläche bekommt einen lokalisierten zugänglichen Namen und ist
im Lesemodus role=document statt eines unbenannten Textfelds (setOptions
im selben Layout-Effekt wie setEditable). Eingeklappte Sidebar zusätzlich
inert (aria-hidden allein ließ fokussierbare Kinder im Tab-Weg). Die
li-Zwischenknoten der Listboxen (Wikilink-/Mention-Autocomplete,
Suchergebnisse) sind role=presentation, damit listbox→option wieder eine
gültige Eltern-Kind-Beziehung ist. Toolbar: Pfeiltasten-Navigation über
die Controls (native Selects behalten ihre Pfeiltasten) und ein
sprechendes Toolbar-Label statt des Absatz-Buttons-Labels.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGM8jo3hwoV9wsCVGfy8iq
2026-07-21 14:04:36 +02:00

181 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { slugify } from '@dorfteich/shared';
import type { Editor } from '@tiptap/react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useWikilinks } from './wikilink-context';
/** An open `[[` context: the query typed so far and where its `[[` began.
* `embed` is true when it was opened as `![[` — a page embed (issue #135);
* `bare` when it was `$[[` — the frameless variant (issue #146). */
interface QueryState {
query: string;
from: number;
embed: boolean;
bare: boolean;
coords: { left: number; bottom: number };
}
/** A suggestion row: an existing page, or a create-phantom hint for a miss. */
type Suggestion =
{ kind: 'page'; slug: string; label: string } | { kind: 'create'; slug: string; label: string };
/** Detects a `[[query` (link), `![[query` (embed, #135) or `$[[query` (bare
* embed, #146) immediately before a collapsed cursor (issue #46). */
function detectQuery(
editor: Editor,
): { query: string; from: number; embed: boolean; bare: boolean } | null {
const { selection } = editor.state;
if (!selection.empty) return null;
const $from = selection.$from;
if (!$from.parent.isTextblock) return null;
const start = Math.max(0, $from.parentOffset - 200);
const before = $from.parent.textBetween(start, $from.parentOffset, undefined, '');
const match = /([!$]?)\[\[([^[\]\n]*)$/.exec(before);
if (!match) return null;
const embed = match[1] === '!' || match[1] === '$';
const bare = match[1] === '$';
const query = match[2] ?? '';
// `[[` is 2 chars; an embed's leading `!`/`$` is one more to swallow.
return { query, from: selection.from - query.length - 2 - (embed ? 1 : 0), embed, bare };
}
/**
* Autocomplete popup for `[[` wikilinks (issue #46). Typing `[[` opens a list
* of the current pond's pages filtered by the query (with a create-new-page
* hint for misses); Enter/click inserts the wikilink node and removes the typed
* `[[query`. Keyboard navigation (↑/↓/Enter/Esc) is intercepted in the capture
* phase so ProseMirror does not act on those keys while the popup is open.
*/
export function WikilinkAutocomplete({ editor }: { editor: Editor }): React.JSX.Element | null {
const { t } = useTranslation('editor');
const { targets } = useWikilinks();
const [state, setState] = useState<QueryState | null>(null);
const [selected, setSelected] = useState(0);
const suggestions = useMemo<Suggestion[]>(() => {
if (!state) return [];
const q = state.query.trim().toLowerCase();
const pages: Suggestion[] = targets
.filter((page) => page.title.toLowerCase().includes(q) || page.slug.toLowerCase().includes(q))
.slice(0, 8)
.map((page) => ({ kind: 'page', slug: page.slug, label: page.title }));
const createSlug = slugify(state.query);
if (createSlug && !targets.some((page) => page.slug === createSlug)) {
pages.push({ kind: 'create', slug: createSlug, label: state.query.trim() });
}
return pages;
}, [state, targets]);
// Latest state/suggestions/selection, so the once-attached keydown handler
// (below) and click handler both act on current values, not a stale closure.
const live = useRef({ state, suggestions, selected });
live.current = { state, suggestions, selected };
function close(): void {
setState(null);
setSelected(0);
}
function choose(item: Suggestion | undefined): void {
const current = live.current.state;
if (!item || !current) return;
const range = { from: current.from, to: editor.state.selection.from };
if (current.embed) {
// A page embed is a block node (#135) — replace the typed `![[query` with
// the transclusion block; ProseMirror lifts it out of the paragraph.
// `$[[` opens the frameless variant (#146).
editor
.chain()
.focus()
.insertContentAt(range, {
type: 'transclusion',
attrs: { targetSlug: item.slug, displayText: null, bare: current.bare },
})
.run();
} else {
editor
.chain()
.focus()
.insertContentAt(range, [
{ type: 'wikilink', attrs: { targetSlug: item.slug, displayText: null } },
{ type: 'text', text: ' ' },
])
.run();
}
close();
}
// Recompute the open query on every doc/selection change.
useEffect(() => {
const update = (): void => {
const found = detectQuery(editor);
if (!found) {
setState(null);
return;
}
const coords = editor.view.coordsAtPos(editor.state.selection.from);
setState({ ...found, coords: { left: coords.left, bottom: coords.bottom } });
setSelected(0);
};
editor.on('transaction', update);
return () => {
editor.off('transaction', update);
};
}, [editor]);
// Keyboard navigation, intercepted before ProseMirror (capture phase).
useEffect(() => {
const dom = editor.view.dom;
const onKeyDown = (event: KeyboardEvent): void => {
const { state: s, suggestions: items, selected: sel } = live.current;
if (!s || items.length === 0) return;
if (event.key === 'ArrowDown') {
event.preventDefault();
setSelected((i) => (i + 1) % items.length);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
setSelected((i) => (i - 1 + items.length) % items.length);
} else if (event.key === 'Enter') {
event.preventDefault();
choose(items[sel]);
} else if (event.key === 'Escape') {
event.preventDefault();
close();
}
};
dom.addEventListener('keydown', onKeyDown, true);
return () => dom.removeEventListener('keydown', onKeyDown, true);
}, [editor]);
if (!state || suggestions.length === 0) return null;
return (
<ul
className="wikilink-suggest"
role="listbox"
aria-label={t('wikilink.autocompleteLabel')}
style={{ position: 'fixed', left: state.coords.left, top: state.coords.bottom + 4 }}
>
{suggestions.map((item, index) => (
<li key={`${item.kind}:${item.slug}`} role="presentation">
<button
type="button"
role="option"
aria-selected={index === selected}
className={
index === selected ? 'wikilink-suggest__item is-active' : 'wikilink-suggest__item'
}
onMouseDown={(event) => {
event.preventDefault();
choose(item);
}}
>
{item.kind === 'create' ? t('wikilink.createHint', { title: item.label }) : item.label}
</button>
</li>
))}
</ul>
);
}