@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
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
import React, {
|
|
27
27
|
useState,
|
|
28
28
|
useCallback,
|
|
29
|
+
useEffect,
|
|
29
30
|
useRef,
|
|
30
31
|
useMemo,
|
|
31
32
|
type ReactNode,
|
|
@@ -53,6 +54,7 @@ import type {ChatComposerInputControl} from './ChatContext';
|
|
|
53
54
|
import {ChatSendButton} from './ChatSendButton';
|
|
54
55
|
import {themeProps} from '../utils/themeProps';
|
|
55
56
|
import {useTranslator} from '../i18n';
|
|
57
|
+
import {useAnnounce} from '../hooks/useAnnounce';
|
|
56
58
|
import {focusOutlineStyles} from '../utils/focusOutline.stylex';
|
|
57
59
|
import {
|
|
58
60
|
getInteractionModality,
|
|
@@ -455,9 +457,31 @@ export function ChatComposer(props: ChatComposerProps) {
|
|
|
455
457
|
[],
|
|
456
458
|
);
|
|
457
459
|
|
|
460
|
+
// The status bar mounts together with its message, and a live region born
|
|
461
|
+
// holding its text is not reliably announced, so the message goes through
|
|
462
|
+
// the shared channel instead: errors assertively, warnings politely
|
|
463
|
+
// (spec:AST-086 FR2, FR5). The ref keeps a rerender or a StrictMode effect
|
|
464
|
+
// replay from repeating it; clearing the status re-arms it, so the same
|
|
465
|
+
// message shown again is a new occasion (FR6).
|
|
466
|
+
const announce = useAnnounce();
|
|
467
|
+
const statusType = status?.type;
|
|
468
|
+
const statusMessage = status?.message;
|
|
469
|
+
const announcedStatusRef = useRef<string | null>(null);
|
|
470
|
+
useEffect(() => {
|
|
471
|
+
if (!statusMessage) {
|
|
472
|
+
announcedStatusRef.current = null;
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
const occasion = `${statusType}\u0000${statusMessage}`;
|
|
476
|
+
if (announcedStatusRef.current === occasion) {
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
announcedStatusRef.current = occasion;
|
|
480
|
+
announce(statusMessage, statusType === 'error' ? 'assertive' : 'polite');
|
|
481
|
+
}, [announce, statusType, statusMessage]);
|
|
482
|
+
|
|
458
483
|
const statusEl = status ? (
|
|
459
484
|
<div
|
|
460
|
-
role={status.type === 'error' ? 'alert' : 'status'}
|
|
461
485
|
{...stylex.props(
|
|
462
486
|
styles.statusBar,
|
|
463
487
|
statusPosition === 'top' ? styles.statusTop : styles.statusBottom,
|
|
@@ -52,7 +52,7 @@ export const docs = {
|
|
|
52
52
|
},
|
|
53
53
|
usage: {
|
|
54
54
|
description:
|
|
55
|
-
'Use ChatSystemMessage for concise, non-sender content inside a chat transcript. Choose the default variant for factual status notices and the divider variant for date or section breaks. Long default content wraps within the available width.
|
|
55
|
+
'Use ChatSystemMessage for concise, non-sender content inside a chat transcript. Choose the default variant for factual status notices and the divider variant for date or section breaks. Long default content wraps within the available width. It carries no live role: inside a ChatMessageList, the log announces new notices. The divider branch includes a labelled separator.',
|
|
56
56
|
bestPractices: [
|
|
57
57
|
{
|
|
58
58
|
guidance: true,
|
|
@@ -4,6 +4,7 @@ import {createRef, Fragment} from 'react';
|
|
|
4
4
|
import {describe, expect, it} from 'vitest';
|
|
5
5
|
import {render, screen} from '@testing-library/react';
|
|
6
6
|
import * as stylex from '@stylexjs/stylex';
|
|
7
|
+
import {ChatMessageList} from './ChatMessageList';
|
|
7
8
|
import {ChatSystemMessage} from './ChatSystemMessage';
|
|
8
9
|
|
|
9
10
|
const xstyleProbe = stylex.create({
|
|
@@ -100,7 +101,7 @@ describe('ChatSystemMessage', () => {
|
|
|
100
101
|
expect(root).toHaveStyle({outlineOffset: '3px'});
|
|
101
102
|
expectProbeClasses(root);
|
|
102
103
|
expect(root).toHaveAttribute('aria-label', `${variant} system message`);
|
|
103
|
-
expect(root).toHaveAttribute('role', '
|
|
104
|
+
expect(root).toHaveAttribute('role', 'note');
|
|
104
105
|
expect(root).toHaveAttribute('data-variant', variant);
|
|
105
106
|
},
|
|
106
107
|
);
|
|
@@ -125,7 +126,7 @@ describe('ChatSystemMessage', () => {
|
|
|
125
126
|
</ChatSystemMessage>,
|
|
126
127
|
);
|
|
127
128
|
const root = screen.getByTestId('empty-partition');
|
|
128
|
-
expect(root).toHaveAttribute('role'
|
|
129
|
+
expect(root).not.toHaveAttribute('role');
|
|
129
130
|
expect(root).toHaveTextContent(text);
|
|
130
131
|
});
|
|
131
132
|
|
|
@@ -148,7 +149,7 @@ describe('ChatSystemMessage', () => {
|
|
|
148
149
|
</ChatSystemMessage>,
|
|
149
150
|
);
|
|
150
151
|
const root = screen.getByTestId('empty-partition');
|
|
151
|
-
expect(root).toHaveAttribute('role'
|
|
152
|
+
expect(root).not.toHaveAttribute('role');
|
|
152
153
|
expect(root).toContainElement(screen.getByRole('separator'));
|
|
153
154
|
});
|
|
154
155
|
|
|
@@ -164,4 +165,25 @@ describe('ChatSystemMessage', () => {
|
|
|
164
165
|
expect(separator).toHaveAccessibleName('0');
|
|
165
166
|
expect(separator).toHaveAttribute('aria-labelledby');
|
|
166
167
|
});
|
|
168
|
+
|
|
169
|
+
it.each(['default', 'divider'] as const)(
|
|
170
|
+
'leaves a %s row inside a chat log to the log (spec:AST-086 FR4)',
|
|
171
|
+
variant => {
|
|
172
|
+
render(
|
|
173
|
+
<ChatMessageList>
|
|
174
|
+
<ChatSystemMessage variant={variant} data-testid="notice">
|
|
175
|
+
Ana joined
|
|
176
|
+
</ChatSystemMessage>
|
|
177
|
+
</ChatMessageList>,
|
|
178
|
+
);
|
|
179
|
+
const notice = screen.getByTestId('notice');
|
|
180
|
+
expect(notice.closest('[role="log"]')).not.toBeNull();
|
|
181
|
+
for (const el of [notice, ...notice.querySelectorAll('*')]) {
|
|
182
|
+
expect(el).not.toHaveAttribute('aria-live');
|
|
183
|
+
expect(['status', 'alert', 'log']).not.toContain(
|
|
184
|
+
el.getAttribute('role'),
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
);
|
|
167
189
|
});
|
|
@@ -104,6 +104,10 @@ const styles = stylex.create({
|
|
|
104
104
|
* "user joined", status changes. Supports a divider variant with horizontal
|
|
105
105
|
* lines on each side of the text.
|
|
106
106
|
*
|
|
107
|
+
* It carries no live role: a notice added to a `ChatMessageList` is announced
|
|
108
|
+
* by the list's log, which owns content added inside it (`spec:AST-086` FR4),
|
|
109
|
+
* and a date or divider row is ordinary transcript content.
|
|
110
|
+
*
|
|
107
111
|
* @example
|
|
108
112
|
* ```
|
|
109
113
|
* <ChatSystemMessage>Conversation started</ChatSystemMessage>
|
|
@@ -131,8 +135,7 @@ export function ChatSystemMessage({
|
|
|
131
135
|
className,
|
|
132
136
|
styleProp,
|
|
133
137
|
)}
|
|
134
|
-
{...rest}
|
|
135
|
-
role="status">
|
|
138
|
+
{...rest}>
|
|
136
139
|
<Divider label={children} />
|
|
137
140
|
</div>
|
|
138
141
|
);
|
|
@@ -147,8 +150,7 @@ export function ChatSystemMessage({
|
|
|
147
150
|
className,
|
|
148
151
|
styleProp,
|
|
149
152
|
)}
|
|
150
|
-
{...rest}
|
|
151
|
-
role="status">
|
|
153
|
+
{...rest}>
|
|
152
154
|
<span {...stylex.props(styles.content)}>
|
|
153
155
|
{icon != null && <span {...stylex.props(styles.icon)}>{icon}</span>}
|
|
154
156
|
{children}
|
|
@@ -458,7 +458,8 @@ async function capture(
|
|
|
458
458
|
forcedColors: false,
|
|
459
459
|
coarsePointer: options.coarsePointer ?? false,
|
|
460
460
|
touchPoints: options.touchPoints ?? false,
|
|
461
|
-
role:
|
|
461
|
+
// No live role of its own: the chat log delivers it (spec:AST-086 FR4).
|
|
462
|
+
role: null,
|
|
462
463
|
variant: auditCase.variant,
|
|
463
464
|
text: auditCase.text,
|
|
464
465
|
separatorCount: auditCase.variant === 'divider' ? 1 : 0,
|
|
@@ -488,7 +489,7 @@ async function capture(
|
|
|
488
489
|
check(actual.selectorCount === 1, 'expected exactly one audit subject');
|
|
489
490
|
check(actual.visible, 'audit subject has no visible geometry');
|
|
490
491
|
check(actual.text === expected.text, 'visible content drifted');
|
|
491
|
-
check(actual.role === expected.role, '
|
|
492
|
+
check(actual.role === expected.role, 'root role drifted');
|
|
492
493
|
check(actual.variant === expected.variant, 'variant reflection drifted');
|
|
493
494
|
check(
|
|
494
495
|
actual.separatorCount === expected.separatorCount,
|
|
@@ -915,11 +916,11 @@ test.afterAll(async () => {
|
|
|
915
916
|
},
|
|
916
917
|
emptyContentPartitions: {
|
|
917
918
|
emptyString:
|
|
918
|
-
'Focused unit evidence: the default variant
|
|
919
|
+
'Focused unit evidence: the default variant has no root role and renders the content unchanged (empty). In the divider variant, the root and separator remain; no visible label or accessible name is produced.',
|
|
919
920
|
numericZero:
|
|
920
|
-
'Focused unit evidence: the default variant
|
|
921
|
+
'Focused unit evidence: the default variant has no root role and renders the content unchanged as one 0. In the divider variant, the root and separator remain; Divider paints two zero text nodes (textContent="00") with no accessible name.',
|
|
921
922
|
emptyFragment:
|
|
922
|
-
'Focused unit evidence: the default variant
|
|
923
|
+
'Focused unit evidence: the default variant has no root role and renders the content unchanged (empty). In the divider variant, the root and separator remain; no visible label or accessible name is produced.',
|
|
923
924
|
},
|
|
924
925
|
sharedAdvisories: {
|
|
925
926
|
dividerFalseyLabel:
|
|
@@ -933,9 +934,7 @@ test.afterAll(async () => {
|
|
|
933
934
|
forcedColors:
|
|
934
935
|
'Forced-colors rendering was not exercised. The default row makes no support claim, and the disappearing Divider rule is a shared component:Divider advisory.',
|
|
935
936
|
assistiveTechnology:
|
|
936
|
-
'Browser role exposure is measured
|
|
937
|
-
nestedLiveRegions:
|
|
938
|
-
'The nested-log frame records role=status inside ChatMessageList role=log aria-live=polite, but double-announcement behavior is unverified under spec:AST-009/FR4 and FR10.',
|
|
937
|
+
'Browser role exposure is measured; spoken output was not recorded with real assistive technology, which spec:AST-009 does not require for routing claims.',
|
|
939
938
|
},
|
|
940
939
|
stateVisualConformance: {
|
|
941
940
|
default: ['text', 'text-with-icon', 'wrapped-long-content'],
|
|
@@ -943,7 +942,7 @@ test.afterAll(async () => {
|
|
|
943
942
|
'labelled-separator',
|
|
944
943
|
'unresolved-divider-icon-observation',
|
|
945
944
|
],
|
|
946
|
-
nestedLiveLog: ['
|
|
945
|
+
nestedLiveLog: ['no-live-role-inside-polite-log'],
|
|
947
946
|
colorModes: ['light', 'dark'],
|
|
948
947
|
narrowCoarsePointer: [
|
|
949
948
|
'default-with-icon',
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* indicators, row paint, hit targets, wrapping, list structure) are recorded
|
|
11
11
|
* for the audit and never gate. Only frames that prove a shipped remediation
|
|
12
12
|
* gate on its outcome. The select-all CLI block renders through its own
|
|
13
|
-
* story, and its source blob is bound in the manifest.
|
|
13
|
+
* story, and its source blob is bound in the manifest. Disabled rows expect
|
|
14
|
+
* focusable aria-disabled controls under AST-085 FR1 and FR7.
|
|
14
15
|
*/
|
|
15
16
|
|
|
16
17
|
import {createHash} from 'node:crypto';
|
|
@@ -271,9 +272,9 @@ const CASES: AuditCase[] = [
|
|
|
271
272
|
expect: {
|
|
272
273
|
groupName: GROUP,
|
|
273
274
|
rows: [
|
|
274
|
-
{checked: true, disabled: '
|
|
275
|
-
{checked: false, disabled: '
|
|
276
|
-
{checked: false, disabled: '
|
|
275
|
+
{checked: true, disabled: 'focusable'},
|
|
276
|
+
{checked: false, disabled: 'focusable'},
|
|
277
|
+
{checked: false, disabled: 'focusable'},
|
|
277
278
|
],
|
|
278
279
|
},
|
|
279
280
|
},
|
|
@@ -285,7 +286,7 @@ const CASES: AuditCase[] = [
|
|
|
285
286
|
groupName: GROUP,
|
|
286
287
|
rows: [
|
|
287
288
|
{checked: false, disabled: false},
|
|
288
|
-
{checked: false, disabled: '
|
|
289
|
+
{checked: false, disabled: 'focusable'},
|
|
289
290
|
{checked: false, disabled: false},
|
|
290
291
|
],
|
|
291
292
|
},
|
|
@@ -471,7 +472,7 @@ const CASES: AuditCase[] = [
|
|
|
471
472
|
rows: [
|
|
472
473
|
{checked: true},
|
|
473
474
|
{checked: false},
|
|
474
|
-
{checked: false, disabled: '
|
|
475
|
+
{checked: false, disabled: 'focusable'},
|
|
475
476
|
],
|
|
476
477
|
},
|
|
477
478
|
},
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file CodeBlock.highlightStyles.test.tsx
|
|
5
|
+
* @input Uses vitest, @testing-library/react, CodeBlock, a mocked CSS Custom Highlight API
|
|
6
|
+
* @output Tests that the ::highlight() rules exist before a commit's layout effects run
|
|
7
|
+
* @position Performance regression evidence. Rules inserted after layout
|
|
8
|
+
* effects make the browser restyle the whole document a second time once
|
|
9
|
+
* any layout effect has read style, so their insertion point is pinned here.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {useLayoutEffect} from 'react';
|
|
13
|
+
import {cleanup, render} from '@testing-library/react';
|
|
14
|
+
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
|
15
|
+
|
|
16
|
+
class MockHighlight extends Set<Range> {}
|
|
17
|
+
const realCss = globalThis.CSS;
|
|
18
|
+
|
|
19
|
+
const STYLE_SELECTOR = 'style[data-astryx-highlight-styles]';
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
// A fresh module instance, so the once-per-document insertion flag starts
|
|
23
|
+
// unset in every test.
|
|
24
|
+
vi.resetModules();
|
|
25
|
+
document.head.querySelectorAll(STYLE_SELECTOR).forEach(el => el.remove());
|
|
26
|
+
const highlights = new Map<string, MockHighlight>();
|
|
27
|
+
globalThis.CSS = {
|
|
28
|
+
escape: realCss.escape,
|
|
29
|
+
highlights: {
|
|
30
|
+
get: (name: string) => highlights.get(name),
|
|
31
|
+
set: (name: string, h: MockHighlight) => highlights.set(name, h),
|
|
32
|
+
} as unknown as HighlightRegistry,
|
|
33
|
+
} as typeof CSS;
|
|
34
|
+
vi.stubGlobal('Highlight', MockHighlight);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
cleanup();
|
|
39
|
+
globalThis.CSS = realCss;
|
|
40
|
+
vi.unstubAllGlobals();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('CodeBlock ::highlight() rules', () => {
|
|
44
|
+
it('are in the document before layout effects in the same commit run', async () => {
|
|
45
|
+
const {CodeBlock} = await import('./CodeBlock');
|
|
46
|
+
const seen: boolean[] = [];
|
|
47
|
+
function LayoutReader() {
|
|
48
|
+
useLayoutEffect(() => {
|
|
49
|
+
seen.push(document.head.querySelector(STYLE_SELECTOR) != null);
|
|
50
|
+
}, []);
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
render(
|
|
54
|
+
<>
|
|
55
|
+
<CodeBlock code="const a = 1;" language="ts" highlightMode="ranges" />
|
|
56
|
+
<LayoutReader />
|
|
57
|
+
</>,
|
|
58
|
+
);
|
|
59
|
+
expect(seen).toEqual([true]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('are inserted once for many code blocks', async () => {
|
|
63
|
+
const {CodeBlock} = await import('./CodeBlock');
|
|
64
|
+
render(
|
|
65
|
+
<>
|
|
66
|
+
{Array.from({length: 20}, (_, i) => (
|
|
67
|
+
<CodeBlock
|
|
68
|
+
key={i}
|
|
69
|
+
code={`const v${i} = ${i};`}
|
|
70
|
+
language="ts"
|
|
71
|
+
highlightMode="ranges"
|
|
72
|
+
/>
|
|
73
|
+
))}
|
|
74
|
+
</>,
|
|
75
|
+
);
|
|
76
|
+
expect(document.head.querySelectorAll(STYLE_SELECTOR)).toHaveLength(1);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('are not inserted by range highlighting without the Highlight API', async () => {
|
|
80
|
+
vi.stubGlobal('Highlight', undefined);
|
|
81
|
+
const {CodeBlock} = await import('./CodeBlock');
|
|
82
|
+
render(
|
|
83
|
+
<CodeBlock code="const a = 1;" language="ts" highlightMode="ranges" />,
|
|
84
|
+
);
|
|
85
|
+
expect(document.head.querySelector(STYLE_SELECTOR)).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -803,11 +803,21 @@ function RangeCodeContent({
|
|
|
803
803
|
}) {
|
|
804
804
|
const codeRef = useRef<HTMLElement>(null);
|
|
805
805
|
|
|
806
|
+
// Insert the ::highlight() rules before the commit's layout reads, as the
|
|
807
|
+
// span path does. Inserted later, the new rules restyle the whole document
|
|
808
|
+
// a second time after any layout read in the same commit has already
|
|
809
|
+
// styled it, which on a long page full of code blocks costs a full style
|
|
810
|
+
// recalculation.
|
|
811
|
+
useInsertionEffect(() => {
|
|
812
|
+
if (hasHighlightAPI()) {
|
|
813
|
+
ensureHighlightStyles();
|
|
814
|
+
}
|
|
815
|
+
}, []);
|
|
816
|
+
|
|
806
817
|
useEffect(() => {
|
|
807
818
|
if (!hasHighlightAPI()) {
|
|
808
819
|
return;
|
|
809
820
|
}
|
|
810
|
-
ensureHighlightStyles();
|
|
811
821
|
|
|
812
822
|
const codeEl = codeRef.current;
|
|
813
823
|
if (!codeEl || tokenLines.length === 0) {
|
|
@@ -45,14 +45,15 @@ export const docs = {
|
|
|
45
45
|
{
|
|
46
46
|
name: 'isDisabled',
|
|
47
47
|
type: 'boolean',
|
|
48
|
-
description:
|
|
48
|
+
description:
|
|
49
|
+
'Disable the trigger and picker. The trigger stays focusable and exposes `aria-disabled`; opening is blocked.',
|
|
49
50
|
default: 'false',
|
|
50
51
|
},
|
|
51
52
|
{
|
|
52
53
|
name: 'disabledMessage',
|
|
53
54
|
type: 'string',
|
|
54
55
|
description:
|
|
55
|
-
'Explains why the input is disabled. With isDisabled, shows a tooltip on hover/keyboard focus and
|
|
56
|
+
'Explains why the input is disabled. With isDisabled, shows a tooltip on hover/keyboard focus and describes the trigger (activation stays blocked). Use this instead of wrapping a disabled DateRangeInput in Tooltip.',
|
|
56
57
|
},
|
|
57
58
|
{
|
|
58
59
|
name: 'value',
|
|
@@ -240,7 +241,7 @@ export const docs = {
|
|
|
240
241
|
{
|
|
241
242
|
guidance: false,
|
|
242
243
|
description:
|
|
243
|
-
'Wrap a disabled DateRangeInput in Tooltip to explain why it is disabled
|
|
244
|
+
'Wrap a disabled DateRangeInput in Tooltip to explain why it is disabled. Use the disabledMessage prop instead, which ties the reason to the field.',
|
|
244
245
|
},
|
|
245
246
|
],
|
|
246
247
|
anatomy: [
|
|
@@ -344,7 +345,7 @@ export const docsDense = {
|
|
|
344
345
|
{
|
|
345
346
|
guidance: false,
|
|
346
347
|
description:
|
|
347
|
-
'Wrap a disabled DateRangeInput in Tooltip to explain why it is disabled
|
|
348
|
+
'Wrap a disabled DateRangeInput in Tooltip to explain why it is disabled. Use the disabledMessage prop instead, which ties the reason to the field.',
|
|
348
349
|
},
|
|
349
350
|
],
|
|
350
351
|
},
|
|
@@ -356,7 +357,7 @@ export const docsDense = {
|
|
|
356
357
|
isRequired: 'mark field required',
|
|
357
358
|
isDisabled: 'disable trigger+picker',
|
|
358
359
|
disabledMessage:
|
|
359
|
-
'reason shown in a tooltip on hover/focus when disabled;
|
|
360
|
+
'reason shown in a tooltip on hover/focus when disabled; describes the trigger',
|
|
360
361
|
value:
|
|
361
362
|
'selected range {start, end} or null; import DateRange type from @astryxdesign/core/DateRangeInput (do not redeclare)',
|
|
362
363
|
onChange: 'callback on range change; null on clear',
|
|
@@ -23,6 +23,8 @@ function generateThemeTestCSS(theme: Parameters<typeof generateThemeCSS>[0]) {
|
|
|
23
23
|
// made every role+name query compute ~85 accessible names through jsdom's
|
|
24
24
|
// slow getComputedStyle (~450ms per query). See fastRoleQueries.ts.
|
|
25
25
|
import {getButton, queryButton} from '../__tests__/fastRoleQueries';
|
|
26
|
+
import * as stylex from '@stylexjs/stylex';
|
|
27
|
+
import {colorVars} from '../theme/tokens.stylex';
|
|
26
28
|
import {DateRangeInput} from './DateRangeInput';
|
|
27
29
|
import type {DateRange} from './DateRangeInput';
|
|
28
30
|
import {Icon} from '../Icon';
|
|
@@ -155,7 +157,46 @@ describe('DateRangeInput', () => {
|
|
|
155
157
|
/>,
|
|
156
158
|
);
|
|
157
159
|
const trigger = getButton(/Range/);
|
|
158
|
-
expect(trigger).
|
|
160
|
+
expect(trigger).not.toHaveAttribute('disabled');
|
|
161
|
+
expect(trigger).toHaveAttribute('aria-disabled', 'true');
|
|
162
|
+
trigger.focus();
|
|
163
|
+
expect(trigger).toHaveFocus();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('restores the focus ring on a focused disabled field (AST-085 FR6)', () => {
|
|
167
|
+
// The same declarations as the field's ring style compile to the same
|
|
168
|
+
// atomic classes.
|
|
169
|
+
const ringClasses = stylex
|
|
170
|
+
.props(
|
|
171
|
+
stylex.create({
|
|
172
|
+
ring: {
|
|
173
|
+
borderColor: {
|
|
174
|
+
default: colorVars['--color-border-emphasized'],
|
|
175
|
+
':focus-within': colorVars['--color-accent'],
|
|
176
|
+
},
|
|
177
|
+
boxShadow: {
|
|
178
|
+
default: 'none',
|
|
179
|
+
':focus-within': `inset 0px 0px 0px 2px ${colorVars['--color-accent-muted']}`,
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
}).ring,
|
|
183
|
+
)
|
|
184
|
+
.className!.split(' ')
|
|
185
|
+
// Keep the atomic classes, not the dev-only debug name.
|
|
186
|
+
.filter(name => !name.includes('__'));
|
|
187
|
+
render(
|
|
188
|
+
<DateRangeInput
|
|
189
|
+
label="Range"
|
|
190
|
+
isDisabled
|
|
191
|
+
value={null}
|
|
192
|
+
onChange={() => {}}
|
|
193
|
+
/>,
|
|
194
|
+
);
|
|
195
|
+
// The shared disabled style alone drops these :focus-within declarations.
|
|
196
|
+
const wrapper = getButton(/Range/).parentElement!;
|
|
197
|
+
for (const name of ringClasses) {
|
|
198
|
+
expect(wrapper).toHaveClass(name);
|
|
199
|
+
}
|
|
159
200
|
});
|
|
160
201
|
|
|
161
202
|
it('is not disabled by default', () => {
|
|
@@ -272,7 +313,8 @@ describe('DateRangeInput', () => {
|
|
|
272
313
|
onChange={() => {}}
|
|
273
314
|
/>,
|
|
274
315
|
);
|
|
275
|
-
expect(getButton('Open calendar')).
|
|
316
|
+
expect(getButton('Open calendar')).not.toHaveAttribute('disabled');
|
|
317
|
+
expect(getButton('Open calendar')).toHaveAttribute('aria-disabled', 'true');
|
|
276
318
|
});
|
|
277
319
|
|
|
278
320
|
it('renders with size="lg"', () => {
|
|
@@ -526,7 +568,8 @@ describe('DateRangeInput', () => {
|
|
|
526
568
|
);
|
|
527
569
|
|
|
528
570
|
const preset = getButton('Last 7 days');
|
|
529
|
-
expect(preset).
|
|
571
|
+
expect(preset).not.toHaveAttribute('disabled');
|
|
572
|
+
expect(preset).toHaveAttribute('aria-disabled', 'true');
|
|
530
573
|
fireEvent.click(preset);
|
|
531
574
|
expect(handleChange).not.toHaveBeenCalled();
|
|
532
575
|
});
|
|
@@ -544,7 +587,8 @@ describe('DateRangeInput', () => {
|
|
|
544
587
|
);
|
|
545
588
|
|
|
546
589
|
const preset = getButton('Last 7 days');
|
|
547
|
-
expect(preset).
|
|
590
|
+
expect(preset).not.toHaveAttribute('disabled');
|
|
591
|
+
expect(preset).toHaveAttribute('aria-disabled', 'true');
|
|
548
592
|
fireEvent.click(preset);
|
|
549
593
|
expect(handleChange).not.toHaveBeenCalled();
|
|
550
594
|
});
|
|
@@ -562,7 +606,8 @@ describe('DateRangeInput', () => {
|
|
|
562
606
|
);
|
|
563
607
|
|
|
564
608
|
const preset = getButton('Last 7 days');
|
|
565
|
-
expect(preset).
|
|
609
|
+
expect(preset).not.toHaveAttribute('disabled');
|
|
610
|
+
expect(preset).toHaveAttribute('aria-disabled', 'true');
|
|
566
611
|
fireEvent.click(preset);
|
|
567
612
|
expect(handleChange).not.toHaveBeenCalled();
|
|
568
613
|
});
|
|
@@ -590,7 +635,7 @@ describe('DateRangeInput', () => {
|
|
|
590
635
|
|
|
591
636
|
const getRangeCallsBeforeClick = getRange.mock.calls.length;
|
|
592
637
|
const preset = getButton('Allowed range');
|
|
593
|
-
expect(preset).not.
|
|
638
|
+
expect(preset).not.toHaveAttribute('aria-disabled');
|
|
594
639
|
fireEvent.click(preset);
|
|
595
640
|
expect(handleChange).toHaveBeenCalledWith(range);
|
|
596
641
|
expect(getRangeCallsAtChange).toBe(getRangeCallsBeforeClick);
|
|
@@ -613,7 +658,10 @@ describe('DateRangeInput', () => {
|
|
|
613
658
|
// accessibility tree; use hidden: true to find it.
|
|
614
659
|
const h = {hidden: true} as const;
|
|
615
660
|
|
|
616
|
-
function renderDisabled(props?: {
|
|
661
|
+
function renderDisabled(props?: {
|
|
662
|
+
disabledMessage?: string;
|
|
663
|
+
onClick?: () => void;
|
|
664
|
+
}) {
|
|
617
665
|
return render(
|
|
618
666
|
<DateRangeInput
|
|
619
667
|
label="Range"
|
|
@@ -699,11 +747,22 @@ describe('DateRangeInput', () => {
|
|
|
699
747
|
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
700
748
|
});
|
|
701
749
|
|
|
702
|
-
it('
|
|
703
|
-
|
|
750
|
+
it('blocks activation and a caller onClick when disabled without a reason (AST-085 FR1, FR2)', async () => {
|
|
751
|
+
const user = userEvent.setup();
|
|
752
|
+
const onClick = vi.fn();
|
|
753
|
+
renderDisabled({onClick});
|
|
704
754
|
const trigger = getButton(/Range:/);
|
|
705
|
-
expect(trigger).
|
|
706
|
-
|
|
755
|
+
expect(trigger).not.toHaveAttribute('disabled');
|
|
756
|
+
await user.click(trigger);
|
|
757
|
+
await user.keyboard('{Enter}');
|
|
758
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
759
|
+
expect(onClick).not.toHaveBeenCalled();
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
it('blocks the calendar button while disabled (AST-085 FR2)', () => {
|
|
763
|
+
renderDisabled();
|
|
764
|
+
fireEvent.click(getButton('Open calendar'));
|
|
765
|
+
expect(getButton(/Range:/)).toHaveAttribute('aria-expanded', 'false');
|
|
707
766
|
});
|
|
708
767
|
});
|
|
709
768
|
});
|
|
@@ -988,13 +1047,19 @@ describe('DateRangeInput range-span forwarding', () => {
|
|
|
988
1047
|
fireEvent.click(getButton('Open calendar'));
|
|
989
1048
|
|
|
990
1049
|
// Before a start is picked, a far-off day is selectable.
|
|
991
|
-
expect(dayButton('2026-01-20')).not.
|
|
1050
|
+
expect(dayButton('2026-01-20')).not.toHaveAttribute(
|
|
1051
|
+
'aria-disabled',
|
|
1052
|
+
'true',
|
|
1053
|
+
);
|
|
992
1054
|
|
|
993
1055
|
fireEvent.click(dayButton('2026-01-10') as HTMLButtonElement);
|
|
994
1056
|
|
|
995
1057
|
// A 7-day window spans start ± 6 days: Jan 16 is the edge, Jan 17 is out.
|
|
996
|
-
expect(dayButton('2026-01-16')).not.
|
|
997
|
-
|
|
1058
|
+
expect(dayButton('2026-01-16')).not.toHaveAttribute(
|
|
1059
|
+
'aria-disabled',
|
|
1060
|
+
'true',
|
|
1061
|
+
);
|
|
1062
|
+
expect(dayButton('2026-01-17')).toHaveAttribute('aria-disabled', 'true');
|
|
998
1063
|
});
|
|
999
1064
|
|
|
1000
1065
|
it('disables a preset whose range violates the span cap', () => {
|
|
@@ -1030,9 +1095,10 @@ describe('DateRangeInput range-span forwarding', () => {
|
|
|
1030
1095
|
// The 3-day preset fits the 7-day cap; the 30-day preset can't be committed.
|
|
1031
1096
|
const withinCap = getButton('Last 3 days');
|
|
1032
1097
|
const overCap = getButton('Last 30 days');
|
|
1033
|
-
expect(withinCap).not.
|
|
1098
|
+
expect(withinCap).not.toHaveAttribute('aria-disabled');
|
|
1034
1099
|
expect(withinCap).not.toHaveAttribute('data-disabled');
|
|
1035
|
-
expect(overCap).
|
|
1100
|
+
expect(overCap).not.toHaveAttribute('disabled');
|
|
1101
|
+
expect(overCap).toHaveAttribute('aria-disabled', 'true');
|
|
1036
1102
|
expect(overCap).toHaveClass('astryx-date-range-input-preset');
|
|
1037
1103
|
expect(overCap).toHaveAttribute('data-disabled', 'disabled');
|
|
1038
1104
|
|