@astryxdesign/core 0.7.3-canary.f94e962 → 0.7.3

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 (133) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
  3. package/dist/BottomSheet/BottomSheet.js +8 -4
  4. package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -1
  5. package/dist/BottomSheet/BottomSheetSwitcher.js +7 -0
  6. package/dist/Button/Button.d.ts.map +1 -1
  7. package/dist/Button/Button.js +15 -2
  8. package/dist/Calendar/Calendar.js +3 -2
  9. package/dist/Chat/ChatComposer.d.ts.map +1 -1
  10. package/dist/Chat/ChatComposer.js +25 -2
  11. package/dist/Chat/ChatSystemMessage.d.ts +4 -0
  12. package/dist/Chat/ChatSystemMessage.d.ts.map +1 -1
  13. package/dist/Chat/ChatSystemMessage.js +4 -2
  14. package/dist/CodeBlock/CodeBlock.d.ts.map +1 -1
  15. package/dist/CodeBlock/CodeBlock.js +11 -1
  16. package/dist/DateRangeInput/DateRangeInput.d.ts +5 -5
  17. package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
  18. package/dist/DateRangeInput/DateRangeInput.js +27 -18
  19. package/dist/DateTimeInput/DateTimeInput.d.ts +5 -5
  20. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  21. package/dist/DateTimeInput/DateTimeInput.js +25 -27
  22. package/dist/DateTimeInput/NativeDateSegment.d.ts +3 -2
  23. package/dist/DateTimeInput/NativeDateSegment.d.ts.map +1 -1
  24. package/dist/DateTimeInput/NativeDateSegment.js +5 -5
  25. package/dist/DateTimeInput/NativeTimeSegment.d.ts +3 -2
  26. package/dist/DateTimeInput/NativeTimeSegment.d.ts.map +1 -1
  27. package/dist/DateTimeInput/NativeTimeSegment.js +5 -5
  28. package/dist/DateTimeInput/TouchDateTimeField.d.ts.map +1 -1
  29. package/dist/DateTimeInput/TouchDateTimeField.js +12 -9
  30. package/dist/Dialog/Dialog.d.ts.map +1 -1
  31. package/dist/Dialog/Dialog.js +9 -4
  32. package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts +9 -5
  33. package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts.map +1 -1
  34. package/dist/DropdownMenu/DropdownMenuRadioGroup.js +2 -0
  35. package/dist/Markdown/Markdown.d.ts.map +1 -1
  36. package/dist/Markdown/Markdown.js +48 -15
  37. package/dist/MultiSelector/MultiSelector.d.ts +4 -4
  38. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  39. package/dist/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.d.ts +2 -0
  40. package/dist/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.d.ts.map +1 -0
  41. package/dist/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.js +48 -0
  42. package/dist/MultiSelector/MultiSelector.js +25 -18
  43. package/dist/Pagination/Pagination.d.ts.map +1 -1
  44. package/dist/Pagination/Pagination.js +5 -2
  45. package/dist/Switch/Switch.d.ts.map +1 -1
  46. package/dist/Switch/Switch.js +13 -3
  47. package/dist/TimeInput/TimeInput.d.ts +5 -5
  48. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  49. package/dist/TimeInput/TimeInput.js +37 -20
  50. package/dist/TimeInput/TouchTimeField.d.ts.map +1 -1
  51. package/dist/TimeInput/TouchTimeField.js +17 -6
  52. package/dist/Timestamp/Timestamp.d.ts.map +1 -1
  53. package/dist/Timestamp/Timestamp.js +41 -8
  54. package/dist/TreeList/TreeListItem.d.ts.map +1 -1
  55. package/dist/TreeList/TreeListItem.js +8 -3
  56. package/dist/hooks/useGridFocus.d.ts.map +1 -1
  57. package/dist/hooks/useGridFocus.js +10 -1
  58. package/package.json +4 -4
  59. package/scripts/agent-doc-state.mjs +24 -2
  60. package/src/BottomSheet/BottomSheet.spec.md +26 -53
  61. package/src/BottomSheet/BottomSheet.test.tsx +29 -0
  62. package/src/BottomSheet/BottomSheet.tsx +8 -4
  63. package/src/BottomSheet/BottomSheetSwitcher.spec.md +45 -48
  64. package/src/BottomSheet/BottomSheetSwitcher.test.tsx +26 -0
  65. package/src/BottomSheet/BottomSheetSwitcher.tsx +6 -0
  66. package/src/Button/Button.test.tsx +13 -2
  67. package/src/Button/Button.tsx +23 -2
  68. package/src/Button/__tests__/Button.a11y.states.ts +4 -3
  69. package/src/Calendar/Calendar.test.tsx +68 -24
  70. package/src/Calendar/Calendar.tsx +2 -1
  71. package/src/Carousel/Carousel.spec.md +146 -0
  72. package/src/Chat/ChatComposer.spec.md +26 -26
  73. package/src/Chat/ChatComposer.test.tsx +58 -2
  74. package/src/Chat/ChatComposer.tsx +25 -1
  75. package/src/Chat/ChatSystemMessage.doc.mjs +1 -1
  76. package/src/Chat/ChatSystemMessage.test.tsx +25 -3
  77. package/src/Chat/ChatSystemMessage.tsx +6 -4
  78. package/src/Chat/__tests__/ChatSystemMessage.a11y.chromium.spec.ts +8 -9
  79. package/src/CheckboxList/__tests__/CheckboxList.a11y.chromium.spec.ts +7 -6
  80. package/src/CodeBlock/CodeBlock.highlightStyles.test.tsx +87 -0
  81. package/src/CodeBlock/CodeBlock.tsx +11 -1
  82. package/src/DateRangeInput/DateRangeInput.doc.mjs +6 -5
  83. package/src/DateRangeInput/DateRangeInput.test.tsx +82 -16
  84. package/src/DateRangeInput/DateRangeInput.tsx +40 -20
  85. package/src/DateTimeInput/DateTimeInput.doc.mjs +9 -8
  86. package/src/DateTimeInput/DateTimeInput.test.tsx +126 -11
  87. package/src/DateTimeInput/DateTimeInput.tsx +42 -26
  88. package/src/DateTimeInput/DateTimeInputTouch.test.tsx +24 -0
  89. package/src/DateTimeInput/NativeDateSegment.tsx +9 -6
  90. package/src/DateTimeInput/NativePickerSegments.test.tsx +31 -0
  91. package/src/DateTimeInput/NativeTimeSegment.tsx +9 -6
  92. package/src/DateTimeInput/TouchDateTimeField.tsx +22 -7
  93. package/src/Dialog/Dialog.test.tsx +39 -0
  94. package/src/Dialog/Dialog.tsx +9 -4
  95. package/src/DropdownMenu/DropdownMenuItem.doc.mjs +66 -7
  96. package/src/DropdownMenu/DropdownMenuItem.spec.md +209 -0
  97. package/src/DropdownMenu/DropdownMenuRadioGroup.doc.mjs +62 -7
  98. package/src/DropdownMenu/DropdownMenuRadioGroup.spec.md +190 -0
  99. package/src/DropdownMenu/DropdownMenuRadioGroup.tsx +10 -4
  100. package/src/DropdownMenu/DropdownMenuSelectable.test.tsx +93 -0
  101. package/src/DropdownMenu/__tests__/DropdownMenuItemAudit.a11y.chromium.spec.ts +366 -0
  102. package/src/DropdownMenu/__tests__/DropdownMenuRadioGroupAudit.a11y.chromium.spec.ts +327 -0
  103. package/src/FieldStatus/FieldStatus.spec.md +4 -4
  104. package/src/FieldStatus/__tests__/StatusMessage.a11y.renders.tsx +0 -18
  105. package/src/FieldStatus/__tests__/StatusMessage.a11y.states.ts +3 -18
  106. package/src/FieldStatus/__tests__/StatusMessage.a11y.test.tsx +1 -2
  107. package/src/Markdown/Markdown.helpers.perf.test.ts +25 -19
  108. package/src/Markdown/Markdown.rerender.test.tsx +143 -0
  109. package/src/Markdown/Markdown.tsx +89 -35
  110. package/src/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.ts +71 -0
  111. package/src/MultiSelector/MultiSelector.doc.mjs +5 -5
  112. package/src/MultiSelector/MultiSelector.focusableDisabled.test.tsx +121 -0
  113. package/src/MultiSelector/MultiSelector.test.tsx +49 -7
  114. package/src/MultiSelector/MultiSelector.tsx +53 -29
  115. package/src/Pagination/Pagination.test.tsx +5 -1
  116. package/src/Pagination/Pagination.tsx +3 -1
  117. package/src/Switch/Switch.test.tsx +56 -0
  118. package/src/Switch/Switch.tsx +13 -2
  119. package/src/Table/plugins/columnResize/useTableColumnResize.spec.md +11 -11
  120. package/src/Table/plugins/rowStatus/useTableRowStatus.spec.md +4 -3
  121. package/src/TimeInput/NativeTimeInput.test.tsx +25 -1
  122. package/src/TimeInput/Presentation.test.tsx +23 -0
  123. package/src/TimeInput/TimeInput.doc.mjs +8 -7
  124. package/src/TimeInput/TimeInput.test.tsx +119 -7
  125. package/src/TimeInput/TimeInput.tsx +51 -23
  126. package/src/TimeInput/TouchTimeField.tsx +27 -5
  127. package/src/Timestamp/Timestamp.doc.mjs +1 -1
  128. package/src/Timestamp/Timestamp.test.tsx +168 -0
  129. package/src/Timestamp/Timestamp.tsx +62 -11
  130. package/src/TreeList/TreeList.test.tsx +28 -0
  131. package/src/TreeList/TreeListItem.tsx +6 -2
  132. package/src/__tests__/DisabledTriggerFocus.a11y.chromium.spec.ts +86 -0
  133. package/src/hooks/useGridFocus.ts +16 -1
