@seed-design/react-menu 1.0.0 → 2.0.1

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.
@@ -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, groupIndexCounter, menuId } = useMenuState(props);
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, useId } from 'react';
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, groupIndexCounter, menuId } = useMenuState(props);
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,11 +1,12 @@
1
1
  {
2
2
  "name": "@seed-design/react-menu",
3
- "version": "1.0.0",
3
+ "version": "2.0.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/daangn/seed-design.git",
7
7
  "directory": "packages/react-headless/menu"
8
8
  },
9
+ "license": "Apache-2.0",
9
10
  "sideEffects": false,
10
11
  "type": "module",
11
12
  "exports": {
@@ -19,29 +20,28 @@
19
20
  "main": "./lib/index.cjs",
20
21
  "files": [
21
22
  "lib",
22
- "src"
23
+ "src",
24
+ "LICENSE",
25
+ "NOTICE"
23
26
  ],
24
27
  "scripts": {
25
28
  "clean": "rm -rf lib",
26
29
  "build": "bunchee",
27
- "lint:publish": "bun publint",
28
- "test": "vitest run"
30
+ "lint:publish": "bun publint"
29
31
  },
30
32
  "dependencies": {
31
33
  "@floating-ui/react": "^0.27.0",
32
34
  "@radix-ui/react-compose-refs": "^1.1.2",
33
35
  "@radix-ui/react-focus-scope": "^1.1.8",
34
- "@seed-design/dom-utils": "^2.0.0",
35
- "@seed-design/react-dismissible-layer": "^1.0.0",
36
- "@seed-design/react-primitive": "^2.0.0",
37
- "@seed-design/react-use-controllable-state": "^2.0.0"
36
+ "@seed-design/dom-utils": "^2.0.1",
37
+ "@seed-design/react-dismissible-layer": "^1.0.1",
38
+ "@seed-design/react-primitive": "^2.0.1",
39
+ "@seed-design/react-use-controllable-state": "^2.0.1"
38
40
  },
39
41
  "devDependencies": {
40
42
  "@types/react": "^19.1.6",
41
- "jsdom": "^29.0.2",
42
43
  "react": "^19.1.0",
43
- "react-dom": "^19.1.0",
44
- "vitest": "^4.1.3"
44
+ "react-dom": "^19.1.0"
45
45
  },
46
46
  "peerDependencies": {
47
47
  "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 { useMenu, type UseMenuItemProps, type UseMenuProps } from "./useMenu";
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 MenuGroupLabelIdContext = createContext<string | null>(null);
25
+ const MenuGroupContext = createContext<UseMenuGroupReturn | null>(null);
20
26
 
21
27
  export interface MenuRootProps extends UseMenuProps {
22
28
  children?: React.ReactNode;
@@ -105,72 +111,71 @@ export const MenuPositioner = forwardRef<HTMLDivElement, MenuPositionerProps>(
105
111
  );
106
112
  MenuPositioner.displayName = "MenuPositioner";
107
113
 
114
+ /**
115
+ * Holds a Radix FocusScope registration for as long as the menu is open, so parent
116
+ * FocusScopes (Dialog, Drawer) pause their trap and focus can reach content
117
+ * rendered in a portal.
118
+ *
119
+ * The scope needs no behavior of its own — trapping and the tab loop stay off and
120
+ * both autofocus events are prevented — so all it does is enter Radix's
121
+ * focusScopesStack, which is keyed on mount, not on the element it wraps. Hence
122
+ * this empty hidden element rather than a wrapper around the content: wrapping
123
+ * swaps the element type at the content's position on every open/close, and React
124
+ * responds by remounting the whole menu subtree, handing the exit transition a
125
+ * scroll container freshly reset to the top.
126
+ *
127
+ * Mounting only while open is what lands it on top of the stack — a permanently
128
+ * mounted scope would register at page load, below any Dialog opened later.
129
+ */
130
+ const FocusScopeRegistration = () => (
131
+ <FocusScope
132
+ hidden
133
+ trapped={false}
134
+ loop={false}
135
+ onMountAutoFocus={(event) => event.preventDefault()}
136
+ onUnmountAutoFocus={(event) => event.preventDefault()}
137
+ />
138
+ );
139
+
108
140
  export interface MenuContentProps extends PrimitiveProps, React.HTMLAttributes<HTMLDivElement> {}
109
141
 
110
142
  export const MenuContent = forwardRef<HTMLDivElement, MenuContentProps>((props, ref) => {
111
143
  const { floatingContext, contentProps, open, setOpen, elementsRef, labelsRef } = useMenuContext();
112
144
 
113
- const content = <Primitive.div ref={ref} {...mergeProps(contentProps, props)} />;
114
-
115
145
  // FloatingFocusManager: handles position-aware initial focus, return focus,
116
146
  // closeOnFocusOut, tab order guards, and useListNavigation coordination.
117
- //
118
- // FocusScope: participates in Radix's focusScopesStack so that parent
119
- // FocusScopes (e.g. Dialog, Drawer) are automatically paused while this
120
- // Menu is open. Without this, focus cannot leave a trapped parent scope
121
- // to reach Menu content rendered in a Portal.
122
- //
123
- // FocusScope is conditionally rendered (only when open) because Menu
124
- // content is always in the DOM (hidden via data-hidden). If FocusScope
125
- // were always mounted, it would register in the stack at page load —
126
- // before any Dialog — and could never re-register above a Dialog that
127
- // mounts later. Mounting only when open ensures it lands at the top of
128
- // the stack, pausing the parent scope.
129
147
  return (
130
- <FloatingFocusManager context={floatingContext} disabled={!open} modal={false}>
131
- <FloatingList elementsRef={elementsRef} labelsRef={labelsRef}>
132
- {/* DismissibleLayer must wrap FocusScope, not the other way around.
133
- FocusScope asChild uses Slot to forward tabIndex/onKeyDown/ref to the
134
- DOM element; if DismissibleLayer sits between them, those props are
135
- swallowed by DismissibleLayer's own destructuring and never reach the DOM. */}
136
- <DismissibleLayer
137
- enabled={open}
138
- pressBehavior="drag"
139
- onEscapeKeyDown={(event) => {
140
- setOpen(false, { reason: "escapeKeyDown", event });
141
- }}
142
- onPressOutside={(event) => {
143
- setOpen(false, { reason: "interactOutside", event });
144
- }}
145
- onCascadeDismiss={({ dismissedParent }) => {
146
- setOpen(false, { reason: "cascadeDismiss", dismissedParent });
147
- }}
148
- onFocusOutside={() => {
149
- // focus trapping is handled by FloatingFocusManager — nothing to do here
150
- }}
151
- exclude={(target) => {
152
- const reference = floatingContext.refs.reference.current;
153
- if (!(reference instanceof HTMLElement)) return false;
154
-
155
- return reference.contains(target);
156
- }}
157
- >
158
- {open ? (
159
- <FocusScope
160
- asChild
161
- trapped={false}
162
- loop={false}
163
- onMountAutoFocus={(e) => e.preventDefault()}
164
- onUnmountAutoFocus={(e) => e.preventDefault()}
165
- >
166
- {content}
167
- </FocusScope>
168
- ) : (
169
- content
170
- )}
171
- </DismissibleLayer>
172
- </FloatingList>
173
- </FloatingFocusManager>
148
+ <>
149
+ <FloatingFocusManager context={floatingContext} disabled={!open} modal={false}>
150
+ <FloatingList elementsRef={elementsRef} labelsRef={labelsRef}>
151
+ <DismissibleLayer
152
+ enabled={open}
153
+ pressBehavior="drag"
154
+ onEscapeKeyDown={(event) => {
155
+ setOpen(false, { reason: "escapeKeyDown", event });
156
+ }}
157
+ onPressOutside={(event) => {
158
+ setOpen(false, { reason: "interactOutside", event });
159
+ }}
160
+ onCascadeDismiss={({ dismissedParent }) => {
161
+ setOpen(false, { reason: "cascadeDismiss", dismissedParent });
162
+ }}
163
+ onFocusOutside={() => {
164
+ // focus trapping is handled by FloatingFocusManager — nothing to do here
165
+ }}
166
+ exclude={(target) => {
167
+ const reference = floatingContext.refs.reference.current;
168
+ if (!(reference instanceof HTMLElement)) return false;
169
+
170
+ return reference.contains(target);
171
+ }}
172
+ >
173
+ <Primitive.div ref={ref} {...mergeProps(contentProps, props)} />
174
+ </DismissibleLayer>
175
+ </FloatingList>
176
+ </FloatingFocusManager>
177
+ {open && <FocusScopeRegistration />}
178
+ </>
174
179
  );
175
180
  });
176
181
  MenuContent.displayName = "MenuContent";
@@ -198,13 +203,12 @@ MenuItem.displayName = "MenuItem";
198
203
  export interface MenuGroupProps extends PrimitiveProps, React.HTMLAttributes<HTMLDivElement> {}
199
204
 
200
205
  export const MenuGroup = forwardRef<HTMLDivElement, MenuGroupProps>((props, ref) => {
201
- const { getGroupProps } = useMenuContext();
202
- const { labelId, rootProps } = getGroupProps();
206
+ const group = useMenuGroup();
203
207
 
204
208
  return (
205
- <MenuGroupLabelIdContext.Provider value={labelId}>
206
- <Primitive.div ref={ref} {...mergeProps(rootProps, props)} />
207
- </MenuGroupLabelIdContext.Provider>
209
+ <MenuGroupContext.Provider value={group}>
210
+ <Primitive.div ref={ref} {...mergeProps(group.rootProps, props)} />
211
+ </MenuGroupContext.Provider>
208
212
  );
209
213
  });
210
214
  MenuGroup.displayName = "MenuGroup";
@@ -212,10 +216,16 @@ MenuGroup.displayName = "MenuGroup";
212
216
  export interface MenuGroupLabelProps extends PrimitiveProps, React.HTMLAttributes<HTMLDivElement> {}
213
217
 
214
218
  export const MenuGroupLabel = forwardRef<HTMLDivElement, MenuGroupLabelProps>((props, ref) => {
215
- const { getGroupLabelProps } = useMenuContext();
216
- const labelId = React.useContext(MenuGroupLabelIdContext);
217
- if (!labelId) throw new Error("MenuGroupLabel must be used within a MenuGroup");
219
+ const group = React.useContext(MenuGroupContext);
220
+ if (!group) throw new Error("MenuGroupLabel must be used within a MenuGroup");
218
221
 
219
- return <Primitive.div ref={ref} {...mergeProps(getGroupLabelProps(labelId), props)} />;
222
+ // Compose the group's label ref so the group advertises aria-labelledby only
223
+ // while this label is actually rendered (see useMenuGroup).
224
+ return (
225
+ <Primitive.div
226
+ ref={composeRefs(group.refs.label, ref)}
227
+ {...mergeProps(group.labelProps, props)}
228
+ />
229
+ );
220
230
  });
221
231
  MenuGroupLabel.displayName = "MenuGroupLabel";