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:
@@ -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(<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)', () => {
|
||||
it('rendert genau die Aufgabenzeilen als anklickbare Kaestchen', () => {
|
||||
const SRC =
|
||||
|
||||
@@ -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<HTMLInputElement>(null);
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="checkbox"
|
||||
checked={!!checked}
|
||||
readOnly
|
||||
onChange={() => {
|
||||
if (inputRef.current) onToggle?.(inputRef.current);
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
<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', () => {
|
||||
themeMock.resolvedTheme = 'dark';
|
||||
const { container } = render(
|
||||
|
||||
@@ -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<typeof MDEditor>['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<HTMLDivElement>) => {
|
||||
// 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<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;
|
||||
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<HTMLInputElement>(
|
||||
'input[type="checkbox"]',
|
||||
),
|
||||
container.querySelectorAll<HTMLInputElement>('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<PreviewOptions>(
|
||||
() => ({
|
||||
rehypePlugins: [[rehypeSanitize]],
|
||||
components: {
|
||||
input: (props: { checked?: boolean }) => (
|
||||
<NoteCheckbox
|
||||
checked={props.checked}
|
||||
onToggle={(element) => handleCheckboxToggleRef.current(element)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden" data-color-mode={colorMode}>
|
||||
{/* Header: title + pencil toggle */}
|
||||
@@ -184,7 +205,7 @@ export function NoteWidget({ instanceId, config }: WidgetProps) {
|
||||
<div
|
||||
className="flex-1 overflow-auto"
|
||||
data-testid="note-preview"
|
||||
onClick={handlePreviewClick}
|
||||
ref={previewRef}
|
||||
>
|
||||
<MDEditor
|
||||
data-testid="md-editor"
|
||||
@@ -195,7 +216,7 @@ export function NoteWidget({ instanceId, config }: WidgetProps) {
|
||||
hideToolbar={!isEditing}
|
||||
height="100%"
|
||||
visibleDragbar={false}
|
||||
previewOptions={PREVIEW_OPTIONS}
|
||||
previewOptions={previewOptions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user