@astryxdesign/core 0.7.4-canary.f32572e → 0.7.4

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 (59) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/Chat/ChatMessageList.d.ts.map +1 -1
  3. package/dist/Chat/ChatMessageList.js +8 -1
  4. package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  5. package/dist/CheckboxInput/CheckboxInput.js +6 -0
  6. package/dist/CommandPalette/CommandPalette.d.ts.map +1 -1
  7. package/dist/CommandPalette/CommandPalette.js +6 -11
  8. package/dist/CommandPalette/CommandPaletteInput.d.ts.map +1 -1
  9. package/dist/CommandPalette/CommandPaletteInput.js +7 -1
  10. package/dist/CommandPalette/CommandPaletteItem.d.ts +1 -0
  11. package/dist/CommandPalette/CommandPaletteItem.d.ts.map +1 -1
  12. package/dist/CommandPalette/CommandPaletteItem.js +5 -1
  13. package/dist/DateInput/DateInput.d.ts +6 -4
  14. package/dist/DateInput/DateInput.d.ts.map +1 -1
  15. package/dist/DateInput/DateInput.js +5 -1
  16. package/dist/DateInput/NativeDateField.d.ts.map +1 -1
  17. package/dist/DateInput/NativeDateField.js +5 -1
  18. package/dist/DateInput/TouchDateField.d.ts.map +1 -1
  19. package/dist/DateInput/TouchDateField.js +5 -1
  20. package/dist/DateInput/tokens.stylex.d.ts.map +1 -1
  21. package/dist/DateInput/tokens.stylex.js +3 -1
  22. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
  23. package/dist/DropdownMenu/DropdownMenuSubMenu.js +7 -1
  24. package/dist/FileInput/FileInput.d.ts.map +1 -1
  25. package/dist/FileInput/FileInput.js +16 -2
  26. package/dist/Thumbnail/Thumbnail.d.ts.map +1 -1
  27. package/dist/Thumbnail/Thumbnail.js +9 -2
  28. package/package.json +4 -4
  29. package/src/BottomSheet/BottomSheet.spec.md +1 -0
  30. package/src/BottomSheet/BottomSheetEdgeTint.test.tsx +25 -2
  31. package/src/BottomSheet/BottomSheetSwitcher.spec.md +7 -6
  32. package/src/BottomSheet/BottomSheetSwitcher.test.tsx +221 -1
  33. package/src/BottomSheet/__tests__/BottomSheetNativeModal.a11y.browser.spec.ts +461 -0
  34. package/src/Chat/ChatMessageList.test.tsx +6 -2
  35. package/src/Chat/ChatMessageList.tsx +7 -1
  36. package/src/Chat/__tests__/ChatMessageList.a11y.chromium.spec.ts +20 -12
  37. package/src/CheckboxInput/CheckboxInput.tsx +6 -0
  38. package/src/CommandPalette/CommandPalette.doc.mjs +4 -1
  39. package/src/CommandPalette/CommandPalette.test.tsx +4 -2
  40. package/src/CommandPalette/CommandPalette.tsx +6 -11
  41. package/src/CommandPalette/CommandPaletteInput.test.tsx +3 -1
  42. package/src/CommandPalette/CommandPaletteInput.tsx +10 -1
  43. package/src/CommandPalette/CommandPaletteItem.doc.mjs +17 -5
  44. package/src/CommandPalette/CommandPaletteItem.test.tsx +65 -12
  45. package/src/CommandPalette/CommandPaletteItem.tsx +6 -1
  46. package/src/CommandPalette/__tests__/CommandPaletteItemAudit.a11y.chromium.spec.ts +250 -20
  47. package/src/DateInput/DateInput.doc.mjs +23 -5
  48. package/src/DateInput/DateInput.spec.md +52 -45
  49. package/src/DateInput/DateInput.tsx +13 -7
  50. package/src/DateInput/NativeDateField.tsx +6 -2
  51. package/src/DateInput/TouchDateField.tsx +6 -2
  52. package/src/DateInput/__tests__/DateInputAudit.a11y.chromium.spec.ts +576 -0
  53. package/src/DateInput/tokens.stylex.ts +3 -1
  54. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +10 -1
  55. package/src/FileInput/FileInput.tsx +18 -2
  56. package/src/Overlay/Overlay.spec.md +304 -0
  57. package/src/Thumbnail/Thumbnail.test.tsx +6 -1
  58. package/src/Thumbnail/Thumbnail.tsx +12 -3
  59. package/src/__tests__/BusyOwnerAnnouncement.test.tsx +324 -2
