A11y: Struktur/Kontraste/Formulare (#166-#168), Nicht-Text (#169), Verhalten (#170), CI-Schutz (#171) #174

Merged
stwaidele merged 6 commits from a11y-mittel into main 2026-07-21 16:29:26 +02:00
9 changed files with 45 additions and 12 deletions
Showing only changes of commit 58c19abfdd - Show all commits

View File

@ -62,7 +62,10 @@ test('page lifecycle: create via the sidebar, rename, appears in the sidebar', a
const page = await context.newPage();
await page.goto(`/p/${pond.slug}`);
await page.getByRole('button', { name: /new page|neue seite/i }).click();
await page.locator('.sidebar').getByLabel(/title|titel/i).fill(title);
await page
.locator('.sidebar')
.getByLabel(/title|titel/i)
.fill(title);
await page.getByRole('button', { name: /create|erstellen/i }).click();
await expect(page.locator('.sidebar__page--active')).toHaveText(title);

View File

@ -110,7 +110,10 @@ test('new-page flow: button opens a title prompt and the editor opens on create'
await page.goto(`/p/${pond.slug}`);
await page.getByRole('button', { name: /new page|neue seite/i }).click();
await page.locator('.sidebar').getByLabel(/title|titel/i).fill(title);
await page
.locator('.sidebar')
.getByLabel(/title|titel/i)
.fill(title);
await page.getByRole('button', { name: /create|erstellen/i }).click();
await expect(page).toHaveURL(new RegExp(`/p/${pond.slug}/.+`));

View File

@ -1,21 +1,29 @@
import { Node } from '@tiptap/core';
import { NodeViewContent, NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';
import type { NodeViewProps } from '@tiptap/react';
import { useTranslation } from 'react-i18next';
import { attributesFromSpec, nodeSpec } from '../spec-utils';
/** `packages/shared`'s task_item.parseDOM does not read `data-checked` back
* (issue #24) checked state only ever comes from the node's own attrs, set
* here via the checkbox, never re-parsed from HTML. */
* here via the checkbox, never re-parsed from HTML.
*
* DOM shape (#169): the render host itself is the `<li>` (see the renderer
* options below) so the `<ul>` has only list items as direct children
* TipTap's default extra `<div>` host broke the list semantics for
* screen readers. The wrapper flattens away via display:contents. */
function TaskItemView({ node, updateAttributes, editor }: NodeViewProps): React.JSX.Element {
const { t } = useTranslation('tasks');
const checked = Boolean(node.attrs.checked);
return (
<NodeViewWrapper as="li" data-type="task_item" data-checked={String(checked)}>
<NodeViewWrapper as="div" style={{ display: 'contents' }}>
<label contentEditable={false}>
<input
type="checkbox"
checked={checked}
disabled={!editor.isEditable}
aria-label={node.textContent || t('colTask')}
onChange={(event) => updateAttributes({ checked: event.target.checked })}
/>
</label>
@ -34,7 +42,13 @@ export const TaskItem = Node.create({
parseHTML: () => taskItemSpec.parseDOM,
renderHTML: ({ node }) => taskItemSpec.toDOM!(node),
addNodeView() {
return ReactNodeViewRenderer(TaskItemView);
return ReactNodeViewRenderer(TaskItemView, {
as: 'li',
attrs: ({ node }) => ({
'data-type': 'task_item',
'data-checked': String(node.attrs.checked === true),
}),
});
},
addKeyboardShortcuts() {
return {

View File

@ -14,6 +14,7 @@ import {
type SimulationNodeDatum,
} from 'd3-force';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
/**
* Self-contained SVG force graph (issue #112). Only `d3-force` is bundled
@ -92,6 +93,7 @@ export function ForceGraph({
onNodeClick?: (id: string) => void;
settings?: ForceGraphSettings;
}): React.JSX.Element {
const { t } = useTranslation('graph');
const [view, setView] = useState({ k: 1, tx: 0, ty: 0 });
/** Last known positions — read by React renders, written by sim ticks. */
const positionsRef = useRef(new Map<string, { x: number; y: number }>());
@ -272,6 +274,7 @@ export function ForceGraph({
className="force-graph"
viewBox={`${-width / 2} ${-height / 2} ${width} ${height}`}
role="img"
aria-label={t('svgLabel', { nodes: nodes.length, edges: edges.length })}
onWheel={onWheel}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}

View File

@ -1701,15 +1701,19 @@ ul[data-type='task_list'] li {
robust form. `:first-of-type`/`:last-of-type` (NOT `:first-child`) because
the `<input>`/`<label>` precedes the paragraph in the read-mode markup. */
ul[data-type='task_list'] li > input[type='checkbox'],
ul[data-type='task_list'] li > label {
ul[data-type='task_list'] li > label,
ul[data-type='task_list'] li > [data-node-view-wrapper] > label {
flex: none;
margin-top: 0.25em;
}
/* Editor/auth NodeView only (the bare-input shape has no label): its line
metrics sit the checkbox ~3px lower than in the public view, so pull the
label up by that much tuned to Stefan's eye on the live stage (#137). */
ul[data-type='task_list'] li > label {
label up by that much tuned to Stefan's eye on the live stage (#137).
Since #169 the nodeview's label sits one display:contents wrapper deep
(`li > [data-node-view-wrapper] > label`); the extra selector keeps the
read/public shape (`li > label` never occurs there) untouched. */
ul[data-type='task_list'] li > [data-node-view-wrapper] > label {
margin-top: calc(0.25em - 3px);
}

View File

@ -19,5 +19,6 @@
"nodeRadius": "Knotengröße",
"fontSize": "Schriftgröße",
"reset": "Zurücksetzen"
}
},
"svgLabel": "Wissensgraph: {{nodes}} Seiten, {{edges}} Verknüpfungen. Gleiche Verbindungen als Liste: Backlinks unter jeder Seite."
}

View File

@ -19,5 +19,6 @@
"nodeRadius": "Node size",
"fontSize": "Font size",
"reset": "Reset"
}
},
"svgLabel": "Knowledge graph: {{nodes}} pages, {{edges}} links. The same connections are listed as backlinks on each page."
}

View File

@ -61,7 +61,9 @@ describe('docToHtml (issue #24)', () => {
const html = docToHtml(doc);
expect(html).toContain('data-checked="false"');
expect(html).toContain('data-checked="true"');
expect(html).toContain('<input type="checkbox" disabled checked>');
// The item text names the checkbox (#169, WCAG 4.1.2).
expect(html).toContain('<input type="checkbox" disabled checked aria-label="Done">');
expect(html).toContain('<input type="checkbox" disabled aria-label="Todo">');
});
it('gives wikilinks a relative href so public/static HTML is clickable', () => {

View File

@ -95,7 +95,9 @@ function renderListItems(node: Node): string {
if (item.type.name === 'task_item') {
const checked = item.attrs.checked === true;
const id = item.attrs.id ? ` data-task-id="${escapeHtml(item.attrs.id as string)}"` : '';
out += `<li data-type="task_item" data-checked="${checked}"${id}><input type="checkbox" disabled${checked ? ' checked' : ''}>${renderBlocks(item)}</li>`;
// aria-label: the disabled checkbox needs a name (#169, WCAG 4.1.2);
// the item text doubles as its label in the static rendering.
out += `<li data-type="task_item" data-checked="${checked}"${id}><input type="checkbox" disabled${checked ? ' checked' : ''} aria-label="${escapeHtml(item.textContent)}">${renderBlocks(item)}</li>`;
} else {
out += `<li>${renderBlocks(item)}</li>`;
}