@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.
Files changed (183) hide show
  1. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
  2. package/dist/BottomSheet/BottomSheet.js +8 -4
  3. package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -1
  4. package/dist/BottomSheet/BottomSheetSwitcher.js +7 -0
  5. package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  6. package/dist/CheckboxInput/CheckboxInput.js +19 -24
  7. package/dist/ClickableCard/ClickableCard.d.ts +2 -2
  8. package/dist/ClickableCard/ClickableCard.d.ts.map +1 -1
  9. package/dist/ClickableCard/ClickableCard.js +11 -9
  10. package/dist/CodeBlock/CodeBlock.d.ts.map +1 -1
  11. package/dist/CodeBlock/CodeBlock.js +11 -1
  12. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  13. package/dist/ComplexSelector/ComplexSelector.js +23 -5
  14. package/dist/DateInput/DateInput.d.ts +4 -4
  15. package/dist/DateInput/DateInput.d.ts.map +1 -1
  16. package/dist/DateInput/DateInput.js +27 -20
  17. package/dist/DateInput/NativeDateField.d.ts.map +1 -1
  18. package/dist/DateInput/NativeDateField.js +14 -8
  19. package/dist/DateInput/TouchDateField.d.ts.map +1 -1
  20. package/dist/DateInput/TouchDateField.js +14 -10
  21. package/dist/Dialog/Dialog.d.ts.map +1 -1
  22. package/dist/Dialog/Dialog.js +9 -4
  23. package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  24. package/dist/DropdownMenu/DropdownMenu.js +52 -10
  25. package/dist/DropdownMenu/DropdownMenuContext.d.ts +34 -1
  26. package/dist/DropdownMenu/DropdownMenuContext.d.ts.map +1 -1
  27. package/dist/DropdownMenu/DropdownMenuContext.js +15 -0
  28. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts +21 -2
  29. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
  30. package/dist/DropdownMenu/DropdownMenuSubMenu.js +375 -42
  31. package/dist/DropdownMenu/menuRegionStops.d.ts +2 -2
  32. package/dist/DropdownMenu/menuRegionStops.js +2 -2
  33. package/dist/FileInput/FileInput.d.ts.map +1 -1
  34. package/dist/FileInput/FileInput.js +14 -12
  35. package/dist/Link/Link.d.ts +2 -3
  36. package/dist/Link/Link.d.ts.map +1 -1
  37. package/dist/Link/Link.js +11 -19
  38. package/dist/Markdown/Markdown.d.ts.map +1 -1
  39. package/dist/Markdown/Markdown.js +48 -15
  40. package/dist/MultiSelector/MultiSelector.d.ts +4 -4
  41. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  42. package/dist/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.d.ts +2 -0
  43. package/dist/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.d.ts.map +1 -0
  44. package/dist/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.js +48 -0
  45. package/dist/MultiSelector/MultiSelector.js +25 -18
  46. package/dist/Popover/Popover.d.ts.map +1 -1
  47. package/dist/Popover/Popover.js +8 -4
  48. package/dist/Selector/Selector.d.ts +4 -4
  49. package/dist/Selector/Selector.d.ts.map +1 -1
  50. package/dist/Selector/Selector.js +23 -14
  51. package/dist/Selector/SelectorOption.d.ts.map +1 -1
  52. package/dist/Selector/SelectorOption.js +1 -0
  53. package/dist/Slider/Slider.d.ts.map +1 -1
  54. package/dist/Slider/Slider.js +7 -14
  55. package/dist/Switch/Switch.d.ts.map +1 -1
  56. package/dist/Switch/Switch.js +25 -17
  57. package/dist/Switch/switch.markers.stylex.d.ts +2 -0
  58. package/dist/Switch/switch.markers.stylex.d.ts.map +1 -1
  59. package/dist/Switch/switch.markers.stylex.js +6 -0
  60. package/dist/TimeInput/TimeInput.d.ts +2 -1
  61. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  62. package/dist/Token/Token.d.ts.map +1 -1
  63. package/dist/Token/Token.js +8 -3
  64. package/dist/Tooltip/Tooltip.d.ts.map +1 -1
  65. package/dist/Tooltip/Tooltip.js +36 -14
  66. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  67. package/dist/Tooltip/useTooltip.js +11 -2
  68. package/dist/TopNav/TopNav.d.ts.map +1 -1
  69. package/dist/TopNav/TopNav.js +2 -2
  70. package/dist/TopNav/TopNavHeading.d.ts.map +1 -1
  71. package/dist/TopNav/TopNavHeading.js +1 -0
  72. package/dist/Typeahead/Typeahead.d.ts.map +1 -1
  73. package/dist/Typeahead/Typeahead.js +14 -2
  74. package/dist/astryx.css +8 -2
  75. package/dist/hooks/useMenuPress.d.ts +11 -1
  76. package/dist/hooks/useMenuPress.d.ts.map +1 -1
  77. package/dist/hooks/useMenuPress.js +43 -5
  78. package/dist/utils/disabledInteraction.d.ts +21 -0
  79. package/dist/utils/disabledInteraction.d.ts.map +1 -0
  80. package/dist/utils/disabledInteraction.js +33 -0
  81. package/dist/utils/timeParser.d.ts +4 -0
  82. package/dist/utils/timeParser.d.ts.map +1 -1
  83. package/dist/utils/timeParser.js +9 -2
  84. package/package.json +4 -4
  85. package/scripts/agent-doc-state.mjs +35 -8
  86. package/src/Banner/Banner.spec.md +37 -32
  87. package/src/BottomSheet/BottomSheet.spec.md +13 -6
  88. package/src/BottomSheet/BottomSheet.test.tsx +29 -0
  89. package/src/BottomSheet/BottomSheet.tsx +8 -4
  90. package/src/BottomSheet/BottomSheetSwitcher.spec.md +121 -51
  91. package/src/BottomSheet/BottomSheetSwitcher.test.tsx +26 -0
  92. package/src/BottomSheet/BottomSheetSwitcher.tsx +6 -0
  93. package/src/CheckboxInput/CheckboxInput.test.tsx +44 -5
  94. package/src/CheckboxInput/CheckboxInput.tsx +16 -26
  95. package/src/CheckboxInput/__tests__/Checkbox.a11y.states.ts +3 -3
  96. package/src/CheckboxList/CheckboxList.test.tsx +9 -6
  97. package/src/ClickableCard/ClickableCard.doc.mjs +2 -2
  98. package/src/ClickableCard/ClickableCard.test.tsx +5 -4
  99. package/src/ClickableCard/ClickableCard.tsx +9 -9
  100. package/src/CodeBlock/CodeBlock.highlightStyles.test.tsx +87 -0
  101. package/src/CodeBlock/CodeBlock.tsx +11 -1
  102. package/src/ComplexSelector/ComplexSelector.focusableDisabled.test.tsx +76 -0
  103. package/src/ComplexSelector/ComplexSelector.tsx +21 -4
  104. package/src/ContextMenu/ContextMenu.test.tsx +41 -0
  105. package/src/DateInput/DateInput.doc.mjs +9 -8
  106. package/src/DateInput/DateInput.test.tsx +88 -13
  107. package/src/DateInput/DateInput.tsx +49 -21
  108. package/src/DateInput/DateInputTouch.test.tsx +22 -1
  109. package/src/DateInput/NativeDateField.test.tsx +29 -3
  110. package/src/DateInput/NativeDateField.tsx +20 -7
  111. package/src/DateInput/TouchDateField.tsx +24 -9
  112. package/src/DateRangeInput/DateRangeInput.spec.md +13 -13
  113. package/src/Dialog/Dialog.test.tsx +39 -0
  114. package/src/Dialog/Dialog.tsx +9 -4
  115. package/src/DropdownMenu/DropdownMenu.spec.md +33 -2
  116. package/src/DropdownMenu/DropdownMenu.tsx +79 -8
  117. package/src/DropdownMenu/DropdownMenuContext.public.test.ts +22 -0
  118. package/src/DropdownMenu/DropdownMenuContext.tsx +39 -1
  119. package/src/DropdownMenu/DropdownMenuSubMenu.doc.mjs +16 -1
  120. package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +623 -1
  121. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +556 -99
  122. package/src/DropdownMenu/__tests__/SubMenuHeaderFooter.a11y.chromium.spec.ts +267 -0
  123. package/src/DropdownMenu/menuRegionStops.ts +2 -2
  124. package/src/DropdownMenu/modules/useMenuPress.spec.md +9 -0
  125. package/src/FieldStatus/FieldStatus.spec.md +3 -2
  126. package/src/FileInput/FileInput.test.tsx +30 -7
  127. package/src/FileInput/FileInput.tsx +21 -11
  128. package/src/Link/Link.test.tsx +21 -7
  129. package/src/Link/Link.tsx +16 -24
  130. package/src/Link/__tests__/Link.a11y.states.ts +1 -1
  131. package/src/Markdown/Markdown.rerender.test.tsx +143 -0
  132. package/src/Markdown/Markdown.tsx +89 -35
  133. package/src/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.ts +71 -0
  134. package/src/MultiSelector/MultiSelector.doc.mjs +5 -5
  135. package/src/MultiSelector/MultiSelector.focusableDisabled.test.tsx +121 -0
  136. package/src/MultiSelector/MultiSelector.test.tsx +49 -7
  137. package/src/MultiSelector/MultiSelector.tsx +53 -29
  138. package/src/Popover/Popover.test.tsx +15 -0
  139. package/src/Popover/Popover.tsx +19 -8
  140. package/src/PowerSearch/PowerSearchFilterEditor.test.tsx +3 -2
  141. package/src/PowerSearch/PowerSearchToken.test.tsx +7 -5
  142. package/src/Selector/Selector.doc.mjs +4 -4
  143. package/src/Selector/Selector.focusableDisabled.test.tsx +127 -0
  144. package/src/Selector/Selector.spec.md +11 -11
  145. package/src/Selector/Selector.test.tsx +64 -6
  146. package/src/Selector/Selector.tsx +51 -21
  147. package/src/Selector/SelectorOption.tsx +4 -0
  148. package/src/SideNav/SideNav.test.tsx +35 -0
  149. package/src/Slider/Slider.spec.md +11 -11
  150. package/src/Slider/Slider.test.tsx +3 -3
  151. package/src/Slider/Slider.tsx +8 -15
  152. package/src/Switch/Switch.test.tsx +85 -8
  153. package/src/Switch/Switch.tsx +26 -16
  154. package/src/Switch/__tests__/Switch.a11y.states.ts +2 -2
  155. package/src/Switch/switch.markers.stylex.ts +4 -0
  156. package/src/Table/Table.spec.md +2 -0
  157. package/src/Table/plugins/infiniteScroll/useTableInfiniteScroll.spec.md +244 -0
  158. package/src/Table/plugins/selection/useTableSelection.test.tsx +5 -3
  159. package/src/Table/plugins/virtualization/useTableVirtualization.spec.md +266 -0
  160. package/src/TimeInput/TimeInput.doc.mjs +5 -3
  161. package/src/TimeInput/TimeInput.test.tsx +20 -0
  162. package/src/TimeInput/TimeInput.tsx +2 -1
  163. package/src/Token/Token.test.tsx +26 -4
  164. package/src/Token/Token.tsx +6 -2
  165. package/src/Tokenizer/Tokenizer.spec.md +28 -28
  166. package/src/Tooltip/Tooltip.test.tsx +51 -6
  167. package/src/Tooltip/Tooltip.tsx +41 -18
  168. package/src/Tooltip/__tests__/Tooltip.a11y.chromium.spec.ts +129 -0
  169. package/src/Tooltip/__tests__/Tooltip.a11y.renders.tsx +86 -0
  170. package/src/Tooltip/__tests__/Tooltip.a11y.states.ts +132 -0
  171. package/src/Tooltip/__tests__/Tooltip.a11y.test.tsx +126 -0
  172. package/src/Tooltip/__tests__/TooltipHoverBridge.a11y.chromium.spec.ts +74 -0
  173. package/src/Tooltip/useTooltip.test.tsx +23 -0
  174. package/src/Tooltip/useTooltip.tsx +22 -9
  175. package/src/TopNav/TopNav.tsx +11 -1
  176. package/src/TopNav/TopNavHeading.tsx +1 -0
  177. package/src/TopNav/__tests__/TopNavMobileLongHeading.a11y.chromium.spec.ts +74 -0
  178. package/src/Typeahead/Typeahead.test.tsx +89 -0
  179. package/src/Typeahead/Typeahead.tsx +15 -2
  180. package/src/hooks/useMenuPress.ts +45 -7
  181. package/src/utils/disabledInteraction.ts +40 -0
  182. package/src/utils/timeParser.test.ts +11 -0
  183. 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