@@ -8,7 +8,13 @@
8
8
  * owning control announces each busy start once through the shared channel
9
9
  */
10
10
 
11
- import {StrictMode, useEffect, useRef, type ReactElement} from 'react';
11
+ import {
12
+ StrictMode,
13
+ useEffect,
14
+ useRef,
15
+ useState,
16
+ type ReactElement,
17
+ } from 'react';
12
18
  import {
13
19
  afterAll,
14
20
  afterEach,
@@ -29,6 +35,16 @@ import {DateInput} from '../DateInput';
29
35
  import {DateRangeInput} from '../DateRangeInput';
30
36
  import {DateTimeInput} from '../DateTimeInput';
31
37
  import {TimeInput} from '../TimeInput';
38
+ import {CheckboxInput} from '../CheckboxInput';
39
+ import {FileInput} from '../FileInput';
40
+ import {CommandPalette} from '../CommandPalette';
41
+ import {Thumbnail} from '../Thumbnail';
42
+ import {
43
+ DropdownMenu,
44
+ DropdownMenuItem,
45
+ DropdownMenuSubMenu,
46
+ } from '../DropdownMenu';
47
+ import {ChatMessageList} from '../Chat';
32
48
  import {BaseTypeahead, Typeahead} from '../Typeahead';
33
49
  import type {SearchableItem, SearchSource} from '../Typeahead';
34
50
  import type {ISOTimeString} from '../utils';
@@ -131,6 +147,38 @@ const OWNERS: ReadonlyArray<
131
147
  ),
132
148
  ] as const,
133
149
  ]),
150
+ [
151
+ 'CheckboxInput',
152
+ isLoading => (
153
+ <CheckboxInput
154
+ label="Subscribe"
155
+ value={false}
156
+ onChange={() => {}}
157
+ isLoading={isLoading}
158
+ />
159
+ ),
160
+ ],
161
+ [
162
+ 'Thumbnail (uploading)',
163
+ isLoading => (
164
+ <Thumbnail src="/local.jpg" alt="Upload" isLoading={isLoading} />
165
+ ),
166
+ ],
167
+ ...(['dropzone', 'input'] as const).map(
168
+ mode =>
169
+ [
170
+ `FileInput (${mode})`,
171
+ (isLoading: boolean) => (
172
+ <FileInput
173
+ label="Attachment"
174
+ mode={mode}
175
+ value={null}
176
+ onChange={() => {}}
177
+ isLoading={isLoading}
178
+ />
179
+ ),
180
+ ] as const,
181
+ ),
134
182
  [
135
183
  'DateRangeInput',
136
184
  isLoading => (
@@ -242,7 +290,8 @@ describe.each(OWNERS)('%s busy state (spec:AST-086)', (_name, renderOwner) => {
242
290
  const {container} = render(renderOwner(true));
243
291
  const spinner = container.querySelector('.astryx-spinner');
244
292
  expect(spinner).not.toBeNull();
245
- expect(spinner).toHaveAttribute('aria-hidden', 'true');
293
+ // Hidden itself, or inside a decorative hidden indicator (CheckboxInput).
294
+ expect(spinner!.closest('[aria-hidden="true"]')).not.toBeNull();
246
295
  expect(screen.queryByRole('status', {name: 'Loading'})).toBeNull();
247
296
  });
248
297
 
@@ -468,3 +517,276 @@ describe.each(SHEET_OWNERS)(
468
517
  });
469
518
  },
470
519
  );
