@seed-design/react-menu 0.0.0-alpha-20260622094810 → 2.0.0
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/lib/{Menu-12s-Cfv1stLD.js → Menu-12s-BZlYyVsk.js} +12 -12
- package/lib/{Menu-12s-BZEIsPP0.cjs → Menu-12s-RqWENzzc.cjs} +12 -12
- package/lib/index.cjs +1 -1
- package/lib/index.d.ts +0 -567
- package/lib/index.js +2 -2
- package/lib/{useMenu-12s-Cv5FQJ3x.cjs → useMenu-12s-B5iYt8f4.cjs} +32 -48
- package/lib/{useMenu-12s-BrOJabEO.js → useMenu-12s-a4Nmkf8N.js} +33 -50
- package/package.json +7 -10
- package/src/Menu.tsx +22 -11
- package/src/{useMenu.vitest.tsx → useMenu.test.tsx} +43 -29
- package/src/useMenu.ts +33 -34
|
@@ -43,9 +43,6 @@ function useMenuState(props) {
|
|
|
43
43
|
const elementsRef = React.useRef([]);
|
|
44
44
|
const labelsRef = React.useRef([]);
|
|
45
45
|
const triggerRef = React.useRef(null);
|
|
46
|
-
const groupIndexCounter = React.useRef(0);
|
|
47
|
-
groupIndexCounter.current = 0;
|
|
48
|
-
const menuId = React.useId();
|
|
49
46
|
return {
|
|
50
47
|
open,
|
|
51
48
|
setOpenState,
|
|
@@ -53,13 +50,11 @@ function useMenuState(props) {
|
|
|
53
50
|
setActiveIndex,
|
|
54
51
|
elementsRef,
|
|
55
52
|
labelsRef,
|
|
56
|
-
triggerRef
|
|
57
|
-
groupIndexCounter,
|
|
58
|
-
menuId
|
|
53
|
+
triggerRef
|
|
59
54
|
};
|
|
60
55
|
}
|
|
61
56
|
function useMenu(props) {
|
|
62
|
-
const { open, setOpenState, activeIndex, setActiveIndex, elementsRef, labelsRef, triggerRef
|
|
57
|
+
const { open, setOpenState, activeIndex, setActiveIndex, elementsRef, labelsRef, triggerRef } = useMenuState(props);
|
|
63
58
|
const { disabled = false, placement = "bottom", gutter = 8, overflowPadding = 8, strategy = "absolute", matchReferenceWidth = false } = props;
|
|
64
59
|
const [safeArea, setSafeArea] = React.useState({
|
|
65
60
|
top: 0,
|
|
@@ -276,49 +271,38 @@ function useMenu(props) {
|
|
|
276
271
|
"aria-disabled": itemProps.disabled ? "true" : undefined
|
|
277
272
|
})
|
|
278
273
|
};
|
|
279
|
-
},
|
|
280
|
-
// implement when submenu is needed
|
|
281
|
-
// getSubmenuTriggerProps: (itemProps: UseMenuSubmenuTriggerProps, index: number) => {
|
|
282
|
-
// const isActive = activeIndex === index;
|
|
283
|
-
//
|
|
284
|
-
// const itemStateProps = elementProps({
|
|
285
|
-
// "data-highlighted": dataAttr(isActive),
|
|
286
|
-
// "data-disabled": dataAttr(itemProps.disabled),
|
|
287
|
-
// });
|
|
288
|
-
//
|
|
289
|
-
// return {
|
|
290
|
-
// isHighlighted: isActive,
|
|
291
|
-
// isDisabled: itemProps.disabled,
|
|
292
|
-
//
|
|
293
|
-
// stateProps: itemStateProps,
|
|
294
|
-
//
|
|
295
|
-
// rootProps: elementProps({
|
|
296
|
-
// ...itemStateProps,
|
|
297
|
-
// ...triggerInteractions.getItemProps(),
|
|
298
|
-
// role: "menuitem",
|
|
299
|
-
// tabIndex: isActive ? 0 : -1,
|
|
300
|
-
// "aria-disabled": itemProps.disabled ? "true" : undefined,
|
|
301
|
-
// }),
|
|
302
|
-
// };
|
|
303
|
-
// },
|
|
304
|
-
getGroupProps: ()=>{
|
|
305
|
-
const groupIndex = groupIndexCounter.current++;
|
|
306
|
-
const labelId = `menu:${menuId}:group-${groupIndex}:label`;
|
|
307
|
-
return {
|
|
308
|
-
labelId,
|
|
309
|
-
rootProps: domUtils.elementProps({
|
|
310
|
-
role: "group",
|
|
311
|
-
"aria-labelledby": labelId
|
|
312
|
-
})
|
|
313
|
-
};
|
|
314
|
-
},
|
|
315
|
-
getGroupLabelProps: (labelId)=>{
|
|
316
|
-
return domUtils.elementProps({
|
|
317
|
-
role: "presentation",
|
|
318
|
-
id: labelId
|
|
319
|
-
});
|
|
320
274
|
}
|
|
321
275
|
};
|
|
322
276
|
}
|
|
277
|
+
// A group advertises aria-labelledby only while its label is actually rendered.
|
|
278
|
+
// Mirrors useFieldset: a callback ref flips a boolean synchronously at commit, so
|
|
279
|
+
// the attribute is present on first paint and never points at a missing id — no
|
|
280
|
+
// menu-global registry or render-order ids needed. Each group owns its own id and
|
|
281
|
+
// presence flag, so conditionally rendering or reordering groups can never make one
|
|
282
|
+
// group inherit another's label reference.
|
|
283
|
+
function useMenuGroup() {
|
|
284
|
+
const id = React.useId();
|
|
285
|
+
const labelId = `menu-group:${id}:label`;
|
|
286
|
+
const [isLabelRendered, setIsLabelRendered] = React.useState(false);
|
|
287
|
+
const labelRef = React.useCallback((node)=>{
|
|
288
|
+
setIsLabelRendered(!!node);
|
|
289
|
+
}, []);
|
|
290
|
+
return {
|
|
291
|
+
refs: {
|
|
292
|
+
label: labelRef
|
|
293
|
+
},
|
|
294
|
+
rootProps: domUtils.elementProps({
|
|
295
|
+
role: "group",
|
|
296
|
+
...isLabelRendered && {
|
|
297
|
+
"aria-labelledby": labelId
|
|
298
|
+
}
|
|
299
|
+
}),
|
|
300
|
+
labelProps: domUtils.elementProps({
|
|
301
|
+
role: "presentation",
|
|
302
|
+
id: labelId
|
|
303
|
+
})
|
|
304
|
+
};
|
|
305
|
+
}
|
|
323
306
|
|
|
324
307
|
exports.useMenu = useMenu;
|
|
308
|
+
exports.useMenuGroup = useMenuGroup;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { useFloating, offset, size, flip, shift, useTransitionStatus, autoUpdate, useClick, useRole, useListNavigation, useTypeahead, useInteractions } from '@floating-ui/react';
|
|
3
3
|
import { useControllableState } from '@seed-design/react-use-controllable-state';
|
|
4
4
|
import { elementProps, dataAttr, buttonProps } from '@seed-design/dom-utils';
|
|
5
|
-
import { useState, useCallback, useEffect, useMemo, useRef
|
|
5
|
+
import { useId, useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
|
6
6
|
|
|
7
7
|
const MIN_HEIGHT = 200;
|
|
8
8
|
// flip/size/shift derive collisions from numeric padding, so the safe-area insets
|
|
@@ -43,9 +43,6 @@ function useMenuState(props) {
|
|
|
43
43
|
const elementsRef = useRef([]);
|
|
44
44
|
const labelsRef = useRef([]);
|
|
45
45
|
const triggerRef = useRef(null);
|
|
46
|
-
const groupIndexCounter = useRef(0);
|
|
47
|
-
groupIndexCounter.current = 0;
|
|
48
|
-
const menuId = useId();
|
|
49
46
|
return {
|
|
50
47
|
open,
|
|
51
48
|
setOpenState,
|
|
@@ -53,13 +50,11 @@ function useMenuState(props) {
|
|
|
53
50
|
setActiveIndex,
|
|
54
51
|
elementsRef,
|
|
55
52
|
labelsRef,
|
|
56
|
-
triggerRef
|
|
57
|
-
groupIndexCounter,
|
|
58
|
-
menuId
|
|
53
|
+
triggerRef
|
|
59
54
|
};
|
|
60
55
|
}
|
|
61
56
|
function useMenu(props) {
|
|
62
|
-
const { open, setOpenState, activeIndex, setActiveIndex, elementsRef, labelsRef, triggerRef
|
|
57
|
+
const { open, setOpenState, activeIndex, setActiveIndex, elementsRef, labelsRef, triggerRef } = useMenuState(props);
|
|
63
58
|
const { disabled = false, placement = "bottom", gutter = 8, overflowPadding = 8, strategy = "absolute", matchReferenceWidth = false } = props;
|
|
64
59
|
const [safeArea, setSafeArea] = useState({
|
|
65
60
|
top: 0,
|
|
@@ -276,49 +271,37 @@ function useMenu(props) {
|
|
|
276
271
|
"aria-disabled": itemProps.disabled ? "true" : undefined
|
|
277
272
|
})
|
|
278
273
|
};
|
|
279
|
-
},
|
|
280
|
-
// implement when submenu is needed
|
|
281
|
-
// getSubmenuTriggerProps: (itemProps: UseMenuSubmenuTriggerProps, index: number) => {
|
|
282
|
-
// const isActive = activeIndex === index;
|
|
283
|
-
//
|
|
284
|
-
// const itemStateProps = elementProps({
|
|
285
|
-
// "data-highlighted": dataAttr(isActive),
|
|
286
|
-
// "data-disabled": dataAttr(itemProps.disabled),
|
|
287
|
-
// });
|
|
288
|
-
//
|
|
289
|
-
// return {
|
|
290
|
-
// isHighlighted: isActive,
|
|
291
|
-
// isDisabled: itemProps.disabled,
|
|
292
|
-
//
|
|
293
|
-
// stateProps: itemStateProps,
|
|
294
|
-
//
|
|
295
|
-
// rootProps: elementProps({
|
|
296
|
-
// ...itemStateProps,
|
|
297
|
-
// ...triggerInteractions.getItemProps(),
|
|
298
|
-
// role: "menuitem",
|
|
299
|
-
// tabIndex: isActive ? 0 : -1,
|
|
300
|
-
// "aria-disabled": itemProps.disabled ? "true" : undefined,
|
|
301
|
-
// }),
|
|
302
|
-
// };
|
|
303
|
-
// },
|
|
304
|
-
getGroupProps: ()=>{
|
|
305
|
-
const groupIndex = groupIndexCounter.current++;
|
|
306
|
-
const labelId = `menu:${menuId}:group-${groupIndex}:label`;
|
|
307
|
-
return {
|
|
308
|
-
labelId,
|
|
309
|
-
rootProps: elementProps({
|
|
310
|
-
role: "group",
|
|
311
|
-
"aria-labelledby": labelId
|
|
312
|
-
})
|
|
313
|
-
};
|
|
314
|
-
},
|
|
315
|
-
getGroupLabelProps: (labelId)=>{
|
|
316
|
-
return elementProps({
|
|
317
|
-
role: "presentation",
|
|
318
|
-
id: labelId
|
|
319
|
-
});
|
|
320
274
|
}
|
|
321
275
|
};
|
|
322
276
|
}
|
|
277
|
+
// A group advertises aria-labelledby only while its label is actually rendered.
|
|
278
|
+
// Mirrors useFieldset: a callback ref flips a boolean synchronously at commit, so
|
|
279
|
+
// the attribute is present on first paint and never points at a missing id — no
|
|
280
|
+
// menu-global registry or render-order ids needed. Each group owns its own id and
|
|
281
|
+
// presence flag, so conditionally rendering or reordering groups can never make one
|
|
282
|
+
// group inherit another's label reference.
|
|
283
|
+
function useMenuGroup() {
|
|
284
|
+
const id = useId();
|
|
285
|
+
const labelId = `menu-group:${id}:label`;
|
|
286
|
+
const [isLabelRendered, setIsLabelRendered] = useState(false);
|
|
287
|
+
const labelRef = useCallback((node)=>{
|
|
288
|
+
setIsLabelRendered(!!node);
|
|
289
|
+
}, []);
|
|
290
|
+
return {
|
|
291
|
+
refs: {
|
|
292
|
+
label: labelRef
|
|
293
|
+
},
|
|
294
|
+
rootProps: elementProps({
|
|
295
|
+
role: "group",
|
|
296
|
+
...isLabelRendered && {
|
|
297
|
+
"aria-labelledby": labelId
|
|
298
|
+
}
|
|
299
|
+
}),
|
|
300
|
+
labelProps: elementProps({
|
|
301
|
+
role: "presentation",
|
|
302
|
+
id: labelId
|
|
303
|
+
})
|
|
304
|
+
};
|
|
305
|
+
}
|
|
323
306
|
|
|
324
|
-
export { useMenu as u };
|
|
307
|
+
export { useMenu as a, useMenuGroup as u };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@seed-design/react-menu",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/daangn/seed-design.git",
|
|
@@ -24,24 +24,21 @@
|
|
|
24
24
|
"scripts": {
|
|
25
25
|
"clean": "rm -rf lib",
|
|
26
26
|
"build": "bunchee",
|
|
27
|
-
"lint:publish": "bun publint"
|
|
28
|
-
"test": "vitest run"
|
|
27
|
+
"lint:publish": "bun publint"
|
|
29
28
|
},
|
|
30
29
|
"dependencies": {
|
|
31
30
|
"@floating-ui/react": "^0.27.0",
|
|
32
31
|
"@radix-ui/react-compose-refs": "^1.1.2",
|
|
33
32
|
"@radix-ui/react-focus-scope": "^1.1.8",
|
|
34
|
-
"@seed-design/dom-utils": "
|
|
35
|
-
"@seed-design/react-dismissible-layer": "
|
|
36
|
-
"@seed-design/react-primitive": "
|
|
37
|
-
"@seed-design/react-use-controllable-state": "
|
|
33
|
+
"@seed-design/dom-utils": "^2.0.0",
|
|
34
|
+
"@seed-design/react-dismissible-layer": "^1.0.0",
|
|
35
|
+
"@seed-design/react-primitive": "^2.0.0",
|
|
36
|
+
"@seed-design/react-use-controllable-state": "^2.0.0"
|
|
38
37
|
},
|
|
39
38
|
"devDependencies": {
|
|
40
39
|
"@types/react": "^19.1.6",
|
|
41
|
-
"jsdom": "^29.0.2",
|
|
42
40
|
"react": "^19.1.0",
|
|
43
|
-
"react-dom": "^19.1.0"
|
|
44
|
-
"vitest": "^4.1.3"
|
|
41
|
+
"react-dom": "^19.1.0"
|
|
45
42
|
},
|
|
46
43
|
"peerDependencies": {
|
|
47
44
|
"react": ">=18.0.0",
|
package/src/Menu.tsx
CHANGED
|
@@ -12,11 +12,17 @@ import { DismissibleLayer } from "@seed-design/react-dismissible-layer";
|
|
|
12
12
|
import { mergeProps } from "@seed-design/dom-utils";
|
|
13
13
|
import { Primitive, type PrimitiveProps } from "@seed-design/react-primitive";
|
|
14
14
|
import React, { forwardRef, createContext } from "react";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
useMenu,
|
|
17
|
+
useMenuGroup,
|
|
18
|
+
type UseMenuGroupReturn,
|
|
19
|
+
type UseMenuItemProps,
|
|
20
|
+
type UseMenuProps,
|
|
21
|
+
} from "./useMenu";
|
|
16
22
|
import { MenuProvider, useMenuContext } from "./useMenuContext";
|
|
17
23
|
import { MenuItemProvider } from "./useMenuItemContext";
|
|
18
24
|
|
|
19
|
-
const
|
|
25
|
+
const MenuGroupContext = createContext<UseMenuGroupReturn | null>(null);
|
|
20
26
|
|
|
21
27
|
export interface MenuRootProps extends UseMenuProps {
|
|
22
28
|
children?: React.ReactNode;
|
|
@@ -198,13 +204,12 @@ MenuItem.displayName = "MenuItem";
|
|
|
198
204
|
export interface MenuGroupProps extends PrimitiveProps, React.HTMLAttributes<HTMLDivElement> {}
|
|
199
205
|
|
|
200
206
|
export const MenuGroup = forwardRef<HTMLDivElement, MenuGroupProps>((props, ref) => {
|
|
201
|
-
const
|
|
202
|
-
const { labelId, rootProps } = getGroupProps();
|
|
207
|
+
const group = useMenuGroup();
|
|
203
208
|
|
|
204
209
|
return (
|
|
205
|
-
<
|
|
206
|
-
<Primitive.div ref={ref} {...mergeProps(rootProps, props)} />
|
|
207
|
-
</
|
|
210
|
+
<MenuGroupContext.Provider value={group}>
|
|
211
|
+
<Primitive.div ref={ref} {...mergeProps(group.rootProps, props)} />
|
|
212
|
+
</MenuGroupContext.Provider>
|
|
208
213
|
);
|
|
209
214
|
});
|
|
210
215
|
MenuGroup.displayName = "MenuGroup";
|
|
@@ -212,10 +217,16 @@ MenuGroup.displayName = "MenuGroup";
|
|
|
212
217
|
export interface MenuGroupLabelProps extends PrimitiveProps, React.HTMLAttributes<HTMLDivElement> {}
|
|
213
218
|
|
|
214
219
|
export const MenuGroupLabel = forwardRef<HTMLDivElement, MenuGroupLabelProps>((props, ref) => {
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
if (!labelId) throw new Error("MenuGroupLabel must be used within a MenuGroup");
|
|
220
|
+
const group = React.useContext(MenuGroupContext);
|
|
221
|
+
if (!group) throw new Error("MenuGroupLabel must be used within a MenuGroup");
|
|
218
222
|
|
|
219
|
-
|
|
223
|
+
// Compose the group's label ref so the group advertises aria-labelledby only
|
|
224
|
+
// while this label is actually rendered (see useMenuGroup).
|
|
225
|
+
return (
|
|
226
|
+
<Primitive.div
|
|
227
|
+
ref={composeRefs(group.refs.label, ref)}
|
|
228
|
+
{...mergeProps(group.labelProps, props)}
|
|
229
|
+
/>
|
|
230
|
+
);
|
|
220
231
|
});
|
|
221
232
|
MenuGroupLabel.displayName = "MenuGroupLabel";
|
|
@@ -1,17 +1,6 @@
|
|
|
1
|
-
// this file is .vitest.tsx, not .test.tsx — so bun test won't pick it up.
|
|
2
|
-
//
|
|
3
|
-
// @floating-ui/react defers focus via requestAnimationFrame (enqueueFocus).
|
|
4
|
-
// bun test preloads happydom, which doesn't fire rAF the way we need.
|
|
5
|
-
// vitest + jsdom (pretendToBeVisual) gives us a real rAF that ticks at ~16ms,
|
|
6
|
-
// letting waitForFocus() actually flush the deferred .focus() calls.
|
|
7
|
-
//
|
|
8
|
-
// see vitest.config.ts for the jsdom environment setup.
|
|
9
|
-
|
|
10
|
-
/// <reference types="@testing-library/jest-dom/vitest" />
|
|
11
|
-
|
|
12
1
|
import { render, fireEvent, act } from "@testing-library/react";
|
|
13
2
|
import userEvent from "@testing-library/user-event";
|
|
14
|
-
import { describe, expect, it,
|
|
3
|
+
import { describe, expect, it, jest } from "bun:test";
|
|
15
4
|
|
|
16
5
|
import * as React from "react";
|
|
17
6
|
|
|
@@ -33,8 +22,8 @@ type UseMenuProps = MenuRootProps;
|
|
|
33
22
|
const waitForPositioning = () => act(async () => {});
|
|
34
23
|
|
|
35
24
|
// Flush rAF-deferred focus from FloatingFocusManager / useListNavigation.
|
|
36
|
-
//
|
|
37
|
-
//
|
|
25
|
+
// happy-dom mocks rAF with setImmediate, so a short timer is needed for
|
|
26
|
+
// enqueueFocus() in @floating-ui/react to land.
|
|
38
27
|
const waitForFocus = () =>
|
|
39
28
|
act(async () => {
|
|
40
29
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
@@ -198,6 +187,31 @@ describe("useMenu", () => {
|
|
|
198
187
|
const label = getByText("Group 1");
|
|
199
188
|
expect(label).toHaveAttribute("id", labelledBy);
|
|
200
189
|
});
|
|
190
|
+
|
|
191
|
+
it("does not set a dangling aria-labelledby on a group without a label", async () => {
|
|
192
|
+
function MenuWithUnlabeledGroup() {
|
|
193
|
+
return (
|
|
194
|
+
<Menu>
|
|
195
|
+
<MenuTrigger>Open Menu</MenuTrigger>
|
|
196
|
+
<MenuPositioner>
|
|
197
|
+
<MenuContent>
|
|
198
|
+
<MenuGroup>
|
|
199
|
+
<MenuItem>Item A</MenuItem>
|
|
200
|
+
<MenuItem>Item B</MenuItem>
|
|
201
|
+
</MenuGroup>
|
|
202
|
+
</MenuContent>
|
|
203
|
+
</MenuPositioner>
|
|
204
|
+
</Menu>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const user = userEvent.setup();
|
|
209
|
+
const { getAllByRole, getByText } = render(<MenuWithUnlabeledGroup />);
|
|
210
|
+
await waitForPositioning();
|
|
211
|
+
await user.click(getByText("Open Menu"));
|
|
212
|
+
const group = getAllByRole("group")[0];
|
|
213
|
+
expect(group).not.toHaveAttribute("aria-labelledby");
|
|
214
|
+
});
|
|
201
215
|
});
|
|
202
216
|
|
|
203
217
|
describe("open/close state", () => {
|
|
@@ -222,7 +236,7 @@ describe("useMenu", () => {
|
|
|
222
236
|
|
|
223
237
|
it("supports controlled open state", async () => {
|
|
224
238
|
const user = userEvent.setup();
|
|
225
|
-
const onOpenChange =
|
|
239
|
+
const onOpenChange = jest.fn();
|
|
226
240
|
const { getByText } = render(<ControlledMenu onOpenChange={onOpenChange} />);
|
|
227
241
|
await waitForPositioning();
|
|
228
242
|
await user.click(getByText("Open Menu"));
|
|
@@ -237,7 +251,7 @@ describe("useMenu", () => {
|
|
|
237
251
|
|
|
238
252
|
it("calls onOpenChange when toggled", async () => {
|
|
239
253
|
const user = userEvent.setup();
|
|
240
|
-
const onOpenChange =
|
|
254
|
+
const onOpenChange = jest.fn();
|
|
241
255
|
const { getByText } = render(<BasicMenu onOpenChange={onOpenChange} />);
|
|
242
256
|
await waitForPositioning();
|
|
243
257
|
await user.click(getByText("Open Menu"));
|
|
@@ -249,7 +263,7 @@ describe("useMenu", () => {
|
|
|
249
263
|
|
|
250
264
|
it("reports reason 'escapeKeyDown' when closed by Escape", async () => {
|
|
251
265
|
const user = userEvent.setup();
|
|
252
|
-
const onOpenChange =
|
|
266
|
+
const onOpenChange = jest.fn();
|
|
253
267
|
const { getByText } = render(<BasicMenu onOpenChange={onOpenChange} />);
|
|
254
268
|
await waitForPositioning();
|
|
255
269
|
await user.click(getByText("Open Menu"));
|
|
@@ -263,7 +277,7 @@ describe("useMenu", () => {
|
|
|
263
277
|
|
|
264
278
|
it("reports reason 'interactOutside' when closed by outside click", async () => {
|
|
265
279
|
const user = userEvent.setup();
|
|
266
|
-
const onOpenChange =
|
|
280
|
+
const onOpenChange = jest.fn();
|
|
267
281
|
const { getByText } = render(
|
|
268
282
|
<div>
|
|
269
283
|
<BasicMenu onOpenChange={onOpenChange} />
|
|
@@ -282,7 +296,7 @@ describe("useMenu", () => {
|
|
|
282
296
|
|
|
283
297
|
it("reports reason 'itemClick' when closed by item selection", async () => {
|
|
284
298
|
const user = userEvent.setup();
|
|
285
|
-
const onOpenChange =
|
|
299
|
+
const onOpenChange = jest.fn();
|
|
286
300
|
const { getByText } = render(<BasicMenu onOpenChange={onOpenChange} />);
|
|
287
301
|
await waitForPositioning();
|
|
288
302
|
await user.click(getByText("Open Menu"));
|
|
@@ -529,7 +543,7 @@ describe("useMenu", () => {
|
|
|
529
543
|
describe("item interaction", () => {
|
|
530
544
|
it("calls onClick on item when clicked", async () => {
|
|
531
545
|
const user = userEvent.setup();
|
|
532
|
-
const onClick =
|
|
546
|
+
const onClick = jest.fn();
|
|
533
547
|
const { getByText } = render(
|
|
534
548
|
<Menu>
|
|
535
549
|
<MenuTrigger>Open Menu</MenuTrigger>
|
|
@@ -566,7 +580,7 @@ describe("useMenu", () => {
|
|
|
566
580
|
|
|
567
581
|
it("activates focused item on Enter key", async () => {
|
|
568
582
|
const user = userEvent.setup();
|
|
569
|
-
const onClick =
|
|
583
|
+
const onClick = jest.fn();
|
|
570
584
|
const { getByText } = render(
|
|
571
585
|
<Menu>
|
|
572
586
|
<MenuTrigger>Open Menu</MenuTrigger>
|
|
@@ -579,14 +593,14 @@ describe("useMenu", () => {
|
|
|
579
593
|
);
|
|
580
594
|
await waitForPositioning();
|
|
581
595
|
await user.click(getByText("Open Menu"));
|
|
582
|
-
getByText("Activatable").focus();
|
|
596
|
+
act(() => getByText("Activatable").focus());
|
|
583
597
|
await user.keyboard("{Enter}");
|
|
584
598
|
expect(onClick).toHaveBeenCalledTimes(1);
|
|
585
599
|
});
|
|
586
600
|
|
|
587
601
|
it("activates focused item on Space key", async () => {
|
|
588
602
|
const user = userEvent.setup();
|
|
589
|
-
const onClick =
|
|
603
|
+
const onClick = jest.fn();
|
|
590
604
|
const { getByText } = render(
|
|
591
605
|
<Menu>
|
|
592
606
|
<MenuTrigger>Open Menu</MenuTrigger>
|
|
@@ -599,14 +613,14 @@ describe("useMenu", () => {
|
|
|
599
613
|
);
|
|
600
614
|
await waitForPositioning();
|
|
601
615
|
await user.click(getByText("Open Menu"));
|
|
602
|
-
getByText("Activatable").focus();
|
|
616
|
+
act(() => getByText("Activatable").focus());
|
|
603
617
|
await user.keyboard(" ");
|
|
604
618
|
expect(onClick).toHaveBeenCalledTimes(1);
|
|
605
619
|
});
|
|
606
620
|
|
|
607
621
|
it("does not activate disabled item on click or keyboard", async () => {
|
|
608
622
|
const user = userEvent.setup();
|
|
609
|
-
const onClick =
|
|
623
|
+
const onClick = jest.fn();
|
|
610
624
|
const { getByText } = render(
|
|
611
625
|
<Menu>
|
|
612
626
|
<MenuTrigger>Open Menu</MenuTrigger>
|
|
@@ -736,7 +750,7 @@ describe("useMenu", () => {
|
|
|
736
750
|
|
|
737
751
|
it("does not cascade-dismiss Menu B when Menu A's layer is removed", async () => {
|
|
738
752
|
const user = userEvent.setup();
|
|
739
|
-
const onMenuBOpenChange =
|
|
753
|
+
const onMenuBOpenChange = jest.fn();
|
|
740
754
|
const { getByText } = render(<TwoSiblingMenus onMenuBOpenChange={onMenuBOpenChange} />);
|
|
741
755
|
await waitForPositioning();
|
|
742
756
|
|
|
@@ -785,7 +799,7 @@ describe("useMenu", () => {
|
|
|
785
799
|
|
|
786
800
|
it("does not cascade-dismiss Menu B on touch switch (touch)", async () => {
|
|
787
801
|
const user = userEvent.setup();
|
|
788
|
-
const onMenuBOpenChange =
|
|
802
|
+
const onMenuBOpenChange = jest.fn();
|
|
789
803
|
const { getByText } = render(<TwoSiblingMenus onMenuBOpenChange={onMenuBOpenChange} />);
|
|
790
804
|
await waitForPositioning();
|
|
791
805
|
|
|
@@ -813,7 +827,7 @@ describe("useMenu", () => {
|
|
|
813
827
|
|
|
814
828
|
it("keeps Menu B open and interactive after switching from Menu A", async () => {
|
|
815
829
|
const user = userEvent.setup();
|
|
816
|
-
const onClick =
|
|
830
|
+
const onClick = jest.fn();
|
|
817
831
|
const { getByText } = render(
|
|
818
832
|
<div>
|
|
819
833
|
<Menu>
|
|
@@ -848,7 +862,7 @@ describe("useMenu", () => {
|
|
|
848
862
|
|
|
849
863
|
describe("mouse interaction", () => {
|
|
850
864
|
it("activates item on mouse-up after drag from trigger", async () => {
|
|
851
|
-
const onClick =
|
|
865
|
+
const onClick = jest.fn();
|
|
852
866
|
const { getByText } = render(
|
|
853
867
|
<Menu>
|
|
854
868
|
<MenuTrigger>Open Menu</MenuTrigger>
|
package/src/useMenu.ts
CHANGED
|
@@ -142,11 +142,6 @@ function useMenuState(props: UseMenuStateProps) {
|
|
|
142
142
|
const labelsRef = useRef<(string | null)[]>([]);
|
|
143
143
|
const triggerRef = useRef<Element | null>(null);
|
|
144
144
|
|
|
145
|
-
const groupIndexCounter = useRef(0);
|
|
146
|
-
groupIndexCounter.current = 0;
|
|
147
|
-
|
|
148
|
-
const menuId = useId();
|
|
149
|
-
|
|
150
145
|
return {
|
|
151
146
|
open,
|
|
152
147
|
setOpenState,
|
|
@@ -155,23 +150,12 @@ function useMenuState(props: UseMenuStateProps) {
|
|
|
155
150
|
elementsRef,
|
|
156
151
|
labelsRef,
|
|
157
152
|
triggerRef,
|
|
158
|
-
groupIndexCounter,
|
|
159
|
-
menuId,
|
|
160
153
|
};
|
|
161
154
|
}
|
|
162
155
|
|
|
163
156
|
export function useMenu(props: UseMenuProps) {
|
|
164
|
-
const {
|
|
165
|
-
|
|
166
|
-
setOpenState,
|
|
167
|
-
activeIndex,
|
|
168
|
-
setActiveIndex,
|
|
169
|
-
elementsRef,
|
|
170
|
-
labelsRef,
|
|
171
|
-
triggerRef,
|
|
172
|
-
groupIndexCounter,
|
|
173
|
-
menuId,
|
|
174
|
-
} = useMenuState(props);
|
|
157
|
+
const { open, setOpenState, activeIndex, setActiveIndex, elementsRef, labelsRef, triggerRef } =
|
|
158
|
+
useMenuState(props);
|
|
175
159
|
|
|
176
160
|
const {
|
|
177
161
|
disabled = false,
|
|
@@ -443,24 +427,39 @@ export function useMenu(props: UseMenuProps) {
|
|
|
443
427
|
// }),
|
|
444
428
|
// };
|
|
445
429
|
// },
|
|
430
|
+
};
|
|
431
|
+
}
|
|
446
432
|
|
|
447
|
-
|
|
448
|
-
const groupIndex = groupIndexCounter.current++;
|
|
449
|
-
const labelId = `menu:${menuId}:group-${groupIndex}:label`;
|
|
450
|
-
return {
|
|
451
|
-
labelId,
|
|
452
|
-
rootProps: elementProps({
|
|
453
|
-
role: "group",
|
|
454
|
-
"aria-labelledby": labelId,
|
|
455
|
-
}),
|
|
456
|
-
};
|
|
457
|
-
},
|
|
433
|
+
export type UseMenuGroupReturn = ReturnType<typeof useMenuGroup>;
|
|
458
434
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
435
|
+
// A group advertises aria-labelledby only while its label is actually rendered.
|
|
436
|
+
// Mirrors useFieldset: a callback ref flips a boolean synchronously at commit, so
|
|
437
|
+
// the attribute is present on first paint and never points at a missing id — no
|
|
438
|
+
// menu-global registry or render-order ids needed. Each group owns its own id and
|
|
439
|
+
// presence flag, so conditionally rendering or reordering groups can never make one
|
|
440
|
+
// group inherit another's label reference.
|
|
441
|
+
export function useMenuGroup() {
|
|
442
|
+
const id = useId();
|
|
443
|
+
const labelId = `menu-group:${id}:label`;
|
|
444
|
+
|
|
445
|
+
const [isLabelRendered, setIsLabelRendered] = useState(false);
|
|
446
|
+
const labelRef = useCallback((node: HTMLDivElement | null) => {
|
|
447
|
+
setIsLabelRendered(!!node);
|
|
448
|
+
}, []);
|
|
449
|
+
|
|
450
|
+
return {
|
|
451
|
+
refs: {
|
|
452
|
+
label: labelRef,
|
|
464
453
|
},
|
|
454
|
+
|
|
455
|
+
rootProps: elementProps({
|
|
456
|
+
role: "group",
|
|
457
|
+
...(isLabelRendered && { "aria-labelledby": labelId }),
|
|
458
|
+
}),
|
|
459
|
+
|
|
460
|
+
labelProps: elementProps({
|
|
461
|
+
role: "presentation",
|
|
462
|
+
id: labelId,
|
|
463
|
+
}),
|
|
465
464
|
};
|
|
466
465
|
}
|