@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.
Files changed (40) hide show
  1. package/dist/BottomSheet/BottomSheet.d.ts +95 -0
  2. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -0
  3. package/dist/BottomSheet/BottomSheet.js +150 -0
  4. package/dist/BottomSheet/index.d.ts +9 -0
  5. package/dist/BottomSheet/index.d.ts.map +1 -0
  6. package/dist/BottomSheet/index.js +10 -0
  7. package/dist/BottomSheet/useSheetGestures.d.ts +69 -0
  8. package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -0
  9. package/dist/BottomSheet/useSheetGestures.js +163 -0
  10. package/dist/RichTextEditor/RichTextEditor.d.ts +67 -6
  11. package/dist/RichTextEditor/RichTextEditor.d.ts.map +1 -1
  12. package/dist/RichTextEditor/RichTextEditor.js +165 -12
  13. package/dist/RichTextEditor/editorTheme.d.ts.map +1 -1
  14. package/dist/RichTextEditor/editorTheme.js +33 -7
  15. package/dist/RichTextEditor/index.d.ts +1 -1
  16. package/dist/RichTextEditor/index.d.ts.map +1 -1
  17. package/dist/index.d.ts +2 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +3 -0
  20. package/dist/lab.css +23 -0
  21. package/package.json +2 -2
  22. package/src/BottomSheet/BottomSheet.doc.mjs +105 -0
  23. package/src/BottomSheet/BottomSheet.test.tsx +233 -0
  24. package/src/BottomSheet/BottomSheet.tsx +226 -0
  25. package/src/BottomSheet/index.ts +11 -0
  26. package/src/BottomSheet/useSheetGestures.doc.mjs +31 -0
  27. package/src/BottomSheet/useSheetGestures.test.ts +160 -0
  28. package/src/BottomSheet/useSheetGestures.ts +226 -0
  29. package/src/ChatReasoning/ChatReasoning.doc.mjs +49 -2
  30. package/src/CodeEditor/CodeEditor.doc.mjs +67 -2
  31. package/src/LogStream/LogStream.doc.mjs +53 -2
  32. package/src/RichTextEditor/RichTextEditor.doc.mjs +19 -1
  33. package/src/RichTextEditor/RichTextEditor.test.tsx +441 -11
  34. package/src/RichTextEditor/RichTextEditor.tsx +289 -31
  35. package/src/RichTextEditor/editorTheme.ts +63 -3
  36. package/src/RichTextEditor/index.ts +2 -0
  37. package/src/Schedule/Schedule.doc.mjs +143 -3
  38. package/src/Stepper/Stepper.doc.mjs +6 -0
  39. package/src/__tests__/labApiContractDrift.test.tsx +57 -0
  40. 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 {RichTextEditor} from './RichTextEditor';
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();