@seed-design/react-menu 0.0.0-alpha-20260511052324 → 1.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.
@@ -7,7 +7,7 @@ var reactDismissibleLayer = require('@seed-design/react-dismissible-layer');
7
7
  var domUtils = require('@seed-design/dom-utils');
8
8
  var reactPrimitive = require('@seed-design/react-primitive');
9
9
  var React = require('react');
10
- var useMenu12s = require('./useMenu-12s-DN0SpRKs.cjs');
10
+ var useMenu12s = require('./useMenu-12s-Cv5FQJ3x.cjs');
11
11
 
12
12
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
13
13
 
@@ -7,7 +7,7 @@ import { DismissibleLayer } from '@seed-design/react-dismissible-layer';
7
7
  import { mergeProps } from '@seed-design/dom-utils';
8
8
  import { Primitive } from '@seed-design/react-primitive';
9
9
  import React, { createContext, useContext, forwardRef } from 'react';
10
- import { u as useMenu } from './useMenu-12s-WINwYlwH.js';
10
+ import { u as useMenu } from './useMenu-12s-BrOJabEO.js';
11
11
 
12
12
  const MenuContext = /*#__PURE__*/ createContext(null);
13
13
  const MenuProvider = MenuContext.Provider;
package/lib/index.cjs CHANGED
@@ -1,4 +1,4 @@
1
- var Menu12s = require('./Menu-12s-KCTCu-et.cjs');
1
+ var Menu12s = require('./Menu-12s-BZEIsPP0.cjs');
2
2
 
