@astryxdesign/core 0.7.3-canary.b74cf21 → 0.7.3-canary.cbe384c

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 (46) hide show
  1. package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  2. package/dist/DropdownMenu/DropdownMenu.js +52 -10
  3. package/dist/DropdownMenu/DropdownMenuContext.d.ts +34 -1
  4. package/dist/DropdownMenu/DropdownMenuContext.d.ts.map +1 -1
  5. package/dist/DropdownMenu/DropdownMenuContext.js +15 -0
  6. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts +21 -2
  7. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
  8. package/dist/DropdownMenu/DropdownMenuSubMenu.js +375 -42
  9. package/dist/DropdownMenu/menuRegionStops.d.ts +2 -2
  10. package/dist/DropdownMenu/menuRegionStops.js +2 -2
  11. package/dist/TimeInput/TimeInput.d.ts +2 -1
  12. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  13. package/dist/Tooltip/Tooltip.d.ts.map +1 -1
  14. package/dist/Tooltip/Tooltip.js +36 -14
  15. package/dist/astryx.css +1 -0
  16. package/dist/hooks/useMenuPress.d.ts +11 -1
  17. package/dist/hooks/useMenuPress.d.ts.map +1 -1
  18. package/dist/hooks/useMenuPress.js +43 -5
  19. package/dist/utils/timeParser.d.ts +4 -0
  20. package/dist/utils/timeParser.d.ts.map +1 -1
  21. package/dist/utils/timeParser.js +9 -2
  22. package/package.json +3 -3
  23. package/src/ContextMenu/ContextMenu.test.tsx +41 -0
  24. package/src/DropdownMenu/DropdownMenu.spec.md +33 -2
  25. package/src/DropdownMenu/DropdownMenu.tsx +79 -8
  26. package/src/DropdownMenu/DropdownMenuContext.public.test.ts +22 -0
  27. package/src/DropdownMenu/DropdownMenuContext.tsx +39 -1
  28. package/src/DropdownMenu/DropdownMenuSubMenu.doc.mjs +16 -1
  29. package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +623 -1
  30. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +556 -99
  31. package/src/DropdownMenu/__tests__/SubMenuHeaderFooter.a11y.chromium.spec.ts +267 -0
  32. package/src/DropdownMenu/menuRegionStops.ts +2 -2
  33. package/src/DropdownMenu/modules/useMenuPress.spec.md +9 -0
  34. package/src/SideNav/SideNav.test.tsx +35 -0
  35. package/src/TimeInput/TimeInput.doc.mjs +5 -3
  36. package/src/TimeInput/TimeInput.test.tsx +20 -0
  37. package/src/TimeInput/TimeInput.tsx +2 -1
  38. package/src/Tooltip/Tooltip.test.tsx +51 -6
  39. package/src/Tooltip/Tooltip.tsx +41 -18
  40. package/src/Tooltip/__tests__/Tooltip.a11y.chromium.spec.ts +129 -0
  41. package/src/Tooltip/__tests__/Tooltip.a11y.renders.tsx +86 -0
  42. package/src/Tooltip/__tests__/Tooltip.a11y.states.ts +132 -0
  43. package/src/Tooltip/__tests__/Tooltip.a11y.test.tsx +126 -0
  44. package/src/hooks/useMenuPress.ts +45 -7
  45. package/src/utils/timeParser.test.ts +11 -0
  46. package/src/utils/timeParser.ts +9 -2
