fix(quick-260921-jt4): Notiz-Aufgabenkaestchen bedient sich selbst

- NoteCheckbox traegt jetzt ein echtes onChange und gibt sein eigenes
  DOM-Element an onToggle weiter; readOnly entfaellt (D-01) — war bisher
  nur da, um Reacts Warnung ueber ein gesteuertes Feld ohne onChange zu
  unterdruecken
- Index-Ermittlung bleibt wortgleich (alle Kaestchen im Behaelter
  einsammeln, indexOf auf dem ausloesenden Element), wandert aber vom
  Behaelter-onClick in handleCheckboxToggle, das den Behaelter ueber ein
  ref statt event.currentTarget findet
- previewOptions als useMemo mit leerer Abhaengigkeitsliste, Rueckruf
  ueber ein ref erreicht — identitaetsstabil wie die alte Modulkonstante,
  T-JT4-03: rehypePlugins: [[rehypeSanitize]] unveraendert erhalten
- Tests: echte Tastaturbetaetigung (Leertaste auf fokussiertem
  Kaestchen) UND echter Klick loesen onToggle/PATCH aus; neue Tests
  belegen readOnly/disabled entfallen

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
This commit is contained in:
2026-09-21 14:47:45 +02:00
parent 0c89c13bb2
commit b601141bcf
4 changed files with 164 additions and 34 deletions
@@ -1,5 +1,6 @@
import { render } from '@testing-library/react'; import { render } from '@testing-library/react';
import { describe, expect, it } from 'vitest'; import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import MDEditor from '@uiw/react-md-editor'; import MDEditor from '@uiw/react-md-editor';
import rehypeSanitize from 'rehype-sanitize'; import rehypeSanitize from 'rehype-sanitize';
import { NoteCheckbox, isTaskLine, toggleTaskLine } from './note-task-list'; import { NoteCheckbox, isTaskLine, toggleTaskLine } from './note-task-list';
@@ -75,6 +76,45 @@ describe('note-task-list', () => {
}); });
}); });
describe('NoteCheckbox — bedient sich selbst (quick-260921-jt4, D-01)', () => {
it('ist nicht mehr readOnly und traegt kein disabled', () => {
const { container } = render(<NoteCheckbox checked={true} />);
const box = container.querySelector('input[type="checkbox"]') as HTMLInputElement;
expect(box.readOnly).toBe(false);
expect(box.disabled).toBe(false);
});
it('ruft onToggle mit dem eigenen DOM-Element auf, wenn eine echte Tastaturbetaetigung (Leertaste) das fokussierte Kaestchen ausloest', async () => {
const onToggle = vi.fn();
const user = userEvent.setup({ delay: null });
const { container } = render(
<NoteCheckbox checked={false} onToggle={onToggle} />,
);
const box = container.querySelector('input[type="checkbox"]') as HTMLInputElement;
box.focus();
await user.keyboard(' ');
expect(onToggle).toHaveBeenCalledOnce();
expect(onToggle).toHaveBeenCalledWith(box);
});
it('ruft onToggle auch bei einem echten Mausklick auf', async () => {
const onToggle = vi.fn();
const user = userEvent.setup({ delay: null });
const { container } = render(
<NoteCheckbox checked={false} onToggle={onToggle} />,
);
const box = container.querySelector('input[type="checkbox"]') as HTMLInputElement;
await user.click(box);
expect(onToggle).toHaveBeenCalledOnce();
expect(onToggle).toHaveBeenCalledWith(box);
});
});
describe('echte Vorschau (MDEditor.Markdown + rehypeSanitize + components-Override)', () => { describe('echte Vorschau (MDEditor.Markdown + rehypeSanitize + components-Override)', () => {
it('rendert genau die Aufgabenzeilen als anklickbare Kaestchen', () => { it('rendert genau die Aufgabenzeilen als anklickbare Kaestchen', () => {
const SRC = const SRC =
@@ -25,6 +25,8 @@
* Anwenders (user-files/personal-dashboard/src/app/page.tsx, ~Z. 1399). * Anwenders (user-files/personal-dashboard/src/app/page.tsx, ~Z. 1399).
*/ */
import { useRef } from 'react';
/** /**
* Erkennt eine Markdown-Aufgabenzeile (`- [ ] Text`, `* [x] Text`, * Erkennt eine Markdown-Aufgabenzeile (`- [ ] Text`, `* [x] Text`,
* `+ [X] Text`, `1. [ ] Text`, `1) [ ] Text`, auch eingerueckt). Bewusst * `+ [X] Text`, `1. [ ] Text`, `1) [ ] Text`, auch eingerueckt). Bewusst
@@ -84,16 +86,31 @@ export function toggleTaskLine(content: string, index: number): string {
* Zieht bewusst NUR `checked` aus den Props (react-markdown reicht * Zieht bewusst NUR `checked` aus den Props (react-markdown reicht
* zusaetzlich `node`, `disabled`, `type` durch) — sonst landen fremde * zusaetzlich `node`, `disabled`, `type` durch) — sonst landen fremde
* Attribute (insbesondere `node`) im DOM. Kein `disabled`: das Kaestchen * Attribute (insbesondere `node`) im DOM. Kein `disabled`: das Kaestchen
* bleibt anklickbar. `readOnly` unterdrueckt Reacts Warnung * bleibt anklickbar.
* "checked ohne onChange" — der Klick wird nicht am Kaestchen selbst, *
* sondern delegiert am Vorschau-Container verarbeitet. * quick-260921-jt4 (D-01): das Kaestchen bedient sich jetzt selbst — es
* traegt ein echtes `onChange` und gibt beim Ausloesen sein eigenes
* DOM-Element an `onToggle` weiter, statt sich vom Vorschau-Behaelter per
* `readOnly` + delegiertem Klick bedienen zu lassen. `readOnly` war bisher
* nur da, um Reacts Warnung ueber ein gesteuertes Feld ohne `onChange` zu
* unterdruecken.
*/ */
export function NoteCheckbox({ checked }: { checked?: boolean }) { export function NoteCheckbox({
checked,
onToggle,
}: {
checked?: boolean;
onToggle?: (element: HTMLInputElement) => void;
}) {
const inputRef = useRef<HTMLInputElement>(null);
return ( return (
<input <input
ref={inputRef}
type="checkbox" type="checkbox"
checked={!!checked} checked={!!checked}
readOnly onChange={() => {
if (inputRef.current) onToggle?.(inputRef.current);
}}
className="cursor-pointer" className="cursor-pointer"
/> />
); );
@@ -1,4 +1,5 @@
import { act, fireEvent, render, screen } from '@testing-library/react'; import { act, fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Mock next-intl // Mock next-intl
@@ -300,6 +301,57 @@ describe('NoteWidget', () => {
expect(fetchSpy).toHaveBeenCalledTimes(1); expect(fetchSpy).toHaveBeenCalledTimes(1);
}); });
// quick-260921-jt4 (D-01): das Kaestchen bedient sich selbst — belegt durch
// eine echte Tastaturbetaetigung (Leertaste auf dem fokussierten
// Kaestchen), nicht durch eine Klick-Attrappe.
it('kippt ein Kaestchen bei einer echten Tastaturbetaetigung (Leertaste), nicht nur per Klick', async () => {
// Abhaken speichert sofort ohne Entprellen (siehe handleCheckboxToggle) —
// fuer diesen einen Test sind echte Zeitgeber unproblematisch und
// vermeiden, dass user-events interne Wartezeiten mit den in
// beforeEach gesetzten Fake-Timern kollidieren.
vi.useRealTimers();
const user = userEvent.setup();
render(
<NoteWidget
instanceId="note-1"
config={{ content: '- [ ] Milch\n- [x] Brot\n- [ ] Eier', title: 'Einkauf' }}
isEditMode={false}
/>,
);
const boxes = screen.getAllByRole('checkbox');
boxes[2].focus();
await user.keyboard(' ');
await act(async () => {});
expect(fetchSpy).toHaveBeenCalledTimes(1);
const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
expect(JSON.parse(init.body as string)).toEqual({
config: { content: '- [ ] Milch\n- [x] Brot\n- [x] Eier', title: 'Einkauf' },
});
expect((screen.getAllByRole('checkbox')[2] as HTMLInputElement).checked).toBe(true);
});
it('im Bearbeitungsmodus passiert beim Kaestchen nichts, weil die Vorschau gar nicht gerendert wird', async () => {
render(
<NoteWidget
instanceId="note-1"
config={{ content: '- [ ] Milch', title: 'Einkauf' }}
isEditMode={false}
/>,
);
// Stift an: Bearbeitungsmodus
await act(async () => {
fireEvent.click(screen.getByRole('button'));
});
// Im Editor gibt es kein Kaestchen mehr — die Vorschau (und damit
// NoteCheckbox) wird im Bearbeitungsmodus nicht gerendert.
expect(screen.queryAllByRole('checkbox')).toHaveLength(0);
});
it('setzt data-color-mode auf dark, wenn Tessera auf Dunkel steht', () => { it('setzt data-color-mode auf dark, wenn Tessera auf Dunkel steht', () => {
themeMock.resolvedTheme = 'dark'; themeMock.resolvedTheme = 'dark';
const { container } = render( const { container } = render(
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useTheme } from 'next-themes'; import { useTheme } from 'next-themes';
import MDEditor, { commands } from '@uiw/react-md-editor'; import MDEditor, { commands } from '@uiw/react-md-editor';
@@ -13,21 +13,14 @@ const DEBOUNCE_MS = 1500;
// quick-260916-iex: Aufgabenlisten sind in der Ansicht direkt abhakbar — // quick-260916-iex: Aufgabenlisten sind in der Ansicht direkt abhakbar —
// `components.input` ersetzt das per rehypeSanitize deaktivierte `input` // `components.input` ersetzt das per rehypeSanitize deaktivierte `input`
// durch NoteCheckbox (siehe note-task-list.tsx). Stabile Konstante ausserhalb // durch NoteCheckbox (siehe note-task-list.tsx). Typ direkt von MDEditor
// der Komponente, damit react-markdown nicht bei jedem Render neu diffed. // abgeleitet statt aus dem transitiven Paket `@uiw/react-markdown-preview`
// Typ direkt von MDEditor abgeleitet statt aus dem transitiven Paket // importiert (das ist keine direkte Dependency von apps/web und daher unter
// `@uiw/react-markdown-preview` importiert (das ist keine direkte // pnpms strikter Isolation nicht von hier aus aufloesbar).
// Dependency von apps/web und daher unter pnpms strikter Isolation nicht
// von hier aus aufloesbar).
type PreviewOptions = NonNullable< type PreviewOptions = NonNullable<
React.ComponentProps<typeof MDEditor>['previewOptions'] React.ComponentProps<typeof MDEditor>['previewOptions']
>; >;
const PREVIEW_OPTIONS: PreviewOptions = {
rehypePlugins: [[rehypeSanitize]],
components: { input: NoteCheckbox },
};
const NOTE_COMMANDS = [ const NOTE_COMMANDS = [
commands.bold, commands.bold,
commands.italic, commands.italic,
@@ -113,23 +106,27 @@ export function NoteWidget({ instanceId, config }: WidgetProps) {
[scheduleSave, content], [scheduleSave, content],
); );
// quick-260916-iex: Klick auf ein Aufgaben-Kaestchen in der Ansicht kippt // Vorschau-Behaelter: die Index-Ermittlung (n-tes Kaestchen im Behaelter ==
// genau diese Zeile und speichert sofort (kein Entprellen — ein Klick ist // n-te Aufgabenzeile) bleibt Wort fuer Wort wie zuvor, findet den Behaelter
// eine abgeschlossene Handlung). Ein noch laufender Entprell-Timer aus dem // jetzt aber ueber ein ref statt ueber event.currentTarget, weil der Klick
// Tippen wird verworfen, damit er den ungekippten Text nicht nachschiebt. // ab jetzt am Kaestchen selbst entsteht (quick-260921-jt4, D-01: das
const handlePreviewClick = useCallback( // Kaestchen bedient sich selbst statt vom Behaelter bedient zu werden).
(event: React.MouseEvent<HTMLDivElement>) => { const previewRef = useRef<HTMLDivElement>(null);
// quick-260916-iex: Abhaken eines Aufgaben-Kaestchens kippt genau diese
// Zeile und speichert sofort (kein Entprellen — eine Tastatur-/Maus-
// betaetigung ist eine abgeschlossene Handlung). Ein noch laufender
// Entprell-Timer aus dem Tippen wird verworfen, damit er den ungekippten
// Text nicht nachschiebt.
const handleCheckboxToggle = useCallback(
(element: HTMLInputElement) => {
if (isEditing) return; if (isEditing) return;
const target = event.target; const container = previewRef.current;
if (!(target instanceof HTMLInputElement) || target.type !== 'checkbox') { if (!container) return;
return;
}
const boxes = Array.from( const boxes = Array.from(
event.currentTarget.querySelectorAll<HTMLInputElement>( container.querySelectorAll<HTMLInputElement>('input[type="checkbox"]'),
'input[type="checkbox"]',
),
); );
const index = boxes.indexOf(target); const index = boxes.indexOf(element);
const next = toggleTaskLine(content, index); const next = toggleTaskLine(content, index);
if (next === content) return; if (next === content) return;
setContent(next); setContent(next);
@@ -139,6 +136,30 @@ export function NoteWidget({ instanceId, config }: WidgetProps) {
[isEditing, content, title, save], [isEditing, content, title, save],
); );
// Der Rueckruf, den `previewOptions.components.input` letztlich aufruft,
// haengt via ref an der jeweils aktuellen `handleCheckboxToggle` — das
// haelt `previewOptions` selbst identitaetsstabil (useMemo mit leerer
// Abhaengigkeitsliste), waehrend der Rueckruf trotzdem immer den frischen
// Zustand sieht. Ein pro Tastendruck neu erzeugtes Optionsobjekt liesse
// react-markdown bei jedem Zeichen neu abgleichen.
const handleCheckboxToggleRef = useRef(handleCheckboxToggle);
handleCheckboxToggleRef.current = handleCheckboxToggle;
const previewOptions = useMemo<PreviewOptions>(
() => ({
rehypePlugins: [[rehypeSanitize]],
components: {
input: (props: { checked?: boolean }) => (
<NoteCheckbox
checked={props.checked}
onToggle={(element) => handleCheckboxToggleRef.current(element)}
/>
),
},
}),
[],
);
return ( return (
<div className="flex h-full flex-col overflow-hidden" data-color-mode={colorMode}> <div className="flex h-full flex-col overflow-hidden" data-color-mode={colorMode}>
{/* Header: title + pencil toggle */} {/* Header: title + pencil toggle */}
@@ -184,7 +205,7 @@ export function NoteWidget({ instanceId, config }: WidgetProps) {
<div <div
className="flex-1 overflow-auto" className="flex-1 overflow-auto"
data-testid="note-preview" data-testid="note-preview"
onClick={handlePreviewClick} ref={previewRef}
> >
<MDEditor <MDEditor
data-testid="md-editor" data-testid="md-editor"
@@ -195,7 +216,7 @@ export function NoteWidget({ instanceId, config }: WidgetProps) {
hideToolbar={!isEditing} hideToolbar={!isEditing}
height="100%" height="100%"
visibleDragbar={false} visibleDragbar={false}
previewOptions={PREVIEW_OPTIONS} previewOptions={previewOptions}
/> />
</div> </div>
</div> </div>