@astryxdesign/core 0.7.2 → 0.7.3-canary.0bf23bd
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/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/CheckboxInput/CheckboxInput.d.ts.map +1 -1
- package/dist/CheckboxInput/CheckboxInput.js +19 -24
- package/dist/ClickableCard/ClickableCard.d.ts +2 -2
- package/dist/ClickableCard/ClickableCard.d.ts.map +1 -1
- package/dist/ClickableCard/ClickableCard.js +11 -9
- package/dist/CodeBlock/CodeBlock.d.ts.map +1 -1
- package/dist/CodeBlock/CodeBlock.js +11 -1
- package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
- package/dist/ComplexSelector/ComplexSelector.js +23 -5
- package/dist/DateInput/DateInput.d.ts +4 -4
- package/dist/DateInput/DateInput.d.ts.map +1 -1
- package/dist/DateInput/DateInput.js +27 -20
- package/dist/DateInput/NativeDateField.d.ts.map +1 -1
- package/dist/DateInput/NativeDateField.js +14 -8
- package/dist/DateInput/TouchDateField.d.ts.map +1 -1
- package/dist/DateInput/TouchDateField.js +14 -10
- package/dist/Dialog/Dialog.d.ts.map +1 -1
- package/dist/Dialog/Dialog.js +9 -4
- package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenu.js +52 -10
- package/dist/DropdownMenu/DropdownMenuContext.d.ts +34 -1
- package/dist/DropdownMenu/DropdownMenuContext.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuContext.js +15 -0
- package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts +21 -2
- package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuSubMenu.js +375 -42
- package/dist/DropdownMenu/menuRegionStops.d.ts +2 -2
- package/dist/DropdownMenu/menuRegionStops.js +2 -2
- package/dist/FileInput/FileInput.d.ts.map +1 -1
- package/dist/FileInput/FileInput.js +14 -12
- package/dist/Link/Link.d.ts +2 -3
- package/dist/Link/Link.d.ts.map +1 -1
- package/dist/Link/Link.js +11 -19
- 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/Popover/Popover.d.ts.map +1 -1
- package/dist/Popover/Popover.js +8 -4
- package/dist/Selector/Selector.d.ts +4 -4
- package/dist/Selector/Selector.d.ts.map +1 -1
- package/dist/Selector/Selector.js +23 -14
- package/dist/Selector/SelectorOption.d.ts.map +1 -1
- package/dist/Selector/SelectorOption.js +1 -0
- package/dist/Slider/Slider.d.ts.map +1 -1
- package/dist/Slider/Slider.js +7 -14
- package/dist/Switch/Switch.d.ts.map +1 -1
- package/dist/Switch/Switch.js +25 -17
- package/dist/Switch/switch.markers.stylex.d.ts +2 -0
- package/dist/Switch/switch.markers.stylex.d.ts.map +1 -1
- package/dist/Switch/switch.markers.stylex.js +6 -0
- package/dist/TimeInput/TimeInput.d.ts +2 -1
- package/dist/TimeInput/TimeInput.d.ts.map +1 -1
- package/dist/Token/Token.d.ts.map +1 -1
- package/dist/Token/Token.js +8 -3
- package/dist/Tooltip/Tooltip.d.ts.map +1 -1
- package/dist/Tooltip/Tooltip.js +36 -14
- package/dist/Tooltip/useTooltip.d.ts.map +1 -1
- package/dist/Tooltip/useTooltip.js +11 -2
- package/dist/TopNav/TopNav.d.ts.map +1 -1
- package/dist/TopNav/TopNav.js +2 -2
- package/dist/TopNav/TopNavHeading.d.ts.map +1 -1
- package/dist/TopNav/TopNavHeading.js +1 -0
- package/dist/Typeahead/Typeahead.d.ts.map +1 -1
- package/dist/Typeahead/Typeahead.js +14 -2
- package/dist/astryx.css +8 -2
- package/dist/hooks/useMenuPress.d.ts +11 -1
- package/dist/hooks/useMenuPress.d.ts.map +1 -1
- package/dist/hooks/useMenuPress.js +43 -5
- package/dist/utils/disabledInteraction.d.ts +21 -0
- package/dist/utils/disabledInteraction.d.ts.map +1 -0
- package/dist/utils/disabledInteraction.js +33 -0
- package/dist/utils/timeParser.d.ts +4 -0
- package/dist/utils/timeParser.d.ts.map +1 -1
- package/dist/utils/timeParser.js +9 -2
- package/package.json +4 -4
- package/scripts/agent-doc-state.mjs +35 -8
- package/src/Banner/Banner.spec.md +37 -32
- package/src/BottomSheet/BottomSheet.spec.md +13 -6
- package/src/BottomSheet/BottomSheet.test.tsx +29 -0
- package/src/BottomSheet/BottomSheet.tsx +8 -4
- package/src/BottomSheet/BottomSheetSwitcher.spec.md +121 -51
- package/src/BottomSheet/BottomSheetSwitcher.test.tsx +26 -0
- package/src/BottomSheet/BottomSheetSwitcher.tsx +6 -0
- package/src/CheckboxInput/CheckboxInput.test.tsx +44 -5
- package/src/CheckboxInput/CheckboxInput.tsx +16 -26
- package/src/CheckboxInput/__tests__/Checkbox.a11y.states.ts +3 -3
- package/src/CheckboxList/CheckboxList.test.tsx +9 -6
- package/src/ClickableCard/ClickableCard.doc.mjs +2 -2
- package/src/ClickableCard/ClickableCard.test.tsx +5 -4
- package/src/ClickableCard/ClickableCard.tsx +9 -9
- package/src/CodeBlock/CodeBlock.highlightStyles.test.tsx +87 -0
- package/src/CodeBlock/CodeBlock.tsx +11 -1
- package/src/ComplexSelector/ComplexSelector.focusableDisabled.test.tsx +76 -0
- package/src/ComplexSelector/ComplexSelector.tsx +21 -4
- package/src/ContextMenu/ContextMenu.test.tsx +41 -0
- package/src/DateInput/DateInput.doc.mjs +9 -8
- package/src/DateInput/DateInput.test.tsx +88 -13
- package/src/DateInput/DateInput.tsx +49 -21
- package/src/DateInput/DateInputTouch.test.tsx +22 -1
- package/src/DateInput/NativeDateField.test.tsx +29 -3
- package/src/DateInput/NativeDateField.tsx +20 -7
- package/src/DateInput/TouchDateField.tsx +24 -9
- package/src/DateRangeInput/DateRangeInput.spec.md +13 -13
- package/src/Dialog/Dialog.test.tsx +39 -0
- package/src/Dialog/Dialog.tsx +9 -4
- package/src/DropdownMenu/DropdownMenu.spec.md +33 -2
- package/src/DropdownMenu/DropdownMenu.tsx +79 -8
- package/src/DropdownMenu/DropdownMenuContext.public.test.ts +22 -0
- package/src/DropdownMenu/DropdownMenuContext.tsx +39 -1
- package/src/DropdownMenu/DropdownMenuSubMenu.doc.mjs +16 -1
- package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +623 -1
- package/src/DropdownMenu/DropdownMenuSubMenu.tsx +556 -99
- package/src/DropdownMenu/__tests__/SubMenuHeaderFooter.a11y.chromium.spec.ts +267 -0
- package/src/DropdownMenu/menuRegionStops.ts +2 -2
- package/src/DropdownMenu/modules/useMenuPress.spec.md +9 -0
- package/src/FieldStatus/FieldStatus.spec.md +3 -2
- package/src/FileInput/FileInput.test.tsx +30 -7
- package/src/FileInput/FileInput.tsx +21 -11
- package/src/Link/Link.test.tsx +21 -7
- package/src/Link/Link.tsx +16 -24
- package/src/Link/__tests__/Link.a11y.states.ts +1 -1
- 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/Popover/Popover.test.tsx +15 -0
- package/src/Popover/Popover.tsx +19 -8
- package/src/PowerSearch/PowerSearchFilterEditor.test.tsx +3 -2
- package/src/PowerSearch/PowerSearchToken.test.tsx +7 -5
- package/src/Selector/Selector.doc.mjs +4 -4
- package/src/Selector/Selector.focusableDisabled.test.tsx +127 -0
- package/src/Selector/Selector.spec.md +11 -11
- package/src/Selector/Selector.test.tsx +64 -6
- package/src/Selector/Selector.tsx +51 -21
- package/src/Selector/SelectorOption.tsx +4 -0
- package/src/SideNav/SideNav.test.tsx +35 -0
- package/src/Slider/Slider.spec.md +11 -11
- package/src/Slider/Slider.test.tsx +3 -3
- package/src/Slider/Slider.tsx +8 -15
- package/src/Switch/Switch.test.tsx +85 -8
- package/src/Switch/Switch.tsx +26 -16
- package/src/Switch/__tests__/Switch.a11y.states.ts +2 -2
- package/src/Switch/switch.markers.stylex.ts +4 -0
- package/src/Table/Table.spec.md +2 -0
- package/src/Table/plugins/infiniteScroll/useTableInfiniteScroll.spec.md +244 -0
- package/src/Table/plugins/selection/useTableSelection.test.tsx +5 -3
- package/src/Table/plugins/virtualization/useTableVirtualization.spec.md +266 -0
- package/src/TimeInput/TimeInput.doc.mjs +5 -3
- package/src/TimeInput/TimeInput.test.tsx +20 -0
- package/src/TimeInput/TimeInput.tsx +2 -1
- package/src/Token/Token.test.tsx +26 -4
- package/src/Token/Token.tsx +6 -2
- package/src/Tokenizer/Tokenizer.spec.md +28 -28
- package/src/Tooltip/Tooltip.test.tsx +51 -6
- package/src/Tooltip/Tooltip.tsx +41 -18
- package/src/Tooltip/__tests__/Tooltip.a11y.chromium.spec.ts +129 -0
- package/src/Tooltip/__tests__/Tooltip.a11y.renders.tsx +86 -0
- package/src/Tooltip/__tests__/Tooltip.a11y.states.ts +132 -0
- package/src/Tooltip/__tests__/Tooltip.a11y.test.tsx +126 -0
- package/src/Tooltip/__tests__/TooltipHoverBridge.a11y.chromium.spec.ts +74 -0
- package/src/Tooltip/useTooltip.test.tsx +23 -0
- package/src/Tooltip/useTooltip.tsx +22 -9
- package/src/TopNav/TopNav.tsx +11 -1
- package/src/TopNav/TopNavHeading.tsx +1 -0
- package/src/TopNav/__tests__/TopNavMobileLongHeading.a11y.chromium.spec.ts +74 -0
- package/src/Typeahead/Typeahead.test.tsx +89 -0
- package/src/Typeahead/Typeahead.tsx +15 -2
- package/src/hooks/useMenuPress.ts +45 -7
- package/src/utils/disabledInteraction.ts +40 -0
- package/src/utils/timeParser.test.ts +11 -0
- package/src/utils/timeParser.ts +9 -2
|
@@ -129,7 +129,7 @@ describe('ClickableCard', () => {
|
|
|
129
129
|
expect(link).toHaveAttribute('target', '_blank');
|
|
130
130
|
});
|
|
131
131
|
|
|
132
|
-
it('disabled button
|
|
132
|
+
it('disabled button stays focusable and the surface runs nothing', () => {
|
|
133
133
|
const handleClick = vi.fn();
|
|
134
134
|
render(
|
|
135
135
|
<ClickableCard label="Disabled" onClick={handleClick} isDisabled>
|
|
@@ -137,13 +137,14 @@ describe('ClickableCard', () => {
|
|
|
137
137
|
</ClickableCard>,
|
|
138
138
|
);
|
|
139
139
|
const button = screen.getByRole('button', {name: 'Disabled'});
|
|
140
|
-
expect(button).
|
|
140
|
+
expect(button).not.toHaveAttribute('disabled');
|
|
141
|
+
expect(button).toHaveAttribute('aria-disabled', 'true');
|
|
141
142
|
fireEvent.click(screen.getByText('Content'));
|
|
142
143
|
fireEvent.click(button);
|
|
143
144
|
expect(handleClick).not.toHaveBeenCalled();
|
|
144
145
|
});
|
|
145
146
|
|
|
146
|
-
it('disabled link has no live destination,
|
|
147
|
+
it('disabled link has no live destination, keeps its tab stop, and runs nothing', () => {
|
|
147
148
|
const handleClick = vi.fn();
|
|
148
149
|
render(
|
|
149
150
|
<ClickableCard
|
|
@@ -157,7 +158,7 @@ describe('ClickableCard', () => {
|
|
|
157
158
|
);
|
|
158
159
|
const link = screen.getByRole('link', {name: 'Disabled link'});
|
|
159
160
|
expect(link).toHaveAttribute('aria-disabled', 'true');
|
|
160
|
-
expect(link).toHaveAttribute('tabindex', '
|
|
161
|
+
expect(link).toHaveAttribute('tabindex', '0');
|
|
161
162
|
expect(link).not.toHaveAttribute('href');
|
|
162
163
|
expect(link).not.toHaveAttribute('target');
|
|
163
164
|
expect(fireEvent.click(link)).toBe(false);
|
|
@@ -51,6 +51,7 @@ import {interactionOverlayStyles} from '../utils/interactionOverlay.stylex';
|
|
|
51
51
|
import {usePressFeedback} from '../hooks/usePressFeedback';
|
|
52
52
|
|
|
53
53
|
import {useMergedRefs} from '../hooks/useMergedRefs';
|
|
54
|
+
import {disabledInteractionProps} from '../utils/disabledInteraction';
|
|
54
55
|
// =============================================================================
|
|
55
56
|
// Styles — only the interactive layer, Card handles everything else
|
|
56
57
|
// =============================================================================
|
|
@@ -210,8 +211,8 @@ export interface ClickableCardProps extends BaseProps {
|
|
|
210
211
|
|
|
211
212
|
/**
|
|
212
213
|
* Set to true to disable the card.
|
|
213
|
-
* Disabled
|
|
214
|
-
*
|
|
214
|
+
* Disabled cards keep their tab stop and expose aria-disabled; activation is
|
|
215
|
+
* blocked, and disabled link cards render no live destination.
|
|
215
216
|
*/
|
|
216
217
|
isDisabled?: boolean;
|
|
217
218
|
|
|
@@ -255,10 +256,6 @@ export interface ClickableCardProps extends BaseProps {
|
|
|
255
256
|
// Component
|
|
256
257
|
// =============================================================================
|
|
257
258
|
|
|
258
|
-
function preventDisabledLinkClick(event: MouseEvent<HTMLAnchorElement>): void {
|
|
259
|
-
event.preventDefault();
|
|
260
|
-
}
|
|
261
|
-
|
|
262
259
|
/**
|
|
263
260
|
* An interactive card that acts as a single navigation or action target.
|
|
264
261
|
*
|
|
@@ -376,11 +373,12 @@ export function ClickableCard({
|
|
|
376
373
|
ref={interactiveRef as React.Ref<HTMLAnchorElement>}
|
|
377
374
|
href={isDisabled ? undefined : href}
|
|
378
375
|
target={isDisabled ? undefined : target}
|
|
379
|
-
onClick={isDisabled ? preventDisabledLinkClick : undefined}
|
|
380
376
|
role={isDisabled ? 'link' : undefined}
|
|
377
|
+
{...(isDisabled ? disabledInteractionProps() : null)}
|
|
381
378
|
aria-label={label}
|
|
382
379
|
aria-disabled={isDisabled || undefined}
|
|
383
|
-
|
|
380
|
+
// A disabled card keeps its Tab stop (spec:AST-085 FR1).
|
|
381
|
+
tabIndex={0}
|
|
384
382
|
{...stylex.props(styles.srOnly)}
|
|
385
383
|
/>
|
|
386
384
|
) : (
|
|
@@ -388,8 +386,10 @@ export function ClickableCard({
|
|
|
388
386
|
ref={interactiveRef as React.Ref<HTMLButtonElement>}
|
|
389
387
|
type="button"
|
|
390
388
|
aria-label={label}
|
|
391
|
-
disabled={isDisabled}
|
|
389
|
+
aria-disabled={isDisabled || undefined}
|
|
392
390
|
onClick={onClickProp}
|
|
391
|
+
// Focusable while disabled (FR1), but inert (FR2).
|
|
392
|
+
{...(isDisabled ? disabledInteractionProps() : null)}
|
|
393
393
|
{...stylex.props(styles.srOnly)}
|
|
394
394
|
/>
|
|
395
395
|
)}
|
|
@@ -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) {
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file ComplexSelector.focusableDisabled.test.tsx
|
|
5
|
+
* @input Uses vitest, @testing-library/react, ComplexSelector
|
|
6
|
+
* @output Conformance tests for spec:AST-085 FR1, FR2 and FR4 on ComplexSelector
|
|
7
|
+
* @position Testing; validates the disabled trigger of ComplexSelector.tsx
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {describe, it, expect, vi} from 'vitest';
|
|
11
|
+
import {act, render, screen} from '@testing-library/react';
|
|
12
|
+
import userEvent from '@testing-library/user-event';
|
|
13
|
+
import {ComplexSelector} from './ComplexSelector';
|
|
14
|
+
|
|
15
|
+
function Harness(props: {
|
|
16
|
+
isDisabled: boolean;
|
|
17
|
+
onClick?: () => void;
|
|
18
|
+
onKeyDown?: () => void;
|
|
19
|
+
}) {
|
|
20
|
+
return (
|
|
21
|
+
<>
|
|
22
|
+
<button type="button">Before</button>
|
|
23
|
+
<ComplexSelector label="View options" value={[]} {...props}>
|
|
24
|
+
{() => <button type="button">Apply</button>}
|
|
25
|
+
</ComplexSelector>
|
|
26
|
+
<button type="button">After</button>
|
|
27
|
+
</>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const trigger = () => screen.getByRole('button', {name: 'View options'});
|
|
32
|
+
|
|
33
|
+
describe('ComplexSelector disabled (spec:AST-085)', () => {
|
|
34
|
+
it('FR1: renders no native disabled and keeps its tab stop', async () => {
|
|
35
|
+
const user = userEvent.setup();
|
|
36
|
+
const {container} = render(<Harness isDisabled />);
|
|
37
|
+
expect(container.querySelector('[disabled]')).toBeNull();
|
|
38
|
+
expect(trigger()).toHaveAttribute('aria-disabled', 'true');
|
|
39
|
+
await user.click(screen.getByRole('button', {name: 'Before'}));
|
|
40
|
+
await user.tab();
|
|
41
|
+
expect(trigger()).toHaveFocus();
|
|
42
|
+
await user.tab();
|
|
43
|
+
expect(screen.getByRole('button', {name: 'After'})).toHaveFocus();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('FR2: no press or key opens it or reaches a caller or ancestor handler', async () => {
|
|
47
|
+
const user = userEvent.setup();
|
|
48
|
+
const onClick = vi.fn();
|
|
49
|
+
const onKeyDown = vi.fn();
|
|
50
|
+
const onAncestorClick = vi.fn();
|
|
51
|
+
render(
|
|
52
|
+
<div onClick={onAncestorClick}>
|
|
53
|
+
<Harness isDisabled onClick={onClick} onKeyDown={onKeyDown} />
|
|
54
|
+
</div>,
|
|
55
|
+
);
|
|
56
|
+
await user.click(trigger());
|
|
57
|
+
await user.click(trigger().parentElement!);
|
|
58
|
+
trigger().focus();
|
|
59
|
+
for (const key of ['{Enter}', ' ', '{ArrowDown}']) {
|
|
60
|
+
await user.keyboard(key);
|
|
61
|
+
}
|
|
62
|
+
expect(trigger()).toHaveAttribute('aria-expanded', 'false');
|
|
63
|
+
expect(screen.queryByRole('dialog')).toBeNull();
|
|
64
|
+
expect(onClick).not.toHaveBeenCalled();
|
|
65
|
+
expect(onKeyDown).not.toHaveBeenCalled();
|
|
66
|
+
expect(onAncestorClick).not.toHaveBeenCalled();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('FR4: keeps focus when it becomes disabled while focused', () => {
|
|
70
|
+
const {rerender} = render(<Harness isDisabled={false} />);
|
|
71
|
+
act(() => trigger().focus());
|
|
72
|
+
rerender(<Harness isDisabled />);
|
|
73
|
+
expect(trigger()).toHaveFocus();
|
|
74
|
+
expect(trigger()).toHaveAttribute('aria-disabled', 'true');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -609,10 +609,24 @@ export function ComplexSelector<Value>({
|
|
|
609
609
|
event: React.KeyboardEvent<HTMLElement>,
|
|
610
610
|
) => {
|
|
611
611
|
handleTriggerKeyDown(event);
|
|
612
|
-
|
|
612
|
+
// A disabled trigger keeps its tab stop but invokes no callback
|
|
613
|
+
// (`spec:AST-085` FR2).
|
|
614
|
+
if (!isDisabled) {
|
|
615
|
+
onKeyDownProp?.(event);
|
|
616
|
+
}
|
|
617
|
+
};
|
|
618
|
+
// A press on a disabled control reaches no handler, the component's or an
|
|
619
|
+
// ancestor's, as with a natively disabled button (`spec:AST-085` FR2).
|
|
620
|
+
const swallowDisabledPress = (event: React.MouseEvent<HTMLElement>) => {
|
|
621
|
+
event.preventDefault();
|
|
622
|
+
event.stopPropagation();
|
|
613
623
|
};
|
|
614
624
|
const handleControlBoxClick = useCallback(
|
|
615
625
|
(event: React.MouseEvent<HTMLDivElement>) => {
|
|
626
|
+
if (isDisabled) {
|
|
627
|
+
event.stopPropagation();
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
616
630
|
if (triggerRef.current?.contains(event.target as Node)) {
|
|
617
631
|
return;
|
|
618
632
|
}
|
|
@@ -620,7 +634,7 @@ export function ComplexSelector<Value>({
|
|
|
620
634
|
// press on the trigger itself, caller `onClick` included.
|
|
621
635
|
triggerRef.current?.click();
|
|
622
636
|
},
|
|
623
|
-
[],
|
|
637
|
+
[isDisabled],
|
|
624
638
|
);
|
|
625
639
|
|
|
626
640
|
useImperativeHandle(
|
|
@@ -767,8 +781,11 @@ export function ComplexSelector<Value>({
|
|
|
767
781
|
aria-required={isEffectivelyRequired ? 'true' : undefined}
|
|
768
782
|
aria-invalid={status?.type === 'error' ? 'true' : undefined}
|
|
769
783
|
aria-busy={isBusy || undefined}
|
|
770
|
-
disabled
|
|
771
|
-
|
|
784
|
+
// A disabled trigger keeps its tab stop and says so through
|
|
785
|
+
// aria-disabled (`spec:AST-085` FR1). Its press runs neither the
|
|
786
|
+
// caller's handler nor the toggle (FR2).
|
|
787
|
+
aria-disabled={isDisabled ? 'true' : undefined}
|
|
788
|
+
onClick={isDisabled ? swallowDisabledPress : handleTriggerButtonClick}
|
|
772
789
|
onKeyDown={handleTriggerButtonKeyDown}
|
|
773
790
|
{...stylex.props(styles.trigger)}>
|
|
774
791
|
<span {...stylex.props(styles.triggerText)}>{triggerContent}</span>
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
ContextMenuRadioItem,
|
|
25
25
|
} from './index';
|
|
26
26
|
import {DropdownMenuItem} from '../DropdownMenu/DropdownMenuItem';
|
|
27
|
+
import {DropdownMenuSubMenu} from '../DropdownMenu/DropdownMenuSubMenu';
|
|
27
28
|
import {Divider} from '../Divider';
|
|
28
29
|
import {useLayerDismissal} from '../Layer/useLayerDismissal';
|
|
29
30
|
import {warmMenuBottomSheet} from '../DropdownMenu/__tests__/warmMenuBottomSheet';
|
|
@@ -706,6 +707,46 @@ describe('ContextMenu dividers', () => {
|
|
|
706
707
|
});
|
|
707
708
|
|
|
708
709
|
describe('ContextMenu compound mode', () => {
|
|
710
|
+
it('shows a sub-menu with a header or footer as its rows alone, and says why in development', async () => {
|
|
711
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
712
|
+
try {
|
|
713
|
+
render(
|
|
714
|
+
<ContextMenu
|
|
715
|
+
menuContent={
|
|
716
|
+
<>
|
|
717
|
+
<DropdownMenuItem label="Rename" onClick={() => {}} />
|
|
718
|
+
<DropdownMenuSubMenu
|
|
719
|
+
label="Labels"
|
|
720
|
+
header={<input aria-label="Filter labels" />}
|
|
721
|
+
footer={<button type="button">Create label</button>}>
|
|
722
|
+
<DropdownMenuItem label="Bug" onClick={() => {}} />
|
|
723
|
+
</DropdownMenuSubMenu>
|
|
724
|
+
</>
|
|
725
|
+
}>
|
|
726
|
+
<div>Right-click me</div>
|
|
727
|
+
</ContextMenu>,
|
|
728
|
+
);
|
|
729
|
+
fireEvent.contextMenu(screen.getByText('Right-click me'));
|
|
730
|
+
const row = screen.getByRole('menuitem', {name: /Labels/, hidden: true});
|
|
731
|
+
// No region can sit outside this menu, so none renders inside it.
|
|
732
|
+
expect(row).toHaveAttribute('aria-haspopup', 'menu');
|
|
733
|
+
fireEvent.click(row, {detail: 1});
|
|
734
|
+
await waitFor(() => expect(row).toHaveAttribute('aria-expanded', 'true'));
|
|
735
|
+
expect(screen.queryByRole('dialog', {hidden: true})).toBeNull();
|
|
736
|
+
expect(
|
|
737
|
+
screen.queryByRole('textbox', {name: 'Filter labels', hidden: true}),
|
|
738
|
+
).toBeNull();
|
|
739
|
+
expect(
|
|
740
|
+
screen.getByRole('menuitem', {name: 'Bug', hidden: true}),
|
|
741
|
+
).toBeInTheDocument();
|
|
742
|
+
expect(warn).toHaveBeenCalledWith(
|
|
743
|
+
expect.stringContaining('DropdownMenuSubMenu: header and footer'),
|
|
744
|
+
);
|
|
745
|
+
} finally {
|
|
746
|
+
warn.mockRestore();
|
|
747
|
+
}
|
|
748
|
+
});
|
|
749
|
+
|
|
709
750
|
it('renders menuContent as menu items', () => {
|
|
710
751
|
render(
|
|
711
752
|
<ContextMenu
|
|
@@ -49,14 +49,15 @@ export const docs = {
|
|
|
49
49
|
{
|
|
50
50
|
name: 'isDisabled',
|
|
51
51
|
type: 'boolean',
|
|
52
|
-
description:
|
|
52
|
+
description:
|
|
53
|
+
'Disable the input and calendar. The field stays focusable and exposes `aria-disabled`; typing and opening are blocked.',
|
|
53
54
|
default: 'false',
|
|
54
55
|
},
|
|
55
56
|
{
|
|
56
57
|
name: 'disabledMessage',
|
|
57
58
|
type: 'string',
|
|
58
59
|
description:
|
|
59
|
-
'Explains why the input is disabled. With isDisabled, shows a tooltip on hover/keyboard focus
|
|
60
|
+
'Explains why the input is disabled. With isDisabled, shows a tooltip on hover/keyboard focus (activation stays blocked). Use this instead of wrapping a disabled DateInput in Tooltip.',
|
|
60
61
|
},
|
|
61
62
|
{
|
|
62
63
|
name: 'value',
|
|
@@ -275,7 +276,7 @@ export const docs = {
|
|
|
275
276
|
{
|
|
276
277
|
guidance: false,
|
|
277
278
|
description:
|
|
278
|
-
'Wrap a disabled DateInput in Tooltip to explain why it is disabled
|
|
279
|
+
'Wrap a disabled DateInput in Tooltip to explain why it is disabled. Use the disabledMessage prop instead, which ties the reason to the field.',
|
|
279
280
|
},
|
|
280
281
|
],
|
|
281
282
|
anatomy: [
|
|
@@ -368,7 +369,7 @@ export const docsZh = {
|
|
|
368
369
|
{
|
|
369
370
|
guidance: false,
|
|
370
371
|
description:
|
|
371
|
-
'Wrap a disabled DateInput in Tooltip to explain why it is disabled
|
|
372
|
+
'Wrap a disabled DateInput in Tooltip to explain why it is disabled. Use the disabledMessage prop instead, which ties the reason to the field.',
|
|
372
373
|
},
|
|
373
374
|
],
|
|
374
375
|
},
|
|
@@ -400,14 +401,14 @@ export const docsZh = {
|
|
|
400
401
|
{
|
|
401
402
|
name: 'isDisabled',
|
|
402
403
|
type: 'boolean',
|
|
403
|
-
description: '
|
|
404
|
+
description: '禁用输入框和日历。输入框仍可聚焦并暴露 `aria-disabled`,但不能输入或打开日历。',
|
|
404
405
|
default: 'false',
|
|
405
406
|
},
|
|
406
407
|
{
|
|
407
408
|
name: 'disabledMessage',
|
|
408
409
|
type: 'string',
|
|
409
410
|
description:
|
|
410
|
-
'说明输入框为何被禁用。与 isDisabled
|
|
411
|
+
'说明输入框为何被禁用。与 isDisabled 一起使用时,在悬停/键盘聚焦时显示提示(仍阻止输入和激活)。请使用此属性,而不是用 Tooltip 包裹已禁用的 DateInput。',
|
|
411
412
|
},
|
|
412
413
|
{
|
|
413
414
|
name: 'value',
|
|
@@ -621,7 +622,7 @@ export const docsDense = {
|
|
|
621
622
|
{
|
|
622
623
|
guidance: false,
|
|
623
624
|
description:
|
|
624
|
-
'Wrap a disabled DateInput in Tooltip to explain why it is disabled
|
|
625
|
+
'Wrap a disabled DateInput in Tooltip to explain why it is disabled. Use the disabledMessage prop instead, which ties the reason to the field.',
|
|
625
626
|
},
|
|
626
627
|
],
|
|
627
628
|
},
|
|
@@ -633,7 +634,7 @@ export const docsDense = {
|
|
|
633
634
|
isRequired: 'mark field required',
|
|
634
635
|
isDisabled: 'disable input+calendar',
|
|
635
636
|
disabledMessage:
|
|
636
|
-
|
|
637
|
+
"reason shown in a tooltip on hover/focus when disabled; describes the field's focusable controls",
|
|
637
638
|
value: 'selected date YYYY-MM-DD',
|
|
638
639
|
onChange: 'callback on date change',
|
|
639
640
|
changeAction: 'async action after onChange; drives optimistic UI',
|
|
@@ -84,9 +84,14 @@ describe('DateInput', () => {
|
|
|
84
84
|
expect(screen.getByRole('combobox')).not.toHaveAttribute('aria-required');
|
|
85
85
|
});
|
|
86
86
|
|
|
87
|
-
it('
|
|
87
|
+
it('keeps a disabled input focusable without native disabled (AST-085 FR1)', () => {
|
|
88
88
|
render(<DateInput label="Date" isDisabled onChange={() => {}} />);
|
|
89
|
-
|
|
89
|
+
const input = screen.getByRole('combobox');
|
|
90
|
+
expect(input).not.toHaveAttribute('disabled');
|
|
91
|
+
expect(input).toHaveAttribute('aria-disabled', 'true');
|
|
92
|
+
expect(input).toHaveAttribute('readonly');
|
|
93
|
+
input.focus();
|
|
94
|
+
expect(input).toHaveFocus();
|
|
90
95
|
});
|
|
91
96
|
|
|
92
97
|
it('is not disabled by default', () => {
|
|
@@ -115,10 +120,16 @@ describe('DateInput', () => {
|
|
|
115
120
|
expect(button).not.toBeDisabled();
|
|
116
121
|
});
|
|
117
122
|
|
|
118
|
-
it('calendar button is disabled when isDisabled is true', () => {
|
|
123
|
+
it('calendar button is focusable-disabled when isDisabled is true', () => {
|
|
119
124
|
render(<DateInput label="Date" isDisabled onChange={() => {}} />);
|
|
120
125
|
const button = getButton('Open calendar');
|
|
121
|
-
expect(button).
|
|
126
|
+
expect(button).not.toHaveAttribute('disabled');
|
|
127
|
+
expect(button).toHaveAttribute('aria-disabled', 'true');
|
|
128
|
+
fireEvent.click(button);
|
|
129
|
+
expect(screen.getByRole('combobox')).toHaveAttribute(
|
|
130
|
+
'aria-expanded',
|
|
131
|
+
'false',
|
|
132
|
+
);
|
|
122
133
|
});
|
|
123
134
|
|
|
124
135
|
it('does not call onChange while typing invalid input', async () => {
|
|
@@ -349,10 +360,15 @@ describe('DateInput', () => {
|
|
|
349
360
|
|
|
350
361
|
// --- P1: Input disabled during isBusy (isLoading) ---
|
|
351
362
|
|
|
352
|
-
it('
|
|
363
|
+
it('blocks input and button without native disabled when isLoading is true', () => {
|
|
353
364
|
render(<DateInput label="Date" isLoading onChange={() => {}} />);
|
|
354
|
-
expect(screen.getByRole('combobox')).
|
|
355
|
-
expect(
|
|
365
|
+
expect(screen.getByRole('combobox')).not.toHaveAttribute('disabled');
|
|
366
|
+
expect(screen.getByRole('combobox')).toHaveAttribute(
|
|
367
|
+
'aria-disabled',
|
|
368
|
+
'true',
|
|
369
|
+
);
|
|
370
|
+
expect(getButton('Open calendar')).not.toHaveAttribute('disabled');
|
|
371
|
+
expect(getButton('Open calendar')).toHaveAttribute('aria-disabled', 'true');
|
|
356
372
|
});
|
|
357
373
|
|
|
358
374
|
it('shows spinner when isLoading is true', () => {
|
|
@@ -526,7 +542,8 @@ describe('DateInput', () => {
|
|
|
526
542
|
render(<DateInput label="Date" isDisabled onChange={() => {}} />);
|
|
527
543
|
|
|
528
544
|
const input = screen.getByRole('combobox');
|
|
529
|
-
expect(input).
|
|
545
|
+
expect(input).toHaveAttribute('aria-disabled', 'true');
|
|
546
|
+
expect(input).not.toHaveAttribute('disabled');
|
|
530
547
|
});
|
|
531
548
|
|
|
532
549
|
// --- P1: aria-busy on input ---
|
|
@@ -818,7 +835,10 @@ describe('DateInput', () => {
|
|
|
818
835
|
expect(input).toHaveAttribute('aria-disabled', 'true');
|
|
819
836
|
expect(input.getAttribute('aria-describedby')).toContain(tooltip.id);
|
|
820
837
|
expect(tooltip).toHaveTextContent('Scheduling is locked');
|
|
821
|
-
expect(getButton('Open calendar')).
|
|
838
|
+
expect(getButton('Open calendar')).toHaveAttribute(
|
|
839
|
+
'aria-disabled',
|
|
840
|
+
'true',
|
|
841
|
+
);
|
|
822
842
|
});
|
|
823
843
|
});
|
|
824
844
|
|
|
@@ -875,6 +895,10 @@ describe('DateInput', () => {
|
|
|
875
895
|
);
|
|
876
896
|
|
|
877
897
|
const tooltip = screen.getByRole('tooltip', h);
|
|
898
|
+
// The calendar button keeps its enabled tab stop (spec:AST-085 FR1),
|
|
899
|
+
// so the input is the second stop.
|
|
900
|
+
await user.tab();
|
|
901
|
+
expect(getButton('Open calendar')).toHaveFocus();
|
|
878
902
|
await user.tab();
|
|
879
903
|
expect(screen.getByRole('combobox')).toHaveFocus();
|
|
880
904
|
await waitFor(() => {
|
|
@@ -908,6 +932,50 @@ describe('DateInput', () => {
|
|
|
908
932
|
expect(input).toHaveAttribute('readonly');
|
|
909
933
|
});
|
|
910
934
|
|
|
935
|
+
it('does not call a caller onClick while disabled (AST-085 FR2)', () => {
|
|
936
|
+
const onClick = vi.fn();
|
|
937
|
+
render(<DateInput label="Date" isDisabled onClick={onClick} />);
|
|
938
|
+
fireEvent.click(screen.getByRole('combobox'));
|
|
939
|
+
expect(onClick).not.toHaveBeenCalled();
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
it('links the reason tooltip from the calendar button too (AST-085 FR5)', () => {
|
|
943
|
+
render(
|
|
944
|
+
<DateInput
|
|
945
|
+
label="Date"
|
|
946
|
+
isDisabled
|
|
947
|
+
disabledMessage="You need the Editor role"
|
|
948
|
+
/>,
|
|
949
|
+
);
|
|
950
|
+
const tooltip = screen.getByRole('tooltip', h);
|
|
951
|
+
expect(
|
|
952
|
+
getButton('Open calendar').getAttribute('aria-describedby'),
|
|
953
|
+
).toContain(tooltip.id);
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
it('drops a draft instead of committing it when the field becomes disabled (AST-085 FR2)', async () => {
|
|
957
|
+
const user = userEvent.setup();
|
|
958
|
+
const onChange = vi.fn();
|
|
959
|
+
const {rerender} = render(
|
|
960
|
+
<DateInput label="Date" value="2026-03-10" onChange={onChange} />,
|
|
961
|
+
);
|
|
962
|
+
const input = screen.getByRole('combobox');
|
|
963
|
+
await user.click(input);
|
|
964
|
+
await user.clear(input);
|
|
965
|
+
rerender(
|
|
966
|
+
<DateInput
|
|
967
|
+
label="Date"
|
|
968
|
+
value="2026-03-10"
|
|
969
|
+
onChange={onChange}
|
|
970
|
+
isDisabled
|
|
971
|
+
/>,
|
|
972
|
+
);
|
|
973
|
+
expect(input).toHaveFocus();
|
|
974
|
+
await user.keyboard('{Enter}');
|
|
975
|
+
await user.tab();
|
|
976
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
977
|
+
});
|
|
978
|
+
|
|
911
979
|
it('links the reason tooltip from the input via aria-describedby', () => {
|
|
912
980
|
render(
|
|
913
981
|
<DateInput
|
|
@@ -941,11 +1009,18 @@ describe('DateInput', () => {
|
|
|
941
1009
|
expect(onChange).not.toHaveBeenCalled();
|
|
942
1010
|
});
|
|
943
1011
|
|
|
944
|
-
it('
|
|
945
|
-
|
|
1012
|
+
it('blocks value changes and opening when disabled without a reason (AST-085 FR1, FR2)', async () => {
|
|
1013
|
+
const user = userEvent.setup();
|
|
1014
|
+
const onChange = vi.fn();
|
|
1015
|
+
render(<DateInput label="Date" onChange={onChange} isDisabled />);
|
|
946
1016
|
const input = screen.getByRole('combobox');
|
|
947
|
-
expect(input).
|
|
948
|
-
|
|
1017
|
+
expect(input).not.toHaveAttribute('disabled');
|
|
1018
|
+
await user.click(input);
|
|
1019
|
+
await user.type(input, '2026-03-15');
|
|
1020
|
+
await user.keyboard('{ArrowDown}');
|
|
1021
|
+
expect(input).toHaveValue('');
|
|
1022
|
+
expect(input).toHaveAttribute('aria-expanded', 'false');
|
|
1023
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
949
1024
|
});
|
|
950
1025
|
});
|
|
951
1026
|
|