@@ -0,0 +1,267 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file SubMenuHeaderFooter.a11y.chromium.spec.ts
5
+ * @input The built Storybook "Submenu / header and footer" story (a Labels
6
+ * sub-menu with a filter field above its checkbox rows and a "Create label"
7
+ * button below them) and real Chromium, once as a laptop (fine pointer) and
8
+ * once as a phone (coarse pointer, touch).
9
+ * @output Evidence for component:DropdownMenu FR20: a sub-menu's header and
10
+ * footer sit with its rows in a modal dialog named by its row, beside every
11
+ * menu element rather than inside one; the row says it opens a dialog; Tab
12
+ * and Shift+Tab walk field, rows and button and wrap; typing filters the
13
+ * rows and Escape returns to the row; a pick keeps the sub-menu open. On a
14
+ * phone the drilled-in view holds the same regions while the root menu
15
+ * steps aside. On the "Submenu / header with a choice" story, a flyout the
16
+ * pointer has left stays open while an arrow moves the header's radio choice.
17
+ * @position Browser proof behind the jsdom suite: the accessibility tree, real
18
+ * Tab order and the pointer decision are what jsdom cannot exercise.
19
+ */
20
+
21
+ import {devices, expect, test, type Locator, type Page} from '@playwright/test';
22
+ import {
23
+ DEFAULT_STORYBOOK_DIR,
24
+ serveStorybook,
25
+ type StaticServer,
26
+ } from '@astryxdesign/a11y-spec/storybook';
27
+
28
+ let storybook: StaticServer;
29
+ const storyId = 'core-dropdownmenu--sub-menu-header-and-footer';
30
+
31
+ test.beforeAll(async () => {
32
+ storybook = await serveStorybook(
33
+ process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
34
+ );
35
+ });
36
+ test.afterAll(async () => {
37
+ await storybook?.close();
38
+ });
39
+
40
+ async function mount(page: Page) {
41
+ await page.goto(
42
+ `${storybook.origin}/iframe.html?id=${storyId}&viewMode=story`,
43
+ );
44
+ await expect(page.getByTestId('submenu-regions-fixture')).toBeVisible();
45
+ }
46
+
47
+ /** Whether the element sits inside any role="menu" element. */
48
+ async function isInsideAMenu(page: Page, testName: string): Promise<boolean> {
49
+ return page
50
+ .getByRole('dialog', {name: testName})
51
+ .evaluate(el => el.parentElement?.closest('[role="menu"]') != null);
52
+ }
53
+
54
+ async function bounds(locator: Locator) {
55
+ const rect = await locator.boundingBox();
56
+ if (rect == null) {
57
+ throw new Error('no rendered bounds');
58
+ }
59
+ return rect;
60
+ }
61
+
62
+ test.describe('on a laptop (fine pointer)', () => {
63
+ test('the sub-menu opens a dialog beside the menu, named by its row, holding field, rows and button', async ({
64
+ page,
65
+ }) => {
66
+ await mount(page);
67
+ await page.getByRole('button', {name: 'Session'}).click();
68
+ const row = page.getByRole('menuitem', {name: 'Labels'});
69
+ await expect(row).toHaveAttribute('aria-haspopup', 'dialog');
70
+ await row.click();
71
+
72
+ const dialog = page.getByRole('dialog', {name: 'Labels'});
73
+ await expect(dialog).toBeVisible();
74
+ await expect(dialog).toHaveAttribute('aria-modal', 'true');
75
+ await expect(row).toHaveAttribute(
76
+ 'aria-controls',
77
+ (await dialog.getAttribute('id')) ?? '',
78
+ );
79
+ expect(await isInsideAMenu(page, 'Labels')).toBe(false);
80
+ // The sub-menu's own menu holds only its rows.
81
+ const menu = dialog.getByRole('menu', {name: 'Labels'});
82
+ expect(
83
+ await menu.evaluate(el =>
84
+ Array.from(el.querySelectorAll('[role]')).map(child =>
85
+ child.getAttribute('role'),
86
+ ),
87
+ ),
88
+ ).toEqual(Array(5).fill('menuitemcheckbox'));
89
+ await expect(
90
+ dialog.getByRole('textbox', {name: 'Filter labels'}),
91
+ ).toBeVisible();
92
+ await expect(
93
+ dialog.getByRole('button', {name: 'Create label'}),
94
+ ).toBeVisible();
95
+ });
96
+
97
+ test('Tab and Shift+Tab walk field, rows and button and wrap; typing filters; Escape returns to the row', async ({
98
+ page,
99
+ }) => {
100
+ await mount(page);
101
+ const trigger = page.getByRole('button', {name: 'Session'});
102
+ await trigger.focus();
103
+ await page.keyboard.press('Enter');
104
+ // A keyboard open lands on the first row; let it land before moving on.
105
+ await expect(page.getByRole('menuitem', {name: 'Rename'})).toBeFocused();
106
+ const row = page.getByRole('menuitem', {name: 'Labels'});
107
+ await page.keyboard.press('ArrowDown');
108
+ await expect(row).toBeFocused();
109
+ await page.keyboard.press('ArrowRight');
110
+
111
+ const dialog = page.getByRole('dialog', {name: 'Labels'});
112
+ const field = dialog.getByRole('textbox', {name: 'Filter labels'});
113
+ const create = dialog.getByRole('button', {name: 'Create label'});
114
+ const menu = dialog.getByRole('menu', {name: 'Labels'});
115
+ // A keyboard open lands on the first row, not the field.
116
+ await expect(
117
+ dialog.getByRole('menuitemcheckbox', {name: 'Bug'}),
118
+ ).toBeFocused();
119
+
120
+ await page.keyboard.press('Tab');
121
+ await expect(create).toBeFocused();
122
+ await page.keyboard.press('Tab');
123
+ await expect(field).toBeFocused();
124
+ await page.keyboard.press('Tab');
125
+ await expect(menu).toBeFocused();
126
+ await page.keyboard.press('Shift+Tab');
127
+ await expect(field).toBeFocused();
128
+ await page.keyboard.press('Shift+Tab');
129
+ await expect(create).toBeFocused();
130
+ await expect(dialog).toBeVisible();
131
+
132
+ // The field keeps its keys: letters type and filter the rows, and the
133
+ // arrow that closes a sub-menu from a row moves the caret instead.
134
+ await field.focus();
135
+ await page.keyboard.type('doc');
136
+ await page.keyboard.press('ArrowLeft');
137
+ await expect(field).toHaveValue('doc');
138
+ await expect(dialog.getByRole('menuitemcheckbox')).toHaveText([
139
+ 'Documentation',
140
+ ]);
141
+
142
+ await page.keyboard.press('Escape');
143
+ await expect(dialog).toBeHidden();
144
+ await expect(row).toBeFocused();
145
+ await expect(page.getByRole('menu', {name: 'Session'})).toBeVisible();
146
+ });
147
+
148
+ test('a pick keeps the sub-menu open; the button acts without closing anything', async ({
149
+ page,
150
+ }) => {
151
+ await mount(page);
152
+ await page.getByRole('button', {name: 'Session'}).click();
153
+ await page.getByRole('menuitem', {name: 'Labels'}).click();
154
+ const dialog = page.getByRole('dialog', {name: 'Labels'});
155
+ await dialog.getByRole('menuitemcheckbox', {name: 'Feature'}).click();
156
+ await expect(page.getByTestId('picked')).toHaveText('Bug, Feature');
157
+ await expect(dialog).toBeVisible();
158
+ await dialog.getByRole('button', {name: 'Create label'}).click();
159
+ await expect(page.getByTestId('created')).toHaveText('1');
160
+ await expect(dialog).toBeVisible();
161
+ });
162
+ });
163
+
164
+ test.describe('the press model', () => {
165
+ test('a press on the row, dragged into the flyout and released on a row, picks it', async ({
166
+ page,
167
+ }) => {
168
+ await mount(page);
169
+ await page.getByRole('button', {name: 'Session'}).click();
170
+ const row = page.getByRole('menuitem', {name: 'Labels'});
171
+ await row.hover();
172
+ const dialog = page.getByRole('dialog', {name: 'Labels'});
173
+ await expect(dialog).toBeVisible();
174
+ const feature = dialog.getByRole('menuitemcheckbox', {name: 'Feature'});
175
+ const [from, to] = await Promise.all([row, feature].map(bounds));
176
+ await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2);
177
+ await page.mouse.down();
178
+ await page.mouse.move(from.x + from.width - 4, from.y + from.height / 2, {
179
+ steps: 4,
180
+ });
181
+ await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2, {
182
+ steps: 8,
183
+ });
184
+ await page.mouse.up();
185
+ await expect(page.getByTestId('picked')).toHaveText('Bug, Feature');
186
+ // A checkbox pick keeps the sub-menu, and the menu, open.
187
+ await expect(dialog).toBeVisible();
188
+ });
189
+ });
190
+
191
+ test.describe('on a phone (coarse pointer)', () => {
192
+ const {defaultBrowserType: _browser, ...phone} = devices['Pixel 7'];
193
+ test.use(phone);
194
+
195
+ test('the drilled-in view holds field, rows and button beside the root menu, which steps aside', async ({
196
+ page,
197
+ }) => {
198
+ await mount(page);
199
+ const trigger = page.getByRole('button', {name: 'Session'});
200
+ await trigger.tap();
201
+ const rootMenu = page.getByRole('menu', {name: 'Session'});
202
+ await expect(rootMenu).toBeVisible();
203
+ await page.getByRole('menuitem', {name: 'Labels'}).tap();
204
+
205
+ const dialog = page.getByRole('dialog', {name: 'Labels'});
206
+ await expect(dialog).toBeVisible();
207
+ await expect(rootMenu).toBeHidden();
208
+ expect(await isInsideAMenu(page, 'Labels')).toBe(false);
209
+ // Same popover: the view replaces the root's rows in place.
210
+ expect(
211
+ await dialog.evaluate(
212
+ el =>
213
+ el.closest('[popover]') ===
214
+ document
215
+ .querySelector('[role="menu"][aria-label="Session"]')
216
+ ?.closest('[popover]'),
217
+ ),
218
+ ).toBe(true);
219
+ await expect(
220
+ dialog.getByRole('menuitem', {name: 'Back to Session'}),
221
+ ).toBeVisible();
222
+ await expect(
223
+ dialog.getByRole('textbox', {name: 'Filter labels'}),
224
+ ).toBeVisible();
225
+ await expect(
226
+ dialog.getByRole('button', {name: 'Create label'}),
227
+ ).toBeVisible();
228
+
229
+ await dialog.getByRole('menuitem', {name: 'Back to Session'}).tap();
230
+ await expect(dialog).toBeHidden();
231
+ await expect(rootMenu).toBeVisible();
232
+ });
233
+ });
234
+
235
+ test.describe('a header with a choice', () => {
236
+ test('a flyout the pointer has left stays open while an arrow moves the choice in its header', async ({
237
+ page,
238
+ }) => {
239
+ await page.goto(
240
+ `${storybook.origin}/iframe.html?id=core-dropdownmenu--sub-menu-header-choice&viewMode=story`,
241
+ );
242
+ await expect(page.getByTestId('submenu-choice-fixture')).toBeVisible();
243
+ await page.getByRole('button', {name: 'Filters'}).click();
244
+ const row = page.getByRole('menuitem', {name: 'Labels'});
245
+ await row.hover();
246
+ const dialog = page.getByRole('dialog', {name: 'Labels'});
247
+ await expect(dialog).toBeVisible();
248
+ const anyLabel = dialog.getByRole('radio', {name: 'Any label'});
249
+ const allLabels = dialog.getByRole('radio', {name: 'All labels'});
250
+ await anyLabel.click();
251
+ await expect(anyLabel).toBeFocused();
252
+
253
+ // The pointer leaves the flyout and the menu, and its hide delay runs out
254
+ // with focus still inside.
255
+ const viewport = page.viewportSize() ?? {width: 1280, height: 720};
256
+ await page.mouse.move(viewport.width - 8, viewport.height - 8, {steps: 4});
257
+ await page.waitForTimeout(500);
258
+ await expect(dialog).toBeVisible();
259
+
260
+ // The browser moves focus and the choice for the arrow itself.
261
+ await page.keyboard.press('ArrowDown');
262
+ await expect(allLabels).toBeFocused();
263
+ await expect(allLabels).toBeChecked();
264
+ await expect(page.getByTestId('match')).toHaveText('all');
265
+ await expect(dialog).toBeVisible();
266
+ });
267
+ });
@@ -8,8 +8,8 @@
8
8
  * @output `tabbablesIn`, `focusRegionControl`, `moveRegionsTab`: the Tab stops
