dorfteich/apps/web/src/editor/Toolbar.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

331 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { Editor } from '@tiptap/core';
import { useEditorState } from '@tiptap/react';
import { useRef } from 'react';
import { useTranslation } from 'react-i18next';
import type { PluginBlockOption, SectionStyleOption } from '../plugins/use-pond-plugins';
import { LinkMenu } from './LinkMenu';
import { PluginBlockMenu } from './PluginBlockMenu';
import { SectionStyleMenu } from './SectionStyleMenu';
interface ToolbarProps {
editor: Editor;
/** Section styles offered by the pond's active plugins (issue #75); the
* section group is omitted while empty. */
sectionStyles?: SectionStyleOption[];
/** Block types offered by the pond's active code plugins (issue #76); the
* insert group is omitted while empty. */
pluginBlocks?: PluginBlockOption[];
}
function ToolbarButton({
label,
active,
disabled,
onClick,
children,
}: {
label: string;
active?: boolean;
disabled?: boolean;
onClick: () => void;
children: React.ReactNode;
}): React.JSX.Element {
return (
<button
type="button"
className={active ? 'toolbar-button toolbar-button--active' : 'toolbar-button'}
title={label}
aria-label={label}
aria-pressed={active ?? false}
disabled={disabled}
// Toolbar clicks must not steal focus/selection from the editor.
onMouseDown={(event) => event.preventDefault()}
onClick={onClick}
>
{children}
</button>
);
}
/** File-picker path into the same async upload as paste/drop (issue #28) —
* a hidden native file input triggered by a normal toolbar button, since
* that is the only way to open the OS file dialog from a click handler. */
function ImageInsertButton({
editor,
label,
}: {
editor: Editor;
label: string;
}): React.JSX.Element {
const inputRef = useRef<HTMLInputElement>(null);
return (
<>
<ToolbarButton label={label} onClick={() => inputRef.current?.click()}>
🖼
</ToolbarButton>
<input
ref={inputRef}
type="file"
accept="image/*"
hidden
onChange={(event) => {
const file = event.target.files?.[0];
if (file) editor.chain().focus().insertImageFile(file).run();
event.target.value = '';
}}
/>
</>
);
}
/** Arrow-key navigation between toolbar controls (#164, role="toolbar"
* convention). Native selects keep their own arrow-key handling. */
function onToolbarArrowKey(event: React.KeyboardEvent<HTMLDivElement>): void {
if (event.key !== 'ArrowRight' && event.key !== 'ArrowLeft') return;
const target = event.target as HTMLElement;
if (target.tagName === 'SELECT' || target.tagName === 'INPUT') return;
const controls = [
...event.currentTarget.querySelectorAll<HTMLElement>('button:not([disabled]), select'),
];
const index = controls.indexOf(target);
if (index === -1) return;
event.preventDefault();
const step = event.key === 'ArrowRight' ? 1 : -1;
controls[(index + step + controls.length) % controls.length]?.focus();
}
/** Keyboard-accessible toolbar for the page editor (issue #25). Table row/
* column controls stay visible but disabled outside a table, so the
* toolbar's layout and tab order never shift while typing. */
export function Toolbar({
editor,
sectionStyles = [],
pluginBlocks = [],
}: ToolbarProps): React.JSX.Element {
const { t } = useTranslation('editor');
const state = useEditorState({
editor,
selector: ({ editor: e }) => ({
paragraph: e.isActive('paragraph'),
heading1: e.isActive('heading', { level: 1 }),
heading2: e.isActive('heading', { level: 2 }),
heading3: e.isActive('heading', { level: 3 }),
heading4: e.isActive('heading', { level: 4 }),
bold: e.isActive('bold'),
italic: e.isActive('italic'),
code: e.isActive('code'),
strikethrough: e.isActive('strikethrough'),
bulletList: e.isActive('bullet_list'),
orderedList: e.isActive('ordered_list'),
taskList: e.isActive('task_list'),
blockquote: e.isActive('blockquote'),
codeBlock: e.isActive('code_block'),
canAddRow: e.can().addRowAfter(),
canDeleteRow: e.can().deleteRow(),
canAddColumn: e.can().addColumnAfter(),
canDeleteColumn: e.can().deleteColumn(),
canDeleteTable: e.can().deleteTable(),
canToggleHeaderRow: e.can().toggleHeaderRow(),
canUndo: e.can().undo(),
canRedo: e.can().redo(),
}),
});
return (
<div
className="editor-toolbar"
role="toolbar"
aria-label={t('toolbar.label')}
onKeyDown={onToolbarArrowKey}
>
<div className="editor-toolbar__group">
<ToolbarButton
label={t('toolbar.paragraph')}
active={state.paragraph}
onClick={() => editor.chain().focus().setParagraph().run()}
>
P
</ToolbarButton>
{([1, 2, 3, 4] as const).map((level) => (
<ToolbarButton
key={level}
label={t(`toolbar.heading${level}` as const)}
active={state[`heading${level}` as const]}
onClick={() => editor.chain().focus().toggleHeading(level).run()}
>
H{level}
</ToolbarButton>
))}
</div>
<div className="editor-toolbar__group">
<ToolbarButton
label={t('toolbar.bold')}
active={state.bold}
onClick={() => editor.chain().focus().toggleBold().run()}
>
<strong>B</strong>
</ToolbarButton>
<ToolbarButton
label={t('toolbar.italic')}
active={state.italic}
onClick={() => editor.chain().focus().toggleItalic().run()}
>
<em>I</em>
</ToolbarButton>
<ToolbarButton
label={t('toolbar.code')}
active={state.code}
onClick={() => editor.chain().focus().toggleCode().run()}
>
{'</>'}
</ToolbarButton>
<ToolbarButton
label={t('toolbar.strikethrough')}
active={state.strikethrough}
onClick={() => editor.chain().focus().toggleStrikethrough().run()}
>
<s>S</s>
</ToolbarButton>
<LinkMenu editor={editor} />
</div>
<div className="editor-toolbar__group">
<ToolbarButton
label={t('toolbar.bulletList')}
active={state.bulletList}
onClick={() => editor.chain().focus().toggleBulletList().run()}
>
</ToolbarButton>
<ToolbarButton
label={t('toolbar.orderedList')}
active={state.orderedList}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
>
1.
</ToolbarButton>
<ToolbarButton
label={t('toolbar.taskList')}
active={state.taskList}
onClick={() => editor.chain().focus().toggleTaskList().run()}
>
</ToolbarButton>
</div>
<div className="editor-toolbar__group">
<ToolbarButton
label={t('toolbar.blockquote')}
active={state.blockquote}
onClick={() => editor.chain().focus().toggleBlockquote().run()}
>
</ToolbarButton>
<ToolbarButton
label={t('toolbar.codeBlock')}
active={state.codeBlock}
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
>
{'{ }'}
</ToolbarButton>
<ToolbarButton
label={t('toolbar.horizontalRule')}
onClick={() => editor.chain().focus().setHorizontalRule().run()}
>
</ToolbarButton>
<ImageInsertButton editor={editor} label={t('toolbar.image')} />
</div>
<SectionStyleMenu editor={editor} options={sectionStyles} />
<PluginBlockMenu editor={editor} options={pluginBlocks} />
<div className="editor-toolbar__group">
<ToolbarButton
label={t('toolbar.table.insert')}
onClick={() => editor.chain().focus().insertTable().run()}
>
</ToolbarButton>
<ToolbarButton
label={t('toolbar.table.addColumnBefore')}
disabled={!state.canAddColumn}
onClick={() => editor.chain().focus().addColumnBefore().run()}
>
</ToolbarButton>
<ToolbarButton
label={t('toolbar.table.addColumnAfter')}
disabled={!state.canAddColumn}
onClick={() => editor.chain().focus().addColumnAfter().run()}
>
</ToolbarButton>
<ToolbarButton
label={t('toolbar.table.deleteColumn')}
disabled={!state.canDeleteColumn}
onClick={() => editor.chain().focus().deleteColumn().run()}
>
</ToolbarButton>
<ToolbarButton
label={t('toolbar.table.addRowBefore')}
disabled={!state.canAddRow}
onClick={() => editor.chain().focus().addRowBefore().run()}
>
</ToolbarButton>
<ToolbarButton
label={t('toolbar.table.addRowAfter')}
disabled={!state.canAddRow}
onClick={() => editor.chain().focus().addRowAfter().run()}
>
</ToolbarButton>
<ToolbarButton
label={t('toolbar.table.deleteRow')}
disabled={!state.canDeleteRow}
onClick={() => editor.chain().focus().deleteRow().run()}
>
</ToolbarButton>
<ToolbarButton
label={t('toolbar.table.toggleHeaderRow')}
disabled={!state.canToggleHeaderRow}
onClick={() => editor.chain().focus().toggleHeaderRow().run()}
>
</ToolbarButton>
<ToolbarButton
label={t('toolbar.table.deleteTable')}
disabled={!state.canDeleteTable}
onClick={() => editor.chain().focus().deleteTable().run()}
>
</ToolbarButton>
</div>
<div className="editor-toolbar__group">
<ToolbarButton
label={t('toolbar.undo')}
disabled={!state.canUndo}
onClick={() => editor.chain().focus().undo().run()}
>
</ToolbarButton>
<ToolbarButton
label={t('toolbar.redo')}
disabled={!state.canRedo}
onClick={() => editor.chain().focus().redo().run()}
>
</ToolbarButton>
</div>
</div>
);
}