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 { 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user