9
9
  * of a header or footer region and the walk through a menu with regions,
10
10
  * with a named radio group as one stop
11
- * @position Internal; DropdownMenu's walk through its header and footer, in a
12
- * module of its own so another menu level with regions can share it.
11
+ * @position Internal; used by DropdownMenu (the root menu's header and footer)
12
+ * and DropdownMenuSubMenu (a sub-menu's header and footer).
13
13
  *
14
14
  * A menu with a header or footer is a dialog: Tab and Shift+Tab walk the
15
15
  * header's controls, the menu as one stop, then the footer's controls, and
@@ -163,6 +163,14 @@ Consumer migration instructions belong in consumer docs and release notes.
163
163
  claimed by the innermost root carrying `data-astryx-menu-press` (a flyout
164
164
  before its parent); the outermost root decides what counts as "inside the
165
165
  menu" for the release.
166
+ - **ORD1a (proposed) — A menu beside the root.** A root carrying
167
+ `data-astryx-menu-press-surface`, which names another root's `id`, is nested
168
+ in that root for ORD1, as a flyout inside the root's DOM is: a release on
169
+ its rows is inside the menu. Only the menu of a sub-menu with a header or
170
+ footer, which the root renders beside itself (`component:DropdownMenu`
171
+ FR20), carries it. Anything else beside the root, a header field or a picker
172
+ placed in a region say, is outside the menu, as the root's own regions are,
173
+ and a picker there stays its own root. Pending owner review.
166
174
  - **ORD2 — Release resolution.** Enabled row under the point → `act`; inside
167
175
  the menu or on the trigger with no row → `settle` (stray click swallowed,
168
176
  menu stays); outside → `settle` with `dismiss` only for a mouse.
@@ -238,6 +246,7 @@ owned by the item components.
238
246
  | FR4 (scroll) | `MenuPress.a11y.chromium.spec.ts` (real Chromium) | Chromium touch pan in an overflowing menu; touch-action by overflow; finger slide and its stray click | Not provable in jsdom; a pan that acts on a row, or a slide that acts on the press row, fails |
239
247
  | FR4 (pinch) | `MenuPinchZoom.a11y.chromium.spec.ts` (real Chromium, phone emulation) | two-finger pinch over a menu whose rows fit, over one that scrolls, and over a picker listbox | Not provable in jsdom; a pinch that leaves the page at scale 1, or acts on a row, fails |
240
248
  | FR8, FR9 (proposed) | `menuPressGesture.test.ts` trigger states; `useMenuPress.test.tsx` trigger suite; `DropdownMenu.test.tsx` press-open cases | mouse opens at once, finger tap, held finger, settle before/after, cancel; press-open + drag-release, unsettled release, trigger toggle, held touch, tap | A menu that does not open on a mouse press, an unsettled release that acts, or a trigger that reopens in the same gesture fails |
249
+ | ORD1a (proposed) | `DropdownMenuSubMenu.test.tsx` press cases; `SubMenuHeaderFooter.a11y.chromium.spec.ts` press-drag case | a mouse press on a menu row released on a row of a sub-menu rendered beside the menu; the same press released on that sub-menu's header field | A release on such a row that acts on nothing or closes the menu, or a release on its header field that acts, fails. |
241
250
 
242
251
  ## Decision log
243
252
 
@@ -3691,3 +3691,38 @@ describe('SideNav collapse conflict warning', () => {
3691
3691
  expect(warn).not.toHaveBeenCalled();
3692
3692
  });
3693
3693
  });
