@ -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);
|
||||
|
||||
|
||||
@ -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}/.+`));
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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."
|
||||
}
|
||||
|
||||
@ -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."
|
||||
}
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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>`;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user