@astryxdesign/core 0.7.3-canary.b74cf21 → 0.7.3-canary.ca48bb7
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/Calendar/Calendar.js +3 -2
- 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/DateRangeInput/DateRangeInput.d.ts +5 -5
- package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
- package/dist/DateRangeInput/DateRangeInput.js +27 -18
- 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/Pagination/Pagination.d.ts.map +1 -1
- package/dist/Pagination/Pagination.js +5 -2
- 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/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/Timestamp/Timestamp.d.ts.map +1 -1
- package/dist/Timestamp/Timestamp.js +41 -8
- 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/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/TreeList/TreeListItem.d.ts.map +1 -1
- package/dist/TreeList/TreeListItem.js +8 -3
- package/dist/astryx.css +7 -2
- package/dist/hooks/useGridFocus.d.ts.map +1 -1
- package/dist/hooks/useGridFocus.js +10 -1
- 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 +3 -3
- package/src/Banner/Banner.spec.md +37 -32
- 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/Calendar/Calendar.test.tsx +68 -24
- package/src/Calendar/Calendar.tsx +2 -1
- 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.doc.mjs +6 -5
- package/src/DateRangeInput/DateRangeInput.test.tsx +82 -16
- package/src/DateRangeInput/DateRangeInput.tsx +40 -20
- 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/Pagination/Pagination.test.tsx +5 -1
- package/src/Pagination/Pagination.tsx +3 -1
- 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.test.tsx +48 -6
- package/src/Selector/Selector.tsx +51 -21
- package/src/SideNav/SideNav.test.tsx +35 -0
- 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/plugins/selection/useTableSelection.test.tsx +5 -3
- 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/Timestamp/Timestamp.doc.mjs +1 -1
- package/src/Timestamp/Timestamp.test.tsx +168 -0
- package/src/Timestamp/Timestamp.tsx +62 -11
- package/src/Token/Token.test.tsx +26 -4
- package/src/Token/Token.tsx +6 -2
- 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/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/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/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
|
@@ -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
|
|
|
@@ -97,6 +97,18 @@ export type {
|
|
|
97
97
|
} from '../Field';
|
|
98
98
|
|
|
99
99
|
const styles = stylex.create({
|
|
100
|
+
// The shared disabled style drops the focus ring; restore it while a
|
|
101
|
+
// control inside the field holds focus (spec:AST-085 FR6). Defaults repeat it.
|
|
102
|
+
disabledFocusRing: {
|
|
103
|
+
borderColor: {
|
|
104
|
+
default: colorVars['--color-border-emphasized'],
|
|
105
|
+
':focus-within': colorVars['--color-accent'],
|
|
106
|
+
},
|
|
107
|
+
boxShadow: {
|
|
108
|
+
default: 'none',
|
|
109
|
+
':focus-within': `inset 0px 0px 0px 2px ${colorVars['--color-accent-muted']}`,
|
|
110
|
+
},
|
|
111
|
+
},
|
|
100
112
|
trigger: {
|
|
101
113
|
display: 'block',
|
|
102
114
|
flex: 1,
|
|
@@ -309,12 +321,12 @@ export interface DateRangeInputProps extends Omit<
|
|
|
309
321
|
/**
|
|
310
322
|
* Explains why the input is disabled. When set together with
|
|
311
323
|
* `isDisabled`, the input shows a tooltip with this text on hover and
|
|
312
|
-
* keyboard focus, and the trigger
|
|
313
|
-
*
|
|
314
|
-
*
|
|
324
|
+
* keyboard focus, and the trigger is described by it, so the reason is
|
|
325
|
+
* discoverable by keyboard and assistive technology. Activation stays
|
|
326
|
+
* blocked.
|
|
315
327
|
*
|
|
316
|
-
* Use this instead of wrapping a disabled input in `Tooltip
|
|
317
|
-
*
|
|
328
|
+
* Use this instead of wrapping a disabled input in `Tooltip`, so the
|
|
329
|
+
* reason is tied to the field.
|
|
318
330
|
*
|
|
319
331
|
* @example
|
|
320
332
|
* ```
|
|
@@ -546,12 +558,12 @@ export function DateRangeInput({
|
|
|
546
558
|
const isBusy = isLoading || optimisticValue !== value;
|
|
547
559
|
const isEffectivelyDisabled = isDisabled || isBusy;
|
|
548
560
|
|
|
549
|
-
//
|
|
550
|
-
//
|
|
551
|
-
//
|
|
552
|
-
// disabled
|
|
553
|
-
//
|
|
554
|
-
//
|
|
561
|
+
// A disabled or busy field never renders native `disabled` (spec:AST-085
|
|
562
|
+
// FR1): its buttons stay focusable via aria-disabled, and activation is
|
|
563
|
+
// blocked by the isEffectivelyDisabled guard in handleToggle. The
|
|
564
|
+
// disabled-reason tooltip's listeners attach to the trigger container. Only
|
|
565
|
+
// the persistent isDisabled state (not the transient busy state) surfaces a
|
|
566
|
+
// reason.
|
|
555
567
|
const showsDisabledMessage = isDisabled && !!disabledMessage;
|
|
556
568
|
const disabledMessageTooltip = useTooltip({
|
|
557
569
|
placement: 'above',
|
|
@@ -695,6 +707,7 @@ export function DateRangeInput({
|
|
|
695
707
|
inputWrapperStyles.base,
|
|
696
708
|
sizeStyles[size],
|
|
697
709
|
isEffectivelyDisabled && inputWrapperStyles.disabled,
|
|
710
|
+
isEffectivelyDisabled && styles.disabledFocusRing,
|
|
698
711
|
status && inputStatusBorderStyles[status.type],
|
|
699
712
|
status &&
|
|
700
713
|
!isEffectivelyDisabled &&
|
|
@@ -708,7 +721,7 @@ export function DateRangeInput({
|
|
|
708
721
|
<button
|
|
709
722
|
type="button"
|
|
710
723
|
onClick={handleToggle}
|
|
711
|
-
disabled={isEffectivelyDisabled}
|
|
724
|
+
aria-disabled={isEffectivelyDisabled || undefined}
|
|
712
725
|
aria-label={
|
|
713
726
|
popover.isOpen
|
|
714
727
|
? t('@astryx.dateInput.toggleCalendarClose')
|
|
@@ -734,12 +747,14 @@ export function DateRangeInput({
|
|
|
734
747
|
ref={triggerButtonRef}
|
|
735
748
|
id={id}
|
|
736
749
|
type="button"
|
|
737
|
-
|
|
738
|
-
//
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
750
|
+
// The browser still dispatches a click on an aria-disabled button,
|
|
751
|
+
// so a caller's onClick is skipped too (spec:AST-085 FR2).
|
|
752
|
+
onClick={
|
|
753
|
+
isEffectivelyDisabled
|
|
754
|
+
? undefined
|
|
755
|
+
: chainEventHandlers(handleToggle, onClickProp)
|
|
756
|
+
}
|
|
757
|
+
aria-disabled={isEffectivelyDisabled ? 'true' : undefined}
|
|
743
758
|
// A caller aria-label replaces the trigger's name; a caller
|
|
744
759
|
// aria-labelledby is read after it (label and selected range).
|
|
745
760
|
aria-label={ariaLabelProp ?? triggerAriaLabel}
|
|
@@ -807,8 +822,13 @@ export function DateRangeInput({
|
|
|
807
822
|
// marked with aria-current (not aria-selected, a listbox
|
|
808
823
|
// concept that contradicted the Tab interaction) (forms-5).
|
|
809
824
|
aria-current={isActive ? 'true' : undefined}
|
|
810
|
-
disabled
|
|
811
|
-
|
|
825
|
+
// Focusable while disabled (AR3, spec:AST-085 FR1).
|
|
826
|
+
aria-disabled={isPresetDisabled || undefined}
|
|
827
|
+
onClick={() => {
|
|
828
|
+
if (!isPresetDisabled) {
|
|
829
|
+
handleRangeSelect(presetRange);
|
|
830
|
+
}
|
|
831
|
+
}}
|
|
812
832
|
{...mergeProps(
|
|
813
833
|
themeProps('date-range-input-preset', {
|
|
814
834
|
selected: isActive ? 'selected' : null,
|
|
@@ -286,6 +286,45 @@ describe('Dialog', () => {
|
|
|
286
286
|
});
|
|
287
287
|
});
|
|
288
288
|
|
|
289
|
+
describe('a cancel from inside the dialog', () => {
|
|
290
|
+
// A file input fires a bubbling `cancel` at itself when its picker is
|
|
291
|
+
// dismissed (Escape, or Cancel in the chooser). It reaches the <dialog>
|
|
292
|
+
// like a close request but is not one.
|
|
293
|
+
function DialogWithFilePicker({onOpenChange}: {onOpenChange: () => void}) {
|
|
294
|
+
return (
|
|
295
|
+
<Dialog isOpen onOpenChange={onOpenChange} aria-label="Upload">
|
|
296
|
+
<input type="file" aria-label="Picture" />
|
|
297
|
+
</Dialog>
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
it('stays open when a file picker inside it is dismissed', () => {
|
|
302
|
+
const onOpenChange = vi.fn();
|
|
303
|
+
render(<DialogWithFilePicker onOpenChange={onOpenChange} />);
|
|
304
|
+
|
|
305
|
+
const fromPicker = new Event('cancel', {bubbles: true, cancelable: true});
|
|
306
|
+
screen.getByLabelText('Picture').dispatchEvent(fromPicker);
|
|
307
|
+
|
|
308
|
+
expect(onOpenChange).not.toHaveBeenCalled();
|
|
309
|
+
expect(fromPicker.defaultPrevented).toBe(false);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('still closes on its own close request', () => {
|
|
313
|
+
const onOpenChange = vi.fn();
|
|
314
|
+
render(<DialogWithFilePicker onOpenChange={onOpenChange} />);
|
|
315
|
+
|
|
316
|
+
screen
|
|
317
|
+
.getByLabelText('Picture')
|
|
318
|
+
.dispatchEvent(new Event('cancel', {bubbles: true, cancelable: true}));
|
|
319
|
+
screen
|
|
320
|
+
.getByRole('dialog')
|
|
321
|
+
.dispatchEvent(new Event('cancel', {cancelable: true}));
|
|
322
|
+
|
|
323
|
+
expect(onOpenChange).toHaveBeenCalledTimes(1);
|
|
324
|
+
expect(onOpenChange).toHaveBeenCalledWith(false);
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
289
328
|
describe('IME composition', () => {
|
|
290
329
|
// A CJK user presses Escape to cancel a half-formed character several times
|
|
291
330
|
// a sentence. jsdom models neither composition nor the close watcher, so
|
package/src/Dialog/Dialog.tsx
CHANGED
|
@@ -643,11 +643,16 @@ export function Dialog({
|
|
|
643
643
|
// Escape presses the stack owns never arrive here — it preventDefault()s
|
|
644
644
|
// those, which suppresses the close watcher.
|
|
645
645
|
//
|
|
646
|
-
//
|
|
647
|
-
//
|
|
648
|
-
//
|
|
649
|
-
//
|
|
646
|
+
// A `cancel` that bubbles up from inside is not a close request for this
|
|
647
|
+
// dialog: a file input fires one when its picker is dismissed. It is left
|
|
648
|
+
// alone. Otherwise always preventDefault so the browser cannot close a
|
|
649
|
+
// controlled <dialog> behind React's back, then answer the request with the
|
|
650
|
+
// stack's own rules: only the top-most layer dismisses, a `required` dialog
|
|
651
|
+
// swallows it, and nothing dismisses while an IME composition is in progress.
|
|
650
652
|
const handleCancel = (event: React.SyntheticEvent<HTMLDialogElement>) => {
|
|
653
|
+
if (event.target !== event.currentTarget) {
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
651
656
|
event.preventDefault();
|
|
652
657
|
if (!shouldDismissOnCloseRequest()) {
|
|
653
658
|
return;
|
|
@@ -86,7 +86,7 @@ Consumer migration instructions belong in consumer docs and release notes.
|
|
|
86
86
|
## Public concepts
|
|
87
87
|
|
|
88
88
|
Consumer props, item shapes, subcomponents, and presentation policy remain
|
|
89
|
-
documented in `DropdownMenu.doc.mjs` and the subcomponent docs.
|
|
89
|
+
documented in `DropdownMenu.doc.mjs` and the subcomponent docs. Four component-local concepts are added, by DEC-2, DEC-3, DEC-8 and proposed DEC-13; each
|
|
90
90
|
keeps its released default.
|
|
91
91
|
|
|
92
92
|
| Concept | Closed values or states | Meaning | Availability by variant/orientation/state | Default | Owner | Stability | Invalid-value behavior |
|
|
@@ -94,6 +94,7 @@ keeps its released default.
|
|
|
94
94
|
| Menu height cap | `menuMaxHeight`: a number of pixels | Lifts the 300px cap for a menu that must fit its rows; the viewport still bounds it. | Pointer presentation | `300px` | `component:DropdownMenu` | stable | Ignored by the touch sheet. |
|
|
95
95
|
| Trigger source | `button` (Button props) or `renderTrigger` (render prop) | Which control the menu hangs off. `trigger` receives `DropdownMenuTriggerProps` — the press model, keyboard opens, toggle click and ARIA wiring — and names the menu. | Both presentations | `button` | `component:DropdownMenu` | stable | Both given: `trigger` wins and a dev warning fires. |
|
|
96
96
|
| Header and footer | `header`, `footer`: any React node | Content above and below the rows, beside the menu inside the same popover or sheet — never inside the `role="menu"` element. With either, the pointer popover is a modal dialog around the menu. | Both presentations | none | `component:DropdownMenu` | stable | A false or empty value renders no region. |
|
|
97
|
+
| Sub-menu regions | `header`, `footer` on a sub-menu: any React node | Content above and below a sub-menu's rows, beside its menu inside its own surface; with either, that surface is a modal dialog named by the row (proposed FR20). | Popover presentation | none | `component:DropdownMenu` | proposed | A false or empty value renders no region. |
|
|
97
98
|
|
|
98
99
|
## Behavioral and layout contract
|
|
99
100
|
|
|
@@ -119,6 +120,7 @@ keeps its released default.
|
|
|
119
120
|
| FR15 | With `header` or `footer`, the pointer popover is a `role="dialog"` with `aria-modal="true"`, named like the menu, holding the header, then the `role="menu"` element, then the footer; the menu element holds only its rows. The trigger says `aria-haspopup="dialog"` and its `aria-controls` names the popover that holds the dialog. A region keeps its own keys and presses: neither the menu's key handling nor its press model claims an event that begins there. The menu is one Tab stop between the regions. Tab and Shift+Tab move through the header's controls, the menu, then the footer's controls, and wrap at either end; a control that is hidden, or under a hidden ancestor within its region, is not a stop. From a row, or a row of an open sub-menu, Tab and Shift+Tab close every open flyout and continue from the menu's stop. With no other stop, focus stays in the menu. Escape closes from anywhere in the popover, and focus returns to the trigger. The bottom sheet, already a dialog, shows the header above the actions and the footer below them. Without either prop, the menu renders and behaves exactly as it does without them: no dialog, `aria-haspopup="menu"`, and Tab closes the menu. | DEC-8, DEC-10, docs, and tests | Proposed; awaiting owner approval |
|
|
120
121
|
| FR16 | `DropdownMenuCheckboxItem` MUST expose one `menuitemcheckbox` row whose `aria-checked` equals its controlled value; enabled activation reports `!value`, stays open by default, and composes consumer click and pointer-move handlers with owned activation and hover focus. Disabled activation calls no consumer activation handler. | Released subcomponent API; `architecture:public-component-api/INV5–INV8`; `DropdownMenuSelectable.test.tsx` | Verified current behavior; consumer handlers are preserved |
|
|
121
122
|
| FR17 | Disabled menu rows SHOULD remain keyboard-discoverable while activation stays blocked. Current shared `useListFocus` deliberately skips every `aria-disabled` row, including checkbox items. | WAI-ARIA Menu pattern; current shared navigation source/tests | Objective mismatch; shared mechanism remains undecided in OQ1 |
|
|
123
|
+
| FR20 | With `header` or `footer` on a sub-menu (`DropdownMenuSubMenu`), its surface is a modal dialog named by its row (AR7), holding the header, the sub-menu's `role="menu"` (rows only), then the footer. It sits in a host `DropdownMenu` renders beside its menu, never inside a menu; under a root with no such host (`ContextMenu`) the sub-menu shows its rows alone. On a fine pointer the dialog is the flyout; on a compact touch display, the drilled-in view, which replaces the root's menu and regions until popped. A region keeps its own keys and presses. The rows are one Tab stop: Tab and Shift+Tab walk the header's controls, the rows, then the footer's, and wrap; from a deeper flyout, that flyout closes and the walk goes on from the rows. An open lands as for any sub-menu (FR11, FR12), not on a region. Escape in the dialog, outside an IME composition and unless a control there handled it, returns focus to the row and leaves the root open. A pointer leaving the flyout keeps it open until focus leaves, and it closes with the flyout holding its row. Without either prop nothing changes. | Proposed DEC-13; FR15; AR5 | Proposed; verified in jsdom and Chromium, pending owner review |
|
|
122
124
|
|
|
123
125
|
### Allowed variation
|
|
124
126
|
|
|
@@ -200,13 +202,20 @@ visibility helper.
|
|
|
200
202
|
trigger keeps `aria-expanded`; its `aria-haspopup` says `dialog`, and its
|
|
201
203
|
`aria-controls` names the popover that holds the dialog rather than the
|
|
202
204
|
menu, as Popover's own trigger does.
|
|
203
|
-
|
|
204
205
|
- **AR6 — Checkbox rows remain one control.** The row owns
|
|
205
206
|
`menuitemcheckbox`, its accessible name, checked state, focus, and activation;
|
|
206
207
|
Indicator's checkbox marker is decorative. Disabled activation is inert.
|
|
207
208
|
Current shared navigation skips disabled rows instead of making them
|
|
208
209
|
discoverable as the WAI-ARIA Menu pattern recommends; FR17 and OQ1 retain that
|
|
209
210
|
gap without inventing a component-local focus path.
|
|
211
|
+
- **AR7 (proposed) — A sub-menu with a header or footer adopts the same APG
|
|
212
|
+
Dialog (Modal) parts for its own surface** (`spec:AST-020/FR15`):
|
|
213
|
+
`role="dialog"`, `aria-modal="true"`, and an accessible name, its row's;
|
|
214
|
+
focus moves into the dialog on open, to the sub-menu's first row or its
|
|
215
|
+
menu as FR11 and FR12 already state; Tab and Shift+Tab cycle within it
|
|
216
|
+
(FR20); Escape closes it and focus returns to the row. The sub-menu's menu
|
|
217
|
+
inside keeps AR3. The row keeps `aria-expanded`; its `aria-haspopup` says
|
|
218
|
+
`dialog` and its `aria-controls` names the dialog.
|
|
210
219
|
|
|
211
220
|
## Design relationships
|
|
212
221
|
|
|
@@ -324,6 +333,7 @@ than adding a DropdownMenu-owned heading target.
|
|
|
324
333
|
| FR15 | `DropdownMenu.test.tsx` header and footer suite; `__tests__/MenuHeaderFooter.a11y.chromium.spec.ts` in real Chromium | dialog role, modality, name and trigger wiring; regions outside the menu element; typing in a header field; Tab walk header → menu → footer with wrap at both ends; Tab and Shift+Tab from an open sub-menu; hidden and hidden-wrapper controls; Escape from the header; a finger tap on a footer button; bottom sheet order; no regions | A region rendered inside the menu element, a popover with regions that is not a named modal dialog, a trigger still saying `aria-haspopup="menu"` with regions, a header key taken by typeahead, a Tab that leaves the dialog or closes it, focus stranded outside the dialog, or a footer tap swallowed by the press model fails. |
|
|
325
334
|
| FR16 | `DropdownMenuSelectable.test.tsx`, source inspection, scoped `pr-a11y` over the `a11y/Checkbox pattern` stories, and owned Storybook states | checked, unchecked, disabled, caller click/pointer handlers, marker size and ownership | A dropped consumer handler, wrong checked state, nested semantic checkbox, disabled activation, or menu that closes by default fails. | `audit:DropdownMenuCheckboxItem/behavior` |
|
|
326
335
|
| FR17, AR6 | Shared navigation source and DropdownMenu keyboard tests | disabled checkbox row first, middle, and last | Current tests prove the shared hook skips the row; no keyboard-discoverability proof exists until OQ1 is settled. | `audit:DropdownMenuCheckboxItem/accessibility` |
|
|
336
|
+
| FR20, AR7 | `DropdownMenuSubMenu.test.tsx` "header and footer" suite; `ContextMenu.test.tsx` rows-alone case; `__tests__/SubMenuHeaderFooter.a11y.chromium.spec.ts` in real Chromium (fine pointer, coarse pointer) | flyout and phone view; dialog role, name, row wiring; regions outside menus; Tab walk and wrap from a field, row, deeper flyout; field keys, presses; Escape, IME and handled Escapes; keyboard open; pointer leaving, focus in then out; parent closing a nested flyout; view tracking its regions; ContextMenu rows alone | A region inside a menu element, a surface with regions that is not a named modal dialog, a row still saying `aria-haspopup="menu"`, a field key taken by the menu, a Tab that leaves or closes the dialog, a flyout closed under focus by a pointer leaving, a flyout outliving its parent, or the root menu beside a view, fails. | `audit:DropdownMenu/behavior` |
|
|
327
337
|
|
|
328
338
|
## Decision log
|
|
329
339
|
|
|
@@ -541,3 +551,24 @@ the menu. Tab and Shift+Tab wrap within the dialog, as the APG Dialog
|
|
|
541
551
|
pattern requires; Escape and a press outside close it. Without either prop
|
|
542
552
|
the component is unchanged: a Menu Button whose Tab closes the menu. Rejected:
|
|
543
553
|
closing on Tab past either end, which leaves a modal dialog by Tab.
|
|
554
|
+
|
|
555
|
+
### DEC-13 (proposed) — A sub-menu's header and footer make its surface a dialog beside the menu
|
|
556
|
+
|
|
557
|
+
**Reference:** `component:DropdownMenu/DEC-13`
|
|
558
|
+
**Decider:** proposed by `vjeux`, 2026-10-14; pending owner review
|
|
559
|
+
|
|
560
|
+
A filter field above a sub-menu's rows, or a door below them, is a control
|
|
561
|
+
that is not a menu item, so DEC-8 and DEC-10 apply to the sub-menu's own
|
|
562
|
+
surface as they apply to the root's: the sub-menu's menu holds only its rows,
|
|
563
|
+
and the surface around it is a named modal dialog whose Tab order wraps. A
|
|
564
|
+
flyout renders inside the menu that holds its row, so a dialog there would
|
|
565
|
+
still sit inside that menu; the `DropdownMenu` therefore renders a host beside its menu,
|
|
566
|
+
inside its popover, and a sub-menu with regions renders its surface there (the
|
|
567
|
+
flyout layer, or the drilled-in view, which then takes the root menu's place).
|
|
568
|
+
Rendered there, a flyout no longer hides with the flyout that holds its row,
|
|
569
|
+
so it closes with it. The pointer leaving that flyout does not close it while
|
|
570
|
+
focus is inside, because what was typed into its field would go with it.
|
|
571
|
+
Rejected: regions inside the flyout beside the sub-menu's menu,
|
|
572
|
+
which puts them inside the parent menu; a separate DropdownMenu for the level,
|
|
573
|
+
which loses the row, the drill-in and the single press model; and closing the
|
|
574
|
+
flyout when the pointer leaves it as any flyout does.
|
|
@@ -61,7 +61,10 @@ import {
|
|
|
61
61
|
import {
|
|
62
62
|
DropdownMenuContext,
|
|
63
63
|
DropdownMenuRootOpenContext,
|
|
64
|
+
DropdownMenuSurfaceContext,
|
|
64
65
|
type DropdownMenuContextValue,
|
|
66
|
+
type DropdownMenuSurfaceHost,
|
|
67
|
+
type DropdownMenuSurfaceValue,
|
|
65
68
|
} from './DropdownMenuContext';
|
|
66
69
|
import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect';
|
|
67
70
|
import {useListFocus} from '../hooks/useListFocus';
|
|
@@ -112,6 +115,14 @@ const MENU_MAX_BLOCK_SIZE = `min(${MENU_BLOCK_CAP}, ${layerViewportInset.maxBloc
|
|
|
112
115
|
const MENU_MAX_BLOCK_SIZE_FALLBACK = `min(${MENU_BLOCK_CAP}, ${layerViewportInset.maxBlockSizeFallback})`;
|
|
113
116
|
const MENU_TRIGGER_OPEN_BACKGROUND = `linear-gradient(${colorVars['--color-overlay-pressed']}, ${colorVars['--color-overlay-pressed']})`;
|
|
114
117
|
|
|
118
|
+
// A sub-menu's drilled-in view with a header or footer renders in the surface
|
|
119
|
+
// host that follows the menu, as the frame marked
|
|
120
|
+
// `data-astryx-submenu-regions="view"` (hidden while a deeper view shows).
|
|
121
|
+
// While one shows, the menu and its header and footer step aside. The rule
|
|
122
|
+
// reads that from the DOM, so no state mirrors which view shows.
|
|
123
|
+
const STEPS_ASIDE_FOR_VIEW =
|
|
124
|
+
':has(~ * > [data-astryx-submenu-regions="view"]:not([hidden]))';
|
|
125
|
+
|
|
115
126
|
const styles = stylex.create({
|
|
116
127
|
triggerOpen: {
|
|
117
128
|
backgroundImage: MENU_TRIGGER_OPEN_BACKGROUND,
|
|
@@ -125,7 +136,12 @@ const styles = stylex.create({
|
|
|
125
136
|
},
|
|
126
137
|
dropdown: {
|
|
127
138
|
boxSizing: 'border-box',
|
|
128
|
-
display:
|
|
139
|
+
display: {
|
|
140
|
+
default: 'flex',
|
|
141
|
+
// A sub-menu's drilled-in view with a header or footer shows in its
|
|
142
|
+
// place (see `stepsAsideForView`).
|
|
143
|
+
[STEPS_ASIDE_FOR_VIEW]: 'none',
|
|
144
|
+
},
|
|
129
145
|
flexDirection: 'column',
|
|
130
146
|
gap: spacingVars['--spacing-0-5'],
|
|
131
147
|
// Pointer opens focus this container so subsequent arrow keys and Escape
|
|
@@ -175,6 +191,22 @@ const styles = stylex.create({
|
|
|
175
191
|
flexShrink: 1,
|
|
176
192
|
minBlockSize: 0,
|
|
177
193
|
},
|
|
194
|
+
// Where a sub-menu with a header or footer renders its surface: beside the
|
|
195
|
+
// menu, never inside it. It owns no box, so the popover's layout is
|
|
196
|
+
// unchanged.
|
|
197
|
+
surfaceHost: {
|
|
198
|
+
display: 'contents',
|
|
199
|
+
},
|
|
200
|
+
// While a sub-menu's drilled-in view with its own header or footer shows
|
|
201
|
+
// in the surface host, the header and footer step aside for it, as the menu
|
|
202
|
+
// does (see `dropdown`). Derived from the DOM by the rule itself: the view
|
|
203
|
+
// is the frame in the host that is not hidden.
|
|
204
|
+
stepsAsideForView: {
|
|
205
|
+
display: {
|
|
206
|
+
default: null,
|
|
207
|
+
[STEPS_ASIDE_FOR_VIEW]: 'none',
|
|
208
|
+
},
|
|
209
|
+
},
|
|
178
210
|
region: {
|
|
179
211
|
flexShrink: 0,
|
|
180
212
|
padding: spacingVars['--spacing-1'],
|
|
@@ -1455,12 +1487,39 @@ function DropdownMenuPopover({
|
|
|
1455
1487
|
|
|
1456
1488
|
// The drill-in view stack for sub-menus on a phone.
|
|
1457
1489
|
const {drillIn, wrapContent} = useMenuDrillIn(isOpen);
|
|
1490
|
+
// The surface host is owned through a ref. State keeps only a key that
|
|
1491
|
+
// changes whenever a host element attaches, the host included when it
|
|
1492
|
+
// moves (a header or footer coming or going), so sub-menus re-render into
|
|
1493
|
+
// the element that is mounted.
|
|
1494
|
+
const surfaceHostRef = useRef<HTMLDivElement | null>(null);
|
|
1495
|
+
const [surfaceHostKey, setSurfaceHostKey] = useState(0);
|
|
1496
|
+
const attachSurfaceHost = useCallback((node: HTMLDivElement | null) => {
|
|
1497
|
+
surfaceHostRef.current = node;
|
|
1498
|
+
if (node != null) {
|
|
1499
|
+
setSurfaceHostKey(key => key + 1);
|
|
1500
|
+
}
|
|
1501
|
+
}, []);
|
|
1502
|
+
const surfaceHost = useMemo<DropdownMenuSurfaceHost>(
|
|
1503
|
+
() => ({
|
|
1504
|
+
getElement: () => (surfaceHostKey > 0 ? surfaceHostRef.current : null),
|
|
1505
|
+
}),
|
|
1506
|
+
[surfaceHostKey],
|
|
1507
|
+
);
|
|
1458
1508
|
const resolvedMaxHeight = menuMaxHeight == null ? null : `${menuMaxHeight}px`;
|
|
1459
1509
|
// Context for compound items
|
|
1460
1510
|
const contextValue = useMemo<DropdownMenuContextValue>(
|
|
1461
1511
|
() => ({closeMenu, menuSize, drillIn, menuLabel}),
|
|
1462
1512
|
[closeMenu, menuSize, drillIn, menuLabel],
|
|
1463
1513
|
);
|
|
1514
|
+
// What a sub-menu with a header or footer needs, on an internal context.
|
|
1515
|
+
const surfaceValue = useMemo<DropdownMenuSurfaceValue>(
|
|
1516
|
+
() => ({
|
|
1517
|
+
host: surfaceHost,
|
|
1518
|
+
ownerId: menuId,
|
|
1519
|
+
menuMaxHeight: resolvedMaxHeight ?? undefined,
|
|
1520
|
+
}),
|
|
1521
|
+
[surfaceHost, menuId, resolvedMaxHeight],
|
|
1522
|
+
);
|
|
1464
1523
|
|
|
1465
1524
|
// Resolve menu content: data-driven items become components
|
|
1466
1525
|
const menuContent = wrapContent(
|
|
@@ -1469,7 +1528,13 @@ function DropdownMenuPopover({
|
|
|
1469
1528
|
const hasOverflow = useMenuOverflow(listRef, menuContent, popover.isOpen);
|
|
1470
1529
|
|
|
1471
1530
|
// The header and footer sit beside the menu inside the popover, never inside
|
|
1472
|
-
// the role="menu" element: a menu holds only its rows.
|
|
1531
|
+
// the role="menu" element: a menu holds only its rows. The surface host
|
|
1532
|
+
// follows the menu, so a sub-menu with a header or footer renders beside the
|
|
1533
|
+
// menu too; while such a sub-menu's drilled-in view shows there, the menu and
|
|
1534
|
+
// the regions step aside (STEPS_ASIDE_FOR_VIEW).
|
|
1535
|
+
const surfaceHostElement = (
|
|
1536
|
+
<div ref={attachSurfaceHost} {...stylex.props(styles.surfaceHost)} />
|
|
1537
|
+
);
|
|
1473
1538
|
const renderWithRegions = (menu: ReactNode): ReactNode =>
|
|
1474
1539
|
hasRegions ? (
|
|
1475
1540
|
<div
|
|
@@ -1482,7 +1547,7 @@ function DropdownMenuPopover({
|
|
|
1482
1547
|
<div
|
|
1483
1548
|
ref={headerRef}
|
|
1484
1549
|
onKeyDown={handleRegionKeyDown}
|
|
1485
|
-
{...stylex.props(styles.region)}>
|
|
1550
|
+
{...stylex.props(styles.region, styles.stepsAsideForView)}>
|
|
1486
1551
|
{header}
|
|
1487
1552
|
</div>
|
|
1488
1553
|
)}
|
|
@@ -1491,13 +1556,17 @@ function DropdownMenuPopover({
|
|
|
1491
1556
|
<div
|
|
1492
1557
|
ref={footerRef}
|
|
1493
1558
|
onKeyDown={handleRegionKeyDown}
|
|
1494
|
-
{...stylex.props(styles.region)}>
|
|
1559
|
+
{...stylex.props(styles.region, styles.stepsAsideForView)}>
|
|
1495
1560
|
{footer}
|
|
1496
1561
|
</div>
|
|
1497
1562
|
)}
|
|
1563
|
+
{surfaceHostElement}
|
|
1498
1564
|
</div>
|
|
1499
1565
|
) : (
|
|
1500
|
-
|
|
1566
|
+
<>
|
|
1567
|
+
{menu}
|
|
1568
|
+
{surfaceHostElement}
|
|
1569
|
+
</>
|
|
1501
1570
|
);
|
|
1502
1571
|
|
|
1503
1572
|
const triggerProps: DropdownMenuTriggerProps = {
|
|
@@ -1636,9 +1705,11 @@ function DropdownMenuPopover({
|
|
|
1636
1705
|
)}>
|
|
1637
1706
|
<MenuRegionsContext value={hasRegions}>
|
|
1638
1707
|
<DropdownMenuContext value={contextValue}>
|
|
1639
|
-
<
|
|
1640
|
-
{
|
|
1641
|
-
|
|
1708
|
+
<DropdownMenuSurfaceContext value={surfaceValue}>
|
|
1709
|
+
<DropdownMenuRootOpenContext value={isOpen}>
|
|
1710
|
+
{menuContent}
|
|
1711
|
+
</DropdownMenuRootOpenContext>
|
|
1712
|
+
</DropdownMenuSurfaceContext>
|
|
1642
1713
|
</DropdownMenuContext>
|
|
1643
1714
|
</MenuRegionsContext>
|
|
1644
1715
|
</div>,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file DropdownMenuContext.public.test.ts
|
|
5
|
+
* @input The DropdownMenu barrel and its public `DropdownMenuContextValue`
|
|
6
|
+
* @output Guards that the menu context keeps its members and that the internal
|
|
7
|
+
* surface context a sub-menu with a header or footer reads is not exported
|
|
8
|
+
* @position Public API test guarding @astryxdesign/core/DropdownMenu
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {describe, expect, expectTypeOf, it} from 'vitest';
|
|
12
|
+
import * as dropdownMenu from './index';
|
|
13
|
+
import type {DropdownMenuContextValue} from './index';
|
|
14
|
+
|
|
15
|
+
describe('DropdownMenu public context', () => {
|
|
16
|
+
it('keeps its members and exports no surface context', () => {
|
|
17
|
+
expectTypeOf<keyof DropdownMenuContextValue>().toEqualTypeOf<
|
|
18
|
+
'closeMenu' | 'menuSize' | 'drillIn' | 'menuLabel'
|
|
19
|
+
>();
|
|
20
|
+
expect('DropdownMenuSurfaceContext' in dropdownMenu).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
});
|