3694
+
3695
+ // =============================================================================
3696
+ // SideNavItem — collapsed-from-mount rail tooltip
3697
+ // =============================================================================
3698
+
3699
+ describe('SideNavItem collapsed-from-mount rail tooltip', () => {
3700
+ // #7692: a childless item that mounts inside an already-collapsed SideNav
3701
+ // renders its Tooltip inside the very element anchorRef points at. React
3702
+ // attaches that ref only after the tooltip's layout effect has run, so the
3703
+ // tooltip never attached and a persisted collapsed rail lost all tooltips
3704
+ // on page load.
3705
+ it('anchors the rail tooltip when the nav is collapsed at mount', async () => {
3706
+ render(
3707
+ <SideNav
3708
+ aria-label="Main"
3709
+ collapsible={{isCollapsed: true, hasButton: false}}>
3710
+ <SideNavItem label="Dashboard" icon={StubIcon} href="/dashboard" />
3711
+ </SideNav>,
3712
+ );
3713
+ const link = screen.getByRole('link', {name: 'Dashboard'});
3714
+ const anchor = link.parentElement as HTMLElement;
3715
+
3716
+ // The tooltip attaches despite mounting inside its own anchor: the item
3717
+ // row is described by the tooltip layer.
3718
+ await waitFor(() => {
3719
+ expect(anchor.getAttribute('aria-describedby')).not.toBe('');
3720
+ });
3721
+ const layer = document.getElementById(
3722
+ anchor.getAttribute('aria-describedby') as string,
3723
+ );
3724
+ expect(layer).not.toBeNull();
3725
+ expect(layer).toHaveAttribute('role', 'tooltip');
3726
+ expect(layer).toHaveTextContent('Dashboard');
3727
+ });
3728
+ });
@@ -212,7 +212,7 @@ export const docs = {
212
212
  name: 'increment',
213
213
  type: 'number',
214
214
  description:
215
- 'Number of minutes to add or subtract when the user presses the up or down arrow key.',
215
+ 'Number of minutes to add or subtract when the user presses the up or down arrow key. Steps are whole minutes; a fractional value leaves the time unchanged.',
216
216
  default: '1',
217
217
  },
