@seed-design/react-menu 0.0.0-alpha-20260414104312

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/index.js ADDED
@@ -0,0 +1,16 @@
1
+ import { M as MenuAnchor, a as MenuContent, b as MenuGroup, c as MenuGroupLabel, d as MenuItem, e as MenuPositioner, f as MenuRoot, g as MenuTrigger } from './Menu-12s-DcQqm37B.js';
2
+ export { u as useMenuContext, h as useMenuItemContext } from './Menu-12s-DcQqm37B.js';
3
+
4
+ var Menu_namespace = {
5
+ __proto__: null,
6
+ Anchor: MenuAnchor,
7
+ Content: MenuContent,
8
+ Group: MenuGroup,
9
+ GroupLabel: MenuGroupLabel,
10
+ Item: MenuItem,
11
+ Positioner: MenuPositioner,
12
+ Root: MenuRoot,
13
+ Trigger: MenuTrigger
14
+ };
15
+
16
+ export { Menu_namespace as Menu, MenuAnchor, MenuContent, MenuGroup, MenuGroupLabel, MenuItem, MenuPositioner, MenuRoot, MenuTrigger };
@@ -0,0 +1,269 @@
1
+ 'use client';
2
+ var react = require('@floating-ui/react');
3
+ var reactUseControllableState = require('@seed-design/react-use-controllable-state');
4
+ var domUtils = require('@seed-design/dom-utils');
5
+ var React = require('react');
6
+
7
+ const MIN_HEIGHT = 200;
8
+ function getTransformOrigin(placement) {
9
+ const [side, align] = placement.split("-");
10
+ const y = {
11
+ top: "bottom",
12
+ bottom: "top",
13
+ left: "center",
14
+ right: "center"
15
+ }[side] ?? "top";
16
+ const x = {
17
+ start: "left",
18
+ end: "right"
19
+ }[align] ?? "center";
20
+ return `${x} ${y}`;
21
+ }
22
+ function useMenuState(props) {
23
+ const [open = false, setOpenState] = reactUseControllableState.useControllableState({
24
+ prop: props.open,
25
+ defaultProp: props.defaultOpen ?? false,
26
+ onChange: props.onOpenChange
27
+ });
28
+ const [activeIndex, setActiveIndex] = React.useState(null);
29
+ const elementsRef = React.useRef([]);
30
+ const labelsRef = React.useRef([]);
31
+ const triggerRef = React.useRef(null);
32
+ const groupIndexCounter = React.useRef(0);
33
+ groupIndexCounter.current = 0;
34
+ const menuId = React.useId();
35
+ return {
36
+ open,
37
+ setOpenState,
38
+ activeIndex,
39
+ setActiveIndex,
40
+ elementsRef,
41
+ labelsRef,
42
+ triggerRef,
43
+ groupIndexCounter,
44
+ menuId
45
+ };
46
+ }
47
+ function useMenu(props) {
48
+ const { open, setOpenState, activeIndex, setActiveIndex, elementsRef, labelsRef, triggerRef, groupIndexCounter, menuId } = useMenuState(props);
49
+ const { disabled = false, placement = "bottom", gutter = 8, overflowPadding = 8, strategy = "absolute", matchReferenceWidth = false } = props;
50
+ const setOpen = React.useCallback((nextOpen, details)=>{
51
+ if (disabled && nextOpen) return;
52
+ setOpenState(nextOpen, details);
53
+ }, [
54
+ disabled,
55
+ setOpenState
56
+ ]);
57
+ const handleFloatingOpenChange = React.useCallback((nextOpen, event, reason)=>{
58
+ if (reason === "click" && event) {
59
+ // NOTE: floating-ui passes click/mousedown/keydown on "click" reason
60
+ setOpen(nextOpen, {
61
+ reason: "trigger",
62
+ event: event
63
+ });
64
+ return;
65
+ }
66
+ setOpen(nextOpen);
67
+ }, [
68
+ setOpen
69
+ ]);
70
+ const { refs: floatingRefs, context, floatingStyles } = react.useFloating({
71
+ open,
72
+ onOpenChange: handleFloatingOpenChange,
73
+ strategy,
74
+ placement,
75
+ middleware: [
76
+ react.offset(gutter),
77
+ react.size({
78
+ padding: overflowPadding,
79
+ apply ({ availableHeight, rects, elements }) {
80
+ elements.floating.style.setProperty("--seed-menu-available-height", `${Math.max(MIN_HEIGHT, availableHeight)}px`);
81
+ if (matchReferenceWidth) {
82
+ elements.floating.style.setProperty("--seed-menu-reference-width", `${rects.reference.width}px`);
83
+ }
84
+ }
85
+ }),
86
+ react.flip({
87
+ padding: overflowPadding,
88
+ fallbackStrategy: "initialPlacement"
89
+ }),
90
+ react.shift({
91
+ padding: overflowPadding
92
+ })
93
+ ]
94
+ });
95
+ const { status } = react.useTransitionStatus(context);
96
+ // Keep autoUpdate alive during exit animation so the positioner
97
+ // follows the trigger even while the menu is animating out.
98
+ const mounted = status !== "unmounted";
99
+ React.useEffect(()=>{
100
+ if (!mounted) return;
101
+ if (!floatingRefs.reference.current || !floatingRefs.floating.current) return;
102
+ return react.autoUpdate(floatingRefs.reference.current, floatingRefs.floating.current, context.update);
103
+ }, [
104
+ mounted,
105
+ floatingRefs.reference,
106
+ floatingRefs.floating,
107
+ context
108
+ ]);
109
+ const click = react.useClick(context, {
110
+ enabled: !disabled
111
+ });
112
+ const role = react.useRole(context, {
113
+ role: "menu"
114
+ });
115
+ const listNavigation = react.useListNavigation(context, {
116
+ listRef: elementsRef,
117
+ activeIndex,
118
+ onNavigate: setActiveIndex,
119
+ loop: true,
120
+ // Hover and focus states should be differentiated; disable focus-on-hover
121
+ // so that hover styling can be handled separately via CSS :hover.
122
+ focusItemOnHover: false
123
+ });
124
+ const typeahead = react.useTypeahead(context, {
125
+ listRef: labelsRef,
126
+ activeIndex,
127
+ onMatch: setActiveIndex
128
+ });
129
+ const triggerInteractions = react.useInteractions([
130
+ click,
131
+ role,
132
+ listNavigation,
133
+ typeahead
134
+ ]);
135
+ const anchorInteractions = react.useInteractions([
136
+ role,
137
+ listNavigation,
138
+ typeahead
139
+ ]);
140
+ const stateProps = React.useMemo(()=>domUtils.elementProps({
141
+ "data-hidden": domUtils.dataAttr(status === "unmounted"),
142
+ "data-open": domUtils.dataAttr(status === "open" || status === "initial")
143
+ }), [
144
+ status
145
+ ]);
146
+ return {
147
+ open,
148
+ setOpen,
149
+ activeIndex,
150
+ stateProps,
151
+ floatingContext: context,
152
+ elementsRef,
153
+ labelsRef,
154
+ refs: {
155
+ anchor: (node)=>{
156
+ floatingRefs.setReference(node);
157
+ },
158
+ trigger: (node)=>{
159
+ floatingRefs.setReference(node);
160
+ triggerRef.current = node;
161
+ },
162
+ positioner: floatingRefs.setFloating
163
+ },
164
+ anchorProps: domUtils.elementProps({
165
+ "data-open": domUtils.dataAttr(open),
166
+ ...anchorInteractions.getReferenceProps()
167
+ }),
168
+ triggerProps: domUtils.buttonProps({
169
+ "aria-haspopup": "menu",
170
+ "aria-expanded": open,
171
+ "data-open": domUtils.dataAttr(open),
172
+ ...triggerInteractions.getReferenceProps()
173
+ }),
174
+ positionerProps: domUtils.elementProps({
175
+ ...stateProps,
176
+ style: floatingStyles
177
+ }),
178
+ contentProps: domUtils.elementProps({
179
+ ...stateProps,
180
+ role: "menu",
181
+ style: {
182
+ "--seed-menu-transform-origin": getTransformOrigin(context.placement)
183
+ },
184
+ ...triggerInteractions.getFloatingProps()
185
+ }),
186
+ getItemProps: (itemProps, index)=>{
187
+ const isActive = activeIndex === index;
188
+ const itemStateProps = domUtils.elementProps({
189
+ // implement when submenu is needed
190
+ // "data-highlighted": dataAttr(isActive),
191
+ "data-disabled": domUtils.dataAttr(itemProps.disabled)
192
+ });
193
+ return {
194
+ // implement when submenu is needed
195
+ // isHighlighted: isActive,
196
+ isDisabled: itemProps.disabled,
197
+ stateProps: itemStateProps,
198
+ rootProps: domUtils.elementProps({
199
+ ...itemStateProps,
200
+ ...triggerInteractions.getItemProps({
201
+ onClick (event) {
202
+ if (itemProps.disabled) return;
203
+ if (event.defaultPrevented) return;
204
+ itemProps.onClick?.(event);
205
+ setOpen(false, {
206
+ reason: "itemClick",
207
+ event: event.nativeEvent
208
+ });
209
+ },
210
+ onKeyDown (event) {
211
+ if (itemProps.disabled) return;
212
+ if (event.key === "Enter" || event.key === " ") {
213
+ event.preventDefault();
214
+ if (!(event.currentTarget instanceof HTMLElement)) return;
215
+ event.currentTarget.click();
216
+ }
217
+ }
218
+ }),
219
+ role: "menuitem",
220
+ tabIndex: isActive ? 0 : -1,
221
+ "aria-disabled": itemProps.disabled ? "true" : undefined
222
+ })
223
+ };
224
+ },
225
+ // implement when submenu is needed
226
+ // getSubmenuTriggerProps: (itemProps: UseMenuSubmenuTriggerProps, index: number) => {
227
+ // const isActive = activeIndex === index;
228
+ //
229
+ // const itemStateProps = elementProps({
230
+ // "data-highlighted": dataAttr(isActive),
231
+ // "data-disabled": dataAttr(itemProps.disabled),
232
+ // });
233
+ //
234
+ // return {
235
+ // isHighlighted: isActive,
236
+ // isDisabled: itemProps.disabled,
237
+ //
238
+ // stateProps: itemStateProps,
239
+ //
240
+ // rootProps: elementProps({
241
+ // ...itemStateProps,
242
+ // ...triggerInteractions.getItemProps(),
243
+ // role: "menuitem",
244
+ // tabIndex: isActive ? 0 : -1,
245
+ // "aria-disabled": itemProps.disabled ? "true" : undefined,
246
+ // }),
247
+ // };
248
+ // },
249
+ getGroupProps: ()=>{
250
+ const groupIndex = groupIndexCounter.current++;
251
+ const labelId = `menu:${menuId}:group-${groupIndex}:label`;
252
+ return {
253
+ labelId,
254
+ rootProps: domUtils.elementProps({
255
+ role: "group",
256
+ "aria-labelledby": labelId
257
+ })
258
+ };
259
+ },
260
+ getGroupLabelProps: (labelId)=>{
261
+ return domUtils.elementProps({
262
+ role: "presentation",
263
+ id: labelId
264
+ });
265
+ }
266
+ };
267
+ }
268
+
269
+ exports.useMenu = useMenu;
@@ -0,0 +1,269 @@
1
+ 'use client';
2
+ import { useFloating, offset, size, flip, shift, useTransitionStatus, autoUpdate, useClick, useRole, useListNavigation, useTypeahead, useInteractions } from '@floating-ui/react';
3
+ import { useControllableState } from '@seed-design/react-use-controllable-state';
4
+ import { elementProps, dataAttr, buttonProps } from '@seed-design/dom-utils';
5
+ import { useCallback, useEffect, useMemo, useState, useRef, useId } from 'react';
6
+
7
+ const MIN_HEIGHT = 200;
8
+ function getTransformOrigin(placement) {
9
+ const [side, align] = placement.split("-");
10
+ const y = {
11
+ top: "bottom",
12
+ bottom: "top",
13
+ left: "center",
14
+ right: "center"
15
+ }[side] ?? "top";
16
+ const x = {
17
+ start: "left",
18
+ end: "right"
19
+ }[align] ?? "center";
20
+ return `${x} ${y}`;
21
+ }
22
+ function useMenuState(props) {
23
+ const [open = false, setOpenState] = useControllableState({
24
+ prop: props.open,
25
+ defaultProp: props.defaultOpen ?? false,
26
+ onChange: props.onOpenChange
27
+ });
28
+ const [activeIndex, setActiveIndex] = useState(null);
29
+ const elementsRef = useRef([]);
30
+ const labelsRef = useRef([]);
31
+ const triggerRef = useRef(null);
32
+ const groupIndexCounter = useRef(0);
33
+ groupIndexCounter.current = 0;
34
+ const menuId = useId();
35
+ return {
36
+ open,
37
+ setOpenState,
38
+ activeIndex,
39
+ setActiveIndex,
40
+ elementsRef,
41
+ labelsRef,
42
+ triggerRef,
43
+ groupIndexCounter,
44
+ menuId
45
+ };
46
+ }
47
+ function useMenu(props) {
48
+ const { open, setOpenState, activeIndex, setActiveIndex, elementsRef, labelsRef, triggerRef, groupIndexCounter, menuId } = useMenuState(props);
49
+ const { disabled = false, placement = "bottom", gutter = 8, overflowPadding = 8, strategy = "absolute", matchReferenceWidth = false } = props;
50
+ const setOpen = useCallback((nextOpen, details)=>{
51
+ if (disabled && nextOpen) return;
52
+ setOpenState(nextOpen, details);
53
+ }, [
54
+ disabled,
55
+ setOpenState
56
+ ]);
57
+ const handleFloatingOpenChange = useCallback((nextOpen, event, reason)=>{
58
+ if (reason === "click" && event) {
59
+ // NOTE: floating-ui passes click/mousedown/keydown on "click" reason
60
+ setOpen(nextOpen, {
61
+ reason: "trigger",
62
+ event: event
63
+ });
64
+ return;
65
+ }
66
+ setOpen(nextOpen);
67
+ }, [
68
+ setOpen
69
+ ]);
70
+ const { refs: floatingRefs, context, floatingStyles } = useFloating({
71
+ open,
72
+ onOpenChange: handleFloatingOpenChange,
73
+ strategy,
74
+ placement,
75
+ middleware: [
76
+ offset(gutter),
77
+ size({
78
+ padding: overflowPadding,
79
+ apply ({ availableHeight, rects, elements }) {
80
+ elements.floating.style.setProperty("--seed-menu-available-height", `${Math.max(MIN_HEIGHT, availableHeight)}px`);
81
+ if (matchReferenceWidth) {
82
+ elements.floating.style.setProperty("--seed-menu-reference-width", `${rects.reference.width}px`);
83
+ }
84
+ }
85
+ }),
86
+ flip({
87
+ padding: overflowPadding,
88
+ fallbackStrategy: "initialPlacement"
89
+ }),
90
+ shift({
91
+ padding: overflowPadding
92
+ })
93
+ ]
94
+ });
95
+ const { status } = useTransitionStatus(context);
96
+ // Keep autoUpdate alive during exit animation so the positioner
97
+ // follows the trigger even while the menu is animating out.
98
+ const mounted = status !== "unmounted";
99
+ useEffect(()=>{
100
+ if (!mounted) return;
101
+ if (!floatingRefs.reference.current || !floatingRefs.floating.current) return;
102
+ return autoUpdate(floatingRefs.reference.current, floatingRefs.floating.current, context.update);
103
+ }, [
104
+ mounted,
105
+ floatingRefs.reference,
106
+ floatingRefs.floating,
107
+ context
108
+ ]);
109
+ const click = useClick(context, {
110
+ enabled: !disabled
111
+ });
112
+ const role = useRole(context, {
113
+ role: "menu"
114
+ });
115
+ const listNavigation = useListNavigation(context, {
116
+ listRef: elementsRef,
117
+ activeIndex,
118
+ onNavigate: setActiveIndex,
119
+ loop: true,
120
+ // Hover and focus states should be differentiated; disable focus-on-hover
121
+ // so that hover styling can be handled separately via CSS :hover.
122
+ focusItemOnHover: false
123
+ });
124
+ const typeahead = useTypeahead(context, {
125
+ listRef: labelsRef,
126
+ activeIndex,
127
+ onMatch: setActiveIndex
128
+ });
129
+ const triggerInteractions = useInteractions([
130
+ click,
131
+ role,
132
+ listNavigation,
133
+ typeahead
134
+ ]);
135
+ const anchorInteractions = useInteractions([
136
+ role,
137
+ listNavigation,
138
+ typeahead
139
+ ]);
140
+ const stateProps = useMemo(()=>elementProps({
141
+ "data-hidden": dataAttr(status === "unmounted"),
142
+ "data-open": dataAttr(status === "open" || status === "initial")
143
+ }), [
144
+ status
145
+ ]);
146
+ return {
147
+ open,
148
+ setOpen,
149
+ activeIndex,
150
+ stateProps,
151
+ floatingContext: context,
152
+ elementsRef,
153
+ labelsRef,
154
+ refs: {
155
+ anchor: (node)=>{
156
+ floatingRefs.setReference(node);
157
+ },
158
+ trigger: (node)=>{
159
+ floatingRefs.setReference(node);
160
+ triggerRef.current = node;
161
+ },
162
+ positioner: floatingRefs.setFloating
163
+ },
164
+ anchorProps: elementProps({
165
+ "data-open": dataAttr(open),
166
+ ...anchorInteractions.getReferenceProps()
167
+ }),
168
+ triggerProps: buttonProps({
169
+ "aria-haspopup": "menu",
170
+ "aria-expanded": open,
171
+ "data-open": dataAttr(open),
172
+ ...triggerInteractions.getReferenceProps()
173
+ }),
174
+ positionerProps: elementProps({
175
+ ...stateProps,
176
+ style: floatingStyles
177
+ }),
178
+ contentProps: elementProps({
179
+ ...stateProps,
180
+ role: "menu",
181
+ style: {
182
+ "--seed-menu-transform-origin": getTransformOrigin(context.placement)
183
+ },
184
+ ...triggerInteractions.getFloatingProps()
185
+ }),
186
+ getItemProps: (itemProps, index)=>{
187
+ const isActive = activeIndex === index;
188
+ const itemStateProps = elementProps({
189
+ // implement when submenu is needed
190
+ // "data-highlighted": dataAttr(isActive),
191
+ "data-disabled": dataAttr(itemProps.disabled)
192
+ });
193
+ return {
194
+ // implement when submenu is needed
195
+ // isHighlighted: isActive,
196
+ isDisabled: itemProps.disabled,
197
+ stateProps: itemStateProps,
198
+ rootProps: elementProps({
199
+ ...itemStateProps,
200
+ ...triggerInteractions.getItemProps({
201
+ onClick (event) {
202
+ if (itemProps.disabled) return;
203
+ if (event.defaultPrevented) return;
204
+ itemProps.onClick?.(event);
205
+ setOpen(false, {
206
+ reason: "itemClick",
207
+ event: event.nativeEvent
208
+ });
209
+ },
210
+ onKeyDown (event) {
211
+ if (itemProps.disabled) return;
212
+ if (event.key === "Enter" || event.key === " ") {
213
+ event.preventDefault();
214
+ if (!(event.currentTarget instanceof HTMLElement)) return;
215
+ event.currentTarget.click();
216
+ }
217
+ }
218
+ }),
219
+ role: "menuitem",
220
+ tabIndex: isActive ? 0 : -1,
221
+ "aria-disabled": itemProps.disabled ? "true" : undefined
222
+ })
223
+ };
224
+ },
225
+ // implement when submenu is needed
226
+ // getSubmenuTriggerProps: (itemProps: UseMenuSubmenuTriggerProps, index: number) => {
227
+ // const isActive = activeIndex === index;
228
+ //
229
+ // const itemStateProps = elementProps({
230
+ // "data-highlighted": dataAttr(isActive),
231
+ // "data-disabled": dataAttr(itemProps.disabled),
232
+ // });
233
+ //
234
+ // return {
235
+ // isHighlighted: isActive,
236
+ // isDisabled: itemProps.disabled,
237
+ //
238
+ // stateProps: itemStateProps,
239
+ //
240
+ // rootProps: elementProps({
241
+ // ...itemStateProps,
242
+ // ...triggerInteractions.getItemProps(),
243
+ // role: "menuitem",
244
+ // tabIndex: isActive ? 0 : -1,
245
+ // "aria-disabled": itemProps.disabled ? "true" : undefined,
246
+ // }),
247
+ // };
248
+ // },
249
+ getGroupProps: ()=>{
250
+ const groupIndex = groupIndexCounter.current++;
251
+ const labelId = `menu:${menuId}:group-${groupIndex}:label`;
252
+ return {
253
+ labelId,
254
+ rootProps: elementProps({
255
+ role: "group",
256
+ "aria-labelledby": labelId
257
+ })
258
+ };
259
+ },
260
+ getGroupLabelProps: (labelId)=>{
261
+ return elementProps({
262
+ role: "presentation",
263
+ id: labelId
264
+ });
265
+ }
266
+ };
267
+ }
268
+
269
+ export { useMenu as u };
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@seed-design/react-menu",
3
+ "version": "0.0.0-alpha-20260414104312",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "git+https://github.com/daangn/seed-design.git",
7
+ "directory": "packages/react-headless/menu"
8
+ },
9
+ "sideEffects": false,
10
+ "type": "module",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./lib/index.d.ts",
14
+ "import": "./lib/index.js",
15
+ "require": "./lib/index.cjs"
16
+ },
17
+ "./package.json": "./package.json"
18
+ },
19
+ "main": "./lib/index.cjs",
20
+ "files": [
21
+ "lib",
22
+ "src"
23
+ ],
24
+ "scripts": {
25
+ "clean": "rm -rf lib",
26
+ "build": "bunchee",
27
+ "lint:publish": "bun publint",
28
+ "test": "vitest run"
29
+ },
30
+ "dependencies": {
31
+ "@floating-ui/react": "^0.27.0",
32
+ "@radix-ui/react-compose-refs": "^1.1.2",
33
+ "@radix-ui/react-focus-scope": "^1.1.8",
34
+ "@seed-design/dom-utils": "1.0.0",
35
+ "@seed-design/react-dismissible-layer": "0.0.0-alpha-20260414104312",
36
+ "@seed-design/react-primitive": "0.0.0-alpha-20260414104312",
37
+ "@seed-design/react-use-controllable-state": "1.0.0"
38
+ },
39
+ "devDependencies": {
40
+ "@types/react": "^19.1.6",
41
+ "jsdom": "^29.0.2",
42
+ "react": "^19.1.0",
43
+ "react-dom": "^19.1.0",
44
+ "vitest": "^4.1.3"
45
+ },
46
+ "peerDependencies": {
47
+ "react": ">=18.0.0",
48
+ "react-dom": ">=18.0.0"
49
+ },
50
+ "publishConfig": {
51
+ "access": "public"
52
+ }
53
+ }
@@ -0,0 +1,18 @@
1
+ export {
2
+ MenuRoot as Root,
3
+ MenuAnchor as Anchor,
4
+ MenuTrigger as Trigger,
5
+ MenuPositioner as Positioner,
6
+ MenuContent as Content,
7
+ MenuItem as Item,
8
+ MenuGroup as Group,
9
+ MenuGroupLabel as GroupLabel,
10
+ type MenuRootProps as RootProps,
11
+ type MenuAnchorProps as AnchorProps,
12
+ type MenuTriggerProps as TriggerProps,
13
+ type MenuPositionerProps as PositionerProps,
14
+ type MenuContentProps as ContentProps,
15
+ type MenuItemProps as ItemProps,
16
+ type MenuGroupProps as GroupProps,
17
+ type MenuGroupLabelProps as GroupLabelProps,
18
+ } from "./Menu";