+ });
@@ -0,0 +1,74 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file TooltipHoverBridge.a11y.chromium.spec.ts
5
+ * @input The `Core/Tooltip` Default story in a built Storybook and real mouse
6
+ * input.
7
+ * @output Real-engine proof that a pointer moving from the trigger onto the
8
+ * tooltip in one step keeps the tooltip open (WCAG 2.2 1.4.13 Content on
9
+ * Hover or Focus, hoverable), and that leaving the tooltip still hides it.
10
+ * @position Run with `pnpm test:a11y-contract`. The defect lives in the order
11
+ * the engine dispatches mouseout and mouseleave on one pointer step, which
12
+ * only a real engine produces.
13
+ */
14
+
15
+ import {expect, test, type Page} from '@playwright/test';
16
+ import {holdMotionStill} from '@astryxdesign/a11y-spec/chromium';
17
+ import {
18
+ DEFAULT_STORYBOOK_DIR,
19
+ serveStorybook,
20
+ type StaticServer,
21
+ } from '@astryxdesign/a11y-spec/storybook';
22
+
23
+ let storybook: StaticServer;
24
+
25
+ test.beforeAll(async () => {
26
+ storybook = await serveStorybook(
27
+ process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
28
+ );
29
+ });
30
+
31
+ test.afterAll(async () => {
32
+ await storybook?.close();
33
+ });
34
+
35
+ const STORY = 'core-tooltip--default';
36
+
37
+ async function openTooltip(page: Page) {
38
+ await page.setViewportSize({width: 900, height: 600});
39
+ await page.goto(`${storybook.origin}/iframe.html?id=${STORY}&viewMode=story`);
40
+ await holdMotionStill(page);
41
+ const trigger = page.getByRole('button', {name: 'Hover me'});
42
+ await trigger.hover();
43
+ const tooltip = page.getByRole('tooltip');
44
+ await expect(tooltip).toBeVisible();
45
+ const t = await trigger.boundingBox();
46
+ const s = await tooltip.boundingBox();
47
+ if (t == null || s == null) {
48
+ throw new Error('the trigger or the tooltip has no layout box');
49
+ }
50
+ return {tooltip, s};
51
+ }
52
+
53
+ test('a one-step move from the trigger onto the tooltip keeps it open', async ({
54
+ page,
55
+ }) => {
56
+ const {tooltip, s} = await openTooltip(page);
57
+
58
+ // One mousemove event straight onto the tooltip, as a quick hand makes.
59
+ await page.mouse.move(s.x + s.width / 2, s.y + s.height / 2);
60
+ // Well past the 100ms hover bridge.
61
+ await page.waitForTimeout(400);
62
+
63
+ await expect(tooltip).toBeVisible();
64
+ });
65
+
66
+ test('leaving the tooltip for the page still hides it', async ({page}) => {
67
+ const {tooltip, s} = await openTooltip(page);
68
+
69
+ await page.mouse.move(s.x + s.width / 2, s.y + s.height / 2);
70
+ await page.waitForTimeout(200);
71
+ await page.mouse.move(5, 5);
72
+
73
+ await expect(tooltip).toBeHidden();
74
+ });
@@ -214,6 +214,29 @@ describe('useTooltip — hide delay', () => {
214
214
  expect(onHide).toHaveBeenCalledTimes(1);
215
215
  });