3
3
  var Menu_namespace = {
4
4
  __proto__: null,
package/lib/index.js CHANGED
@@ -1,5 +1,5 @@
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';
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-Cfv1stLD.js';
2
+ export { u as useMenuContext, h as useMenuItemContext } from './Menu-12s-Cfv1stLD.js';
3
3
 
4
4
  var Menu_namespace = {
5
5
  __proto__: null,
@@ -2,9 +2,23 @@
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 { useCallback, useEffect, useMemo, useState, useRef, useId } from 'react';
5
+ import { useState, useCallback, useEffect, useMemo, useRef, useId } from 'react';
6
6
 
7
7
  const MIN_HEIGHT = 200;
8
+ // flip/size/shift derive collisions from numeric padding, so the safe-area insets
9
+ // have to reach floating-ui as numbers — a CSS env() value alone can't. The first
10
+ // cut stayed in CSS, insetting the menu's max-height per placement; on iOS WebKit
11
+ // that coupled height to placement, so flip kept re-measuring, oscillated
12
+ // top<->bottom, and the synchronous reposition re-renders pinned the main thread.
13
+ // Folding the insets into the collision boundary instead keeps placement stable.
14
+ //
15
+ // So, like Snackbar's `useSafeOffset`, the positioner re-declares the insets from
16
+ // env() here and the hook reads them back as px (see the effect after useFloating),
17
+ // which also keeps this layer self-contained from the global SEED safe-area tokens.
18
+ const SAFE_AREA_STYLE = {
19
+ "--seed-safe-area-top": "env(safe-area-inset-top)",
20
+ "--seed-safe-area-bottom": "env(safe-area-inset-bottom)"
21
+ };
8
22
  function getTransformOrigin(placement) {
9
23
  const [side, align] = placement.split("-");
10
24
  const y = {
@@ -47,6 +61,20 @@ function useMenuState(props) {
47
61
  function useMenu(props) {
48
62
  const { open, setOpenState, activeIndex, setActiveIndex, elementsRef, labelsRef, triggerRef, groupIndexCounter, menuId } = useMenuState(props);
49
63
  const { disabled = false, placement = "bottom", gutter = 8, overflowPadding = 8, strategy = "absolute", matchReferenceWidth = false } = props;
64
+ const [safeArea, setSafeArea] = useState({
65
+ top: 0,
66
+ bottom: 0
67
+ });
68
+ // Inset the viewport collision boundary so flip/size/shift keep the menu clear of
69
+ // the notch and home indicator, not just the viewport edge. The safe area is already
70
+ // a visual buffer, so where it exists the menu sits right at its boundary; only where
71
+ // there is none does it fall back to overflowPadding off the bare viewport edge.
72
+ const collisionPadding = {
73
+ top: safeArea.top || overflowPadding,
74
+ right: overflowPadding,
75
+ bottom: safeArea.bottom || overflowPadding,
76
+ left: overflowPadding
77
+ };
50
78
  const setOpen = useCallback((nextOpen, details)=>{
51
79
  if (disabled && nextOpen) return;
52
80
  setOpenState(nextOpen, details);
@@ -75,7 +103,7 @@ function useMenu(props) {
75
103
  middleware: [
76
104
  offset(gutter),
77
105
  size({
78
- padding: overflowPadding,
106
+ padding: collisionPadding,
79
107
  apply ({ availableHeight, rects, elements }) {
80
108
  elements.floating.style.setProperty("--seed-menu-available-height", `${Math.max(MIN_HEIGHT, availableHeight)}px`);
81
109
  if (matchReferenceWidth) {
@@ -84,11 +112,11 @@ function useMenu(props) {
84
112
  }
85
113
  }),
86
114
  flip({
87
- padding: overflowPadding,
115
+ padding: collisionPadding,
88
116
  fallbackStrategy: "initialPlacement"
89
117
  }),
90
118
  shift({
91
- padding: overflowPadding
119
+ padding: collisionPadding
92
120
  })
93
121
  ]
94
122
  });
@@ -106,6 +134,30 @@ function useMenu(props) {
106
134
  floatingRefs.floating,
107
135
  context
108
136
  ]);
137
+ // Read the env()-resolved insets off the positioner, which carries the env()
138
+ // declarations via SAFE_AREA_STYLE. Key on the reactive `elements.floating`, not
139
+ // `refs.floating`: the ref object's identity never changes, so an effect depending
140
+ // on it runs only once at mount — before FloatingPortal has committed the positioner
141
+ // child — reads a null ref, bails, and never re-fires, leaving `safeArea` stuck at
142
+ // {0,0}. `elements.floating` updates when the positioner mounts (it stays mounted
143
+ // even while closed), so the insets are read before the first open and the menu
144
+ // clears the safe area on its first frame. Re-read on resize for orientation changes.
145
+ const floatingElement = context.elements.floating;
146
+ useEffect(()=>{
147
+ if (!floatingElement) return;
148
+ const read = ()=>{
149
+ const styles = getComputedStyle(floatingElement);
150
+ setSafeArea({
151
+ top: Number.parseInt(styles.getPropertyValue("--seed-safe-area-top"), 10) || 0,
152
+ bottom: Number.parseInt(styles.getPropertyValue("--seed-safe-area-bottom"), 10) || 0
153
+ });
154
+ };
155
+ read();
156
+ window.addEventListener("resize", read);
157
+ return ()=>window.removeEventListener("resize", read);
158
+ }, [
159
+ floatingElement
160
+ ]);
109
161
  const click = useClick(context, {
110
162
  enabled: !disabled
111
163
  });
@@ -173,7 +225,10 @@ function useMenu(props) {
173
225
  }),
174
226
  positionerProps: elementProps({
175
227
  ...stateProps,
176
- style: floatingStyles
228
+ style: {
229
+ ...SAFE_AREA_STYLE,
230
+ ...floatingStyles
231
+ }
177
232
  }),
178
233
  contentProps: elementProps({
179
234
  ...stateProps,
@@ -5,6 +5,20 @@ var domUtils = require('@seed-design/dom-utils');
5
5
  var React = require('react');
6
6
 
7
7
  const MIN_HEIGHT = 200;
8
+ // flip/size/shift derive collisions from numeric padding, so the safe-area insets
9
+ // have to reach floating-ui as numbers — a CSS env() value alone can't. The first
10
+ // cut stayed in CSS, insetting the menu's max-height per placement; on iOS WebKit
11
+ // that coupled height to placement, so flip kept re-measuring, oscillated
12
+ // top<->bottom, and the synchronous reposition re-renders pinned the main thread.
13
+ // Folding the insets into the collision boundary instead keeps placement stable.
14
+ //
15
+ // So, like Snackbar's `useSafeOffset`, the positioner re-declares the insets from
16
+ // env() here and the hook reads them back as px (see the effect after useFloating),
17
+ // which also keeps this layer self-contained from the global SEED safe-area tokens.
18
+ const SAFE_AREA_STYLE = {
19
+ "--seed-safe-area-top": "env(safe-area-inset-top)",
20
+ "--seed-safe-area-bottom": "env(safe-area-inset-bottom)"
21
+ };
8
22
  function getTransformOrigin(placement) {
9
23
  const [side, align] = placement.split("-");
10
24
  const y = {
@@ -47,6 +61,20 @@ function useMenuState(props) {
47
61
  function useMenu(props) {
48
62
  const { open, setOpenState, activeIndex, setActiveIndex, elementsRef, labelsRef, triggerRef, groupIndexCounter, menuId } = useMenuState(props);
49
63
  const { disabled = false, placement = "bottom", gutter = 8, overflowPadding = 8, strategy = "absolute", matchReferenceWidth = false } = props;
64
+ const [safeArea, setSafeArea] = React.useState({
65
+ top: 0,
66
+ bottom: 0
67
+ });
68
+ // Inset the viewport collision boundary so flip/size/shift keep the menu clear of
69
+ // the notch and home indicator, not just the viewport edge. The safe area is already
70
+ // a visual buffer, so where it exists the menu sits right at its boundary; only where
71
+ // there is none does it fall back to overflowPadding off the bare viewport edge.
72
+ const collisionPadding = {
73
+ top: safeArea.top || overflowPadding,
74
+ right: overflowPadding,
75
+ bottom: safeArea.bottom || overflowPadding,
76
+ left: overflowPadding
77
+ };
50
78
  const setOpen = React.useCallback((nextOpen, details)=>{
51
79
  if (disabled && nextOpen) return;
52
80
  setOpenState(nextOpen, details);
@@ -75,7 +103,7 @@ function useMenu(props) {
75
103
  middleware: [
76
104
  react.offset(gutter),
77
105
  react.size({
78
- padding: overflowPadding,
106
+ padding: collisionPadding,
79
107
  apply ({ availableHeight, rects, elements }) {
80
108
  elements.floating.style.setProperty("--seed-menu-available-height", `${Math.max(MIN_HEIGHT, availableHeight)}px`);
81
109
  if (matchReferenceWidth) {
@@ -84,11 +112,11 @@ function useMenu(props) {
84
112
  }
85
113
  }),
86
114
  react.flip({
87
- padding: overflowPadding,
115
+ padding: collisionPadding,
88
116
  fallbackStrategy: "initialPlacement"
89
117
  }),
90
118
  react.shift({
91
- padding: overflowPadding
119
+ padding: collisionPadding
92
120
  })
93
121
  ]
94
122
  });
@@ -106,6 +134,30 @@ function useMenu(props) {
106
134
  floatingRefs.floating,
107
135
  context
108
136
  ]);
137
+ // Read the env()-resolved insets off the positioner, which carries the env()
138
+ // declarations via SAFE_AREA_STYLE. Key on the reactive `elements.floating`, not
139
+ // `refs.floating`: the ref object's identity never changes, so an effect depending
140
+ // on it runs only once at mount — before FloatingPortal has committed the positioner
141
+ // child — reads a null ref, bails, and never re-fires, leaving `safeArea` stuck at
142
+ // {0,0}. `elements.floating` updates when the positioner mounts (it stays mounted
143
+ // even while closed), so the insets are read before the first open and the menu
144
+ // clears the safe area on its first frame. Re-read on resize for orientation changes.
145
+ const floatingElement = context.elements.floating;
146
+ React.useEffect(()=>{
147
+ if (!floatingElement) return;
148
+ const read = ()=>{
149
+ const styles = getComputedStyle(floatingElement);
150
+ setSafeArea({
151
+ top: Number.parseInt(styles.getPropertyValue("--seed-safe-area-top"), 10) || 0,
152
+ bottom: Number.parseInt(styles.getPropertyValue("--seed-safe-area-bottom"), 10) || 0
153
+ });
154
+ };
155
+ read();
156
+ window.addEventListener("resize", read);
157
+ return ()=>window.removeEventListener("resize", read);
158
+ }, [
159
+ floatingElement
160
+ ]);
109
161
  const click = react.useClick(context, {
110
162
  enabled: !disabled
111
163
  });
@@ -173,7 +225,10 @@ function useMenu(props) {
173
225
  }),
174
226
  positionerProps: domUtils.elementProps({
175
227
  ...stateProps,
176
- style: floatingStyles
228
+ style: {
229
+ ...SAFE_AREA_STYLE,
230
+ ...floatingStyles
231
+ }
177
232
  }),
178
233
  contentProps: domUtils.elementProps({
179
234
  ...stateProps,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seed-design/react-menu",
3
- "version": "0.0.0-alpha-20260511052324",
3
+ "version": "1.0.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/daangn/seed-design.git",
@@ -31,10 +31,10 @@
31
31
  "@floating-ui/react": "^0.27.0",
32
32
  "@radix-ui/react-compose-refs": "^1.1.2",
33
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-20260511052324",
36
- "@seed-design/react-primitive": "0.0.0-alpha-20260511052324",
37
- "@seed-design/react-use-controllable-state": "1.0.0"
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"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@types/react": "^19.1.6",
package/src/useMenu.ts CHANGED
@@ -21,6 +21,21 @@ import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"
21
21
 
22
22
  const MIN_HEIGHT = 200;
23
23
 
24
+ // flip/size/shift derive collisions from numeric padding, so the safe-area insets
25
+ // have to reach floating-ui as numbers — a CSS env() value alone can't. The first
26
+ // cut stayed in CSS, insetting the menu's max-height per placement; on iOS WebKit
27
+ // that coupled height to placement, so flip kept re-measuring, oscillated
28
+ // top<->bottom, and the synchronous reposition re-renders pinned the main thread.
29
+ // Folding the insets into the collision boundary instead keeps placement stable.
30
+ //
31
+ // So, like Snackbar's `useSafeOffset`, the positioner re-declares the insets from
32
+ // env() here and the hook reads them back as px (see the effect after useFloating),
33
+ // which also keeps this layer self-contained from the global SEED safe-area tokens.
34
+ const SAFE_AREA_STYLE = {
35
+ "--seed-safe-area-top": "env(safe-area-inset-top)",
36
+ "--seed-safe-area-bottom": "env(safe-area-inset-bottom)",
37
+ } as React.CSSProperties;
38
+
24
39
  function getTransformOrigin(placement: string) {
25
40
  const [side, align] = placement.split("-");
26
41
  const y = { top: "bottom", bottom: "top", left: "center", right: "center" }[side] ?? "top";
@@ -167,6 +182,19 @@ export function useMenu(props: UseMenuProps) {
167
182
  matchReferenceWidth = false,
168
183
  } = props;
169
184
 
185
+ const [safeArea, setSafeArea] = useState({ top: 0, bottom: 0 });
186
+
187
+ // Inset the viewport collision boundary so flip/size/shift keep the menu clear of
188
+ // the notch and home indicator, not just the viewport edge. The safe area is already
189
+ // a visual buffer, so where it exists the menu sits right at its boundary; only where
190
+ // there is none does it fall back to overflowPadding off the bare viewport edge.
191
+ const collisionPadding = {
192
+ top: safeArea.top || overflowPadding,
193
+ right: overflowPadding,
194
+ bottom: safeArea.bottom || overflowPadding,
195
+ left: overflowPadding,
196
+ };
197
+
170
198
  const setOpen = useCallback(
171
199
  (nextOpen: boolean, details?: MenuChangeDetails) => {
172
200
  if (disabled && nextOpen) return;
@@ -201,7 +229,7 @@ export function useMenu(props: UseMenuProps) {
201
229
  middleware: [
202
230
  offset(gutter),
203
231
  size({
204
- padding: overflowPadding,
232
+ padding: collisionPadding,
205
233
  apply({ availableHeight, rects, elements }) {
206
234
  elements.floating.style.setProperty(
207
235
  "--seed-menu-available-height",
@@ -215,8 +243,8 @@ export function useMenu(props: UseMenuProps) {
215
243
  }
216
244
  },
217
245
  }),
218
- flip({ padding: overflowPadding, fallbackStrategy: "initialPlacement" }),
219
- shift({ padding: overflowPadding }),
246
+ flip({ padding: collisionPadding, fallbackStrategy: "initialPlacement" }),
247
+ shift({ padding: collisionPadding }),
220
248
  ],
221
249
  });
222
250
 
@@ -237,6 +265,33 @@ export function useMenu(props: UseMenuProps) {
237
265
  );
238
266
  }, [mounted, floatingRefs.reference, floatingRefs.floating, context]);
239
267
 
268
+ // Read the env()-resolved insets off the positioner, which carries the env()
269
+ // declarations via SAFE_AREA_STYLE. Key on the reactive `elements.floating`, not
270
+ // `refs.floating`: the ref object's identity never changes, so an effect depending
271
+ // on it runs only once at mount — before FloatingPortal has committed the positioner
272
+ // child — reads a null ref, bails, and never re-fires, leaving `safeArea` stuck at
273
+ // {0,0}. `elements.floating` updates when the positioner mounts (it stays mounted
274
+ // even while closed), so the insets are read before the first open and the menu
275
+ // clears the safe area on its first frame. Re-read on resize for orientation changes.
276
+ const floatingElement = context.elements.floating;
277
+
278
+ useEffect(() => {
279
+ if (!floatingElement) return;
280
+
281
+ const read = () => {
282
+ const styles = getComputedStyle(floatingElement);
283
+ setSafeArea({
284
+ top: Number.parseInt(styles.getPropertyValue("--seed-safe-area-top"), 10) || 0,
285
+ bottom: Number.parseInt(styles.getPropertyValue("--seed-safe-area-bottom"), 10) || 0,
286
+ });
287
+ };
288
+
289
+ read();
290
+ window.addEventListener("resize", read);
291
+
292
+ return () => window.removeEventListener("resize", read);
293
+ }, [floatingElement]);
294
+
240
295
  const click = useClick(context, {
241
296
  enabled: !disabled,
242
297
  });
@@ -309,7 +364,7 @@ export function useMenu(props: UseMenuProps) {
309
364
 
310
365
  positionerProps: elementProps({
311
366
  ...stateProps,
312
- style: floatingStyles,
367
+ style: { ...SAFE_AREA_STYLE, ...floatingStyles },
313
368
  }),
314
369
 
315
370
  contentProps: elementProps({