@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
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file Tooltip.a11y.states.ts
|
|
5
|
+
* @input Tooltip's and useTooltip's representative states and the facts TOOLTIP_PATTERN reads
|
|
6
|
+
* @output TOOLTIP_BINDING_STATES and the explicit ownership exclusions for this binding
|
|
7
|
+
* @position Binding inventory shared by the jsdom test and the Chromium spec (Tooltip and useTooltip under spec:AST-020 FR15)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type {TooltipStateFacts} from '@astryxdesign/a11y-spec';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* How the caller attaches the tooltip: wrapping an element, wrapping plain
|
|
14
|
+
* text, pointing `anchorRef` at its own element, or calling `useTooltip`.
|
|
15
|
+
*/
|
|
16
|
+
export type TooltipAttachment =
|
|
17
|
+
'wrapped-element' | 'text' | 'anchor-ref' | 'hook';
|
|
18
|
+
|
|
19
|
+
export interface TooltipBindingState {
|
|
20
|
+
readonly id: string;
|
|
21
|
+
readonly summary: string;
|
|
22
|
+
readonly storyId: string;
|
|
23
|
+
readonly attachment: TooltipAttachment;
|
|
24
|
+
/**
|
|
25
|
+
* The trigger's visible text. A `button` trigger is also named by it; a
|
|
26
|
+
* text trigger has no role, so the lanes find it by this text.
|
|
27
|
+
*/
|
|
28
|
+
readonly triggerText: string;
|
|
29
|
+
readonly triggerRole: 'button' | null;
|
|
30
|
+
/** The tooltip `content`. */
|
|
31
|
+
readonly content: string;
|
|
32
|
+
/** A visible hint the trigger is already described by, besides the tooltip. */
|
|
33
|
+
readonly hint?: string;
|
|
34
|
+
readonly facts: TooltipStateFacts;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const story = (id: string) => `a11y-tooltip--${id}`;
|
|
38
|
+
|
|
39
|
+
/** Tooltip's and useTooltip's default hover delay. */
|
|
40
|
+
const DEFAULT_DELAY: TooltipStateFacts = {showsOnHover: true, showDelayMs: 200};
|
|
41
|
+
|
|
42
|
+
export const TOOLTIP_BINDING_STATES = [
|
|
43
|
+
{
|
|
44
|
+
id: 'wrapped-button',
|
|
45
|
+
summary:
|
|
46
|
+
'a Tooltip wrapping a Button, which attaches to the Button and describes it',
|
|
47
|
+
storyId: story('wrapped-button'),
|
|
48
|
+
attachment: 'wrapped-element',
|
|
49
|
+
triggerText: 'Save',
|
|
50
|
+
triggerRole: 'button',
|
|
51
|
+
content: 'Saves your changes to this draft',
|
|
52
|
+
facts: DEFAULT_DELAY,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: 'text-trigger',
|
|
56
|
+
summary:
|
|
57
|
+
'a Tooltip wrapping plain text, which renders its own focusable inline trigger',
|
|
58
|
+
storyId: story('text-trigger'),
|
|
59
|
+
attachment: 'text',
|
|
60
|
+
triggerText: 'Draft',
|
|
61
|
+
triggerRole: null,
|
|
62
|
+
content: 'Only you can see a draft',
|
|
63
|
+
facts: DEFAULT_DELAY,
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: 'anchor-ref-with-hint',
|
|
67
|
+
summary:
|
|
68
|
+
'a Tooltip attached through anchorRef to a caller button that is already described by a visible hint, so the trigger is described by both',
|
|
69
|
+
storyId: story('anchor-ref-with-hint'),
|
|
70
|
+
attachment: 'anchor-ref',
|
|
71
|
+
triggerText: 'Publish',
|
|
72
|
+
triggerRole: 'button',
|
|
73
|
+
content: 'Makes this page visible to everyone',
|
|
74
|
+
hint: 'Last published yesterday',
|
|
75
|
+
facts: DEFAULT_DELAY,
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: 'use-tooltip-hook',
|
|
79
|
+
summary:
|
|
80
|
+
"useTooltip on a caller button, which spreads the hook's ref and describedBy onto it and renders the tooltip",
|
|
81
|
+
storyId: story('use-tooltip-hook'),
|
|
82
|
+
attachment: 'hook',
|
|
83
|
+
triggerText: 'Archive',
|
|
84
|
+
triggerRole: 'button',
|
|
85
|
+
content: 'Moves this page out of the list',
|
|
86
|
+
facts: DEFAULT_DELAY,
|
|
87
|
+
},
|
|
88
|
+
] as const satisfies ReadonlyArray<TooltipBindingState>;
|
|
89
|
+
|
|
90
|
+
export const TOOLTIP_EXCLUSIONS = [
|
|
91
|
+
{
|
|
92
|
+
id: 'focus-trigger',
|
|
93
|
+
owner:
|
|
94
|
+
"the 24 core hosts that pass focusTrigger 'always' — the disabledMessage tooltips on the field and choice controls (TextInput, TextArea, NumberInput, Switch, CheckboxInput, CheckboxList, RadioList, Selector, MultiSelector, SegmentedControl, Slider, Typeahead, Tokenizer, FileInput, and the date and time inputs), the Slider value bubble and ProgressBar marks — each through its own suite (for example TextInput.test.tsx 'shows the reason tooltip on keyboard focus'); and any caller that passes 'never'",
|
|
95
|
+
reason:
|
|
96
|
+
"'always' attaches the focus listeners to a wrapper that is not itself focusable, so focusin bubbling from the control inside it shows the hint; the described element and the focused element differ, which this contract's trigger-centred rows do not model, so each host proves its own keyboard path. 'never' detaches focus showing for a trigger inside a composite widget that moves focus itself, which then owns how a keyboard user reaches the hint; no shipped composition uses it. Every state here uses the default 'auto'.",
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
id: 'touch-trigger',
|
|
100
|
+
owner:
|
|
101
|
+
'useTooltip.test.tsx and Tooltip.test.tsx (touchTrigger suites) through the shared useTouchTrigger hook',
|
|
102
|
+
reason:
|
|
103
|
+
'On a touch pointer there is no hover, so a tap opens the tooltip or leaves it to the control the tap operates; the APG defines no touch behaviour, and the Chromium lane drives a mouse and a keyboard only.',
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
id: 'controlled-and-disabled',
|
|
107
|
+
owner:
|
|
108
|
+
'Tooltip.test.tsx and useTooltip.test.tsx (isOpen, isDefaultOpen and isEnabled suites)',
|
|
109
|
+
reason:
|
|
110
|
+
'A controlled tooltip answers Escape by reporting instead of hiding, and a disabled one never shows; both hand visibility to the caller, so the hover and focus outcomes this contract reads belong to the uncontrolled, enabled tooltip every state renders.',
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
id: 'interactive-hover-content',
|
|
114
|
+
owner: 'the HoverCard binding and the overlay-dismissal family',
|
|
115
|
+
reason:
|
|
116
|
+
'Hover content a user can interact with is a non-modal dialog, not a tooltip, so HoverCard and other interactive popovers are reviewed separately; no state here places a focusable control inside a tooltip.',
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
id: 'host-compositions',
|
|
120
|
+
owner:
|
|
121
|
+
"the composing components' own contracts (Button for the IconButton label tooltip, the input status binding for useInputStatusIcon, which renders the on-field status tooltip) and their tests",
|
|
122
|
+
reason:
|
|
123
|
+
'IconButton, useInputStatusIcon and Lab InfoTip render Tooltip or useTooltip for their own purposes; this binding proves the tooltip they render, and what each host adds around it is that host.',
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
id: 'placement-and-paint',
|
|
127
|
+
owner:
|
|
128
|
+
'Tooltip.test.tsx (placement and alignment), the layer runtime anchor tests, and the repository visual gate',
|
|
129
|
+
reason:
|
|
130
|
+
'Placement, alignment, the inverted surface and the hover underline on a text trigger are layout and paint; they change no role, description, focus or dismissal outcome, so every state uses the default placement.',
|
|
131
|
+
},
|
|
132
|
+
] as const;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
/** @vitest-environment jsdom */
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @file Tooltip.a11y.test.tsx
|
|
6
|
+
* @input TOOLTIP_PATTERN bound to Tooltip's and useTooltip's representative states
|
|
7
|
+
* @output jsdom-lane proof of the DOM expectations and an honest "unrun" report for the browser layers
|
|
8
|
+
* @position Binding test for Tooltip and useTooltip (the Chromium spec covers the rest)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {cleanup, render, screen} from '@testing-library/react';
|
|
12
|
+
import {describe, expect, it} from 'vitest';
|
|
13
|
+
import {
|
|
14
|
+
TOOLTIP_PATTERN,
|
|
15
|
+
checkAccessibilitySpec,
|
|
16
|
+
createJsdomHarness,
|
|
17
|
+
expectAccessibilitySpec,
|
|
18
|
+
summarize,
|
|
19
|
+
type BindingResult,
|
|
20
|
+
} from '@astryxdesign/a11y-spec';
|
|
21
|
+
import {renderTooltipState} from './Tooltip.a11y.renders';
|
|
22
|
+
import {
|
|
23
|
+
TOOLTIP_BINDING_STATES,
|
|
24
|
+
TOOLTIP_EXCLUSIONS,
|
|
25
|
+
type TooltipBindingState,
|
|
26
|
+
} from './Tooltip.a11y.states';
|
|
27
|
+
|
|
28
|
+
function triggerFor(state: TooltipBindingState): HTMLElement {
|
|
29
|
+
return state.triggerRole == null
|
|
30
|
+
? screen.getByText(state.triggerText, {exact: true})
|
|
31
|
+
: screen.getByRole(state.triggerRole, {name: state.triggerText});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function relatedFor(): Readonly<Record<string, Element>> {
|
|
35
|
+
return {
|
|
36
|
+
// The tooltip stays in the page while hidden; it is found with hidden
|
|
37
|
+
// elements included so the DOM rows can read it before it shows.
|
|
38
|
+
tooltip: screen.getByRole('tooltip', {hidden: true}),
|
|
39
|
+
before: screen.getByRole('button', {name: 'Before the trigger'}),
|
|
40
|
+
after: screen.getByRole('button', {name: 'After the trigger'}),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function checkState(state: TooltipBindingState): Promise<BindingResult> {
|
|
45
|
+
return checkAccessibilitySpec({
|
|
46
|
+
spec: TOOLTIP_PATTERN,
|
|
47
|
+
binding: 'Tooltip',
|
|
48
|
+
state: state.id,
|
|
49
|
+
facts: state.facts,
|
|
50
|
+
mount: async () => {
|
|
51
|
+
render(renderTooltipState(state));
|
|
52
|
+
return createJsdomHarness({
|
|
53
|
+
subject: triggerFor(state),
|
|
54
|
+
related: relatedFor(),
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
unmount: cleanup,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe('Tooltip — tooltip pattern, jsdom lane', () => {
|
|
62
|
+
it.each(
|
|
63
|
+
TOOLTIP_BINDING_STATES.map(
|
|
64
|
+
state => [state.id, state.summary, state] as const,
|
|
65
|
+
),
|
|
66
|
+
)('%s — %s', async (_id, _summary, state) => {
|
|
67
|
+
await expectAccessibilitySpec({
|
|
68
|
+
spec: TOOLTIP_PATTERN,
|
|
69
|
+
binding: 'Tooltip',
|
|
70
|
+
state: state.id,
|
|
71
|
+
facts: state.facts,
|
|
72
|
+
render: () => {
|
|
73
|
+
render(renderTooltipState(state));
|
|
74
|
+
},
|
|
75
|
+
subject: () => triggerFor(state),
|
|
76
|
+
related: () => relatedFor(),
|
|
77
|
+
cleanup,
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('runs the DOM layer and reports the browser-owned outcomes as unrun', async () => {
|
|
82
|
+
const results: BindingResult[] = [];
|
|
83
|
+
for (const state of TOOLTIP_BINDING_STATES) {
|
|
84
|
+
results.push(await checkState(state));
|
|
85
|
+
}
|
|
86
|
+
const report = summarize(TOOLTIP_PATTERN, results);
|
|
87
|
+
expect(report.counts.pass).toBeGreaterThan(0);
|
|
88
|
+
expect(report.unrunLayers).toEqual(['accessibility-tree', 'real-browser']);
|
|
89
|
+
expect(report.counts.unexpectedPass).toBe(0);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('tooltip binding inventory', () => {
|
|
94
|
+
it('gives every representative state a distinct checked-in story', () => {
|
|
95
|
+
const stories = TOOLTIP_BINDING_STATES.map(state => state.storyId);
|
|
96
|
+
expect(new Set(stories).size).toBe(stories.length);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('keeps focus-trigger opt-outs, touch, controlled visibility, interactive hover content, host compositions and paint explicit', () => {
|
|
100
|
+
expect(TOOLTIP_EXCLUSIONS.map(row => row.id)).toEqual([
|
|
101
|
+
'focus-trigger',
|
|
102
|
+
'touch-trigger',
|
|
103
|
+
'controlled-and-disabled',
|
|
104
|
+
'interactive-hover-content',
|
|
105
|
+
'host-compositions',
|
|
106
|
+
'placement-and-paint',
|
|
107
|
+
]);
|
|
108
|
+
for (const exclusion of TOOLTIP_EXCLUSIONS) {
|
|
109
|
+
expect(exclusion.owner.length, exclusion.id).toBeGreaterThan(20);
|
|
110
|
+
expect(exclusion.reason.length, exclusion.id).toBeGreaterThan(80);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('renders the trigger, one tooltip and both page buttons for every state', () => {
|
|
115
|
+
for (const state of TOOLTIP_BINDING_STATES) {
|
|
116
|
+
render(renderTooltipState(state));
|
|
117
|
+
expect(triggerFor(state), state.id).toBeTruthy();
|
|
118
|
+
expect(
|
|
119
|
+
screen.getAllByRole('tooltip', {hidden: true}),
|
|
120
|
+
state.id,
|
|
121
|
+
).toHaveLength(1);
|
|
122
|
+
expect(Object.keys(relatedFor())).toEqual(['tooltip', 'before', 'after']);
|
|
123
|
+
cleanup();
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
});
|
package/src/TopNav/TopNav.tsx
CHANGED
|
@@ -94,10 +94,18 @@ const styles = stylex.create({
|
|
|
94
94
|
padding: spacingVars['--spacing-2'],
|
|
95
95
|
boxSizing: 'border-box',
|
|
96
96
|
},
|
|
97
|
+
// The heading gives up space first so the end content and toggle stay in view
|
|
98
|
+
mobileBarHeading: {
|
|
99
|
+
display: 'flex',
|
|
100
|
+
alignItems: 'center',
|
|
101
|
+
flex: '0 1 auto',
|
|
102
|
+
minWidth: 0,
|
|
103
|
+
},
|
|
97
104
|
mobileBarEnd: {
|
|
98
105
|
display: 'flex',
|
|
99
106
|
alignItems: 'center',
|
|
100
107
|
gap: spacingVars['--spacing-1'],
|
|
108
|
+
flexShrink: 0,
|
|
101
109
|
marginInlineStart: 'auto',
|
|
102
110
|
},
|
|
103
111
|
// Drawer mode — vertical list of nav items
|
|
@@ -215,7 +223,9 @@ export function TopNav({
|
|
|
215
223
|
style,
|
|
216
224
|
)}
|
|
217
225
|
{...props}>
|
|
218
|
-
{heading &&
|
|
226
|
+
{heading && (
|
|
227
|
+
<div {...stylex.props(styles.mobileBarHeading)}>{heading}</div>
|
|
228
|
+
)}
|
|
219
229
|
<div {...stylex.props(styles.mobileBarEnd)}>
|
|
220
230
|
{endContent}
|
|
221
231
|
{hasMobileDrawerContent && hasAutoToggle && <MobileNavToggle />}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file TopNavMobileLongHeading.a11y.chromium.spec.ts
|
|
5
|
+
* @input The `Core/AppShell` Long Heading On Mobile story in a built Storybook
|
|
6
|
+
* at a 320px phone viewport.
|
|
7
|
+
* @output Real-engine proof that a long TopNav heading gives way on a narrow
|
|
8
|
+
* screen: the menu toggle stays inside the compact bar, and the drawer's
|
|
9
|
+
* close button stays inside the drawer.
|
|
10
|
+
* @position Run with `pnpm test:a11y-contract`. jsdom has no layout, so where
|
|
11
|
+
* the toggle lands is only observable here.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {expect, test, type Locator} from '@playwright/test';
|
|
15
|
+
import {
|
|
16
|
+
DEFAULT_STORYBOOK_DIR,
|
|
17
|
+
serveStorybook,
|
|
18
|
+
type StaticServer,
|
|
19
|
+
} from '@astryxdesign/a11y-spec/storybook';
|
|
20
|
+
|
|
21
|
+
let storybook: StaticServer;
|
|
22
|
+
|
|
23
|
+
test.beforeAll(async () => {
|
|
24
|
+
storybook = await serveStorybook(
|
|
25
|
+
process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test.afterAll(async () => {
|
|
30
|
+
await storybook?.close();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const STORY = 'core-appshell--long-heading-on-mobile';
|
|
34
|
+
|
|
35
|
+
async function boxOf(locator: Locator) {
|
|
36
|
+
const box = await locator.boundingBox();
|
|
37
|
+
if (box == null) {
|
|
38
|
+
throw new Error('the element has no layout box');
|
|
39
|
+
}
|
|
40
|
+
return box;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
test('a long heading keeps the menu toggle and the drawer close button on screen', async ({
|
|
44
|
+
page,
|
|
45
|
+
}) => {
|
|
46
|
+
await page.setViewportSize({width: 320, height: 568});
|
|
47
|
+
await page.goto(`${storybook.origin}/iframe.html?id=${STORY}&viewMode=story`);
|
|
48
|
+
|
|
49
|
+
const bar = page.getByRole('navigation', {name: 'Main navigation'});
|
|
50
|
+
const toggle = page.getByRole('button', {name: 'Open navigation'});
|
|
51
|
+
await expect(toggle).toBeVisible();
|
|
52
|
+
|
|
53
|
+
const barBox = await boxOf(bar);
|
|
54
|
+
const toggleBox = await boxOf(toggle);
|
|
55
|
+
expect(toggleBox.x).toBeGreaterThanOrEqual(barBox.x);
|
|
56
|
+
expect(toggleBox.x + toggleBox.width).toBeLessThanOrEqual(
|
|
57
|
+
barBox.x + barBox.width,
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
await toggle.click();
|
|
61
|
+
|
|
62
|
+
const drawer = page.getByRole('dialog', {name: 'Navigation'});
|
|
63
|
+
const close = drawer.getByRole('button', {name: 'Close navigation'});
|
|
64
|
+
await expect(close).toBeVisible();
|
|
65
|
+
|
|
66
|
+
// The drawer slides in from the inline end; measure once it has settled.
|
|
67
|
+
await expect(close).toBeInViewport({ratio: 1});
|
|
68
|
+
const drawerBox = await boxOf(drawer);
|
|
69
|
+
const closeBox = await boxOf(close);
|
|
70
|
+
expect(closeBox.x).toBeGreaterThanOrEqual(drawerBox.x);
|
|
71
|
+
expect(closeBox.x + closeBox.width).toBeLessThanOrEqual(
|
|
72
|
+
drawerBox.x + drawerBox.width,
|
|
73
|
+
);
|
|
74
|
+
});
|
|
@@ -942,6 +942,34 @@ describe('TreeList', () => {
|
|
|
942
942
|
expect(screen.queryByText('File')).not.toBeInTheDocument();
|
|
943
943
|
});
|
|
944
944
|
|
|
945
|
+
it("exposes a disabled row's toggle and action as aria-disabled and keeps them inert", () => {
|
|
946
|
+
const onClick = vi.fn();
|
|
947
|
+
const {container} = render(
|
|
948
|
+
<TreeList
|
|
949
|
+
items={underRoot([
|
|
950
|
+
{id: 'run', label: 'Run', onClick, isDisabled: true},
|
|
951
|
+
{...folder, isDisabled: true},
|
|
952
|
+
])}
|
|
953
|
+
/>,
|
|
954
|
+
);
|
|
955
|
+
// spec:AST-085 FR1/FR2: no native disabled anywhere, and a click
|
|
956
|
+
// dispatched straight on the inner controls does nothing.
|
|
957
|
+
expect(container.querySelector('[disabled]')).toBeNull();
|
|
958
|
+
const toggle = container.querySelector<HTMLElement>(
|
|
959
|
+
'[data-tree-toggle][aria-disabled="true"]',
|
|
960
|
+
)!;
|
|
961
|
+
expect(toggle).not.toBeNull();
|
|
962
|
+
toggle.click();
|
|
963
|
+
fireEvent.keyDown(toggle, {key: 'Enter'});
|
|
964
|
+
expect(screen.queryByText('File')).not.toBeInTheDocument();
|
|
965
|
+
const action = Array.from(container.querySelectorAll('button')).find(
|
|
966
|
+
button => button.textContent?.includes('Run'),
|
|
967
|
+
)!;
|
|
968
|
+
expect(action).toHaveAttribute('aria-disabled', 'true');
|
|
969
|
+
action.click();
|
|
970
|
+
expect(onClick).not.toHaveBeenCalled();
|
|
971
|
+
});
|
|
972
|
+
|
|
945
973
|
it('leaves a role-based control in start or end content to itself', () => {
|
|
946
974
|
// Native and role-based controls alike keep their own activation,
|
|
947
975
|
// the same rule link rows get from the shared enlarged-surface hook.
|
|
@@ -38,6 +38,7 @@ import {TreeListBranches} from './TreeListBranches';
|
|
|
38
38
|
import type {TreeListDensity, TreeListVariant} from './TreeListTypes';
|
|
39
39
|
import {themeProps} from '../utils/themeProps';
|
|
40
40
|
import {useTranslator} from '../i18n';
|
|
41
|
+
import {disabledInteractionProps} from '../utils/disabledInteraction';
|
|
41
42
|
|
|
42
43
|
// =============================================================================
|
|
43
44
|
// Styles
|
|
@@ -552,11 +553,13 @@ export function TreeListItem({
|
|
|
552
553
|
// Stable identity for TreeList's activateItem selector — do not
|
|
553
554
|
// remove without also updating TreeList.tsx.
|
|
554
555
|
data-tree-toggle=""
|
|
555
|
-
|
|
556
|
+
// Disabled stays focusable (spec:AST-085 FR1) but inert (FR2).
|
|
557
|
+
aria-disabled={isDisabled || undefined}
|
|
556
558
|
// Roving tabindex lives on the treeitem row; the chevron toggle is not
|
|
557
559
|
// a separate tab stop. Row-level Enter/Space forwards to this button.
|
|
558
560
|
tabIndex={-1}
|
|
559
561
|
onClick={handleToggle}
|
|
562
|
+
{...(isDisabled ? disabledInteractionProps() : null)}
|
|
560
563
|
{...mergeProps(
|
|
561
564
|
// Stable theme target for the expand/collapse control. `data-tree-toggle`
|
|
562
565
|
// stays as the functional activation hook; this adds a themeable
|
|
@@ -608,7 +611,8 @@ export function TreeListItem({
|
|
|
608
611
|
<button
|
|
609
612
|
type="button"
|
|
610
613
|
onClick={onClick}
|
|
611
|
-
disabled={isDisabled}
|
|
614
|
+
aria-disabled={isDisabled || undefined}
|
|
615
|
+
{...(isDisabled ? disabledInteractionProps() : null)}
|
|
612
616
|
aria-labelledby={labelId}
|
|
613
617
|
aria-describedby={description != null ? descriptionId : undefined}
|
|
614
618
|
// Roving tabindex lives on the treeitem row; inner action is not a
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file DisabledTriggerFocus.a11y.chromium.spec.ts
|
|
5
|
+
* @input The `a11y/Disabled trigger focus` stories in a built Storybook.
|
|
6
|
+
* @output Real-engine proof of `spec:AST-085` FR4 for Selector and
|
|
7
|
+
* ComplexSelector: a trigger disabled while it holds keyboard focus keeps
|
|
8
|
+
* focus and reports itself disabled.
|
|
9
|
+
* @position Run in `pr-a11y`; a DOM emulator does not move focus off an
|
|
10
|
+
* element that becomes disabled, so it cannot show the loss.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {expect, test} from '@playwright/test';
|
|
14
|
+
import {
|
|
15
|
+
DEFAULT_STORYBOOK_DIR,
|
|
16
|
+
serveStorybook,
|
|
17
|
+
type StaticServer,
|
|
18
|
+
} from '@astryxdesign/a11y-spec/storybook';
|
|
19
|
+
|
|
20
|
+
let storybook: StaticServer;
|
|
21
|
+
|
|
22
|
+
test.beforeAll(async () => {
|
|
23
|
+
storybook = await serveStorybook(
|
|
24
|
+
process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test.afterAll(async () => {
|
|
29
|
+
await storybook?.close();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const CASES = [
|
|
33
|
+
{
|
|
34
|
+
storyId: 'a11y-disabled-trigger-focus--selector-trigger',
|
|
35
|
+
role: 'combobox',
|
|
36
|
+
name: 'Fruit',
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
storyId: 'a11y-disabled-trigger-focus--complex-selector-trigger',
|
|
40
|
+
role: 'button',
|
|
41
|
+
name: 'View options',
|
|
42
|
+
},
|
|
43
|
+
] as const;
|
|
44
|
+
|
|
45
|
+
for (const {storyId, role, name} of CASES) {
|
|
46
|
+
test(`${name}: a focused trigger keeps focus when it becomes disabled`, async ({
|
|
47
|
+
page,
|
|
48
|
+
}) => {
|
|
49
|
+
await page.goto(
|
|
50
|
+
`${storybook.origin}/iframe.html?id=${storyId}&viewMode=story`,
|
|
51
|
+
);
|
|
52
|
+
const trigger = page.getByRole(role, {name});
|
|
53
|
+
await trigger.focus();
|
|
54
|
+
await expect(trigger).toBeFocused();
|
|
55
|
+
|
|
56
|
+
await page.evaluate(id => {
|
|
57
|
+
const channel = (
|
|
58
|
+
window as unknown as {
|
|
59
|
+
__STORYBOOK_ADDONS_CHANNEL__?: {
|
|
60
|
+
emit: (event: string, payload: unknown) => void;
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
).__STORYBOOK_ADDONS_CHANNEL__;
|
|
64
|
+
if (channel == null) {
|
|
65
|
+
throw new Error('Storybook channel is unavailable');
|
|
66
|
+
}
|
|
67
|
+
channel.emit('updateStoryArgs', {
|
|
68
|
+
storyId: id,
|
|
69
|
+
updatedArgs: {isDisabled: true},
|
|
70
|
+
});
|
|
71
|
+
}, storyId);
|
|
72
|
+
|
|
73
|
+
// Wait for the disabled re-render in either form, so focus is checked
|
|
74
|
+
// after it lands.
|
|
75
|
+
await expect
|
|
76
|
+
.poll(async () =>
|
|
77
|
+
trigger.evaluate(
|
|
78
|
+
el =>
|
|
79
|
+
el.hasAttribute('disabled') ||
|
|
80
|
+
el.getAttribute('aria-disabled') === 'true',
|
|
81
|
+
),
|
|
82
|
+
)
|
|
83
|
+
.toBe(true);
|
|
84
|
+
await expect(trigger).toBeFocused();
|
|
85
|
+
});
|
|
86
|
+
}
|
|
@@ -298,7 +298,22 @@ export function useGridFocus<T extends HTMLElement = HTMLElement>(
|
|
|
298
298
|
for (const el of targets) {
|
|
299
299
|
setTabIndex(el, el === tabbable ? 0 : -1);
|
|
300
300
|
}
|
|
301
|
-
|
|
301
|
+
// A cell that stopped being focusable (an aria-disabled day, for example,
|
|
302
|
+
// which stays reachable by focus) can still hold the stop from earlier
|
|
303
|
+
// navigation; it never stays tabbable, so the grid keeps one Tab stop.
|
|
304
|
+
for (const cell of getCells()) {
|
|
305
|
+
const el = cellFocusable(cell) ? null : resolveFocusTarget(cell);
|
|
306
|
+
if (el != null) {
|
|
307
|
+
setTabIndex(el, -1);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}, [
|
|
311
|
+
getFocusTargets,
|
|
312
|
+
getCells,
|
|
313
|
+
cellFocusable,
|
|
314
|
+
resolveFocusTarget,
|
|
315
|
+
setTabIndex,
|
|
316
|
+
]);
|
|
302
317
|
|
|
303
318
|
// Keep the tab stop valid across renders (cells added/removed, focusability
|
|
304
319
|
// toggled). Runs after every commit but only when roving tabindex is on.
|
|
@@ -36,7 +36,9 @@
|
|
|
36
36
|
*
|
|
37
37
|
* The menu root is marked `data-astryx-menu-press` so a nested flyout (a DOM
|
|
38
38
|
* descendant of its parent menu) can tell which level owns a press, and so a
|
|
39
|
-
* surface can be recognised as carrying this model.
|
|
39
|
+
* surface can be recognised as carrying this model. A menu the root renders
|
|
40
|
+
* beside itself names the root with `data-astryx-menu-press-surface` and counts
|
|
41
|
+
* as nested in it.
|
|
40
42
|
*
|
|
41
43
|
* SYNC: When modified, update:
|
|
42
44
|
* - /packages/core/src/hooks/index.ts
|
|
@@ -60,6 +62,14 @@ import {useLongPress, type UseLongPressHandlers} from './useLongPress';
|
|
|
60
62
|
/** Marks a menu root that carries this press model. */
|
|
61
63
|
export const MENU_PRESS_MARKER = 'data-astryx-menu-press';
|
|
62
64
|
const MENU_PRESS_ROOT_SELECTOR = `[${MENU_PRESS_MARKER}]`;
|
|
65
|
+
/**
|
|
66
|
+
* Carried by a menu root that another root renders beside itself rather than
|
|
67
|
+
* inside it, naming that root's `id`: the menu of a sub-menu with a header or
|
|
68
|
+
* footer, in a dialog beside the root's menu, is nested in that root for the
|
|
69
|
+
* press model, as a flyout inside the root's DOM is. Nothing else beside the
|
|
70
|
+
* root carries it, so a picker placed in a header or footer stays its own root.
|
|
71
|
+
*/
|
|
72
|
+
export const MENU_PRESS_SURFACE_ATTR = 'data-astryx-menu-press-surface';
|
|
63
73
|
|
|
64
74
|
/** How long after a tracked release the browser's click is still swallowed. */
|
|
65
75
|
export const MENU_PRESS_STRAY_CLICK_MS = 400;
|
|
@@ -246,16 +256,44 @@ function isRowFocusable(row: HTMLElement): boolean {
|
|
|
246
256
|
function getOutermostRoot(menu: HTMLElement): HTMLElement {
|
|
247
257
|
let root = menu;
|
|
248
258
|
for (;;) {
|
|
249
|
-
const parent =
|
|
250
|
-
MENU_PRESS_ROOT_SELECTOR
|
|
251
|
-
|
|
252
|
-
if (parent == null) {
|
|
259
|
+
const parent =
|
|
260
|
+
root.parentElement?.closest<HTMLElement>(MENU_PRESS_ROOT_SELECTOR) ??
|
|
261
|
+
getSurfaceOwner(root);
|
|
262
|
+
if (parent == null || parent === root) {
|
|
253
263
|
return root;
|
|
254
264
|
}
|
|
255
265
|
root = parent;
|
|
256
266
|
}
|
|
257
267
|
}
|
|
258
268
|
|
|
269
|
+
/**
|
|
270
|
+
* The menu root that renders `menu` beside itself, if any (see
|
|
271
|
+
* `MENU_PRESS_SURFACE_ATTR`). Read off the menu itself, never an ancestor, so
|
|
272
|
+
* a root placed in a region of that surface is not adopted.
|
|
273
|
+
*/
|
|
274
|
+
function getSurfaceOwner(menu: HTMLElement): HTMLElement | null {
|
|
275
|
+
const ownerId = menu.getAttribute(MENU_PRESS_SURFACE_ATTR);
|
|
276
|
+
if (!ownerId) {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
const owner = menu.ownerDocument.getElementById(ownerId);
|
|
280
|
+
return owner?.matches(MENU_PRESS_ROOT_SELECTOR) === true ? owner : null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Whether `element` is inside the menu tree whose outermost root is `root`:
|
|
285
|
+
* inside the root itself, or inside a menu nested in it through a surface
|
|
286
|
+
* the root renders beside itself. Anything else in such a surface (a header
|
|
287
|
+
* field, say) is outside the menu, as the root's own regions are.
|
|
288
|
+
*/
|
|
289
|
+
function isInMenuTree(element: Element, root: HTMLElement): boolean {
|
|
290
|
+
if (root.contains(element)) {
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
const owner = element.closest<HTMLElement>(MENU_PRESS_ROOT_SELECTOR);
|
|
294
|
+
return owner != null && getOutermostRoot(owner) === root;
|
|
295
|
+
}
|
|
296
|
+
|
|
259
297
|
interface ResolvedPoint {
|
|
260
298
|
row: HTMLElement | null;
|
|
261
299
|
isInMenu: boolean;
|
|
@@ -332,14 +370,14 @@ export function useMenuPress(options: UseMenuPressOptions): UseMenuPressReturn {
|
|
|
332
370
|
}
|
|
333
371
|
const root = menu == null ? null : getOutermostRoot(menu);
|
|
334
372
|
const isInMenu =
|
|
335
|
-
root != null && element != null &&
|
|
373
|
+
root != null && element != null && isInMenuTree(element, root);
|
|
336
374
|
const trigger = triggerRef?.current ?? null;
|
|
337
375
|
const isOnTrigger =
|
|
338
376
|
trigger != null && element != null && trigger.contains(element);
|
|
339
377
|
let row: HTMLElement | null = null;
|
|
340
378
|
if (isInMenu && element != null) {
|
|
341
379
|
row = element.closest<HTMLElement>(itemSelector);
|
|
342
|
-
if (row != null && !
|
|
380
|
+
if (row != null && !isInMenuTree(row, root)) {
|
|
343
381
|
row = null;
|
|
344
382
|
}
|
|
345
383
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file disabledInteraction.ts
|
|
5
|
+
* @input An optional caller keydown handler
|
|
6
|
+
* @output Event props that keep a focusable, aria-disabled control inert
|
|
7
|
+
* @position Shared by controls that replace native `disabled` with
|
|
8
|
+
* `aria-disabled` under spec:AST-085 (FR1, FR2)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type {KeyboardEvent, KeyboardEventHandler, MouseEvent} from 'react';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Event props for a disabled control that stays focusable. Native `disabled`
|
|
15
|
+
* dispatched no click at all; without it, Enter and Space still synthesize a
|
|
16
|
+
* click that native listeners on the element (a wrapping Popover's trigger)
|
|
17
|
+
* receive. Cancelling those keys keeps the click from existing, and the click
|
|
18
|
+
* handler cancels pointer clicks and keeps them from reaching ancestors. Other
|
|
19
|
+
* keys still reach the caller's handler.
|
|
20
|
+
*/
|
|
21
|
+
export function disabledInteractionProps<T extends HTMLElement>(
|
|
22
|
+
onKeyDown?: KeyboardEventHandler<T>,
|
|
23
|
+
): {
|
|
24
|
+
onClick: (event: MouseEvent<T>) => void;
|
|
25
|
+
onKeyDown: (event: KeyboardEvent<T>) => void;
|
|
26
|
+
} {
|
|
27
|
+
return {
|
|
28
|
+
onClick: event => {
|
|
29
|
+
event.preventDefault();
|
|
30
|
+
event.stopPropagation();
|
|
31
|
+
},
|
|
32
|
+
onKeyDown: event => {
|
|
33
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
34
|
+
event.preventDefault();
|
|
35
|
+
} else {
|
|
36
|
+
onKeyDown?.(event);
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|