520
+
521
+ describe('DropdownMenuSubMenu loading state (spec:AST-086 FR8)', () => {
522
+ function Menu({isLoading}: {isLoading: boolean}) {
523
+ return (
524
+ <DropdownMenu button={{label: 'Actions'}}>
525
+ <DropdownMenuSubMenu label="Move to" hasSpinner={isLoading}>
526
+ <DropdownMenuItem label="Inbox" onClick={() => {}} />
527
+ </DropdownMenuSubMenu>
528
+ </DropdownMenu>
529
+ );
530
+ }
531
+
532
+ it('announces nothing while its menu is closed', async () => {
533
+ render(strict(<Menu isLoading />));
534
+ await nextFrame();
535
+ expect(busyAnnouncements()).toEqual([]);
536
+ });
537
+
538
+ it('announces the busy start when its menu opens while loading', async () => {
539
+ render(strict(<Menu isLoading />));
540
+ fireEvent.click(screen.getByRole('button', {name: /Actions/}));
541
+ await nextFrame();
542
+ expect(busyAnnouncements()).toEqual([['Loading', 'polite']]);
543
+ });
544
+
545
+ it('keeps the trigger Spinner silent and announces each busy start once', async () => {
546
+ const {rerender} = render(strict(<Menu isLoading={false} />));
547
+ fireEvent.click(screen.getByRole('button', {name: /Actions/}));
548
+ await nextFrame();
549
+ expect(busyAnnouncements()).toEqual([]);
550
+ rerender(strict(<Menu isLoading />));
551
+ await nextFrame();
552
+ const spinner = document.querySelector('.astryx-spinner');
553
+ expect(spinner).toHaveAttribute('aria-hidden', 'true');
554
+ expect(busyAnnouncements()).toEqual([['Loading', 'polite']]);
555
+ rerender(strict(<Menu isLoading={false} />));
556
+ rerender(strict(<Menu isLoading />));
557
+ await nextFrame();
558
+ expect(busyAnnouncements()).toEqual([
559
+ ['Loading', 'polite'],
560
+ ['Loading', 'polite'],
561
+ ]);
562
+ });
563
+ });
564
+
565
+ describe('ChatMessageList top loader (spec:AST-086 FR4, FR8)', () => {
566
+ it('keeps the loader inside the log silent and announces loading once through the channel', async () => {
567
+ let notify!: IntersectionObserverCallback;
568
+ vi.stubGlobal(
569
+ 'IntersectionObserver',
570
+ class {
571
+ constructor(callback: IntersectionObserverCallback) {
572
+ notify = callback;
573
+ }
574
+ observe() {}
575
+ disconnect() {}
576
+ },
577
+ );
578
+ render(
579
+ strict(
580
+ <ChatMessageList
581
+ scrollToTopAction={async () => new Promise<void>(() => {})}>
582
+ <div>Earlier message</div>
583
+ </ChatMessageList>,
584
+ ),
585
+ );
586
+ act(() => {
587
+ notify(
588
+ [{isIntersecting: true} as IntersectionObserverEntry],
589
+ {} as IntersectionObserver,
590
+ );
591
+ });
592
+ await nextFrame();
593
+ expect(document.querySelector('.astryx-spinner')).toHaveAttribute(
594
+ 'aria-hidden',
595
+ 'true',
596
+ );
597
+ expect(busyAnnouncements()).toEqual([['Loading', 'polite']]);
598
+ });
599
+ });
600
+
601
+ describe('FileInput busy start from a pick or a clear (spec:AST-086 FR6, FR8)', () => {
602
+ const file = new File(['x'], 'report.pdf', {type: 'application/pdf'});
603
+
604
+ it('keeps a pick under a pending changeAction to its one "selected" message', async () => {
605
+ const {container} = render(
606
+ strict(
607
+ <FileInput
608
+ label="Attachment"
609
+ value={null}
610
+ onChange={() => {}}
611
+ changeAction={async () => new Promise<void>(() => {})}
612
+ />,
613
+ ),
614
+ );
615
+ act(() => {
616
+ fireEvent.change(
617
+ container.querySelector('input[type="file"]') as HTMLInputElement,
618
+ {target: {files: [file]}},
619
+ );
620
+ });
621
+ await nextFrame();
622
+ expect(container.querySelector('.astryx-spinner')).toHaveAttribute(
623
+ 'aria-hidden',
624
+ 'true',
625
+ );
626
+ expect(announce).toHaveBeenCalledWith('1 file selected: report.pdf');
627
+ expect(busyAnnouncements()).toEqual([]);
628
+ });
629
+
630
+ it('keeps a pick whose onChange starts an upload (isLoading) to its one "selected" message', async () => {
631
+ function Uploader() {
632
+ const [value, setValue] = useState<File | null>(null);
633
+ const [isLoading, setIsLoading] = useState(false);
634
+ return (
635
+ <FileInput
636
+ label="Attachment"
637
+ value={value}
638
+ isLoading={isLoading}
639
+ onChange={next => {
640
+ setValue(next as File | null);
641
+ setIsLoading(true);
642
+ }}
643
+ />
644
+ );
645
+ }
646
+ const {container} = render(strict(<Uploader />));
647
+ act(() => {
648
+ fireEvent.change(
649
+ container.querySelector('input[type="file"]') as HTMLInputElement,
650
+ {target: {files: [file]}},
651
+ );
652
+ });
653
+ await nextFrame();
654
+ await nextFrame();
655
+ expect(announce).toHaveBeenCalledWith('1 file selected: report.pdf');
656
+ expect(busyAnnouncements()).toEqual([]);
657
+ });
658
+
659
+ it('announces nothing more when an earlier pick settles while a later one is still pending', async () => {
660
+ const settles: (() => void)[] = [];
661
+ const {container} = render(
662
+ strict(
663
+ <FileInput
664
+ label="Attachment"
665
+ value={null}
666
+ onChange={() => {}}
667
+ changeAction={async () =>
668
+ new Promise<void>(resolve => {
669
+ settles.push(resolve);
670
+ })
671
+ }
672
+ />,
673
+ ),
674
+ );
675
+ const input = container.querySelector(
676
+ 'input[type="file"]',
677
+ ) as HTMLInputElement;
678
+ act(() => {
679
+ fireEvent.change(input, {target: {files: [file]}});
680
+ });
681
+ await nextFrame();
682
+ act(() => {
683
+ fireEvent.change(input, {
684
+ target: {files: [new File(['y'], 'notes.txt', {type: 'text/plain'})]},
685
+ });
686
+ });
687
+ await nextFrame();
688
+ await act(async () => {
689
+ settles[0]();
690
+ });
691
+ await nextFrame();
692
+ await nextFrame();
693
+ expect(busyAnnouncements()).toEqual([]);
694
+ });
695
+
696
+ it('announces the busy start of a clear under a pending changeAction', async () => {
697
+ render(
698
+ strict(
699
+ <FileInput
700
+ label="Attachment"
701
+ value={file}
702
+ onChange={() => {}}
703
+ changeAction={async () => new Promise<void>(() => {})}
704
+ />,
705
+ ),
706
+ );
707
+ act(() => {
708
+ fireEvent.click(screen.getByRole('button', {name: /Clear/}));
709
+ });
710
+ await nextFrame();
711
+ expect(busyAnnouncements()).toEqual([['Loading', 'polite']]);
712
+ });
713
+ });
714
+
715
+ describe('CommandPalette busy start inside its modal (spec:AST-086 FR8, FR3)', () => {
716
+ beforeEach(() => {
717
+ HTMLDialogElement.prototype.showModal = vi.fn(function (
718
+ this: HTMLDialogElement,
719
+ ) {
720
+ this.setAttribute('open', '');
721
+ });
722
+ HTMLDialogElement.prototype.close = vi.fn(function (
723
+ this: HTMLDialogElement,
724
+ ) {
725
+ this.removeAttribute('open');
726
+ });
727
+ const matches = Element.prototype.matches;
728
+ vi.spyOn(Element.prototype, 'matches').mockImplementation(function (
729
+ this: Element,
730
+ selector: string,
731
+ ) {
732
+ return selector === ':modal'
733
+ ? this instanceof HTMLDialogElement && this.open
734
+ : matches.call(this, selector);
735
+ });
736
+ });
737
+ afterEach(() => {
738
+ vi.restoreAllMocks();
739
+ });
740
+
741
+ function regionOf(): Element | null {
742
+ return (
743
+ document
744
+ .querySelector('dialog[open]')
745
+ ?.querySelector('[data-astryx-modal-busy-region]') ?? null
746
+ );
747
+ }
748
+
749
+ it('announces the load on opening from a region inside its dialog, with a silent Spinner', async () => {
750
+ render(
751
+ strict(
752
+ <CommandPalette
753
+ isOpen
754
+ onOpenChange={() => {}}
755
+ searchSource={{
756
+ bootstrap: async () => new Promise<SearchableItem[]>(() => {}),
757
+ search: async () => new Promise<SearchableItem[]>(() => {}),
758
+ }}
759
+ />,
760
+ ),
761
+ );
762
+ await nextFrame();
763
+ await nextFrame();
764
+ const dialog = document.querySelector('dialog[open]') as HTMLElement;
765
+ expect(dialog.querySelector('.astryx-spinner')).toHaveAttribute(
766
+ 'aria-hidden',
767
+ 'true',
768
+ );
769
+ expect(regionOf()).toHaveTextContent('Loading');
770
+ expect(busyAnnouncements()).toEqual([]);
771
+ });
772
+
773
+ it('announces a search start once from inside its dialog, not on the hidden page channel', async () => {
774
+ render(
775
+ strict(
776
+ <CommandPalette
777
+ isOpen
778
+ onOpenChange={() => {}}
779
+ searchSource={pendingSource()}
780
+ />,
781
+ ),
782
+ );
783
+ await nextFrame();
784
+ act(() => {
785
+ fireEvent.change(screen.getByRole('combobox'), {target: {value: 'se'}});
786
+ });
787
+ await nextFrame();
788
+ await nextFrame();
789
+ expect(regionOf()).toHaveTextContent('Loading');
790
+ expect(busyAnnouncements()).toEqual([]);
791
+ });
792
+ });