216
216
 
217
+ it('stays open when the pointer crosses onto the tooltip in one step', () => {
218
+ const onHide = vi.fn();
219
+ showTooltip(onHide);
220
+ const surface = tooltipElement();
221
+
222
+ // A one-step crossing dispatches in browser order: the trigger's mouseout
223
+ // (React derives the surface's onMouseEnter from it), then the trigger's
224
+ // native mouseleave, both pointing at the surface. fireEvent.mouseLeave
225
+ // would add a mouseout after the leave, so the leave is dispatched raw.
226
+ fireEvent.mouseOut(trigger(), {relatedTarget: surface});
227
+ act(() => {
228
+ trigger().dispatchEvent(
229
+ new MouseEvent('mouseleave', {relatedTarget: surface}),
230
+ );
231
+ });
232
+ tick(500);
233
+ expect(onHide).not.toHaveBeenCalled();
234
+
235
+ fireEvent.mouseOut(surface, {relatedTarget: document.body});
236
+ tick(100);
237
+ expect(onHide).toHaveBeenCalledTimes(1);
238
+ });
239
+
217
240
  it('uses an explicit hideDelay instead of the hover bridge', () => {
218
241
  const onHide = vi.fn();
219
242
  showTooltip(onHide, {hideDelay: 500});
@@ -397,15 +397,28 @@ export function useTooltip(options: TooltipOptions = {}): TooltipReturn {
397
397
  scheduleShow();
398
398
  }, [isTouchPointerRef, scheduleShow]);
399
399
 
400
- const handleMouseLeave = useCallback(() => {
401
- // On touch the synthesized mouseleave arrives with the next tap elsewhere,
402
- // which the outside-tap dismissal already handles — and handling it here
403
- // too would close the tooltip behind the tap-open bookkeeping's back.
404
- if (isTouchPointerRef.current) {
405
- return;
406
- }
407
- scheduleHide();
408
- }, [isTouchPointerRef, scheduleHide]);
400
+ const handleMouseLeave = useCallback(
401
+ (event: Event) => {
402
+ // On touch the synthesized mouseleave arrives with the next tap elsewhere,
403
+ // which the outside-tap dismissal already handles — and handling it here
404
+ // too would close the tooltip behind the tap-open bookkeeping's back.
405
+ if (isTouchPointerRef.current) {
406
+ return;
407
+ }
408
+ // Leaving straight onto the tooltip: the surface's onMouseEnter already
409
+ // ran, because React dispatches it from the mouseout that precedes this
410
+ // mouseleave, so a hide scheduled now would never be cancelled. The
411
+ // surface's own mouse leave schedules the hide instead.
412
+ const next = (event as MouseEvent).relatedTarget as Node | null;
413
+ const surface =
414
+ next == null ? null : next.ownerDocument?.getElementById(layerId);
415
+ if (surface != null && surface.contains(next)) {
416
+ return;
417
+ }
418
+ scheduleHide();
419
+ },
420
+ [isTouchPointerRef, layerId, scheduleHide],
421
+ );
409
422
 
410
423
  const handleFocusIn = useCallback(
411
424
  (e: Event) => {
@@ -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 && <div {...stylex.props(styles.heading)}>{heading}</div>}
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 />}
@@ -52,6 +52,7 @@ const styles = stylex.create({
52
52
  alignItems: 'center',
53
53
  gap: spacingVars['--spacing-2'],
54
54
  minHeight: spacingVars['--spacing-8'],
55
+ minWidth: 0,
55
56
  paddingInlineStart: {
56
57
  default: spacingVars['--spacing-2'],
57
58
  ':has(.astryx-nav-icon)': 0,
@@ -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
+ });
@@ -1637,6 +1637,95 @@ describe('Typeahead edit mode', () => {
1637
1637
  expect(onChange).not.toHaveBeenCalled();
1638
1638
  });
1639
1639
 
1640
+ it('empties the input when cleared while editing the selected value (#7038)', async () => {
1641
+ const onChangeQuery = vi.fn();
1642
+ function Controlled() {
1643
+ const [value, setValue] = useState<SearchableItem | null>(fruits[0]);
1644
+ return (
1645
+ <Typeahead
1646
+ label="Fruit"
1647
+ searchSource={fruitSource}
1648
+ value={value}
1649
+ onChange={setValue}
1650
+ onChangeQuery={onChangeQuery}
1651
+ hasClear
1652
+ />
1653
+ );
1654
+ }
1655
+ render(<Controlled />);
1656
+ const input = screen.getByRole<HTMLInputElement>('combobox');
1657
+
1658
+ // Enter edit mode: the token gives way to the label as editable text
1659
+ fireEvent.click(screen.getByText(fruits[0].label).closest('div')!);
1660
+ await waitFor(() => expect(input.value).toBe(fruits[0].label));
1661
+
1662
+ fireEvent.click(screen.getByRole('button', {name: 'Clear selection'}));
1663
+
1664
+ // The value and the visible text agree: nothing selected, nothing typed
1665
+ await waitFor(() => expect(input.value).toBe(''));
1666
+ expect(onChangeQuery).toHaveBeenLastCalledWith('');
1667
+ expect(input).toHaveFocus();
1668
+ });
1669
+
1670
+ // Escape and blur restore the token but leave the edit-mode label in the
1671
+ // base's query, so a clear from the restored token faces the same stale text.
1672
+ it.each([
1673
+ [
1674
+ 'Escape',
1675
+ (input: HTMLElement) => fireEvent.keyDown(input, {key: 'Escape'}),
1676
+ ],
1677
+ ['blur', (input: HTMLElement) => fireEvent.blur(input)],
1678
+ ])(
1679
+ 'empties the input when cleared after %s restored the token (#7038)',
1680
+ async (_name, leaveEditMode) => {
1681
+ const onChangeQuery = vi.fn();
1682
+ function Controlled() {
1683
+ const [value, setValue] = useState<SearchableItem | null>(fruits[0]);
1684
+ return (
1685
+ <Typeahead
1686
+ label="Fruit"
1687
+ searchSource={fruitSource}
1688
+ value={value}
1689
+ onChange={setValue}
1690
+ onChangeQuery={onChangeQuery}
1691
+ hasClear
1692
+ />
1693
+ );
1694
+ }
1695
+ render(<Controlled />);
1696
+ const input = screen.getByRole<HTMLInputElement>('combobox');
1697
+
1698
+ fireEvent.click(screen.getByText(fruits[0].label).closest('div')!);
1699
+ await waitFor(() => expect(input.value).toBe(fruits[0].label));
1700
+
1701
+ leaveEditMode(input);
1702
+ await screen.findByRole('button', {name: 'Clear selection'});
1703
+
1704
+ fireEvent.click(screen.getByRole('button', {name: 'Clear selection'}));
1705
+
1706
+ await waitFor(() => expect(input.value).toBe(''));
1707
+ expect(onChangeQuery).toHaveBeenLastCalledWith('');
1708
+ },
1709
+ );
1710
+
1711
+ it('does not report a query change when cleared outside edit mode', () => {
1712
+ const onChangeQuery = vi.fn();
1713
+ render(
1714
+ <Typeahead
1715
+ label="Fruit"
1716
+ searchSource={fruitSource}
1717
+ value={fruits[0]}
1718
+ onChange={vi.fn()}
1719
+ onChangeQuery={onChangeQuery}
1720
+ hasClear
1721
+ />,
1722
+ );
1723
+
1724
+ fireEvent.click(screen.getByRole('button', {name: 'Clear selection'}));
1725
+
1726
+ expect(onChangeQuery).not.toHaveBeenCalled();
1727
+ });
1728
+
1640
1729
  it('restores token on blur without action', async () => {
1641
1730
  const onChange = vi.fn();
1642
1731
  render(
@@ -519,12 +519,25 @@ export function Typeahead<T extends SearchableItem>({
519
519
  [onChange],
520
520
  );
521
521
 
522
- // Handle clear (explicit X button on token)
522
+ // Handle clear (explicit X button on token). Edit mode copied the label into
523
+ // the base's query, and the base only resets that query on a selection, so
524
+ // clearing the value has to empty it the way entering edit mode filled it
525
+ // (#7038): a native set plus a synthetic input event, which reaches the
526
+ // base's own change handler and its onChangeQuery.
523
527
  const handleClear = useCallback(() => {
528
+ const input = inputRef.current;
529
+ if (input && input.value !== '') {
530
+ const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
531
+ window.HTMLInputElement.prototype,
532
+ 'value',
533
+ )?.set;
534
+ nativeInputValueSetter?.call(input, '');
535
+ input.dispatchEvent(new Event('input', {bubbles: true}));
536
+ }
524
537
  setIsEditing(false);
525
538
  setEditingValue(null);
526
539
  onChange(null);
527
- inputRef.current?.focus();
540
+ input?.focus();
528
541
  }, [onChange]);
529
542
 
530
543
  // Handle Escape during edit mode — restore token