#169: Nicht-Text-Inhalte — Task-Checkboxen, Wissensgraph

Task-Checkboxen tragen in beiden Renderpfaden einen Namen: docToHtml
setzt aria-label aus dem Aufgabentext, die Editor-NodeView ebenso. Die
NodeView rendert ihr Host-Element jetzt selbst als li (ReactNodeView-
Renderer as/attrs) — TipTaps zusätzliches div-Host-Element zwischen ul
und li brach die Listensemantik; der Wrapper flacht per display:contents
ab, die #137-Pixel-Abstimmung bleibt erhalten (Selektor auf die neue
Tiefe nachgeführt, Ausrichtung nachgemessen: 1px-Versatz unverändert).
Der Wissensgraph-SVG bekommt ein beschreibendes aria-label inklusive
Verweis auf die Backlinks als gleichwertige Listenform. Der
Bild-Alt-Editor existierte bereits (Bild-Controls bei Auswahl) — kein
Änderungsbedarf. Hinweis: gecachte Seiten übernehmen das
Checkbox-Label wie bei jeder docToHtml-Änderung erst mit dem nächsten
Persist ihrer Inhalte.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGM8jo3hwoV9wsCVGfy8iq
This commit is contained in:
Claude Fable 5 2026-07-21 14:35:16 +02:00
parent 8719b0ee1e
commit 58c19abfdd
9 changed files with 45 additions and 12 deletions

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>`;
}