@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
|
@@ -79,6 +79,18 @@ import {
|
|
|
79
79
|
import {useDevWarning} from '../hooks/useDevWarning';
|
|
80
80
|
|
|
81
81
|
const styles = stylex.create({
|
|
82
|
+
// The shared disabled style drops the focus ring; restore it while this
|
|
83
|
+
// field's own input holds focus (spec:AST-085 FR6). Defaults repeat it.
|
|
84
|
+
disabledFocusRing: {
|
|
85
|
+
borderColor: {
|
|
86
|
+
default: colorVars['--color-border-emphasized'],
|
|
87
|
+
':has(input:focus)': colorVars['--color-accent'],
|
|
88
|
+
},
|
|
89
|
+
boxShadow: {
|
|
90
|
+
default: 'none',
|
|
91
|
+
':has(input:focus)': `inset 0px 0px 0px 2px ${colorVars['--color-accent-muted']}`,
|
|
92
|
+
},
|
|
93
|
+
},
|
|
82
94
|
iconButton: {
|
|
83
95
|
display: 'flex',
|
|
84
96
|
alignItems: 'center',
|
|
@@ -241,13 +253,13 @@ export interface DateInputProps extends Omit<
|
|
|
241
253
|
|
|
242
254
|
/**
|
|
243
255
|
* Explains why the input is disabled. When set together with
|
|
244
|
-
* `isDisabled`, the
|
|
245
|
-
* keyboard focus, and the field
|
|
256
|
+
* `isDisabled`, the field shows a tooltip with this text on hover and
|
|
257
|
+
* keyboard focus, and the field's focusable controls are described by it,
|
|
246
258
|
* so the reason is discoverable by keyboard and assistive technology.
|
|
247
259
|
* Typing and calendar activation stay blocked.
|
|
248
260
|
*
|
|
249
|
-
* Use this instead of wrapping a disabled input in `Tooltip
|
|
250
|
-
*
|
|
261
|
+
* Use this instead of wrapping a disabled input in `Tooltip`, so the
|
|
262
|
+
* reason is tied to the field.
|
|
251
263
|
*
|
|
252
264
|
* @example
|
|
253
265
|
* ```
|
|
@@ -537,11 +549,11 @@ function PointerDateField({
|
|
|
537
549
|
const isBusy = isLoading || optimisticValue !== value;
|
|
538
550
|
const isEffectivelyDisabled = isDisabled || isBusy;
|
|
539
551
|
|
|
540
|
-
//
|
|
541
|
-
//
|
|
542
|
-
//
|
|
543
|
-
//
|
|
544
|
-
//
|
|
552
|
+
// A disabled or busy field never renders native `disabled` (spec:AST-085
|
|
553
|
+
// FR1): the input and calendar button stay focusable via aria-disabled.
|
|
554
|
+
// Typing is blocked with readOnly and value mutation guards; calendar
|
|
555
|
+
// activation is blocked by the isEffectivelyDisabled guards. The
|
|
556
|
+
// disabled-reason tooltip's listeners attach to the input container. Only
|
|
545
557
|
// the persistent isDisabled state (not the transient busy state) surfaces a
|
|
546
558
|
// reason.
|
|
547
559
|
const showsDisabledMessage = isDisabled && !!disabledMessage;
|
|
@@ -703,8 +715,8 @@ function PointerDateField({
|
|
|
703
715
|
// Handle input text change - update immediately if valid and allowed
|
|
704
716
|
const handleInputChange = useCallback(
|
|
705
717
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
706
|
-
//
|
|
707
|
-
//
|
|
718
|
+
// The input is never natively disabled (spec:AST-085 FR1), so guard
|
|
719
|
+
// value mutation explicitly (readOnly also blocks typing).
|
|
708
720
|
if (isEffectivelyDisabled) {
|
|
709
721
|
return;
|
|
710
722
|
}
|
|
@@ -733,6 +745,11 @@ function PointerDateField({
|
|
|
733
745
|
if (pendingInput === null) {
|
|
734
746
|
return;
|
|
735
747
|
}
|
|
748
|
+
// Disabled or busy: keep the value, drop the draft (spec:AST-085 FR2).
|
|
749
|
+
if (isEffectivelyDisabled) {
|
|
750
|
+
setPendingInput(null);
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
736
753
|
|
|
737
754
|
if (!pendingInput.trim()) {
|
|
738
755
|
if (value !== undefined) {
|
|
@@ -750,7 +767,14 @@ function PointerDateField({
|
|
|
750
767
|
}
|
|
751
768
|
}
|
|
752
769
|
setPendingInput(null);
|
|
753
|
-
}, [
|
|
770
|
+
}, [
|
|
771
|
+
pendingInput,
|
|
772
|
+
isEffectivelyDisabled,
|
|
773
|
+
value,
|
|
774
|
+
fireChange,
|
|
775
|
+
isDateDisabled,
|
|
776
|
+
locale,
|
|
777
|
+
]);
|
|
754
778
|
|
|
755
779
|
// Handle blur - validate, check constraints, and clear pending input
|
|
756
780
|
const handleBlur = useCallback(() => {
|
|
@@ -808,6 +832,7 @@ function PointerDateField({
|
|
|
808
832
|
inputWrapperStyles.base,
|
|
809
833
|
sizeStyles[size],
|
|
810
834
|
isEffectivelyDisabled && inputWrapperStyles.disabled,
|
|
835
|
+
isEffectivelyDisabled && styles.disabledFocusRing,
|
|
811
836
|
status && inputStatusBorderStyles[status.type],
|
|
812
837
|
status &&
|
|
813
838
|
!isEffectivelyDisabled &&
|
|
@@ -823,7 +848,11 @@ function PointerDateField({
|
|
|
823
848
|
<button
|
|
824
849
|
type="button"
|
|
825
850
|
onClick={handleToggle}
|
|
826
|
-
disabled={isEffectivelyDisabled}
|
|
851
|
+
aria-disabled={isEffectivelyDisabled || undefined}
|
|
852
|
+
// A tab stop while disabled too, so it carries the reason (FR5).
|
|
853
|
+
aria-describedby={
|
|
854
|
+
showsDisabledMessage ? disabledMessageTooltip.describedBy : undefined
|
|
855
|
+
}
|
|
827
856
|
aria-label={
|
|
828
857
|
popover.isOpen
|
|
829
858
|
? t('@astryx.dateInput.toggleCalendarClose')
|
|
@@ -858,16 +887,15 @@ function PointerDateField({
|
|
|
858
887
|
value={displayValue}
|
|
859
888
|
onChange={handleInputChange}
|
|
860
889
|
onBlur={chainEventHandlers(handleBlur, onBlurProp)}
|
|
861
|
-
onClick={
|
|
890
|
+
onClick={
|
|
891
|
+
isEffectivelyDisabled
|
|
892
|
+
? undefined
|
|
893
|
+
: chainEventHandlers(handleInputClick, onClickProp)
|
|
894
|
+
}
|
|
862
895
|
onKeyDown={chainEventHandlers(handleInputKeyDown, onKeyDownProp)}
|
|
863
896
|
placeholder={placeholder}
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
// blocked with readOnly and the mutation guards, and calendar
|
|
867
|
-
// activation is blocked by the isEffectivelyDisabled guards.
|
|
868
|
-
disabled={isEffectivelyDisabled && !showsDisabledMessage}
|
|
869
|
-
aria-disabled={showsDisabledMessage ? 'true' : undefined}
|
|
870
|
-
readOnly={showsDisabledMessage || undefined}
|
|
897
|
+
aria-disabled={isEffectivelyDisabled ? 'true' : undefined}
|
|
898
|
+
readOnly={isEffectivelyDisabled || undefined}
|
|
871
899
|
aria-labelledby={
|
|
872
900
|
ariaLabelledByProp != null
|
|
873
901
|
? joinAriaIDs(ariaLabelledBy ?? fieldLabelID, ariaLabelledByProp)
|
|
@@ -554,11 +554,32 @@ describe('DateInput — field parity', () => {
|
|
|
554
554
|
onChange={() => {}}
|
|
555
555
|
/>,
|
|
556
556
|
);
|
|
557
|
-
|
|
557
|
+
// Focusable, never natively disabled (AST-085 FR1), and still inert.
|
|
558
|
+
expect(field()).not.toHaveAttribute('disabled');
|
|
559
|
+
expect(field()).toHaveAttribute('aria-disabled', 'true');
|
|
560
|
+
field().focus();
|
|
561
|
+
expect(field()).toHaveFocus();
|
|
558
562
|
fireEvent.click(field());
|
|
563
|
+
fireEvent.keyDown(field(), {key: 'Enter'});
|
|
564
|
+
expect(field()).toHaveAttribute('aria-expanded', 'false');
|
|
559
565
|
expect(screen.queryByRole('grid')).not.toBeInTheDocument();
|
|
560
566
|
});
|
|
561
567
|
|
|
568
|
+
it('does not call a caller onClick while disabled (AST-085 FR2)', () => {
|
|
569
|
+
const onClick = vi.fn();
|
|
570
|
+
render(
|
|
571
|
+
<DateInput
|
|
572
|
+
nativePicker="never"
|
|
573
|
+
label="Ship date"
|
|
574
|
+
isDisabled
|
|
575
|
+
onClick={onClick}
|
|
576
|
+
onChange={() => {}}
|
|
577
|
+
/>,
|
|
578
|
+
);
|
|
579
|
+
fireEvent.click(field());
|
|
580
|
+
expect(onClick).not.toHaveBeenCalled();
|
|
581
|
+
});
|
|
582
|
+
|
|
562
583
|
it('stays focusable and explains itself when disabled with a reason', () => {
|
|
563
584
|
render(
|
|
564
585
|
<DateInput
|
|
@@ -601,12 +601,38 @@ describe('DateInput nativePicker', () => {
|
|
|
601
601
|
await act(async () => resolveAction());
|
|
602
602
|
});
|
|
603
603
|
|
|
604
|
-
it('
|
|
604
|
+
it('keeps a disabled control focusable and its toggle inert (AST-085 FR1, FR2)', () => {
|
|
605
605
|
stubPointer(true);
|
|
606
606
|
render(<DateInput label="Date" isDisabled onChange={() => {}} />);
|
|
607
607
|
|
|
608
|
-
|
|
609
|
-
expect(
|
|
608
|
+
const input = getInput();
|
|
609
|
+
expect(input).not.toHaveAttribute('disabled');
|
|
610
|
+
expect(input).toHaveAttribute('aria-disabled', 'true');
|
|
611
|
+
expect(input).toHaveAttribute('readonly');
|
|
612
|
+
input.focus();
|
|
613
|
+
expect(input).toHaveFocus();
|
|
614
|
+
const toggle = screen.getByLabelText('Open calendar');
|
|
615
|
+
expect(toggle).not.toHaveAttribute('disabled');
|
|
616
|
+
expect(toggle).toHaveAttribute('aria-disabled', 'true');
|
|
617
|
+
const showPicker = vi.fn();
|
|
618
|
+
Object.defineProperty(input, 'showPicker', {value: showPicker});
|
|
619
|
+
fireEvent.click(toggle);
|
|
620
|
+
expect(showPicker).not.toHaveBeenCalled();
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
it('does not call a caller onClick while disabled (AST-085 FR2)', () => {
|
|
624
|
+
stubPointer(true);
|
|
625
|
+
const onClick = vi.fn();
|
|
626
|
+
render(
|
|
627
|
+
<DateInput
|
|
628
|
+
label="Date"
|
|
629
|
+
isDisabled
|
|
630
|
+
onClick={onClick}
|
|
631
|
+
onChange={() => {}}
|
|
632
|
+
/>,
|
|
633
|
+
);
|
|
634
|
+
fireEvent.click(getInput());
|
|
635
|
+
expect(onClick).not.toHaveBeenCalled();
|
|
610
636
|
});
|
|
611
637
|
|
|
612
638
|
it('ignores a change while disabled', () => {
|
|
@@ -82,6 +82,17 @@ import {
|
|
|
82
82
|
import {chainEventHandlers} from '../utils/chainEventHandlers';
|
|
83
83
|
|
|
84
84
|
const styles = stylex.create({
|
|
85
|
+
// Same as DateInput's: the focus ring for a focused disabled field.
|
|
86
|
+
disabledFocusRing: {
|
|
87
|
+
borderColor: {
|
|
88
|
+
default: colorVars['--color-border-emphasized'],
|
|
89
|
+
':has(input:focus)': colorVars['--color-accent'],
|
|
90
|
+
},
|
|
91
|
+
boxShadow: {
|
|
92
|
+
default: 'none',
|
|
93
|
+
':has(input:focus)': `inset 0px 0px 0px 2px ${colorVars['--color-accent-muted']}`,
|
|
94
|
+
},
|
|
95
|
+
},
|
|
85
96
|
wrapper: {
|
|
86
97
|
gap: spacingVars['--spacing-2'],
|
|
87
98
|
},
|
|
@@ -323,9 +334,10 @@ export function NativeDateField({
|
|
|
323
334
|
// platform picker from its input.
|
|
324
335
|
const isEffectivelyDisabled = isDisabled || isLoading;
|
|
325
336
|
|
|
326
|
-
//
|
|
327
|
-
// disabled
|
|
328
|
-
//
|
|
337
|
+
// Same contract as the other two surfaces: a disabled field never renders
|
|
338
|
+
// native `disabled` (spec:AST-085 FR1); the input stays focusable via
|
|
339
|
+
// aria-disabled and readOnly. The disabled-reason tooltip's listeners
|
|
340
|
+
// attach to the wrapper.
|
|
329
341
|
const showsDisabledMessage = isDisabled && !!disabledMessage;
|
|
330
342
|
const disabledMessageTooltip = useTooltip({
|
|
331
343
|
placement: 'above',
|
|
@@ -582,6 +594,7 @@ export function NativeDateField({
|
|
|
582
594
|
sizeStyles[size],
|
|
583
595
|
styles.wrapper,
|
|
584
596
|
isEffectivelyDisabled && inputWrapperStyles.disabled,
|
|
597
|
+
isEffectivelyDisabled && styles.disabledFocusRing,
|
|
585
598
|
status && inputStatusBorderStyles[status.type],
|
|
586
599
|
status &&
|
|
587
600
|
!isEffectivelyDisabled &&
|
|
@@ -597,7 +610,7 @@ export function NativeDateField({
|
|
|
597
610
|
<button
|
|
598
611
|
type="button"
|
|
599
612
|
onClick={openPicker}
|
|
600
|
-
disabled={isEffectivelyDisabled}
|
|
613
|
+
aria-disabled={isEffectivelyDisabled || undefined}
|
|
601
614
|
aria-label={t('@astryx.dateInput.openCalendar')}
|
|
602
615
|
tabIndex={-1}
|
|
603
616
|
{...stylex.props(
|
|
@@ -615,6 +628,7 @@ export function NativeDateField({
|
|
|
615
628
|
<span {...stylex.props(styles.slot)}>
|
|
616
629
|
<input
|
|
617
630
|
{...controlProps}
|
|
631
|
+
onClick={isEffectivelyDisabled ? undefined : controlProps.onClick}
|
|
618
632
|
ref={mergedInputRef}
|
|
619
633
|
id={id}
|
|
620
634
|
type="date"
|
|
@@ -626,9 +640,8 @@ export function NativeDateField({
|
|
|
626
640
|
onBlur={chainEventHandlers(handleBlur, onBlurProp)}
|
|
627
641
|
min={min}
|
|
628
642
|
max={max}
|
|
629
|
-
disabled={isEffectivelyDisabled
|
|
630
|
-
|
|
631
|
-
readOnly={showsDisabledMessage || undefined}
|
|
643
|
+
aria-disabled={isEffectivelyDisabled ? 'true' : undefined}
|
|
644
|
+
readOnly={isEffectivelyDisabled || undefined}
|
|
632
645
|
aria-labelledby={
|
|
633
646
|
ariaLabelledByProp != null
|
|
634
647
|
? joinAriaIDs(ariaLabelledBy ?? fieldLabelID, ariaLabelledByProp)
|
|
@@ -166,6 +166,17 @@ const sizeStyles = stylex.create({
|
|
|
166
166
|
});
|
|
167
167
|
|
|
168
168
|
const styles = stylex.create({
|
|
169
|
+
// Same as DateInput's: the focus ring for a focused disabled field.
|
|
170
|
+
disabledFocusRing: {
|
|
171
|
+
borderColor: {
|
|
172
|
+
default: colorVars['--color-border-emphasized'],
|
|
173
|
+
':has(input:focus)': colorVars['--color-accent'],
|
|
174
|
+
},
|
|
175
|
+
boxShadow: {
|
|
176
|
+
default: 'none',
|
|
177
|
+
':has(input:focus)': `inset 0px 0px 0px 2px ${colorVars['--color-accent-muted']}`,
|
|
178
|
+
},
|
|
179
|
+
},
|
|
169
180
|
// ---- the closed field ----
|
|
170
181
|
wrapper: {
|
|
171
182
|
gap: spacingVars['--spacing-2'],
|
|
@@ -620,11 +631,11 @@ export function TouchDateField({
|
|
|
620
631
|
const isBusy = isLoading || optimisticValue !== value;
|
|
621
632
|
const isEffectivelyDisabled = isDisabled || isBusy;
|
|
622
633
|
|
|
623
|
-
//
|
|
624
|
-
//
|
|
625
|
-
//
|
|
626
|
-
//
|
|
627
|
-
//
|
|
634
|
+
// Same contract as DateInput: a disabled or busy field never renders native
|
|
635
|
+
// `disabled` (spec:AST-085 FR1); the input stays focusable via
|
|
636
|
+
// aria-disabled. The disabled-reason tooltip's listeners attach to the
|
|
637
|
+
// wrapper. Only the persistent disabled state surfaces a reason, never the
|
|
638
|
+
// transient busy one.
|
|
628
639
|
const showsDisabledMessage = isDisabled && !!disabledMessage;
|
|
629
640
|
const disabledMessageTooltip = useTooltip({
|
|
630
641
|
placement: 'above',
|
|
@@ -1213,6 +1224,7 @@ export function TouchDateField({
|
|
|
1213
1224
|
sizeStyles[size],
|
|
1214
1225
|
styles.wrapper,
|
|
1215
1226
|
isEffectivelyDisabled && inputWrapperStyles.disabled,
|
|
1227
|
+
isEffectivelyDisabled && styles.disabledFocusRing,
|
|
1216
1228
|
status && inputStatusBorderStyles[status.type],
|
|
1217
1229
|
status &&
|
|
1218
1230
|
!isEffectivelyDisabled &&
|
|
@@ -1228,7 +1240,7 @@ export function TouchDateField({
|
|
|
1228
1240
|
<button
|
|
1229
1241
|
type="button"
|
|
1230
1242
|
onClick={openSheet}
|
|
1231
|
-
disabled={isEffectivelyDisabled}
|
|
1243
|
+
aria-disabled={isEffectivelyDisabled || undefined}
|
|
1232
1244
|
aria-label={t('@astryx.dateInput.openCalendar')}
|
|
1233
1245
|
tabIndex={-1}
|
|
1234
1246
|
{...stylex.props(
|
|
@@ -1259,11 +1271,14 @@ export function TouchDateField({
|
|
|
1259
1271
|
readOnly
|
|
1260
1272
|
inputMode="none"
|
|
1261
1273
|
onChange={() => {}}
|
|
1262
|
-
onClick={
|
|
1274
|
+
onClick={
|
|
1275
|
+
isEffectivelyDisabled
|
|
1276
|
+
? undefined
|
|
1277
|
+
: chainEventHandlers(openSheet, onClickProp)
|
|
1278
|
+
}
|
|
1263
1279
|
onKeyDown={chainEventHandlers(handleInputKeyDown, onKeyDownProp)}
|
|
1264
1280
|
placeholder={placeholder}
|
|
1265
|
-
disabled={isEffectivelyDisabled
|
|
1266
|
-
aria-disabled={showsDisabledMessage ? 'true' : undefined}
|
|
1281
|
+
aria-disabled={isEffectivelyDisabled ? 'true' : undefined}
|
|
1267
1282
|
aria-labelledby={
|
|
1268
1283
|
ariaLabelledByProp != null
|
|
1269
1284
|
? joinAriaIDs(ariaLabelledBy ?? fieldLabelID, ariaLabelledByProp)
|
|
@@ -90,12 +90,12 @@ preset-list anatomy and its additive theming surface.
|
|
|
90
90
|
|
|
91
91
|
### Representative states
|
|
92
92
|
|
|
93
|
-
| State | Required invariant
|
|
94
|
-
| ----------------- |
|
|
95
|
-
| No presets | No preset group or preset-button target renders.
|
|
96
|
-
| Presets, no match | Every button carries the preset target with no selected state.
|
|
97
|
-
| Applied preset | The matching button carries `aria-current="true"` and the target's selected state.
|
|
98
|
-
| Disabled preset | A preset that violates an endpoint date constraint or a range-span constraint is
|
|
93
|
+
| State | Required invariant | Allowed variation |
|
|
94
|
+
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- |
|
|
95
|
+
| No presets | No preset group or preset-button target renders. | Calendar configuration |
|
|
96
|
+
| Presets, no match | Every button carries the preset target with no selected state. | Preset count and labels |
|
|
97
|
+
| Applied preset | The matching button carries `aria-current="true"` and the target's selected state. | Selected range |
|
|
98
|
+
| Disabled preset | A preset that violates an endpoint date constraint or a range-span constraint is disabled per `spec:AST-085` and carries the target's disabled state. | Constraint source |
|
|
99
99
|
|
|
100
100
|
### Transformation and precedence order
|
|
101
101
|
|
|
@@ -120,8 +120,8 @@ No new performance or resource constraint is introduced.
|
|
|
120
120
|
- **AR2 — Current state.** The applied preset remains exposed with
|
|
121
121
|
`aria-current="true"`; theme state reflection is additive.
|
|
122
122
|
- **AR3 — Disabled state.** A preset that violates an endpoint date constraint or
|
|
123
|
-
a range-span constraint
|
|
124
|
-
not replace that behavior.
|
|
123
|
+
a range-span constraint is disabled and stays focusable (`spec:AST-085`); theme
|
|
124
|
+
state reflection does not replace that behavior.
|
|
125
125
|
|
|
126
126
|
## Design relationships
|
|
127
127
|
|
|
@@ -172,11 +172,11 @@ No new performance or resource constraint is introduced.
|
|
|
172
172
|
|
|
173
173
|
## Verification map
|
|
174
174
|
|
|
175
|
-
| Contract | Verification
|
|
176
|
-
| ------------------- |
|
|
177
|
-
| FR1, FR2, AR1–AR3 | `DateRangeInput.test.tsx
|
|
178
|
-
| FR3, FR4 | `DateRangeInput.test.tsx`, `themingTargets.test.ts`, generated probe theme
|
|
179
|
-
| Theming anatomy map | `scripts/check-knowledge.mjs`
|
|
175
|
+
| Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
|
|
176
|
+
| ------------------- | --------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------------- | ------------------------------ |
|
|
177
|
+
| FR1, FR2, AR1–AR3 | `DateRangeInput.test.tsx`; disabled-preset focusability (AR3) pending conformance | no match, applied preset, endpoint-invalid preset, span-invalid preset | Semantics or state attributes disappear | `audit:DateRangeInput/presets` |
|
|
178
|
+
| FR3, FR4 | `DateRangeInput.test.tsx`, `themingTargets.test.ts`, generated probe theme | group target, selected button, disabled button | Target class/state is missing, undocumented, or placed on the wrong element | `audit:DateRangeInput/theming` |
|
|
179
|
+
| Theming anatomy map | `scripts/check-knowledge.mjs` | all documented anatomy | Target ownership or anatomy names drift | `audit:DateRangeInput/theming` |
|
|
180
180
|
|
|
181
181
|
## Decision log
|
|
182
182
|
|
|
@@ -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.
|