@@ -18,7 +18,13 @@
18
18
  * Last synced props: label, variant, size, isDisabled, isLoading, isInterruptible, clickAction, icon, isIconOnly, width, children, tooltip, endContent, href, as, target, rel
19
19
  */
20
20
 
21
- import {useRef, useTransition, type ReactNode} from 'react';
21
+ import {
22
+ useEffect,
23
+ useRef,
24
+ useState,
25
+ useTransition,
26
+ type ReactNode,
27
+ } from 'react';
22
28
  import type {BaseProps} from '../BaseProps';
23
29
  import type {Elevation, SizeValue} from '../utils/types';
24
30
  import * as stylex from '@stylexjs/stylex';
@@ -587,6 +593,21 @@ export function Button({
587
593
  // re-click can land and interrupt the in-flight action with a fresh one.
588
594
  const actionInFlightRef = useRef(false);
589
595
  const isLoadingState = isLoading || isPending;
596
+ // The loading live region must exist empty before it receives "Loading":
597
+ // many screen readers skip a live region that mounts already holding its
598
+ // text, which is what a Button first rendered loading did (spec:AST-086
599
+ // FR3). Only that case holds the text back, until the frame after the
600
+ // region mounts; a Button mounted idle never re-renders for this.
601
+ const [isLiveRegionReady, setIsLiveRegionReady] = useState(
602
+ () => !isLoadingState,
603
+ );
604
+ useEffect(() => {
605
+ if (isLiveRegionReady) {
606
+ return;
607
+ }
608
+ const frame = requestAnimationFrame(() => setIsLiveRegionReady(true));
609
+ return () => cancelAnimationFrame(frame);
610
+ }, [isLiveRegionReady]);
590
611
  // Delay the spinner reveal for action-driven loading (clickAction's own
591
612
  // transition) so a fast action that settles within the delay does not flash
592
613
  // a spinner. Interruptible loading is delayed too, so rapid re-clicks settle
@@ -767,7 +788,7 @@ export function Button({
767
788
  </span>
768
789
  {/* Live region for loading state announcements */}
769
790
  <VisuallyHidden role="status" aria-live="polite">
770
- {isLoadingState ? t('@astryx.button.loading') : ''}
791
+ {isLoadingState && isLiveRegionReady ? t('@astryx.button.loading') : ''}
771
792
  </VisuallyHidden>
772
793
  </>
773
794
  );
@@ -8,7 +8,8 @@
8
8
  * BUTTON_PATTERN_EXCLUSIONS, the parts deliberately left to another pattern.
9
9
  * @position The binding inventory required before any assertion moves
10
10
  * (`docs/specs/AST-021/spec.md` FR2, FR4), and the single definition of what
11
- * each state IS.
11
+ * each state IS. Disabled ClickableCard remains focusable and inert under
12
+ * `docs/specs/AST-085-focusable-disabled/spec.md` FR1 and FR2.
12
13
  *
13
14
  * A state earns a row when it can change what the button pattern promises: a
14
15
  * different name source, a different operability, a different exposure of
@@ -223,8 +224,8 @@ export const BUTTON_BINDING_STATES = [
223
224
  {
224
225
  id: 'clickable-card-disabled',
225
226
  binding: 'ClickableCard',
226
- summary: 'a disabled action card',
227
- facts: facts({operable: false, focusable: false, unavailable: true}),
227
+ summary: 'a disabled action card: focusable, unavailable, and inert',
228
+ facts: facts({operable: false, focusable: true, unavailable: true}),
228
229
  visibleLabel: null,
229
230
  pointerTargetSelector: '[data-a11y-pointer-target]',
230
231
  storyId: 'a11y-button-pattern--clickable-card-disabled',
@@ -382,25 +382,35 @@ describe('Calendar', () => {
382
382
  // ─── Date Constraints ────────────────────────────────────────
383
383
 
384
384
  it('respects min date constraint', () => {
385
- render(<Calendar focusDate="2026-01-01" min="2026-01-10" />);
385
+ const onChange = vi.fn();
386
+ render(
387
+ <Calendar focusDate="2026-01-01" min="2026-01-10" onChange={onChange} />,
388
+ );
386
389
 
387
390
  // Day 5 should be disabled (before min)
388
391
  const day5 = getDayButton(5);
389
- expect(day5).toBeDisabled();
392
+ expect(day5).toHaveAttribute('aria-disabled', 'true');
393
+ // spec:AST-085 FR1: focusable through aria-disabled, not natively disabled;
394
+ // a click still selects nothing.
395
+ expect(day5).not.toHaveAttribute('disabled');
396
+ day5.focus();
397
+ expect(day5).toHaveFocus();
398
+ day5.click();
399
+ expect(onChange).not.toHaveBeenCalled();
390
400
 
391
401
  // Day 15 should be enabled (after min)
392
402
  const day15 = getDayButton(15);
393
- expect(day15).not.toBeDisabled();
403
+ expect(day15).not.toHaveAttribute('aria-disabled', 'true');
394
404
  });
395
405
 
396
406
  it('respects max date constraint', () => {
397
407
  render(<Calendar focusDate="2026-01-01" max="2026-01-20" />);
398
408
 
399
409
  const day25 = getDayButton(25);
400
- expect(day25).toBeDisabled();
410
+ expect(day25).toHaveAttribute('aria-disabled', 'true');
401
411
 
402
412
  const day15 = getDayButton(15);
403
- expect(day15).not.toBeDisabled();
413
+ expect(day15).not.toHaveAttribute('aria-disabled', 'true');
404
414
  });
405
415
 
406
416
  // ─── Initial Visible Month ───────────────────────────────────
@@ -424,14 +434,20 @@ describe('Calendar', () => {
424
434
  render(<Calendar min="2027-03-04" max="2027-06-30" />);
425
435
 
426
436
  expect(screen.getByText('March 2027')).toBeInTheDocument();
427
- expect(getDayButton(4, 'March', 2027)).not.toBeDisabled();
437
+ expect(getDayButton(4, 'March', 2027)).not.toHaveAttribute(
438
+ 'aria-disabled',
439
+ 'true',
440
+ );
428
441
  });
429
442
 
430
443
  it('opens on max when the window is entirely in the past', () => {
431
444
  render(<Calendar min="2019-01-01" max="2019-04-30" />);
432
445
 
433
446
  expect(screen.getByText('April 2019')).toBeInTheDocument();
434
- expect(getDayButton(30, 'April', 2019)).not.toBeDisabled();
447
+ expect(getDayButton(30, 'April', 2019)).not.toHaveAttribute(
448
+ 'aria-disabled',
449
+ 'true',
450
+ );
435
451
  });
436
452
 
437
453
  it('keeps max in the last pane in the two-month layout', () => {
@@ -466,7 +482,7 @@ describe('Calendar', () => {
466
482
 
467
483
  // January 4, 2026 is a Sunday - should be disabled
468
484
  const sunday = getDayButton(4);
469
- expect(sunday).toBeDisabled();
485
+ expect(sunday).toHaveAttribute('aria-disabled', 'true');
470
486
  });
471
487
 
472
488
  it('caps the end date to maxRangeSpan once a start is picked', async () => {
@@ -475,15 +491,43 @@ describe('Calendar', () => {
475
491
  render(<Calendar mode="range" focusDate="2026-01-01" maxRangeSpan={7} />);
476
492
 
477
493
  // Before a start is picked every day is selectable.
478
- expect(getDayButton(20)).not.toBeDisabled();
494
+ expect(getDayButton(20)).not.toHaveAttribute('aria-disabled', 'true');
479
495
 
480
496
  // Pick Jan 10 as the start. A 7-day window spans Jan 4–16 (start ± 6).
481
497
  await user.click(getDayButton(10));
482
498
 
483
- expect(getDayButton(16)).not.toBeDisabled(); // 6 days after — the edge
484
- expect(getDayButton(17)).toBeDisabled(); // 7 days after — beyond the cap
485
- expect(getDayButton(4)).not.toBeDisabled(); // 6 days before — symmetric
486
- expect(getDayButton(3)).toBeDisabled(); // 7 days before — beyond the cap
499
+ expect(getDayButton(16)).not.toHaveAttribute('aria-disabled', 'true'); // 6 days after — the edge
500
+ expect(getDayButton(17)).toHaveAttribute('aria-disabled', 'true'); // 7 days after — beyond the cap
501
+ expect(getDayButton(4)).not.toHaveAttribute('aria-disabled', 'true'); // 6 days before — symmetric
502
+ expect(getDayButton(3)).toHaveAttribute('aria-disabled', 'true'); // 7 days before — beyond the cap
503
+ });
504
+
505
+ it('keeps one Tab stop when the span cap disables the day that held it', async () => {
506
+ const user = userEvent.setup();
507
+
508
+ render(<Calendar mode="range" focusDate="2026-01-01" maxRangeSpan={7} />);
509
+
510
+ // Arrow from Jan 1 to Jan 25 so Jan 25 holds the grid's Tab stop.
511
+ getDayButton(1).focus();
512
+ await user.keyboard('{ArrowDown}{ArrowDown}{ArrowDown}');
513
+ for (let i = 0; i < 3; i += 1) {
514
+ await user.keyboard('{ArrowRight}');
515
+ }
516
+ expect(getDayButton(25)).toHaveFocus();
517
+
518
+ // Picking Jan 10 as the start disables Jan 25 (beyond the 7-day cap).
519
+ await user.click(getDayButton(10));
520
+ expect(getDayButton(25)).toHaveAttribute('aria-disabled', 'true');
521
+
522
+ // spec:AST-085 FR1: a disabled day stays focusable but never keeps the
523
+ // grid's Tab stop, so the grid still has exactly one, on an enabled day.
524
+ const grid = screen.getByRole('grid');
525
+ const stops = Array.from(
526
+ grid.querySelectorAll<HTMLElement>('button[data-date][tabindex="0"]'),
527
+ );
528
+ expect(stops).toHaveLength(1);
529
+ expect(stops[0]).not.toHaveAttribute('aria-disabled', 'true');
530
+ expect(getDayButton(25)).toHaveAttribute('tabindex', '-1');
487
531
  });
488
532
 
489
533
  it('enforces minRangeSpan once a start is picked', async () => {
@@ -496,11 +540,11 @@ describe('Calendar', () => {
496
540
 
497
541
  // The picked start itself stays enabled — it is the active selection
498
542
  // anchor, not an unreachable end.
499
- expect(getDayButton(10)).not.toBeDisabled();
500
- expect(getDayButton(11)).toBeDisabled(); // 1 day apart — too short
501
- expect(getDayButton(12)).not.toBeDisabled(); // 3-day span — allowed
502
- expect(getDayButton(8)).not.toBeDisabled(); // 3-day span the other way
503
- expect(getDayButton(9)).toBeDisabled(); // 2-day span — too short
543
+ expect(getDayButton(10)).not.toHaveAttribute('aria-disabled', 'true');
544
+ expect(getDayButton(11)).toHaveAttribute('aria-disabled', 'true'); // 1 day apart — too short
545
+ expect(getDayButton(12)).not.toHaveAttribute('aria-disabled', 'true'); // 3-day span — allowed
546
+ expect(getDayButton(8)).not.toHaveAttribute('aria-disabled', 'true'); // 3-day span the other way
547
+ expect(getDayButton(9)).toHaveAttribute('aria-disabled', 'true'); // 2-day span — too short
504
548
  });
505
549
 
506
550
  it('commits a same-day range with the default minimum', async () => {
@@ -565,8 +609,8 @@ describe('Calendar', () => {
565
609
 
566
610
  // …and the start is cleared: the days that minRangeSpan disabled around
567
611
  // the old anchor are selectable again, so a new start can be placed there.
568
- expect(getDayButton(11)).not.toBeDisabled();
569
- expect(getDayButton(9)).not.toBeDisabled();
612
+ expect(getDayButton(11)).not.toHaveAttribute('aria-disabled', 'true');
613
+ expect(getDayButton(9)).not.toHaveAttribute('aria-disabled', 'true');
570
614
  });
571
615
 
572
616
  it('announces the cleared range in the provider locale', async () => {
@@ -603,7 +647,7 @@ describe('Calendar', () => {
603
647
 
604
648
  // A picked single date must not disable far-away days.
605
649
  await user.click(getDayButton(10));
606
- expect(getDayButton(25)).not.toBeDisabled();
650
+ expect(getDayButton(25)).not.toHaveAttribute('aria-disabled', 'true');
607
651
  });
608
652
 
609
653
  it('keeps a controlled value that is wider than maxRangeSpan (selection-only)', () => {
@@ -621,7 +665,7 @@ describe('Calendar', () => {
621
665
 
622
666
  // No selection is in progress (both endpoints are set), so no anchor →
623
667
  // every day is pickable and both endpoints render selected.
624
- expect(getDayButton(20)).not.toBeDisabled();
668
+ expect(getDayButton(20)).not.toHaveAttribute('aria-disabled', 'true');
625
669
  expect(getDayButton(5).closest('[role="gridcell"]')).toHaveAttribute(
626
670
  'aria-selected',
627
671
  'true',
@@ -1713,7 +1757,7 @@ describe('Calendar', () => {
1713
1757
  render(<Calendar focusDate="2026-01-01" min="2026-01-05" />);
1714
1758
 
1715
1759
  const day1 = getDayButton(1);
1716
- expect(day1).toBeDisabled();
1760
+ expect(day1).toHaveAttribute('aria-disabled', 'true');
1717
1761
 
1718
1762
  const day8 = getDayButton(8);
1719
1763
  day8.focus();
@@ -1741,7 +1785,7 @@ describe('Calendar', () => {
1741
1785
  render(<Calendar focusDate="2026-01-01" dateConstraints={[disableJan8]} />);
1742
1786
 
1743
1787
  const day8 = getDayButton(8);
1744
- expect(day8).toBeDisabled();
1788
+ expect(day8).toHaveAttribute('aria-disabled', 'true');
1745
1789
 
1746
1790
  const day15 = getDayButton(15);
1747
1791
  day15.focus();
@@ -1372,7 +1372,8 @@ function DayCell({
1372
1372
  // Mark today's cell programmatically (APG date-picker pattern), not just
1373
1373
  // visually, so screen-reader users can identify the current date.
1374
1374
  aria-current={state.isToday ? 'date' : undefined}
1375
- disabled={isDisabled}
1375
+ // A disabled day stays focusable through aria-disabled (spec:AST-085
1376
+ // FR1); arrows still skip it and its click and hover are guarded.
1376
1377
  // Initial roving tab-stop seed; useGridFocus owns it after mount.
1377
1378
  tabIndex={isTabbableDay ? 0 : -1}
1378
1379
  onClick={() => !state.effectivelyDisabled && onDayClick(date)}
@@ -0,0 +1,146 @@
1
+ ---
2
+ schema_version: 3
3
+ template_version: 7
4
+ kind: component
5
+ id: component:Carousel
6
+ authority: current
7
+ archive_reason: null
8
+ superseded_by: null
9
+ approved_by: cixzhang
10
+ approved_at: 2026-10-22
11
+ owners: [cixzhang]
12
+ review_triggers: [behavior, accessibility]
13
+ verified_by:
14
+ [
15
+ packages/core/src/Carousel/Carousel.test.tsx,
16
+ packages/core/src/Carousel/__tests__/Carousel.a11y.chromium.spec.ts,
17
+ ]
18
+ modules: []
19
+ families: [family:buttons]
20
+ design_specs: []
21
+ architecture: [architecture:interaction-modality]
22
+ contributing: []
23
+ system_specs: [spec:AST-085, spec:AST-025]
24
+ ---
25
+
26
+ # Carousel component contract
27
+
28
+ ## Contract at a glance
29
+
30
+ | Area | Contract |
31
+ | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
32
+ | Public contract | None changes. |
33
+ | Behavior | An edge scroll button whose direction has run out is hidden and takes no tab stop. When the focused edge button runs out, focus moves to the scroll area. |
34
+ | End-user impact | A keyboard user who scrolls to the first or last item stays inside the carousel instead of landing on the page body or on an invisible control. The scroll area has no role or name yet, so screen-reader users hear little at the destination until it meets `spec:AST-025` FR12. |
35
+ | Builder impact | None. |
36
+ | Compatibility/readiness | No API change. Shipped Carousel and its current tests still use native `disabled` and hand focus to the opposite button; they change with conformance work. |
37
+ | Review checks | Reject focus left on a hidden edge button, focus dropped to the page body, or an edge handoff that moves focus to anything but the scroll area. |
38
+ | Governing rules | `spec:AST-085` FR1, FR4; `spec:AST-025` FR12; `architecture:interaction-modality` INV1. |
39
+
40
+ This table is a review projection; the body below is authoritative.
41
+
42
+ ## Intent
43
+
44
+ Carousel shows a horizontally scrolling row of items inside a labelled region,
45
+ with optional scroll buttons at each edge. This record covers only what the edge
46
+ scroll buttons do when their direction runs out. Other Carousel behavior is
47
+ uncontracted until this record grows.
48
+
49
+ ## Compatibility and migration
50
+
51
+ - Released default preserved: yes, except where focus lands at an edge.
52
+ - Compatibility class: behavior correction; no API change.
53
+ - Controlled/uncontrolled behavior: unchanged.
54
+ - Migration decision: DEC-1.
55
+
56
+ Consumer migration instructions belong in consumer docs and release notes.
57
+
58
+ ## Ownership boundary
59
+
60
+ **Owns**
61
+
62
+ - Edge scroll button visibility, tab order, and the focus destination when one
63
+ runs out.
64
+
65
+ **Does not own / non-goals**
66
+
67
+ - Scroll area keyboard access and naming — `spec:AST-025`.
68
+ - Disabled control semantics in general — `spec:AST-085`.
69
+ - Button naming, sizing, and activation — `family:buttons`.
70
+ - Looping, snapping, edge fades, and slide semantics — uncontracted.
71
+
72
+ ## Public concepts
73
+
74
+ | Concept | Closed values or states | Meaning | Availability by variant/orientation/state | Default | Owner | Stability | Invalid-value behavior |
75
+ | ------------------ | ----------------------- | -------------------------------------------------- | ----------------------------------------- | ------- | -------------------- | --------- | ---------------------- |
76
+ | Edge scroll button | available, run out | Whether content remains in that button's direction | Both edges when the carousel overflows | — | `component:Carousel` | stable | not applicable |
77
+
78
+ ## Behavioral and layout contract
79
+
80
+ | ID | Candidate invariant | Basis | Draft review state |
81
+ | --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | ------------------ |
82
+ | FR1 | An edge scroll button whose direction has run out is hidden. While hidden it takes no tab stop and is not exposed to assistive technology. It stays mounted so layout does not shift. This is removal under `spec:AST-085` FR4, not a disabled control. | DEC-1 | settled |
83
+ | FR2 | When the focused edge scroll button runs out, focus moves to the scroll area without scrolling it. This holds when the content no longer overflows and the scroll area is out of the tab order (`spec:AST-025` FR12): it still takes focus programmatically. Focus never lands on the page body or on a hidden button. | DEC-1; `spec:AST-085` FR4 | settled |
84
+ | FR3 | The handoff happens once per edge transition, and only while the edge button held focus. An edge reached by swipe, resize, or wheel while focus is elsewhere moves nothing. | `architecture:interaction-modality` INV8 | settled |
85
+ | FR4 | An edge scroll button that is available behaves as an ordinary Button. | `family:buttons` | settled |
86
+
87
+ ### Representative states
88
+
89
+ | State | Required invariant | Allowed variation |
90
+ | -------------------------------------- | --------------------------------------------------------------- | ----------------------- |
91
+ | Mid-scroll | Both edge buttons available | Loop keeps both enabled |
92
+ | Focused "Scroll right" reaches the end | Button hides; focus is on the scroll area; no extra scroll | Reduced motion |
93
+ | Edge reached by swipe, focus elsewhere | Button hides; focus does not move | — |
94
+ | Content shrinks to fit | Both buttons hidden; a focused one hands off to the scroll area | — |
95
+
96
+ ## Accessibility contract
97
+
98
+ - **AR1 — No invisible tab stops.** A hidden edge button is not focusable and is
99
+ not exposed to assistive technology (FR1).
100
+ - **AR2 — Focus stays in the region.** The edge handoff keeps focus inside the
101
+ labelled carousel region (FR2), and the keyboard focus indicator follows
102
+ `architecture:interaction-modality` INV1.
103
+
104
+ ## Design relationships
105
+
106
+ No design record governs Carousel yet. Edge button visuals stay in code and
107
+ visual evidence.
108
+
109
+ ## Family and system relationships
110
+
111
+ - `spec:AST-085` FR4 lets a component name a focus destination for a control it
112
+ removes; FR2 names the scroll area.
113
+ - `spec:AST-025` FR12 owns the scroll area's tab stop and naming.
114
+ - `family:buttons` owns the edge buttons' behavior while available.
115
+
116
+ ## Verification map
117
+
118
+ | Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
119
+ | -------- | --------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------ |
120
+ | FR1, AR1 | Unit and accessibility-tree tests | at rest at each edge; no overflow | A hidden button that is focusable or exposed fails | `audit:Carousel/accessibility` |
121
+ | FR2, AR2 | Real-browser keyboard test | Enter on "Scroll right" until the end; same on the start | Focus on the page body, a hidden button, or the opposite button fails | `audit:Carousel/accessibility` |
122
+ | FR3 | Real-browser test with swipe and resize | edge reached with focus outside the carousel | Focus moving without an edge-button press, or moving twice for one edge, fails | `audit:Carousel/accessibility` |
123
+
124
+ ## Decision log
125
+
126
+ ### DEC-1 — The scroll area receives focus when an edge button runs out
127
+
128
+ **Reference:** `component:Carousel/DEC-1`
129
+ **Decider:** cixzhang, 2026-10-22
130
+
131
+ Carousel hides an edge button whose direction has run out, so keeping focus on
132
+ it would leave a keyboard user on an invisible control. The scroll area is always
133
+ mounted inside the labelled region and can take focus whether or not it is in the
134
+ tab order, so focus moving there keeps the user in place inside the carousel.
135
+
136
+ Rejected: the opposite edge button — it may be hidden too, and focus would jump
137
+ across the carousel.
138
+
139
+ ## Open questions
140
+
141
+ None.
142
+
143
+ ## Content boundary
144
+
145
+ This file does not duplicate consumer prop tables/examples, current audit
146
+ results, implementation steps, or family/system rules. It links to their owners.
@@ -104,7 +104,7 @@ Consumer migration instructions belong in consumer docs and release notes.
104
104
  | Primary action | send; stop | Sends the current draft or requests interruption. | Default ChatSendButton or a custom sendButton | send | `component:ChatComposer` | stable | A shown Stop action without `onStop` is currently rendered but inert. |
105
105
  | Density | compact; balanced; spacious | Selects composer spacing. | Every ChatComposer | balanced | `component:ChatComposer` | stable | Unknown values are rejected by TypeScript. |
106
106
  | Elevation | low; none | Selects a raised body or a flat bordered body. | Every ChatComposer | low | `component:ChatComposer` | stable | Unknown values are rejected by TypeScript. |
107
- | Status | absent; error; warning; top; bottom | Adds semantic feedback before or after the body. | Optional status object | absent; bottom when present | `component:ChatComposer` | stable | An omitted message currently leaves the semantic status region empty. |
107
+ | Status | absent; error; warning; top; bottom | Adds announced feedback before or after the body. | Optional status object | absent; bottom when present | `component:ChatComposer` | stable | An omitted message renders the bar and announces nothing. |
108
108
  | Named composition slots | drawer; header actions/context; input; footer/send actions | Lets callers compose supporting controls and information around the draft. | Optional, independently supplied | omitted | `component:ChatComposer` | stable | Omitted slot groups emit no empty header; footer remains for the send action. |
109
109
  | BaseProps surface | ref; DOM props/events; className/style; xstyle | Extends the component with DOM identity, behavior, and styling. | Every ChatComposer | omitted | `architecture:public-component-api` | stable | These surfaces currently have split root/body ownership; see OQ2. |
110
110
 
@@ -113,19 +113,19 @@ Consumer migration instructions belong in consumer docs and release notes.
113
113
  Draft requirements identify their basis so observed code is not mistaken for an
114
114
  intentional decision. A `current` contract contains no unresolved rows.
115
115
 
116
- | ID | Candidate invariant | Basis | Draft review state |
117
- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- | ------------------ |
118
- | FR1 | Controlled mode MUST report changes through `onChange`; uncontrolled mode MUST retain them locally. Every successful submit MUST trim and deliver the value supplied to that operation, then clear through the same value channel. | public types and docs; `architecture:public-component-api/INV3, INV9, INV12`; focused tests | settled |
119
- | FR2 | The default editor MUST serialize its current text/tokens, submit Enter without Shift, preserve Shift+Enter, defer during IME composition, and allow a caller key handler to prevent the default operation. | implementation, docs, and focused tests | verify |
120
- | FR3 | `isDisabled` MUST prevent draft edits and submissions and MUST expose `aria-disabled="true"` on the default textbox; removing the prop MUST remove that state. | `spec:AST-020/FR1` (WCAG 2.2 4.1.2); `architecture:interaction-modality/INV5`; red/green focused test | settled |
121
- | FR4 | When `isStopShown` is true, the default primary action MUST remain an enabled, named Stop button and call `onStop`; if editing is also disabled, ancestor styling MUST NOT block its pointer, touch, or keyboard activation. | `architecture:interaction-modality/INV4, INV7, INV9`; red/green focused and browser-story tests | settled |
122
- | FR5 | Omitted header slots MUST omit the header row; the drawer precedes the body; input fills the body between header and footer; footer actions precede send actions and the primary action; status follows `statusPosition`. | implementation, props, stories, and blocks | verify |
123
- | FR6 | Compact currently reduces body padding and gap. Balanced and spacious currently use the same body spacing. Whether spacious needs distinct output MUST remain unresolved until a component/design owner decides it. | implementation and published union; no current owner selects distinct spacious geometry | human decision |
124
- | FR7 | `low` currently applies low rest and medium hover/focus-within elevation; `none` applies no shadow and a border. Public theme reachability MUST cover the painting body or the public target/model MUST change through an approved compatibility path. | implementation; `architecture:component-theming-surface/INV4, INV6`; existing public issue | human decision |
125
- | FR8 | Keyboard focus entering the registered/default editor MUST add the shared focus indication to the body; pointer focus MUST not add that extra ring. Clicking non-interactive body space MUST focus the registered control, then a textarea fallback. | implementation; `architecture:interaction-modality/INV1–INV3, INV9`; focused tests | verify |
126
- | FR9 | Error status currently renders an alert; warning status renders a status; both pair a semantic icon with color and can render above or below the body. | implementation, docs, and stories; `spec:AST-020/FR1` | verify |
127
- | FR10 | BaseProps currently split across elements: ref, DOM props/events, `className`, and `style` reach the outer frame while `xstyle` reaches the painted body. The owning element MUST remain unresolved pending compatibility review. | implementation; `architecture:public-component-api/INV5–INV6, INV8` | human decision |
128
- | FR11 | The exported context MUST expose the current value, change and submit operations, placeholder, disabled/stop/send state, stop callback, and mutable input focus control to custom composition. | public export, types, docs, stories, and focused tests; `architecture:public-component-api/INV12` | verify |
116
+ | ID | Candidate invariant | Basis | Draft review state |
117
+ | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- | ------------------ |
118
+ | FR1 | Controlled mode MUST report changes through `onChange`; uncontrolled mode MUST retain them locally. Every successful submit MUST trim and deliver the value supplied to that operation, then clear through the same value channel. | public types and docs; `architecture:public-component-api/INV3, INV9, INV12`; focused tests | settled |
119
+ | FR2 | The default editor MUST serialize its current text/tokens, submit Enter without Shift, preserve Shift+Enter, defer during IME composition, and allow a caller key handler to prevent the default operation. | implementation, docs, and focused tests | verify |
120
+ | FR3 | `isDisabled` MUST prevent draft edits and submissions and MUST expose `aria-disabled="true"` on the default textbox; removing the prop MUST remove that state. | `spec:AST-020/FR1` (WCAG 2.2 4.1.2); `architecture:interaction-modality/INV5`; red/green focused test | settled |
121
+ | FR4 | When `isStopShown` is true, the default primary action MUST remain an enabled, named Stop button and call `onStop`; if editing is also disabled, ancestor styling MUST NOT block its pointer, touch, or keyboard activation. | `architecture:interaction-modality/INV4, INV7, INV9`; red/green focused and browser-story tests | settled |
122
+ | FR5 | Omitted header slots MUST omit the header row; the drawer precedes the body; input fills the body between header and footer; footer actions precede send actions and the primary action; status follows `statusPosition`. | implementation, props, stories, and blocks | verify |
123
+ | FR6 | Compact currently reduces body padding and gap. Balanced and spacious currently use the same body spacing. Whether spacious needs distinct output MUST remain unresolved until a component/design owner decides it. | implementation and published union; no current owner selects distinct spacious geometry | human decision |
124
+ | FR7 | `low` currently applies low rest and medium hover/focus-within elevation; `none` applies no shadow and a border. Public theme reachability MUST cover the painting body or the public target/model MUST change through an approved compatibility path. | implementation; `architecture:component-theming-surface/INV4, INV6`; existing public issue | human decision |
125
+ | FR8 | Keyboard focus entering the registered/default editor MUST add the shared focus indication to the body; pointer focus MUST not add that extra ring. Clicking non-interactive body space MUST focus the registered control, then a textarea fallback. | implementation; `architecture:interaction-modality/INV1–INV3, INV9`; focused tests | verify |
126
+ | FR9 | Error status is announced assertively and warning status politely through the shared announcement channel, once per occasion: one state transition, which a rerender or an update to the same value does not repeat (`spec:AST-086` FR6); the status bar itself carries no live role. Both pair a semantic icon with color and can render above or below the body. | implementation, docs, and stories; `spec:AST-020/FR1`; `spec:AST-086/FR2, FR5, FR6` | verify |
127
+ | FR10 | BaseProps currently split across elements: ref, DOM props/events, `className`, and `style` reach the outer frame while `xstyle` reaches the painted body. The owning element MUST remain unresolved pending compatibility review. | implementation; `architecture:public-component-api/INV5–INV6, INV8` | human decision |
128
+ | FR11 | The exported context MUST expose the current value, change and submit operations, placeholder, disabled/stop/send state, stop callback, and mutable input focus control to custom composition. | public export, types, docs, stories, and focused tests; `architecture:public-component-api/INV12` | verify |
129
129
 
130
130
  ### Allowed variation
131
131
 
@@ -144,7 +144,7 @@ intentional decision. A `current` contract contains no unresolved rows.
144
144
  | Disabled editing | Default textbox exposes disabled state and cannot edit or submit. | Supporting content remains visible. |
145
145
  | Disabled, interruptible work | Stop remains named, enabled, and reachable by pointer, touch, and keyboard. | Product cancellation behavior varies. |
146
146
  | Flat elevation | Body has no shadow and uses its bordered presentation; keyboard focus indication remains available. | Theme tokens vary. |
147
- | Status above or below | Semantic role, icon, color, and selected placement remain synchronized. | Message content and status type vary. |
147
+ | Status above or below | Announcement urgency, icon, color, and selected placement remain synchronized. | Message content and status type vary. |
148
148
  | Narrow viewport | Composer remains within its containing inline size and the primary action remains operable. | Available inline size and optional slot content vary. |
149
149
 
150
150
  ### Transformation and precedence order
@@ -165,20 +165,20 @@ intentional decision. A `current` contract contains no unresolved rows.
165
165
  - **AR2 — Native actions.** The default Send and Stop controls remain native buttons with translated accessible names; disabled Send uses native disabled behavior, while explicit Stop remains operable.
166
166
  - **AR3 — Keyboard editing.** Enter submission MUST preserve Shift+Enter newline behavior, IME composition, and caller cancellation through `preventDefault()`.
167
167
  - **AR4 — Focus indication.** Keyboard focus in the editor MUST produce the shared visible focus treatment; pointer focus does not claim keyboard modality, and focus on an internal action remains owned by that action.
168
- - **AR5 — Status feedback.** Error feedback uses `role="alert"`; warning feedback uses `role="status"`; visible icon and color do not replace the caller's message content.
168
+ - **AR5 — Status feedback.** Error feedback is announced assertively and warning feedback politely through the shared announcement channel (`spec:AST-086` FR2, FR5); the visible status bar carries no live role and stays readable. Visible icon and color do not replace the caller's message content.
169
169
 
170
170
  ## Design relationships
171
171
 
172
- | Anatomy or state | Design requirement | Representation authority | Hierarchy role | Component contract |
173
- | ------------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | -------------- | ------------------ |
174
- | Composer frame | Groups body and optional status into one compositional shell. | observed implementation | container | FR5 |
175
- | Composer body | Provides the painted rounded input/action surface and elevation. | elevation is shipped; public theme reachability is unsettled | prominent | FR7 |
176
- | Drawer | Places attachment/context content before the body. | delegated to `component:ChatComposerDrawer` | supporting | FR5 |
177
- | Header | Places caller actions at start and caller context at end. | observed implementation | supporting | FR5 |
178
- | Input | Owns or hosts the editing surface and its focus control. | delegated to `component:ChatComposerInput` or caller custom input | prominent | FR1–FR3, FR8, FR11 |
179
- | Footer | Places caller actions and the primary action on the final body row. | observed implementation | supporting | FR4–FR5 |
180
- | Send or stop action | Presents the current primary operation. | delegated to `component:ChatSendButton` or caller custom send button | prominent | FR1, FR4 |
181
- | Status message | Adds error or warning feedback before or after the body. | semantic role/color are shipped; separate public theme reachability is not asserted here | supporting | FR5, FR9, AR5 |
172
+ | Anatomy or state | Design requirement | Representation authority | Hierarchy role | Component contract |
173
+ | ------------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | -------------- | ------------------ |
174
+ | Composer frame | Groups body and optional status into one compositional shell. | observed implementation | container | FR5 |
175
+ | Composer body | Provides the painted rounded input/action surface and elevation. | elevation is shipped; public theme reachability is unsettled | prominent | FR7 |
176
+ | Drawer | Places attachment/context content before the body. | delegated to `component:ChatComposerDrawer` | supporting | FR5 |
177
+ | Header | Places caller actions at start and caller context at end. | observed implementation | supporting | FR5 |
178
+ | Input | Owns or hosts the editing surface and its focus control. | delegated to `component:ChatComposerInput` or caller custom input | prominent | FR1–FR3, FR8, FR11 |
179
+ | Footer | Places caller actions and the primary action on the final body row. | observed implementation | supporting | FR4–FR5 |
180
+ | Send or stop action | Presents the current primary operation. | delegated to `component:ChatSendButton` or caller custom send button | prominent | FR1, FR4 |
181
+ | Status message | Adds error or warning feedback before or after the body. | shared-channel announcement and color are shipped; separate public theme reachability is not asserted here | supporting | FR5, FR9, AR5 |
182
182
 
183
183
  The aggregate Chat record currently owns the public `chat-composer` target. That
184
184
  target sits on the frame and cannot select the body's non-inherited elevation and
@@ -1,15 +1,23 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
 
3
3
  import {beforeEach, describe, it, expect, vi} from 'vitest';
4
- import {act, render, fireEvent} from '@testing-library/react';
4
+ import {act, render, fireEvent, screen} from '@testing-library/react';
5
5
  import userEvent from '@testing-library/user-event';
6
- import {useEffect, useRef} from 'react';
6
+ import {StrictMode, useEffect, useRef} from 'react';
7
7
  import {ChatComposer} from './ChatComposer';
8
8
  import {useChatComposerContext} from './ChatContext';
9
9
  import {__resetInteractionModalityForTest} from '../utils/interactionModality';
10
+ import type * as AnnounceModule from '../hooks/useAnnounce';
11
+
12
+ const announce = vi.fn();
13
+ vi.mock('../hooks/useAnnounce', async importOriginal => ({
14
+ ...(await importOriginal<typeof AnnounceModule>()),
15
+ useAnnounce: () => announce,
16
+ }));
10
17
 
11
18
  beforeEach(() => {
12
19
  __resetInteractionModalityForTest();
20
+ announce.mockClear();
13
21
  });
14
22
 
15
23
  // The composer body — the elevated surface — is the div that wraps the input
@@ -252,3 +260,51 @@ describe('ChatComposer input composition contract', () => {
252
260
  expect(document.activeElement).toBe(textarea);
253
261
  });
254
262
  });
263
+
264
+ describe('ChatComposer status announcement (spec:AST-086)', () => {
265
+ const composer = (status?: {type: 'error' | 'warning'; message?: string}) => (
266
+ <ChatComposer onSubmit={() => {}} input={<div />} status={status} />
267
+ );
268
+
269
+ it('shows the status message but carries no live role (FR2)', () => {
270
+ render(composer({type: 'error', message: 'Send failed'}));
271
+ const message = screen.getByText('Send failed');
272
+ expect(
273
+ message.closest('[role="alert"], [role="status"], [aria-live]'),
274
+ ).toBeNull();
275
+ });
276
+
277
+ it('announces an error assertively and a warning politely (FR5)', () => {
278
+ const {rerender} = render(composer());
279
+ expect(announce).not.toHaveBeenCalled();
280
+ rerender(composer({type: 'error', message: 'Send failed'}));
281
+ rerender(composer({type: 'warning', message: 'Offline'}));
282
+ expect(announce.mock.calls).toEqual([
283
+ ['Send failed', 'assertive'],
284
+ ['Offline', 'polite'],
285
+ ]);
286
+ });
287
+
288
+ it('announces a mounted status once, even under StrictMode (FR6)', () => {
289
+ const status = {type: 'error' as const, message: 'Send failed'};
290
+ const {rerender} = render(<StrictMode>{composer(status)}</StrictMode>);
291
+ rerender(<StrictMode>{composer({...status})}</StrictMode>);
292
+ expect(announce.mock.calls).toEqual([['Send failed', 'assertive']]);
293
+ });
294
+
295
+ it('announces the same message again after the status clears (FR6)', () => {
296
+ const status = {type: 'error' as const, message: 'Send failed'};
297
+ const {rerender} = render(composer(status));
298
+ rerender(composer());
299
+ rerender(composer(status));
300
+ expect(announce.mock.calls).toEqual([
301
+ ['Send failed', 'assertive'],
302
+ ['Send failed', 'assertive'],
303
+ ]);
304
+ });
305
+
306
+ it('announces nothing for a status without a message', () => {
307
+ render(composer({type: 'warning'}));
308
+ expect(announce).not.toHaveBeenCalled();
309
+ });
310
+ });