#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:
parent
8719b0ee1e
commit
58c19abfdd
@ -62,7 +62,10 @@ test('page lifecycle: create via the sidebar, rename, appears in the sidebar', a
|
|||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
await page.goto(`/p/${pond.slug}`);
|
await page.goto(`/p/${pond.slug}`);
|
||||||
await page.getByRole('button', { name: /new page|neue seite/i }).click();
|
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 page.getByRole('button', { name: /create|erstellen/i }).click();
|
||||||
await expect(page.locator('.sidebar__page--active')).toHaveText(title);
|
await expect(page.locator('.sidebar__page--active')).toHaveText(title);
|
||||||
|
|
||||||
|
|||||||
@ -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.goto(`/p/${pond.slug}`);
|
||||||
|
|
||||||
await page.getByRole('button', { name: /new page|neue seite/i }).click();
|
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 page.getByRole('button', { name: /create|erstellen/i }).click();
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/p/${pond.slug}/.+`));
|
await expect(page).toHaveURL(new RegExp(`/p/${pond.slug}/.+`));
|
||||||
|
|||||||
@ -1,21 +1,29 @@
|
|||||||
import { Node } from '@tiptap/core';
|
import { Node } from '@tiptap/core';
|
||||||
import { NodeViewContent, NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';
|
import { NodeViewContent, NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';
|
||||||
import type { NodeViewProps } from '@tiptap/react';
|
import type { NodeViewProps } from '@tiptap/react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { attributesFromSpec, nodeSpec } from '../spec-utils';
|
import { attributesFromSpec, nodeSpec } from '../spec-utils';
|
||||||
|
|
||||||
/** `packages/shared`'s task_item.parseDOM does not read `data-checked` back
|
/** `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
|
* (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 {
|
function TaskItemView({ node, updateAttributes, editor }: NodeViewProps): React.JSX.Element {
|
||||||
|
const { t } = useTranslation('tasks');
|
||||||
const checked = Boolean(node.attrs.checked);
|
const checked = Boolean(node.attrs.checked);
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper as="li" data-type="task_item" data-checked={String(checked)}>
|
<NodeViewWrapper as="div" style={{ display: 'contents' }}>
|
||||||
<label contentEditable={false}>
|
<label contentEditable={false}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={checked}
|
checked={checked}
|
||||||
disabled={!editor.isEditable}
|
disabled={!editor.isEditable}
|
||||||
|
aria-label={node.textContent || t('colTask')}
|
||||||
onChange={(event) => updateAttributes({ checked: event.target.checked })}
|
onChange={(event) => updateAttributes({ checked: event.target.checked })}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@ -34,7 +42,13 @@ export const TaskItem = Node.create({
|
|||||||
parseHTML: () => taskItemSpec.parseDOM,
|
parseHTML: () => taskItemSpec.parseDOM,
|
||||||
renderHTML: ({ node }) => taskItemSpec.toDOM!(node),
|
renderHTML: ({ node }) => taskItemSpec.toDOM!(node),
|
||||||
addNodeView() {
|
addNodeView() {
|
||||||
return ReactNodeViewRenderer(TaskItemView);
|
return ReactNodeViewRenderer(TaskItemView, {
|
||||||
|
as: 'li',
|
||||||
|
attrs: ({ node }) => ({
|
||||||
|
'data-type': 'task_item',
|
||||||
|
'data-checked': String(node.attrs.checked === true),
|
||||||
|
}),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
addKeyboardShortcuts() {
|
addKeyboardShortcuts() {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import {
|
|||||||
type SimulationNodeDatum,
|
type SimulationNodeDatum,
|
||||||
} from 'd3-force';
|
} from 'd3-force';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Self-contained SVG force graph (issue #112). Only `d3-force` is bundled —
|
* Self-contained SVG force graph (issue #112). Only `d3-force` is bundled —
|
||||||
@ -92,6 +93,7 @@ export function ForceGraph({
|
|||||||
onNodeClick?: (id: string) => void;
|
onNodeClick?: (id: string) => void;
|
||||||
settings?: ForceGraphSettings;
|
settings?: ForceGraphSettings;
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
|
const { t } = useTranslation('graph');
|
||||||
const [view, setView] = useState({ k: 1, tx: 0, ty: 0 });
|
const [view, setView] = useState({ k: 1, tx: 0, ty: 0 });
|
||||||
/** Last known positions — read by React renders, written by sim ticks. */
|
/** Last known positions — read by React renders, written by sim ticks. */
|
||||||
const positionsRef = useRef(new Map<string, { x: number; y: number }>());
|
const positionsRef = useRef(new Map<string, { x: number; y: number }>());
|
||||||
@ -272,6 +274,7 @@ export function ForceGraph({
|
|||||||
className="force-graph"
|
className="force-graph"
|
||||||
viewBox={`${-width / 2} ${-height / 2} ${width} ${height}`}
|
viewBox={`${-width / 2} ${-height / 2} ${width} ${height}`}
|
||||||
role="img"
|
role="img"
|
||||||
|
aria-label={t('svgLabel', { nodes: nodes.length, edges: edges.length })}
|
||||||
onWheel={onWheel}
|
onWheel={onWheel}
|
||||||
onPointerDown={onPointerDown}
|
onPointerDown={onPointerDown}
|
||||||
onPointerMove={onPointerMove}
|
onPointerMove={onPointerMove}
|
||||||
|
|||||||
@ -1701,15 +1701,19 @@ ul[data-type='task_list'] li {
|
|||||||
robust form. `:first-of-type`/`:last-of-type` (NOT `:first-child`) because
|
robust form. `:first-of-type`/`:last-of-type` (NOT `:first-child`) because
|
||||||
the `<input>`/`<label>` precedes the paragraph in the read-mode markup. */
|
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 > 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;
|
flex: none;
|
||||||
margin-top: 0.25em;
|
margin-top: 0.25em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Editor/auth NodeView only (the bare-input shape has no label): its line
|
/* 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
|
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). */
|
label up by that much — tuned to Stefan's eye on the live stage (#137).
|
||||||
ul[data-type='task_list'] li > label {
|
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);
|
margin-top: calc(0.25em - 3px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -19,5 +19,6 @@
|
|||||||
"nodeRadius": "Knotengröße",
|
"nodeRadius": "Knotengröße",
|
||||||
"fontSize": "Schriftgröße",
|
"fontSize": "Schriftgröße",
|
||||||
"reset": "Zurücksetzen"
|
"reset": "Zurücksetzen"
|
||||||
}
|
},
|
||||||
|
"svgLabel": "Wissensgraph: {{nodes}} Seiten, {{edges}} Verknüpfungen. Gleiche Verbindungen als Liste: Backlinks unter jeder Seite."
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,5 +19,6 @@
|
|||||||
"nodeRadius": "Node size",
|
"nodeRadius": "Node size",
|
||||||
"fontSize": "Font size",
|
"fontSize": "Font size",
|
||||||
"reset": "Reset"
|
"reset": "Reset"
|
||||||
}
|
},
|
||||||
|
"svgLabel": "Knowledge graph: {{nodes}} pages, {{edges}} links. The same connections are listed as backlinks on each page."
|
||||||
}
|
}
|
||||||
|
|||||||
@ -61,7 +61,9 @@ describe('docToHtml (issue #24)', () => {
|
|||||||
const html = docToHtml(doc);
|
const html = docToHtml(doc);
|
||||||
expect(html).toContain('data-checked="false"');
|
expect(html).toContain('data-checked="false"');
|
||||||
expect(html).toContain('data-checked="true"');
|
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', () => {
|
it('gives wikilinks a relative href so public/static HTML is clickable', () => {
|
||||||
|
|||||||
@ -95,7 +95,9 @@ function renderListItems(node: Node): string {
|
|||||||
if (item.type.name === 'task_item') {
|
if (item.type.name === 'task_item') {
|
||||||
const checked = item.attrs.checked === true;
|
const checked = item.attrs.checked === true;
|
||||||
const id = item.attrs.id ? ` data-task-id="${escapeHtml(item.attrs.id as string)}"` : '';
|
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 {
|
} else {
|
||||||
out += `<li>${renderBlocks(item)}</li>`;
|
out += `<li>${renderBlocks(item)}</li>`;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user