@astryxdesign/lab 0.1.9-canary.d2d84f8 → 0.1.9-canary.e00a241
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/BottomSheet/BottomSheet.d.ts +95 -0
- package/dist/BottomSheet/BottomSheet.d.ts.map +1 -0
- package/dist/BottomSheet/BottomSheet.js +150 -0
- package/dist/BottomSheet/index.d.ts +9 -0
- package/dist/BottomSheet/index.d.ts.map +1 -0
- package/dist/BottomSheet/index.js +10 -0
- package/dist/BottomSheet/useSheetGestures.d.ts +69 -0
- package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -0
- package/dist/BottomSheet/useSheetGestures.js +163 -0
- package/dist/RichTextEditor/RichTextEditor.d.ts +67 -6
- package/dist/RichTextEditor/RichTextEditor.d.ts.map +1 -1
- package/dist/RichTextEditor/RichTextEditor.js +165 -12
- package/dist/RichTextEditor/editorTheme.d.ts.map +1 -1
- package/dist/RichTextEditor/editorTheme.js +33 -7
- package/dist/RichTextEditor/index.d.ts +1 -1
- package/dist/RichTextEditor/index.d.ts.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/lab.css +23 -0
- package/package.json +2 -2
- package/src/BottomSheet/BottomSheet.doc.mjs +105 -0
- package/src/BottomSheet/BottomSheet.test.tsx +233 -0
- package/src/BottomSheet/BottomSheet.tsx +226 -0
- package/src/BottomSheet/index.ts +11 -0
- package/src/BottomSheet/useSheetGestures.doc.mjs +31 -0
- package/src/BottomSheet/useSheetGestures.test.ts +160 -0
- package/src/BottomSheet/useSheetGestures.ts +226 -0
- package/src/ChatReasoning/ChatReasoning.doc.mjs +49 -2
- package/src/CodeEditor/CodeEditor.doc.mjs +67 -2
- package/src/LogStream/LogStream.doc.mjs +53 -2
- package/src/RichTextEditor/RichTextEditor.doc.mjs +19 -1
- package/src/RichTextEditor/RichTextEditor.test.tsx +441 -11
- package/src/RichTextEditor/RichTextEditor.tsx +289 -31
- package/src/RichTextEditor/editorTheme.ts +63 -3
- package/src/RichTextEditor/index.ts +2 -0
- package/src/Schedule/Schedule.doc.mjs +143 -3
- package/src/Stepper/Stepper.doc.mjs +6 -0
- package/src/__tests__/labApiContractDrift.test.tsx +57 -0
- package/src/index.ts +5 -0
|
@@ -11,16 +11,137 @@
|
|
|
11
11
|
|
|
12
12
|
import {describe, it, expect, vi} from 'vitest';
|
|
13
13
|
import {render, screen, waitFor} from '@testing-library/react';
|
|
14
|
-
import {useEffect} from 'react';
|
|
14
|
+
import {createRef, useEffect} from 'react';
|
|
15
15
|
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
|
|
16
|
-
import type {LexicalEditor} from 'lexical';
|
|
16
|
+
import type {EditorState, LexicalEditor} from 'lexical';
|
|
17
17
|
import {$getRoot, $createParagraphNode, $createTextNode} from 'lexical';
|
|
18
18
|
import {HeadingNode} from '@lexical/rich-text';
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
TRANSFORMERS,
|
|
21
|
+
$convertFromMarkdownString,
|
|
22
|
+
} from '@lexical/markdown';
|
|
23
|
+
import {RichTextEditor, type RichTextEditorRef} from './RichTextEditor';
|
|
20
24
|
import {RichTextView} from './RichTextView';
|
|
21
25
|
|
|
26
|
+
// Small plugin that captures the editor instance so tests can drive real
|
|
27
|
+
// Lexical updates (jsdom does not implement contenteditable editing).
|
|
28
|
+
function CaptureEditor({
|
|
29
|
+
onReady,
|
|
30
|
+
}: {
|
|
31
|
+
onReady: (editor: LexicalEditor) => void;
|
|
32
|
+
}) {
|
|
33
|
+
const [editor] = useLexicalComposerContext();
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
onReady(editor);
|
|
36
|
+
}, [editor, onReady]);
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
22
40
|
// A minimal valid serialized Lexical editor state containing a single
|
|
23
41
|
// paragraph with the text "Hello world".
|
|
42
|
+
// Builds a serialized Lexical state containing a list. `listType` is
|
|
43
|
+
// 'bullet' (renders <ul>) or 'number' (renders <ol>). Used to verify that the
|
|
44
|
+
// editor theme applies visible list markers rather than bare indentation.
|
|
45
|
+
function makeListState(listType: 'bullet' | 'number'): string {
|
|
46
|
+
const tag = listType === 'number' ? 'ol' : 'ul';
|
|
47
|
+
return JSON.stringify({
|
|
48
|
+
root: {
|
|
49
|
+
children: [
|
|
50
|
+
{
|
|
51
|
+
children: [
|
|
52
|
+
{
|
|
53
|
+
children: [
|
|
54
|
+
{
|
|
55
|
+
detail: 0,
|
|
56
|
+
format: 0,
|
|
57
|
+
mode: 'normal',
|
|
58
|
+
style: '',
|
|
59
|
+
text: 'Item one',
|
|
60
|
+
type: 'text',
|
|
61
|
+
version: 1,
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
direction: 'ltr',
|
|
65
|
+
format: '',
|
|
66
|
+
indent: 0,
|
|
67
|
+
type: 'listitem',
|
|
68
|
+
version: 1,
|
|
69
|
+
value: 1,
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
direction: 'ltr',
|
|
73
|
+
format: '',
|
|
74
|
+
indent: 0,
|
|
75
|
+
type: 'list',
|
|
76
|
+
version: 1,
|
|
77
|
+
listType,
|
|
78
|
+
start: 1,
|
|
79
|
+
tag,
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
direction: 'ltr',
|
|
83
|
+
format: '',
|
|
84
|
+
indent: 0,
|
|
85
|
+
type: 'root',
|
|
86
|
+
version: 1,
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Builds a serialized Lexical state with a two-level nested bullet list:
|
|
92
|
+
// • Item one
|
|
93
|
+
// ◦ Nested item
|
|
94
|
+
// Lexical models nesting as a child `list` node inside a `listitem`. Used to
|
|
95
|
+
// verify that depth-2 markers differ from depth-1 (disc → circle) rather than
|
|
96
|
+
// falling back to bare indentation.
|
|
97
|
+
function makeNestedBulletState(): string {
|
|
98
|
+
const textNode = (text: string) => ({
|
|
99
|
+
detail: 0,
|
|
100
|
+
format: 0,
|
|
101
|
+
mode: 'normal',
|
|
102
|
+
style: '',
|
|
103
|
+
text,
|
|
104
|
+
type: 'text',
|
|
105
|
+
version: 1,
|
|
106
|
+
});
|
|
107
|
+
const listItem = (children: unknown[], value: number) => ({
|
|
108
|
+
children,
|
|
109
|
+
direction: 'ltr',
|
|
110
|
+
format: '',
|
|
111
|
+
indent: 0,
|
|
112
|
+
type: 'listitem',
|
|
113
|
+
version: 1,
|
|
114
|
+
value,
|
|
115
|
+
});
|
|
116
|
+
const bulletList = (children: unknown[]) => ({
|
|
117
|
+
children,
|
|
118
|
+
direction: 'ltr',
|
|
119
|
+
format: '',
|
|
120
|
+
indent: 0,
|
|
121
|
+
type: 'list',
|
|
122
|
+
version: 1,
|
|
123
|
+
listType: 'bullet',
|
|
124
|
+
start: 1,
|
|
125
|
+
tag: 'ul',
|
|
126
|
+
});
|
|
127
|
+
return JSON.stringify({
|
|
128
|
+
root: {
|
|
129
|
+
children: [
|
|
130
|
+
bulletList([
|
|
131
|
+
listItem([textNode('Item one')], 1),
|
|
132
|
+
// A nested list lives inside its own list item wrapper.
|
|
133
|
+
listItem([bulletList([listItem([textNode('Nested item')], 1)])], 2),
|
|
134
|
+
]),
|
|
135
|
+
],
|
|
136
|
+
direction: 'ltr',
|
|
137
|
+
format: '',
|
|
138
|
+
indent: 0,
|
|
139
|
+
type: 'root',
|
|
140
|
+
version: 1,
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
24
145
|
const HELLO_STATE = JSON.stringify({
|
|
25
146
|
root: {
|
|
26
147
|
children: [
|
|
@@ -112,19 +233,12 @@ describe('RichTextEditor', () => {
|
|
|
112
233
|
// instance via a small capture plugin and drive a real Lexical update,
|
|
113
234
|
// then assert onChange fires.
|
|
114
235
|
let editorRef: LexicalEditor | undefined;
|
|
115
|
-
function CaptureEditor() {
|
|
116
|
-
const [editor] = useLexicalComposerContext();
|
|
117
|
-
useEffect(() => {
|
|
118
|
-
editorRef = editor;
|
|
119
|
-
}, [editor]);
|
|
120
|
-
return null;
|
|
121
|
-
}
|
|
122
236
|
const onChange = vi.fn();
|
|
123
237
|
render(
|
|
124
238
|
<RichTextEditor
|
|
125
239
|
label="Notes"
|
|
126
240
|
onChange={onChange}
|
|
127
|
-
plugins={<CaptureEditor />}
|
|
241
|
+
plugins={<CaptureEditor onReady={e => (editorRef = e)} />}
|
|
128
242
|
/>,
|
|
129
243
|
);
|
|
130
244
|
await waitFor(() => expect(editorRef).toBeDefined());
|
|
@@ -155,6 +269,280 @@ describe('RichTextEditor', () => {
|
|
|
155
269
|
);
|
|
156
270
|
expect(screen.getByTestId('custom-plugin')).toBeInTheDocument();
|
|
157
271
|
});
|
|
272
|
+
|
|
273
|
+
it('accepts a custom transformers array without throwing', () => {
|
|
274
|
+
// Empty transformer set is a valid custom configuration (disables all
|
|
275
|
+
// markdown shortcuts while keeping the plugin mounted).
|
|
276
|
+
render(<RichTextEditor label="Notes" transformers={[]} />);
|
|
277
|
+
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('renders when markdown shortcuts are disabled', () => {
|
|
281
|
+
render(<RichTextEditor label="Notes" hasMarkdownShortcuts={false} />);
|
|
282
|
+
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('applies the default transformers to convert markdown to a heading', async () => {
|
|
286
|
+
// jsdom can't dispatch the keystrokes that trigger registerMarkdownShortcuts
|
|
287
|
+
// live, so we drive $convertFromMarkdownString with the same TRANSFORMERS
|
|
288
|
+
// the component registers by default. This proves the default transformer
|
|
289
|
+
// set actually produces the expected node structure (a heading), rather
|
|
290
|
+
// than only asserting the editor mounts.
|
|
291
|
+
let editorRef: LexicalEditor | undefined;
|
|
292
|
+
render(
|
|
293
|
+
<RichTextEditor
|
|
294
|
+
label="Notes"
|
|
295
|
+
plugins={<CaptureEditor onReady={e => (editorRef = e)} />}
|
|
296
|
+
/>,
|
|
297
|
+
);
|
|
298
|
+
await waitFor(() => expect(editorRef).toBeDefined());
|
|
299
|
+
editorRef!.update(() => {
|
|
300
|
+
$convertFromMarkdownString('# Title', TRANSFORMERS);
|
|
301
|
+
});
|
|
302
|
+
await waitFor(() => {
|
|
303
|
+
editorRef!.getEditorState().read(() => {
|
|
304
|
+
const first = $getRoot().getFirstChild();
|
|
305
|
+
expect(first?.getType()).toBe('heading');
|
|
306
|
+
expect(first?.getTextContent()).toBe('Title');
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('leaves markdown untransformed when given an empty transformers array', async () => {
|
|
312
|
+
// With no transformers, the same markdown text stays a plain paragraph —
|
|
313
|
+
// demonstrating the transformers prop is the effective source of truth for
|
|
314
|
+
// markdown behaviour, not a fixed internal default.
|
|
315
|
+
let editorRef: LexicalEditor | undefined;
|
|
316
|
+
render(
|
|
317
|
+
<RichTextEditor
|
|
318
|
+
label="Notes"
|
|
319
|
+
transformers={[]}
|
|
320
|
+
plugins={<CaptureEditor onReady={e => (editorRef = e)} />}
|
|
321
|
+
/>,
|
|
322
|
+
);
|
|
323
|
+
await waitFor(() => expect(editorRef).toBeDefined());
|
|
324
|
+
editorRef!.update(() => {
|
|
325
|
+
// Empty transformer set: markdown syntax is preserved verbatim.
|
|
326
|
+
$convertFromMarkdownString('# Title', []);
|
|
327
|
+
});
|
|
328
|
+
await waitFor(() => {
|
|
329
|
+
editorRef!.getEditorState().read(() => {
|
|
330
|
+
const first = $getRoot().getFirstChild();
|
|
331
|
+
expect(first?.getType()).toBe('paragraph');
|
|
332
|
+
expect(first?.getTextContent()).toBe('# Title');
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('exposes an imperative ref handle after mount', () => {
|
|
338
|
+
const ref = createRef<RichTextEditorRef>();
|
|
339
|
+
render(<RichTextEditor ref={ref} label="Notes" />);
|
|
340
|
+
expect(ref.current).not.toBeNull();
|
|
341
|
+
expect(typeof ref.current?.focus).toBe('function');
|
|
342
|
+
expect(typeof ref.current?.clear).toBe('function');
|
|
343
|
+
expect(typeof ref.current?.getEditorState).toBe('function');
|
|
344
|
+
expect(typeof ref.current?.getMarkdown).toBe('function');
|
|
345
|
+
expect(typeof ref.current?.getEditor).toBe('function');
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('ref.focus() runs without throwing and targets the editable surface', () => {
|
|
349
|
+
const ref = createRef<RichTextEditorRef>();
|
|
350
|
+
render(<RichTextEditor ref={ref} label="Notes" />);
|
|
351
|
+
// Lexical dispatches focus via its editor command; jsdom does not always
|
|
352
|
+
// reflect programmatic contenteditable focus onto document.activeElement,
|
|
353
|
+
// so assert the call is wired and the root element is reachable rather
|
|
354
|
+
// than asserting jsdom focus state.
|
|
355
|
+
expect(() => ref.current?.focus()).not.toThrow();
|
|
356
|
+
const root = ref.current?.getEditor().getRootElement();
|
|
357
|
+
expect(root).toBe(screen.getByRole('textbox'));
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('ref.getEditorState() returns the current EditorState', () => {
|
|
361
|
+
const ref = createRef<RichTextEditorRef>();
|
|
362
|
+
render(
|
|
363
|
+
<RichTextEditor ref={ref} label="Notes" defaultValue={HELLO_STATE} />,
|
|
364
|
+
);
|
|
365
|
+
const state = ref.current?.getEditorState();
|
|
366
|
+
expect(state).toBeDefined();
|
|
367
|
+
const text = state?.read(() => $getRoot().getTextContent());
|
|
368
|
+
expect(text).toBe('Hello world');
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('ref.getEditor() returns the underlying LexicalEditor', () => {
|
|
372
|
+
const ref = createRef<RichTextEditorRef>();
|
|
373
|
+
render(<RichTextEditor ref={ref} label="Notes" />);
|
|
374
|
+
const editor = ref.current?.getEditor();
|
|
375
|
+
expect(editor).toBeDefined();
|
|
376
|
+
expect(typeof editor?.update).toBe('function');
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it('ref.getMarkdown() serializes plain text content', () => {
|
|
380
|
+
const ref = createRef<RichTextEditorRef>();
|
|
381
|
+
render(
|
|
382
|
+
<RichTextEditor ref={ref} label="Notes" defaultValue={HELLO_STATE} />,
|
|
383
|
+
);
|
|
384
|
+
expect(ref.current?.getMarkdown()).toBe('Hello world');
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it('ref.getMarkdown() serializes a heading with the default transformers', async () => {
|
|
388
|
+
const ref = createRef<RichTextEditorRef>();
|
|
389
|
+
let editorRef: LexicalEditor | undefined;
|
|
390
|
+
render(
|
|
391
|
+
<RichTextEditor
|
|
392
|
+
ref={ref}
|
|
393
|
+
label="Notes"
|
|
394
|
+
plugins={<CaptureEditor onReady={e => (editorRef = e)} />}
|
|
395
|
+
/>,
|
|
396
|
+
);
|
|
397
|
+
await waitFor(() => expect(editorRef).toBeDefined());
|
|
398
|
+
editorRef!.update(() => {
|
|
399
|
+
$convertFromMarkdownString('# Title', TRANSFORMERS);
|
|
400
|
+
});
|
|
401
|
+
await waitFor(() =>
|
|
402
|
+
expect(ref.current?.getMarkdown()).toBe('# Title'),
|
|
403
|
+
);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it('ref.getMarkdown() honors a custom transformers prop', async () => {
|
|
407
|
+
// With an empty transformers set, a heading node cannot be represented in
|
|
408
|
+
// markdown, so its text is emitted as a plain paragraph (no "# "). This
|
|
409
|
+
// proves getMarkdown() uses the same transformers the editor is
|
|
410
|
+
// configured with, not a hardcoded default.
|
|
411
|
+
const ref = createRef<RichTextEditorRef>();
|
|
412
|
+
let editorRef: LexicalEditor | undefined;
|
|
413
|
+
render(
|
|
414
|
+
<RichTextEditor
|
|
415
|
+
ref={ref}
|
|
416
|
+
label="Notes"
|
|
417
|
+
transformers={[]}
|
|
418
|
+
plugins={<CaptureEditor onReady={e => (editorRef = e)} />}
|
|
419
|
+
/>,
|
|
420
|
+
);
|
|
421
|
+
await waitFor(() => expect(editorRef).toBeDefined());
|
|
422
|
+
// Seed a heading node directly (bypassing shortcuts) using the full set.
|
|
423
|
+
editorRef!.update(() => {
|
|
424
|
+
$convertFromMarkdownString('# Title', TRANSFORMERS);
|
|
425
|
+
});
|
|
426
|
+
await waitFor(() => expect(ref.current?.getMarkdown()).toBe('Title'));
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it('ref.clear() resets the editor to a single empty paragraph', async () => {
|
|
430
|
+
const ref = createRef<RichTextEditorRef>();
|
|
431
|
+
render(
|
|
432
|
+
<RichTextEditor ref={ref} label="Notes" defaultValue={HELLO_STATE} />,
|
|
433
|
+
);
|
|
434
|
+
ref.current?.clear();
|
|
435
|
+
await waitFor(() => {
|
|
436
|
+
const state: EditorState | undefined = ref.current?.getEditorState();
|
|
437
|
+
const text = state?.read(() => $getRoot().getTextContent());
|
|
438
|
+
expect(text).toBe('');
|
|
439
|
+
});
|
|
440
|
+
// The root should hold exactly one (empty) paragraph, not zero children —
|
|
441
|
+
// a bare root breaks selection/typing.
|
|
442
|
+
const state = ref.current?.getEditorState();
|
|
443
|
+
const childCount = state?.read(() => $getRoot().getChildrenSize());
|
|
444
|
+
expect(childCount).toBe(1);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it('ref.clear() fires onChange', async () => {
|
|
448
|
+
const ref = createRef<RichTextEditorRef>();
|
|
449
|
+
const onChange = vi.fn();
|
|
450
|
+
render(
|
|
451
|
+
<RichTextEditor
|
|
452
|
+
ref={ref}
|
|
453
|
+
label="Notes"
|
|
454
|
+
defaultValue={HELLO_STATE}
|
|
455
|
+
onChange={onChange}
|
|
456
|
+
/>,
|
|
457
|
+
);
|
|
458
|
+
// Wait for the editor to mount and the initial state to settle so any
|
|
459
|
+
// seed-time onChange has already fired before we assert on clear().
|
|
460
|
+
await waitFor(() => expect(ref.current).not.toBeNull());
|
|
461
|
+
onChange.mockClear();
|
|
462
|
+
ref.current?.clear();
|
|
463
|
+
await waitFor(() => expect(onChange).toHaveBeenCalled());
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it('ref.clear() and ref.focus() are no-ops when isReadOnly', async () => {
|
|
467
|
+
const ref = createRef<RichTextEditorRef>();
|
|
468
|
+
const onChange = vi.fn();
|
|
469
|
+
render(
|
|
470
|
+
<RichTextEditor
|
|
471
|
+
ref={ref}
|
|
472
|
+
label="Notes"
|
|
473
|
+
defaultValue={HELLO_STATE}
|
|
474
|
+
onChange={onChange}
|
|
475
|
+
isReadOnly
|
|
476
|
+
/>,
|
|
477
|
+
);
|
|
478
|
+
onChange.mockClear();
|
|
479
|
+
ref.current?.focus();
|
|
480
|
+
ref.current?.clear();
|
|
481
|
+
// Give any (unexpected) async update a chance to flush before asserting.
|
|
482
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
483
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
484
|
+
const state = ref.current?.getEditorState();
|
|
485
|
+
const text = state?.read(() => $getRoot().getTextContent());
|
|
486
|
+
expect(text).toBe('Hello world');
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it('ref.clear() is a no-op when isDisabled', async () => {
|
|
490
|
+
const ref = createRef<RichTextEditorRef>();
|
|
491
|
+
const onChange = vi.fn();
|
|
492
|
+
render(
|
|
493
|
+
<RichTextEditor
|
|
494
|
+
ref={ref}
|
|
495
|
+
label="Notes"
|
|
496
|
+
defaultValue={HELLO_STATE}
|
|
497
|
+
onChange={onChange}
|
|
498
|
+
isDisabled
|
|
499
|
+
/>,
|
|
500
|
+
);
|
|
501
|
+
onChange.mockClear();
|
|
502
|
+
ref.current?.clear();
|
|
503
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
504
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
505
|
+
const state = ref.current?.getEditorState();
|
|
506
|
+
const text = state?.read(() => $getRoot().getTextContent());
|
|
507
|
+
expect(text).toBe('Hello world');
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it('does not render a character counter when maxLength is not set', () => {
|
|
511
|
+
render(<RichTextEditor label="Notes" defaultValue={HELLO_STATE} />);
|
|
512
|
+
expect(screen.queryByText(/\d+\/\d+/)).not.toBeInTheDocument();
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it('renders a character counter reflecting the seeded content length', async () => {
|
|
516
|
+
// HELLO_STATE is "Hello world" (11 chars).
|
|
517
|
+
render(
|
|
518
|
+
<RichTextEditor label="Notes" defaultValue={HELLO_STATE} maxLength={100} />,
|
|
519
|
+
);
|
|
520
|
+
await waitFor(() =>
|
|
521
|
+
expect(screen.getByText('11/100')).toBeInTheDocument(),
|
|
522
|
+
);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it('shows an over-limit counter when content exceeds maxLength', async () => {
|
|
526
|
+
// 11 chars with a limit of 5 -> over limit.
|
|
527
|
+
render(
|
|
528
|
+
<RichTextEditor label="Notes" defaultValue={HELLO_STATE} maxLength={5} />,
|
|
529
|
+
);
|
|
530
|
+
await waitFor(() => expect(screen.getByText('11/5')).toBeInTheDocument());
|
|
531
|
+
// aria-live region announces the overflow for screen readers.
|
|
532
|
+
expect(screen.getByText('6 characters over limit')).toBeInTheDocument();
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it('associates the counter with the editor via aria-describedby', async () => {
|
|
536
|
+
render(
|
|
537
|
+
<RichTextEditor label="Notes" defaultValue={HELLO_STATE} maxLength={100} />,
|
|
538
|
+
);
|
|
539
|
+
const counter = await screen.findByText('11/100');
|
|
540
|
+
const describedBy = screen
|
|
541
|
+
.getByRole('textbox')
|
|
542
|
+
.getAttribute('aria-describedby');
|
|
543
|
+
expect(describedBy).toBeTruthy();
|
|
544
|
+
expect(describedBy!.split(' ')).toContain(counter.id);
|
|
545
|
+
});
|
|
158
546
|
});
|
|
159
547
|
|
|
160
548
|
describe('RichTextView', () => {
|
|
@@ -213,6 +601,48 @@ describe('RichTextView', () => {
|
|
|
213
601
|
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|
|
214
602
|
});
|
|
215
603
|
|
|
604
|
+
it('renders bullet lists with a disc marker (not bare indentation)', async () => {
|
|
605
|
+
const {container} = render(<RichTextView value={makeListState('bullet')} />);
|
|
606
|
+
await waitFor(() =>
|
|
607
|
+
expect(screen.getByText('Item one')).toBeInTheDocument(),
|
|
608
|
+
);
|
|
609
|
+
const ul = container.querySelector('ul');
|
|
610
|
+
expect(ul).not.toBeNull();
|
|
611
|
+
// The theme must give the <ul> a marker class so the browser draws a
|
|
612
|
+
// bullet. A bare list with only padding would show indentation only —
|
|
613
|
+
// the exact bug this guards against.
|
|
614
|
+
expect(ul?.className.trim()).not.toBe('');
|
|
615
|
+
expect(getComputedStyle(ul as Element).listStyleType).toBe('disc');
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
it('renders numbered lists with a decimal marker (not bare indentation)', async () => {
|
|
619
|
+
const {container} = render(<RichTextView value={makeListState('number')} />);
|
|
620
|
+
await waitFor(() =>
|
|
621
|
+
expect(screen.getByText('Item one')).toBeInTheDocument(),
|
|
622
|
+
);
|
|
623
|
+
const ol = container.querySelector('ol');
|
|
624
|
+
expect(ol).not.toBeNull();
|
|
625
|
+
expect(ol?.className.trim()).not.toBe('');
|
|
626
|
+
expect(getComputedStyle(ol as Element).listStyleType).toBe('decimal');
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
it('renders nested bullet lists with a distinct depth-2 marker (circle)', async () => {
|
|
630
|
+
const {container} = render(
|
|
631
|
+
<RichTextView value={makeNestedBulletState()} />,
|
|
632
|
+
);
|
|
633
|
+
await waitFor(() =>
|
|
634
|
+
expect(screen.getByText('Nested item')).toBeInTheDocument(),
|
|
635
|
+
);
|
|
636
|
+
const lists = container.querySelectorAll('ul');
|
|
637
|
+
// Outer <ul> plus the nested <ul>.
|
|
638
|
+
expect(lists.length).toBe(2);
|
|
639
|
+
const [outer, nested] = lists;
|
|
640
|
+
expect(getComputedStyle(outer).listStyleType).toBe('disc');
|
|
641
|
+
// Depth-2 must cycle to a different marker so nesting is visually legible,
|
|
642
|
+
// matching the native browser disc → circle progression.
|
|
643
|
+
expect(getComputedStyle(nested).listStyleType).toBe('circle');
|
|
644
|
+
});
|
|
645
|
+
|
|
216
646
|
it('renders nothing (no crash) on malformed JSON with no fallback', () => {
|
|
217
647
|
expect(() => render(<RichTextView value={'garbage'} />)).not.toThrow();
|
|
218
648
|
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|