From b601141bcf019845e9347a110e3a330de729925d Mon Sep 17 00:00:00 2001 From: Schalli Date: Mon, 21 Sep 2026 14:47:45 +0200 Subject: [PATCH] fix(quick-260921-jt4): Notiz-Aufgabenkaestchen bedient sich selbst MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J --- .../dashboard/widgets/note-task-list.test.tsx | 42 +++++++++- .../dashboard/widgets/note-task-list.tsx | 27 +++++-- .../dashboard/widgets/note-widget.test.tsx | 52 +++++++++++++ .../dashboard/widgets/note-widget.tsx | 77 ++++++++++++------- 4 files changed, 164 insertions(+), 34 deletions(-) diff --git a/apps/web/src/components/dashboard/widgets/note-task-list.test.tsx b/apps/web/src/components/dashboard/widgets/note-task-list.test.tsx index 5409235..1a419a7 100644 --- a/apps/web/src/components/dashboard/widgets/note-task-list.test.tsx +++ b/apps/web/src/components/dashboard/widgets/note-task-list.test.tsx @@ -1,5 +1,6 @@ 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 rehypeSanitize from 'rehype-sanitize'; 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(); + 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( + , + ); + 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( + , + ); + 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)', () => { it('rendert genau die Aufgabenzeilen als anklickbare Kaestchen', () => { const SRC = diff --git a/apps/web/src/components/dashboard/widgets/note-task-list.tsx b/apps/web/src/components/dashboard/widgets/note-task-list.tsx index dc2d7ee..7073cc2 100644 --- a/apps/web/src/components/dashboard/widgets/note-task-list.tsx +++ b/apps/web/src/components/dashboard/widgets/note-task-list.tsx @@ -25,6 +25,8 @@ * Anwenders (user-files/personal-dashboard/src/app/page.tsx, ~Z. 1399). */ +import { useRef } from 'react'; + /** * Erkennt eine Markdown-Aufgabenzeile (`- [ ] Text`, `* [x] Text`, * `+ [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 * zusaetzlich `node`, `disabled`, `type` durch) — sonst landen fremde * Attribute (insbesondere `node`) im DOM. Kein `disabled`: das Kaestchen - * bleibt anklickbar. `readOnly` unterdrueckt Reacts Warnung - * "checked ohne onChange" — der Klick wird nicht am Kaestchen selbst, - * sondern delegiert am Vorschau-Container verarbeitet. + * bleibt anklickbar. + * + * 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(null); return ( { + if (inputRef.current) onToggle?.(inputRef.current); + }} className="cursor-pointer" /> ); diff --git a/apps/web/src/components/dashboard/widgets/note-widget.test.tsx b/apps/web/src/components/dashboard/widgets/note-widget.test.tsx index 77fb4fc..98ea8a0 100644 --- a/apps/web/src/components/dashboard/widgets/note-widget.test.tsx +++ b/apps/web/src/components/dashboard/widgets/note-widget.test.tsx @@ -1,4 +1,5 @@ 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'; // Mock next-intl @@ -300,6 +301,57 @@ describe('NoteWidget', () => { 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( + , + ); + + 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( + , + ); + + // 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', () => { themeMock.resolvedTheme = 'dark'; const { container } = render( diff --git a/apps/web/src/components/dashboard/widgets/note-widget.tsx b/apps/web/src/components/dashboard/widgets/note-widget.tsx index d8d2edb..23bbee1 100644 --- a/apps/web/src/components/dashboard/widgets/note-widget.tsx +++ b/apps/web/src/components/dashboard/widgets/note-widget.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslations } from 'next-intl'; import { useTheme } from 'next-themes'; 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 — // `components.input` ersetzt das per rehypeSanitize deaktivierte `input` -// durch NoteCheckbox (siehe note-task-list.tsx). Stabile Konstante ausserhalb -// der Komponente, damit react-markdown nicht bei jedem Render neu diffed. -// Typ direkt von MDEditor abgeleitet statt aus dem transitiven Paket -// `@uiw/react-markdown-preview` importiert (das ist keine direkte -// Dependency von apps/web und daher unter pnpms strikter Isolation nicht -// von hier aus aufloesbar). +// durch NoteCheckbox (siehe note-task-list.tsx). Typ direkt von MDEditor +// abgeleitet statt aus dem transitiven Paket `@uiw/react-markdown-preview` +// importiert (das ist keine direkte Dependency von apps/web und daher unter +// pnpms strikter Isolation nicht von hier aus aufloesbar). type PreviewOptions = NonNullable< React.ComponentProps['previewOptions'] >; -const PREVIEW_OPTIONS: PreviewOptions = { - rehypePlugins: [[rehypeSanitize]], - components: { input: NoteCheckbox }, -}; - const NOTE_COMMANDS = [ commands.bold, commands.italic, @@ -113,23 +106,27 @@ export function NoteWidget({ instanceId, config }: WidgetProps) { [scheduleSave, content], ); - // quick-260916-iex: Klick auf ein Aufgaben-Kaestchen in der Ansicht kippt - // genau diese Zeile und speichert sofort (kein Entprellen — ein Klick ist - // eine abgeschlossene Handlung). Ein noch laufender Entprell-Timer aus dem - // Tippen wird verworfen, damit er den ungekippten Text nicht nachschiebt. - const handlePreviewClick = useCallback( - (event: React.MouseEvent) => { + // Vorschau-Behaelter: die Index-Ermittlung (n-tes Kaestchen im Behaelter == + // n-te Aufgabenzeile) bleibt Wort fuer Wort wie zuvor, findet den Behaelter + // jetzt aber ueber ein ref statt ueber event.currentTarget, weil der Klick + // ab jetzt am Kaestchen selbst entsteht (quick-260921-jt4, D-01: das + // Kaestchen bedient sich selbst statt vom Behaelter bedient zu werden). + const previewRef = useRef(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; - const target = event.target; - if (!(target instanceof HTMLInputElement) || target.type !== 'checkbox') { - return; - } + const container = previewRef.current; + if (!container) return; const boxes = Array.from( - event.currentTarget.querySelectorAll( - 'input[type="checkbox"]', - ), + container.querySelectorAll('input[type="checkbox"]'), ); - const index = boxes.indexOf(target); + const index = boxes.indexOf(element); const next = toggleTaskLine(content, index); if (next === content) return; setContent(next); @@ -139,6 +136,30 @@ export function NoteWidget({ instanceId, config }: WidgetProps) { [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( + () => ({ + rehypePlugins: [[rehypeSanitize]], + components: { + input: (props: { checked?: boolean }) => ( + handleCheckboxToggleRef.current(element)} + /> + ), + }, + }), + [], + ); + return (
{/* Header: title + pencil toggle */} @@ -184,7 +205,7 @@ export function NoteWidget({ instanceId, config }: WidgetProps) {