218
218
  {
@@ -392,7 +392,8 @@ export const docsZh = {
392
392
  {
393
393
  name: 'increment',
394
394
  type: 'number',
395
- description: '用户按上或下方向键时增加或减少的分钟数。',
395
+ description:
396
+ '用户按上或下方向键时增加或减少的分钟数。步长为整分钟;传入小数时时间保持不变。',
396
397
  default: '1',
397
398
  },
398
399
  {
@@ -623,7 +624,8 @@ export const docsDense = {
623
624
  hasClear: 'Shows clear button when value set+not disabled.',
624
625
  hourFormat:
625
626
  "Display format. '12h' shows AM/PM; '24h' uses 24-hour notation.",
626
- increment: 'Minutes to add/subtract on arrow up/down.',
627
+ increment:
628
+ 'Minutes to add/subtract on arrow up/down. Whole minutes only; a fractional value leaves the time unchanged.',
627
629
  presentation:
628
630
  "picker surface: 'adaptive-native' (default) = typed field fine / browser/OS coarse (hasSeconds or increment!=1 retains typed); 'native' = browser/OS always, no fallback; 'adaptive-bottom-sheet' = typed fine / Astryx wheel sheet coarse; 'bottom-sheet' = sheet always; 'text-input'/'popover' = typed field only.",
629
631
  nativePicker:
@@ -65,6 +65,26 @@ describe('TimeInput', () => {
65
65
  expect(onChange).toHaveBeenCalledWith('14:31');
66
66
  });
67
67
 
68
+ it('does not step into a fractional minute when increment is fractional', () => {
69
+ const onChange = vi.fn();
70
+ render(
71
+ <TimeInput
72
+ label="Time"
73
+ value={'14:30' as ISOTimeString}
74
+ increment={0.5}
75
+ onChange={onChange}
76
+ />,
77
+ );
78
+ const input = screen.getByLabelText('Time');
79
+
80
+ // `increment` is documented as minutes and typed `number`. A fractional one
81
+ // used to reach formatISOTime unrounded, which pads "30.5" to two
82
+ // characters and leaves it, so the arrow key handed the consumer a value
83
+ // that is no longer a time at all.
84
+ fireEvent.keyDown(input, {key: 'ArrowUp'});
85
+ expect(onChange).toHaveBeenCalledWith('14:30');
86
+ });
87
+
68
88
  it('displays formatted time in 12h format', () => {
69
89
  render(
70
90
  <TimeInput
@@ -286,7 +286,8 @@ export interface TimeInputProps extends Omit<
286
286
  hourFormat?: TimeInputHourFormat;
287
287
 
288
288
  /**
289
- * Increment in minutes when using arrow keys.
289
+ * Increment in minutes when using arrow keys. Steps are whole minutes: a
290
+ * fractional value leaves the time unchanged.
290
291
  * @default 1
291
292
  */
292
293
  increment?: number;
@@ -25,6 +25,7 @@ import {
25
25
  fireEvent,
26
26
  waitFor,
27
27
  } from '@testing-library/react';
28
+ import {useRef} from 'react';
28
29
  import {Tooltip} from './Tooltip';
29
30
  import {useTooltip} from './useTooltip';
30
31
  import {__resetInteractionModalityForTest} from '../utils/interactionModality';
@@ -84,17 +85,15 @@ describe('Tooltip', () => {
84
85
  expect(screen.getByRole('button', {name: 'Trigger'})).toBeInTheDocument();
85
86
  });
86
87
 
87
- it('gives the tooltip layer role="tooltip" linked from the trigger', () => {
88
+ it('renders the content prop inside the tooltip layer', () => {
88
89
  render(
89
90
  <Tooltip content="Tooltip text">
90
91
  <button type="button">Trigger</button>
91
92
  </Tooltip>,
92
93
  );
93
- const layer = screen.getByRole('tooltip', {hidden: true});
94
- expect(layer).toHaveTextContent('Tooltip text');
95
- // ARIA tooltip pattern: trigger references the layer via aria-describedby.
96
- const trigger = screen.getByRole('button', {name: 'Trigger'});
97
- expect(trigger.getAttribute('aria-describedby')).toBe(layer.id);
94
+ expect(screen.getByRole('tooltip', {hidden: true})).toHaveTextContent(
95
+ 'Tooltip text',
96
+ );
98
97
  });
99
98
 
100
99
  it('calls onOpenChange(true) when shown via hover', async () => {
@@ -604,3 +603,49 @@ describe('Tooltip', () => {
604
603
  });
605
604
  });
606
605
  });
606
+
607
+ describe('Tooltip sibling mode (anchorRef)', () => {
608
+ // SideNavItem's collapsed rail tooltip renders the Tooltip inside the very
609
+ // element anchorRef points at. React attaches an ancestor's ref after its
610
+ // descendants' layout effects, so on mount the tooltip's effect used to see
611
+ // a null anchor and never retried (#7692).
612
+ it('attaches to an anchor it is rendered inside of', async () => {
613
+ const onOpenChange = vi.fn();
614
+ function AnchorInside() {
615
+ const anchorRef = useRef<HTMLDivElement>(null);
616
+ return (
617
+ <div ref={anchorRef}>
618
+ <button type="button">Inner</button>
619
+ <Tooltip
620
+ content="Sibling tooltip"
621
+ anchorRef={anchorRef}
622
+ delay={0}
623
+ onOpenChange={onOpenChange}
624
+ />
625
+ </div>
626
+ );
627
+ }
628
+
629
+ const {unmount} = render(<AnchorInside />);
630
+ const anchor = screen.getByRole('button', {name: 'Inner'})
631
+ .parentElement as HTMLElement;
632
+
633
+ // The anchor is described by the tooltip once the commit has attached refs.
634
+ await waitFor(() => {
635
+ expect(anchor.getAttribute('aria-describedby')).not.toBe('');
636
+ });
637
+ const layer = screen.getByRole('tooltip', {hidden: true});
638
+ expect(anchor.getAttribute('aria-describedby')).toBe(layer.id);
639
+
640
+ // Hovering the anchor opens the tooltip: the interaction listeners were
641
+ // attached to the late anchor too.
642
+ fireEvent.mouseEnter(anchor);
643
+ await waitFor(() => {
644
+ expect(onOpenChange).toHaveBeenCalledWith(true);
645
+ });
646
+
647
+ // Unmounting detaches: the aria-describedby the tooltip added is removed.
648
+ unmount();
649
+ expect(anchor.getAttribute('aria-describedby')).toBe(null);
650
+ });
651
+ });
@@ -242,28 +242,51 @@ export function Tooltip({
242
242
  return;
243
243
  }
244
244
 
245
+ const attach = (el: HTMLElement) => {
246
+ // Use combined ref for position + interaction
247
+ tooltip.ref(el);
248
+
249
+ // Set aria-describedby, merging with existing
250
+ const existingDescribedBy = el.getAttribute('aria-describedby');
251
+ el.setAttribute(
252
+ 'aria-describedby',
253
+ mergeIds(existingDescribedBy, tooltip.describedBy) ?? '',
254
+ );
255
+
256
+ return () => {
257
+ tooltip.ref(null);
258
+ if (existingDescribedBy) {
259
+ el.setAttribute('aria-describedby', existingDescribedBy);
260
+ } else {
261
+ el.removeAttribute('aria-describedby');
262
+ }
263
+ };
264
+ };
265
+
245
266
  const el = anchorRef.current;
246
- if (!el) {
247
- return;
267
+ if (el) {
268
+ return attach(el);
248
269
  }
249
270
 
250
- // Use combined ref for position + interaction
251
- tooltip.ref(el);
252
-
253
- // Set aria-describedby, merging with existing
254
- const existingDescribedBy = el.getAttribute('aria-describedby');
255
- el.setAttribute(
256
- 'aria-describedby',
257
- mergeIds(existingDescribedBy, tooltip.describedBy) ?? '',
258
- );
259
-
260
- return () => {
261
- tooltip.ref(null);
262
- if (existingDescribedBy) {
263
- el.setAttribute('aria-describedby', existingDescribedBy);
264
- } else {
265
- el.removeAttribute('aria-describedby');
271
+ // React attaches an ancestor's ref after its descendants' layout effects,
272
+ // so a tooltip rendered inside its own anchor (SideNavItem's collapsed
273
+ // rail tooltip) still sees null here on mount, and this effect's deps
274
+ // never change to re-run it. Re-check once the commit has attached all
275
+ // refs; if the anchor is still gone, this tooltip has no anchor, as before.
276
+ let detach: (() => void) | undefined;
277
+ let cancelled = false;
278
+ queueMicrotask(() => {
279
+ if (cancelled) {
280
+ return;
266
281
  }
282
+ const el = anchorRef.current;
283
+ if (el) {
284
+ detach = attach(el);
285
+ }
286
+ });
287
+ return () => {
288
+ cancelled = true;
289
+ detach?.();
267
290
  };
268
291
  }, [anchorRef, tooltip.ref, tooltip.describedBy]);
269
292