@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.
- package/CHANGELOG.md +88 -0
- package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheet.js +8 -4
- package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheetSwitcher.js +7 -0
- package/dist/Button/Button.d.ts.map +1 -1
- package/dist/Button/Button.js +15 -2
- package/dist/Calendar/Calendar.js +3 -2
- package/dist/Chat/ChatComposer.d.ts.map +1 -1
- package/dist/Chat/ChatComposer.js +25 -2
- package/dist/Chat/ChatSystemMessage.d.ts +4 -0
- package/dist/Chat/ChatSystemMessage.d.ts.map +1 -1
- package/dist/Chat/ChatSystemMessage.js +4 -2
- package/dist/CodeBlock/CodeBlock.d.ts.map +1 -1
- package/dist/CodeBlock/CodeBlock.js +11 -1
- package/dist/DateRangeInput/DateRangeInput.d.ts +5 -5
- package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
- package/dist/DateRangeInput/DateRangeInput.js +27 -18
- package/dist/DateTimeInput/DateTimeInput.d.ts +5 -5
- package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
- package/dist/DateTimeInput/DateTimeInput.js +25 -27
- package/dist/DateTimeInput/NativeDateSegment.d.ts +3 -2
- package/dist/DateTimeInput/NativeDateSegment.d.ts.map +1 -1
- package/dist/DateTimeInput/NativeDateSegment.js +5 -5
- package/dist/DateTimeInput/NativeTimeSegment.d.ts +3 -2
- package/dist/DateTimeInput/NativeTimeSegment.d.ts.map +1 -1
- package/dist/DateTimeInput/NativeTimeSegment.js +5 -5
- package/dist/DateTimeInput/TouchDateTimeField.d.ts.map +1 -1
- package/dist/DateTimeInput/TouchDateTimeField.js +12 -9
- package/dist/Dialog/Dialog.d.ts.map +1 -1
- package/dist/Dialog/Dialog.js +9 -4
- package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts +9 -5
- package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuRadioGroup.js +2 -0
- package/dist/Markdown/Markdown.d.ts.map +1 -1
- package/dist/Markdown/Markdown.js +48 -15
- package/dist/MultiSelector/MultiSelector.d.ts +4 -4
- package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
- package/dist/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.d.ts +2 -0
- package/dist/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.d.ts.map +1 -0
- package/dist/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.js +48 -0
- package/dist/MultiSelector/MultiSelector.js +25 -18
- package/dist/Pagination/Pagination.d.ts.map +1 -1
- package/dist/Pagination/Pagination.js +5 -2
- package/dist/Switch/Switch.d.ts.map +1 -1
- package/dist/Switch/Switch.js +13 -3
- package/dist/TimeInput/TimeInput.d.ts +5 -5
- package/dist/TimeInput/TimeInput.d.ts.map +1 -1
- package/dist/TimeInput/TimeInput.js +37 -20
- package/dist/TimeInput/TouchTimeField.d.ts.map +1 -1
- package/dist/TimeInput/TouchTimeField.js +17 -6
- package/dist/Timestamp/Timestamp.d.ts.map +1 -1
- package/dist/Timestamp/Timestamp.js +41 -8
- package/dist/TreeList/TreeListItem.d.ts.map +1 -1
- package/dist/TreeList/TreeListItem.js +8 -3
- package/dist/hooks/useGridFocus.d.ts.map +1 -1
- package/dist/hooks/useGridFocus.js +10 -1
- package/package.json +4 -4
- package/scripts/agent-doc-state.mjs +24 -2
- package/src/BottomSheet/BottomSheet.spec.md +26 -53
- package/src/BottomSheet/BottomSheet.test.tsx +29 -0
- package/src/BottomSheet/BottomSheet.tsx +8 -4
- package/src/BottomSheet/BottomSheetSwitcher.spec.md +45 -48
- package/src/BottomSheet/BottomSheetSwitcher.test.tsx +26 -0
- package/src/BottomSheet/BottomSheetSwitcher.tsx +6 -0
- package/src/Button/Button.test.tsx +13 -2
- package/src/Button/Button.tsx +23 -2
- package/src/Button/__tests__/Button.a11y.states.ts +4 -3
- package/src/Calendar/Calendar.test.tsx +68 -24
- package/src/Calendar/Calendar.tsx +2 -1
- package/src/Carousel/Carousel.spec.md +146 -0
- package/src/Chat/ChatComposer.spec.md +26 -26
- package/src/Chat/ChatComposer.test.tsx +58 -2
- package/src/Chat/ChatComposer.tsx +25 -1
- package/src/Chat/ChatSystemMessage.doc.mjs +1 -1
- package/src/Chat/ChatSystemMessage.test.tsx +25 -3
- package/src/Chat/ChatSystemMessage.tsx +6 -4
- package/src/Chat/__tests__/ChatSystemMessage.a11y.chromium.spec.ts +8 -9
- package/src/CheckboxList/__tests__/CheckboxList.a11y.chromium.spec.ts +7 -6
- package/src/CodeBlock/CodeBlock.highlightStyles.test.tsx +87 -0
- package/src/CodeBlock/CodeBlock.tsx +11 -1
- package/src/DateRangeInput/DateRangeInput.doc.mjs +6 -5
- package/src/DateRangeInput/DateRangeInput.test.tsx +82 -16
- package/src/DateRangeInput/DateRangeInput.tsx +40 -20
- package/src/DateTimeInput/DateTimeInput.doc.mjs +9 -8
- package/src/DateTimeInput/DateTimeInput.test.tsx +126 -11
- package/src/DateTimeInput/DateTimeInput.tsx +42 -26
- package/src/DateTimeInput/DateTimeInputTouch.test.tsx +24 -0
- package/src/DateTimeInput/NativeDateSegment.tsx +9 -6
- package/src/DateTimeInput/NativePickerSegments.test.tsx +31 -0
- package/src/DateTimeInput/NativeTimeSegment.tsx +9 -6
- package/src/DateTimeInput/TouchDateTimeField.tsx +22 -7
- package/src/Dialog/Dialog.test.tsx +39 -0
- package/src/Dialog/Dialog.tsx +9 -4
- package/src/DropdownMenu/DropdownMenuItem.doc.mjs +66 -7
- package/src/DropdownMenu/DropdownMenuItem.spec.md +209 -0
- package/src/DropdownMenu/DropdownMenuRadioGroup.doc.mjs +62 -7
- package/src/DropdownMenu/DropdownMenuRadioGroup.spec.md +190 -0
- package/src/DropdownMenu/DropdownMenuRadioGroup.tsx +10 -4
- package/src/DropdownMenu/DropdownMenuSelectable.test.tsx +93 -0
- package/src/DropdownMenu/__tests__/DropdownMenuItemAudit.a11y.chromium.spec.ts +366 -0
- package/src/DropdownMenu/__tests__/DropdownMenuRadioGroupAudit.a11y.chromium.spec.ts +327 -0
- package/src/FieldStatus/FieldStatus.spec.md +4 -4
- package/src/FieldStatus/__tests__/StatusMessage.a11y.renders.tsx +0 -18
- package/src/FieldStatus/__tests__/StatusMessage.a11y.states.ts +3 -18
- package/src/FieldStatus/__tests__/StatusMessage.a11y.test.tsx +1 -2
- package/src/Markdown/Markdown.helpers.perf.test.ts +25 -19
- package/src/Markdown/Markdown.rerender.test.tsx +143 -0
- package/src/Markdown/Markdown.tsx +89 -35
- package/src/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.ts +71 -0
- package/src/MultiSelector/MultiSelector.doc.mjs +5 -5
- package/src/MultiSelector/MultiSelector.focusableDisabled.test.tsx +121 -0
- package/src/MultiSelector/MultiSelector.test.tsx +49 -7
- package/src/MultiSelector/MultiSelector.tsx +53 -29
- package/src/Pagination/Pagination.test.tsx +5 -1
- package/src/Pagination/Pagination.tsx +3 -1
- package/src/Switch/Switch.test.tsx +56 -0
- package/src/Switch/Switch.tsx +13 -2
- package/src/Table/plugins/columnResize/useTableColumnResize.spec.md +11 -11
- package/src/Table/plugins/rowStatus/useTableRowStatus.spec.md +4 -3
- package/src/TimeInput/NativeTimeInput.test.tsx +25 -1
- package/src/TimeInput/Presentation.test.tsx +23 -0
- package/src/TimeInput/TimeInput.doc.mjs +8 -7
- package/src/TimeInput/TimeInput.test.tsx +119 -7
- package/src/TimeInput/TimeInput.tsx +51 -23
- package/src/TimeInput/TouchTimeField.tsx +27 -5
- package/src/Timestamp/Timestamp.doc.mjs +1 -1
- package/src/Timestamp/Timestamp.test.tsx +168 -0
- package/src/Timestamp/Timestamp.tsx +62 -11
- package/src/TreeList/TreeList.test.tsx +28 -0
- package/src/TreeList/TreeListItem.tsx +6 -2
- package/src/__tests__/DisabledTriggerFocus.a11y.chromium.spec.ts +86 -0
- package/src/hooks/useGridFocus.ts +16 -1
package/src/Button/Button.tsx
CHANGED
|
@@ -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 {
|
|
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:
|
|
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
|
-
|
|
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).
|
|
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.
|
|
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).
|
|
410
|
+
expect(day25).toHaveAttribute('aria-disabled', 'true');
|
|
401
411
|
|
|
402
412
|
const day15 = getDayButton(15);
|
|
403
|
-
expect(day15).not.
|
|
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.
|
|
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.
|
|
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).
|
|
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.
|
|
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.
|
|
484
|
-
expect(getDayButton(17)).
|
|
485
|
-
expect(getDayButton(4)).not.
|
|
486
|
-
expect(getDayButton(3)).
|
|
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.
|
|
500
|
-
expect(getDayButton(11)).
|
|
501
|
-
expect(getDayButton(12)).not.
|
|
502
|
-
expect(getDayButton(8)).not.
|
|
503
|
-
expect(getDayButton(9)).
|
|
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.
|
|
569
|
-
expect(getDayButton(9)).not.
|
|
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.
|
|
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.
|
|
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).
|
|
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).
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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`.
|
|
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.
|
|
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.
|
|
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.
|
|
126
|
-
| FR9 | Error status
|
|
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.
|
|
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.
|
|
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 |
|
|
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
|
|
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
|
|
173
|
-
| ------------------- | ------------------------------------------------------------------- |
|
|
174
|
-
| Composer frame | Groups body and optional status into one compositional shell. | observed implementation
|
|
175
|
-
| Composer body | Provides the painted rounded input/action surface and elevation. | elevation is shipped; public theme reachability is unsettled
|
|
176
|
-
| Drawer | Places attachment/context content before the body. | delegated to `component:ChatComposerDrawer`
|
|
177
|
-
| Header | Places caller actions at start and caller context at end. | observed implementation
|
|
178
|
-
| Input | Owns or hosts the editing surface and its focus control. | delegated to `component:ChatComposerInput` or caller custom input
|
|
179
|
-
| Footer | Places caller actions and the primary action on the final body row. | observed implementation
|
|
180
|
-
| Send or stop action | Presents the current primary operation. | delegated to `component:ChatSendButton` or caller custom send button
|
|
181
|
-
| Status message | Adds error or warning feedback before or after the body. |
|
|
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
|
+
});
|