@trackunit/react-components 2.5.2-alpha-3b19e5fe058.0 → 2.6.2-alpha-770d9994af7.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.
Files changed (59) hide show
  1. package/index.cjs.js +1266 -142
  2. package/index.esm.js +1266 -145
  3. package/migrations/v3-0-0/menulist-rename-to-menucontent.js +105 -0
  4. package/migrations.json +5 -0
  5. package/package.json +6 -6
  6. package/src/components/Menu/MenuContent/MenuContent.d.ts +96 -0
  7. package/src/components/Menu/MenuDivider/MenuDivider.d.ts +5 -5
  8. package/src/components/Menu/MenuItem/MenuItem.d.ts +57 -10
  9. package/src/components/Menu/MoreMenu/MoreMenu.d.ts +7 -7
  10. package/src/components/Popover/MenuTree.d.ts +130 -0
  11. package/src/components/Popover/Popover.d.ts +8 -0
  12. package/src/components/Popover/PopoverContent.d.ts +13 -8
  13. package/src/components/Popover/PopoverNestedClearance.d.ts +56 -0
  14. package/src/components/Popover/PopoverSizing.d.ts +54 -2
  15. package/src/components/Popover/PopoverTypes.d.ts +21 -0
  16. package/src/components/Popover/hoverScrollGuard.d.ts +85 -0
  17. package/src/components/Popover/isElementVisible.d.ts +15 -0
  18. package/src/components/Popover/useHoverScrollGuard.d.ts +54 -0
  19. package/src/components/Popover/usePopover.d.ts +1 -1
  20. package/src/components/Sidebar/Sidebar.d.ts +3 -3
  21. package/src/hooks/getScrollbarWidth.d.ts +13 -0
  22. package/src/hooks/persistence/usePersistedState.d.ts +6 -0
  23. package/src/index.d.ts +4 -2
  24. package/translation.cjs.js +0 -1
  25. package/translation.esm.js +0 -1
  26. package/translation10.cjs.js +0 -1
  27. package/translation10.esm.js +0 -1
  28. package/translation11.cjs.js +0 -1
  29. package/translation11.esm.js +0 -1
  30. package/translation12.cjs.js +0 -1
  31. package/translation12.esm.js +0 -1
  32. package/translation13.cjs.js +0 -1
  33. package/translation13.esm.js +0 -1
  34. package/translation14.cjs.js +0 -1
  35. package/translation14.esm.js +0 -1
  36. package/translation15.cjs.js +0 -1
  37. package/translation15.esm.js +0 -1
  38. package/translation16.cjs.js +0 -1
  39. package/translation16.esm.js +0 -1
  40. package/translation17.cjs.js +0 -1
  41. package/translation17.esm.js +0 -1
  42. package/translation2.cjs.js +0 -1
  43. package/translation2.esm.js +0 -1
  44. package/translation3.cjs.js +0 -1
  45. package/translation3.esm.js +0 -1
  46. package/translation4.cjs.js +0 -1
  47. package/translation4.esm.js +0 -1
  48. package/translation5.cjs.js +0 -1
  49. package/translation5.esm.js +0 -1
  50. package/translation6.cjs.js +0 -1
  51. package/translation6.esm.js +0 -1
  52. package/translation7.cjs.js +0 -1
  53. package/translation7.esm.js +0 -1
  54. package/translation8.cjs.js +0 -1
  55. package/translation8.esm.js +0 -1
  56. package/translation9.cjs.js +0 -1
  57. package/translation9.esm.js +0 -1
  58. package/src/components/Menu/MenuList/MenuList.d.ts +0 -83
  59. /package/src/components/Menu/{MenuList/MenuList.variants.d.ts → MenuContent/MenuContent.variants.d.ts} +0 -0
package/index.esm.js CHANGED
@@ -1,8 +1,8 @@
1
- import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
1
+ import { jsx, Fragment, jsxs } from 'react/jsx-runtime';
2
2
  import { registerTranslations, useNamespaceTranslation } from '@trackunit/i18n-library-translation';
3
3
  import { twMerge } from 'tailwind-merge';
4
4
  import { objectKeys, uuidv4, parseTailwindArbitraryValue, objectEntries, nonNullable, objectValues, filterByMultiple } from '@trackunit/shared-utils';
5
- import { useRef, useMemo, useEffect, useState, createContext, useContext, isValidElement, cloneElement, createElement, useLayoutEffect, useCallback, Fragment as Fragment$1, memo, forwardRef, useId, useReducer, Children } from 'react';
5
+ import { useRef, useMemo, useEffect, useSyncExternalStore, useState, useLayoutEffect, createContext, useContext, isValidElement, cloneElement, createElement, useCallback, Fragment as Fragment$1, memo, forwardRef, useId, useReducer, Children } from 'react';
6
6
  import { rentalStatusPalette, sitesPalette, utilizationPalette, activityPalette, criticalityPalette, generalPalette, intentPalette, themeScreenSizeAsNumber, themeContainerSize, color } from '@trackunit/ui-design-tokens';
7
7
  import { iconNames } from '@trackunit/ui-icons';
8
8
  import IconSpriteMicro from '@trackunit/ui-icons/icons-sprite-micro.svg';
@@ -11,11 +11,12 @@ import IconSpriteOutline from '@trackunit/ui-icons/icons-sprite-outline.svg';
11
11
  import IconSpriteSolid from '@trackunit/ui-icons/icons-sprite-solid.svg';
12
12
  import { snakeCase, titleCase } from 'string-ts';
13
13
  import { cvaMerge } from '@trackunit/css-class-variance-utilities';
14
- import { useFloating, autoUpdate, offset, flip, shift, size, useClick, useDismiss, useHover as useHover$1, safePolygon, useRole, useInteractions, FloatingPortal, useMergeRefs as useMergeRefs$1, FloatingFocusManager, arrow, useTransitionStatus, FloatingArrow } from '@floating-ui/react';
14
+ import { FloatingTree, useFloatingNodeId, useFloatingParentNodeId, useFloatingTree, offset, flip, shift, size, useFloating, autoUpdate, useClick, useDismiss, useHover as useHover$1, safePolygon, useRole, useInteractions, FloatingNode, FloatingPortal, useMergeRefs as useMergeRefs$1, FloatingFocusManager, arrow, useTransitionStatus, FloatingArrow, useListNavigation, useTypeahead } from '@floating-ui/react';
15
15
  import { Slot, Slottable } from '@radix-ui/react-slot';
16
16
  import { omit, isEqual } from 'es-toolkit';
17
17
  import { Link, useBlocker, useNavigate, useLocation, useRouter, useSearch } from '@tanstack/react-router';
18
18
  import { useVirtualizer } from '@tanstack/react-virtual';
19
+ import { isTypeableElement } from '@floating-ui/react/utils';
19
20
  import { HelmetProvider, Helmet } from 'react-helmet-async';
20
21
  import { Trigger, Content as Content$1, List as List$1, Root } from '@radix-ui/react-tabs';
21
22
  import { gzipSync, gunzipSync } from 'fflate';
@@ -26,7 +27,6 @@ import { dequal } from 'dequal';
26
27
  var defaultTranslations = {
27
28
  "breadcrumb.backButton": "back",
28
29
  "breadcrumb.expandButton": "expand",
29
- "cardHeader.closeButton": "Close",
30
30
  "moreMenu.buttonLabel": "Show more"
31
31
  };
32
32
 
@@ -212,7 +212,212 @@ const Icon = ({ name, size = "medium", className, "data-testid": dataTestId, col
212
212
  return (jsx("span", { ...rest, "aria-describedby": ariaDescribedBy, "aria-hidden": ariaHidden, "aria-label": ariaLabel ? ariaLabel : titleCase(iconName), "aria-labelledby": ariaLabelledBy, className: cvaIcon({ color, size, fontSize, className }), "data-testid": dataTestId, id: iconContainerId, onClick: onClick, ref: ref, style: style, children: jsx("svg", { "aria-labelledby": iconContainerId, "data-testid": dataTestId ? `${dataTestId}-${iconName}` : iconName, role: "img", viewBox: correctViewBox, children: jsx("use", { href: href[correctIconType], ref: useTagRef }) }) }));
213
213
  };
214
214
 
215
+ /**
216
+ * Emitted on the tree's event emitter whenever a node transitions to open, so `MenuTree` can close
217
+ * that node's siblings. See `usePopover`'s emit side and `MenuTree`'s listener below.
218
+ */
219
+ const MENU_TREE_OPEN_EVENT = "menuopen";
220
+ /**
221
+ * Emitted whenever a node transitions to closed, so siblings can tell when a `reason: "click"` open
222
+ * (see `MenuTreeOpenEvent`) has been dismissed and stop treating that branch as click-pinned. See
223
+ * `usePopover`'s emit side and its `openSiblingPinned` listener.
224
+ */
225
+ const MENU_TREE_CLOSE_EVENT = "menuclose";
226
+ /**
227
+ * Returns whether a pointer event landed on any `MenuTree` member's trigger or floating surface.
228
+ * Used by nested `Popover`s to avoid treating a click on a sibling row (or the parent panel) as an
229
+ * outside press that dismisses the whole branch.
230
+ */
231
+ const isPressInsideMenuTree = (event, tree) => {
232
+ const { target } = event;
233
+ if (!(target instanceof Node)) {
234
+ return false;
235
+ }
236
+ return tree.nodesRef.current.some(node => {
237
+ const floating = node.context?.refs.floating.current;
238
+ const reference = node.context?.refs.reference.current;
239
+ return ((floating instanceof Element && floating.contains(target)) ||
240
+ (reference instanceof Element && reference.contains(target)));
241
+ });
242
+ };
243
+ /**
244
+ * Reads (and joins) the nearest `MenuTree` boundary, if any.
245
+ *
246
+ * Returns explicit tree state so fully custom nested-menu UI can be built without going through
247
+ * `Popover`/`PopoverTrigger`/`PopoverContent`'s implicit prop-cloning. `Popover` is one consumer of
248
+ * this hook, not the only way to participate in a `MenuTree`.
249
+ *
250
+ * Safe to call outside a `<MenuTree>`: `parentId`/`tree` are `null` and `isNested` is `false`.
251
+ *
252
+ * @returns {UseMenuTreeType} Explicit tree state for the calling node
253
+ */
254
+ const useMenuTree = () => {
255
+ const nodeId = useFloatingNodeId();
256
+ const parentId = useFloatingParentNodeId();
257
+ const tree = useFloatingTree();
258
+ return useMemo(() => ({ nodeId, parentId, isNested: parentId !== null, tree }), [nodeId, parentId, tree]);
259
+ };
260
+ const MenuTreeSiblingCloseBoundary = ({ children }) => {
261
+ const tree = useFloatingTree();
262
+ useEffect(() => {
263
+ if (!tree) {
264
+ return;
265
+ }
266
+ const closeSiblings = ({ nodeId, parentId }) => {
267
+ tree.nodesRef.current
268
+ .filter(node => node.id !== nodeId && node.parentId === parentId && node.context?.open === true)
269
+ .forEach(node => node.context?.onOpenChange(false));
270
+ };
271
+ tree.events.on(MENU_TREE_OPEN_EVENT, closeSiblings);
272
+ return () => {
273
+ tree.events.off(MENU_TREE_OPEN_EVENT, closeSiblings);
274
+ };
275
+ }, [tree]);
276
+ return jsx(Fragment, { children: children });
277
+ };
278
+ /**
279
+ * Opt-in tree-coordination boundary for a family of `Popover`s: sibling-close-on-open, dismiss
280
+ * bubbling (Escape closes one level at a time; outside-press is scoped per node so sibling rows
281
+ * don't collapse the root), and hover safe-polygon between nested rows. Every `Popover` rendered
282
+ * automatically joins it — there is no per-instance flag to set. A `Popover` outside any `MenuTree`
283
+ * is unaffected and behaves exactly as it does today.
284
+ *
285
+ * Only the immediate siblings of a newly-opened node (nodes sharing the same parent) are closed.
286
+ * Closing a node cascades to close its own open subtree as a structural consequence of
287
+ * `PopoverContent` unmounting when its `Popover` closes, not extra logic here.
288
+ *
289
+ * ### When to use
290
+ * Wrap a family of nested `Popover`s (e.g. a menu bar or a filter bar's flyouts) that should
291
+ * coordinate open/close state with each other.
292
+ *
293
+ * ### When not to use
294
+ * Do not wrap unrelated `Popover`s that merely happen to render inside each other's React tree —
295
+ * they will incorrectly join tree coordination and can be closed by sibling opens.
296
+ *
297
+ * ### Click-opened rows vs. sibling hovers
298
+ * `hover: { delayed: true }` on a nested `Popover` (used below, instead of a plain `hover: true`)
299
+ * keeps a row opened by a click open while the cursor passes over its siblings, matching what
300
+ * `MenuItem`'s `submenu` prop already does internally. See the "Advanced" section of the
301
+ * `Components/Menu/Nested Menu` docs page for the full explanation.
302
+ *
303
+ * @example
304
+ * ```tsx
305
+ * import { MenuTree, Popover, PopoverContent, PopoverTrigger } from "@trackunit/react-components";
306
+ *
307
+ * const NestedMenu = () => (
308
+ * <MenuTree>
309
+ * <Popover placement="bottom-start">
310
+ * <PopoverTrigger>Open</PopoverTrigger>
311
+ * <PopoverContent>
312
+ * <Popover activation={{ click: true, hover: { delayed: true } }} placement="right-start">
313
+ * <PopoverTrigger>Row A</PopoverTrigger>
314
+ * <PopoverContent>Row A's flyout</PopoverContent>
315
+ * </Popover>
316
+ * <Popover activation={{ click: true, hover: { delayed: true } }} placement="right-start">
317
+ * <PopoverTrigger>Row B</PopoverTrigger>
318
+ * <PopoverContent>Row B's flyout</PopoverContent>
319
+ * </Popover>
320
+ * </PopoverContent>
321
+ * </Popover>
322
+ * </MenuTree>
323
+ * );
324
+ * ```
325
+ * @param {object} props The props for MenuTree
326
+ * @param {ReactNode} props.children The `Popover`s (and anything else) to coordinate
327
+ * @returns {ReactElement} A `FloatingTree` provider wired up for sibling-close coordination
328
+ */
329
+ const MenuTree = ({ children }) => {
330
+ return (jsx(FloatingTree, { children: jsx(MenuTreeSiblingCloseBoundary, { children: children }) }));
331
+ };
332
+
333
+ /**
334
+ * The parent Popover's own floating panel element, found via the `MenuTree`'s Floating UI tree
335
+ * bookkeeping rather than by walking the DOM -- this is the exact element `PopoverContent` set as
336
+ * `context.refs.floating.current` when it rendered, so its rect is the parent panel's true visual
337
+ * boundary (border, shadow, and any internal scrollbar all already baked in), regardless of how
338
+ * deeply the current node's own trigger row is nested inside it.
339
+ *
340
+ * @param tree The `MenuTree`'s Floating UI tree, or `null` outside a `MenuTree`
341
+ * @param parentId The nearest ancestor node's id from `useMenuTree`, or `null` at the tree's root
342
+ * @returns {HTMLElement | null} The parent's floating panel element, or `null` if there isn't one
343
+ */
344
+ const getParentPanelElement = (tree, parentId) => {
345
+ if (tree === null || parentId === null) {
346
+ return null;
347
+ }
348
+ return tree.nodesRef.current.find(node => node.id === parentId)?.context?.refs.floating.current ?? null;
349
+ };
350
+ /**
351
+ * Extra horizontal offset (in pixels) a `right`/`left`-placed submenu needs so it clears its parent
352
+ * Popover panel's true edge, rather than sitting flush with (or overlapping) whatever is at the edge
353
+ * of the specific row that triggered it.
354
+ *
355
+ * A submenu's `elements.reference` is the trigger row it opened from, not the parent panel itself --
356
+ * and that row's own edge is inset from the panel's true visual boundary by the panel's padding and,
357
+ * when the panel's list happens to be scrolling, by its scrollbar too. Measuring against the parent
358
+ * panel's actual rendered edge (via `getParentPanelElement` above) gets both of those for free and
359
+ * keeps the visual gap identical whether or not the parent list happens to be scrolling -- rather
360
+ * than re-deriving "how much padding/scrollbar is in the way" and landing on a different visual gap
361
+ * depending on the parent's internal layout.
362
+ *
363
+ * @param parentPanelElement The parent Popover's own floating panel element
364
+ * @param referenceElement The trigger element the submenu is anchored to
365
+ * @param resolvedPlacement Floating UI's resolved placement (e.g. `"right-start"`)
366
+ * @returns {number} Additional pixels to add to the base `offset()` `mainAxis` value
367
+ */
368
+ const getNestedSubmenuClearance = (parentPanelElement, referenceElement, resolvedPlacement) => {
369
+ const panelRect = parentPanelElement.getBoundingClientRect();
370
+ const referenceRect = referenceElement.getBoundingClientRect();
371
+ if (resolvedPlacement.startsWith("right")) {
372
+ return Math.max(0, panelRect.right - referenceRect.right);
373
+ }
374
+ if (resolvedPlacement.startsWith("left")) {
375
+ return Math.max(0, referenceRect.left - panelRect.left);
376
+ }
377
+ return 0;
378
+ };
379
+ const rectsOverlap = (a, b) => !(a.x + a.width <= b.x || a.x >= b.x + b.width || a.y + a.height <= b.y || a.y >= b.y + b.height);
380
+ /**
381
+ * When a nested submenu has shifted horizontally over its parent (cascade-with-overlap), it must
382
+ * not cover the trigger row that opened it. Returns a new `y` that places the floating panel
383
+ * entirely below or above the reference -- whichever side has more room -- or `null` when the
384
+ * panels do not overlap and no adjustment is needed.
385
+ */
386
+ const getYToClearReference = ({ floatingX, floatingY, floatingWidth, floatingHeight, reference, gap, viewportHeight, padding, }) => {
387
+ const floating = { x: floatingX, y: floatingY, width: floatingWidth, height: floatingHeight };
388
+ if (!rectsOverlap(floating, reference)) {
389
+ return null;
390
+ }
391
+ const spaceBelow = viewportHeight - padding - (reference.y + reference.height);
392
+ const spaceAbove = reference.y - padding;
393
+ if (spaceBelow >= spaceAbove) {
394
+ return reference.y + reference.height + gap;
395
+ }
396
+ return reference.y - floatingHeight - gap;
397
+ };
398
+
215
399
  const PADDING = 12;
400
+ /**
401
+ * Width budget passed into `getMaxWidthValue` / `getMinWidthValue` from `size()`'s `apply()`.
402
+ *
403
+ * For nested horizontal submenus, Floating UI's side-relative `availableWidth` is only the
404
+ * remaining strip beside the parent panel. Capping to that shrinks the panel until `shift` no
405
+ * longer sees overflow -- so the intentional "cascade with overlap" path never runs, and any
406
+ * CSS min-width on the content (e.g. `MenuContent`'s `min-w-[200px]`) then overflows the
407
+ * wrapper and the page.
408
+ *
409
+ * Using the viewport width as the budget keeps the panel at its preferred size; `flip` still
410
+ * tries both sides, and `shift` slides it over the parent when neither side fits.
411
+ */
412
+ const getFloatingWidthBudget = ({ availableWidth, viewportWidth, preferViewportBudget, }) => (preferViewportBudget ? viewportWidth : availableWidth);
413
+ /**
414
+ * Options for Floating UI's `shift()` middleware.
415
+ *
416
+ * For `right`/`left` placement, `shift`'s default only moves along the alignment (vertical) axis.
417
+ * Nested horizontal submenus need `crossAxis: true` so they can slide horizontally over the parent
418
+ * when neither side has room -- the "cascade with overlap" path paired with `getFloatingWidthBudget`.
419
+ */
420
+ const getShiftOptions = ({ isNestedHorizontal, }) => isNestedHorizontal ? { padding: PADDING, crossAxis: true } : { padding: PADDING };
216
421
  /**
217
422
  * Converts a width size value into a CSS dimension value for max constraints
218
423
  *
@@ -239,13 +444,20 @@ const getMaxWidthValue = ({ value, referenceWidth, availableWidth }) => {
239
444
  }
240
445
  };
241
446
  /**
242
- * Converts a width size value into a CSS dimension value for min constraints
447
+ * Converts a width size value into a CSS dimension value for min constraints.
448
+ *
449
+ * A numeric `value` is clamped to `availableWidth` so a caller's desired minimum can never force the
450
+ * floating panel wider than the viewport space `size()` already determined was safe -- without this, a
451
+ * fixed minWidth would win a losing fight against `getMaxWidthValue`'s cap (CSS resolves `min-width` over
452
+ * `max-width` on conflict) and the panel would overflow past its container/viewport edge instead of
453
+ * shrinking to fit.
243
454
  *
244
455
  * @param params - The parameters object
245
456
  * @param params.value - The size value: number for pixels, "trigger-width" to match trigger, "none" for no constraint
246
457
  * @param params.referenceWidth - The width of the trigger element in pixels
458
+ * @param params.availableWidth - The available width in the viewport
247
459
  */
248
- const getMinWidthValue = ({ value, referenceWidth, }) => {
460
+ const getMinWidthValue = ({ value, referenceWidth, availableWidth }) => {
249
461
  switch (value) {
250
462
  case "trigger-width": {
251
463
  return `${referenceWidth}px`;
@@ -256,7 +468,7 @@ const getMinWidthValue = ({ value, referenceWidth, }) => {
256
468
  }
257
469
  default: {
258
470
  if (typeof value === "number") {
259
- return `${value}px`;
471
+ return `${Math.max(0, Math.min(value, availableWidth - PADDING * 2))}px`;
260
472
  }
261
473
  throw new Error(`${value} is not known`);
262
474
  }
@@ -304,6 +516,248 @@ const getMinHeightValue = ({ value }) => {
304
516
  }
305
517
  };
306
518
 
519
+ /**
520
+ * Module-level (not React state) tracking of whether hover-driven `Popover` opening should be
521
+ * held back right now, because scrolling is either happening or has not yet settled.
522
+ *
523
+ * ### Root cause this guards against
524
+ * Scrolling any container recomputes the browser's pointer hit-test target on every frame, which
525
+ * fires real `pointerenter`/`pointerleave` pairs on whichever element now sits under an
526
+ * otherwise-stationary cursor -- indistinguishable, at the DOM level, from the user actually
527
+ * hovering that element. A hover-activated `Popover` reacting to those phantom events opens every
528
+ * row a scrolling list happens to carry under the cursor (most visible with `MenuItem`'s nested
529
+ * submenus in a scrolling list, but the same browser behavior affects any hover-activated
530
+ * `Popover`, e.g. a `Tooltip` over a scrolling table row).
531
+ *
532
+ * ### Why "settled", not "next pointer move"
533
+ * An earlier version of this guard cleared suppression on the next genuine `pointermove`, so a
534
+ * still-scrolling list would keep flipping back and forth between "suppressed" and "open the row
535
+ * the cursor happens to be over this frame" the moment the user so much as twitched the mouse
536
+ * mid-scroll. That's backwards from how this is meant to feel: while the list is moving, whatever
537
+ * was hovered before the scroll started should just stay put, and only once the scroll actually
538
+ * comes to rest should the row the cursor now stably rests over take over. Debouncing off the
539
+ * `scroll` event itself -- clearing suppression only after a lull longer than
540
+ * {@link SCROLL_SETTLE_DELAY_MS} with no further scroll events -- gets exactly that: suppression
541
+ * spans the whole scroll gesture (however bumpy), not just individual frames of it.
542
+ *
543
+ * ### Why not intercept/cancel the phantom event instead
544
+ * `mouseenter`/`pointerenter` don't natively bubble, so React simulates their capture/bubble
545
+ * dispatch internally -- not a stable surface to hook into from outside, and Floating UI's
546
+ * `useHover` owns that event handling anyway (it also drives `handleClose`/`safePolygon`, which
547
+ * still needs to work normally). Gating hover on "no scroll event anywhere in the last
548
+ * {@link SCROLL_SETTLE_DELAY_MS}ms" gets the same real-world result without any of that.
549
+ *
550
+ * ### Why page-wide listeners, not scoped to a particular scroll container
551
+ * A `scroll` event reaches a capturing-phase listener on `document` regardless of which element
552
+ * scrolled or whether the event itself bubbles, so a single pair of listeners here -- shared by
553
+ * every *opted-in* hover instance on the page, rather than each one attaching its own -- is both
554
+ * simpler and cheaper than resolving each `Popover`'s own scrollable ancestors. Subscription is
555
+ * gated in `usePopover` to delayed-hover nested `MenuTree` members only (ADR-0002), so plain
556
+ * Popovers/tooltips never attach the listeners or inherit suppression. The remaining trade-off is
557
+ * that scrolling *anywhere* briefly holds back hover-opens for those opted-in menu rows, not just
558
+ * near the scrolling container; acceptable since the guard clears itself shortly after scrolling
559
+ * settles, wherever the cursor then happens to rest.
560
+ *
561
+ * ### Visual hover highlight (CSS)
562
+ * The same scroll-induced `:hover` flips that would open phantom submenus also restyle every row
563
+ * the cursor passes over via CSS `:hover` backgrounds -- even while this guard holds the *open*
564
+ * state still. Consumers that want the highlight to freeze with the open state (notably
565
+ * `MenuItem`) key off {@link HOVER_SCROLL_SUPPRESSED_ATTR} on `<html>`, which this module mirrors
566
+ * onto the document in lockstep with suppression, so the highlight can follow open-state rules
567
+ * without re-rendering every row on each scroll tick.
568
+ *
569
+ * ### Why this also tracks the last real pointer position
570
+ * Once scrolling settles, `useHoverScrollGuard` needs to know which element the cursor now rests
571
+ * over so it can open that one. The obvious answer -- ask the browser via `:hover` -- turns out
572
+ * not to work: browsers only recompute an element's cached `:hover` state in response to a new
573
+ * real pointer event, not just because a re-render or attribute change made it hit-testable again.
574
+ * Since scrolling itself is exactly what carried the cursor's hit-test target across a run of rows
575
+ * without the physical mouse ever moving, there's typically no fresh pointer event left to trigger
576
+ * that recomputation right when settling happens -- so `:hover` stays stuck reporting whatever it
577
+ * last had, easily confirmed by comparing it against `document.elementFromPoint` at the same
578
+ * coordinates, which performs a fresh, on-demand hit-test instead of reading a cached flag. Tracking
579
+ * real client coordinates here (via a `pointermove` listener, sharing this module's lifecycle) lets
580
+ * `useHoverScrollGuard` run that same fresh `elementFromPoint` check itself once settled, rather
581
+ * than trusting a pseudo-class the browser hasn't gotten around to updating yet.
582
+ */
583
+ const HOVER_SCROLL_SUPPRESSED_ATTR = "data-hover-scroll-suppressed";
584
+ /**
585
+ * How long a lull in `scroll` events must last before scrolling is considered "settled" and
586
+ * suppression clears. Long enough to swallow the gap between individual momentum-scroll frames
587
+ * (which can easily exceed a single animation frame), short enough that the now-stable hover
588
+ * still feels immediate once the list actually stops.
589
+ */
590
+ const SCROLL_SETTLE_DELAY_MS = 100;
591
+ let isSuppressed = false;
592
+ let settleTimeoutId = null;
593
+ const listeners = new Set();
594
+ let listenerRefCount = 0;
595
+ /** Last real pointer position seen anywhere on the page, in viewport (`clientX`/`clientY`) coordinates. */
596
+ let lastPointerPosition = null;
597
+ const notify = () => {
598
+ listeners.forEach(listener => listener());
599
+ };
600
+ const setSuppressed = (next) => {
601
+ if (isSuppressed === next) {
602
+ return;
603
+ }
604
+ isSuppressed = next;
605
+ if (next) {
606
+ document.documentElement.setAttribute(HOVER_SCROLL_SUPPRESSED_ATTR, "");
607
+ }
608
+ else {
609
+ document.documentElement.removeAttribute(HOVER_SCROLL_SUPPRESSED_ATTR);
610
+ }
611
+ notify();
612
+ };
613
+ const clearSettleTimeout = () => {
614
+ if (settleTimeoutId !== null) {
615
+ clearTimeout(settleTimeoutId);
616
+ settleTimeoutId = null;
617
+ }
618
+ };
619
+ const handleScroll = () => {
620
+ setSuppressed(true);
621
+ // Every scroll event pushes the settle deadline back out -- suppression only clears once a full
622
+ // `SCROLL_SETTLE_DELAY_MS` lull passes with no further scrolling, however long the gesture itself
623
+ // (or its momentum tail) runs.
624
+ clearSettleTimeout();
625
+ settleTimeoutId = setTimeout(() => {
626
+ settleTimeoutId = null;
627
+ setSuppressed(false);
628
+ }, SCROLL_SETTLE_DELAY_MS);
629
+ };
630
+ const handlePointerMove = (event) => {
631
+ lastPointerPosition = { x: event.clientX, y: event.clientY };
632
+ };
633
+ const hoverScrollGuard = {
634
+ isSuppressed: () => isSuppressed,
635
+ /**
636
+ * The last real pointer position seen anywhere on the page, or `null` if no `pointermove` has
637
+ * happened yet (e.g. scrolling driven by keyboard/touch before any mouse input). See this
638
+ * module's doc comment for why consumers should use this over reading `:hover` directly.
639
+ */
640
+ getLastPointerPosition: () => lastPointerPosition,
641
+ /**
642
+ * Subscribes to changes in suppression state, lazily attaching the shared `scroll` listener on
643
+ * first subscriber and tearing it down once the last one unsubscribes.
644
+ *
645
+ * @param listener Called whenever suppression state flips
646
+ * @returns {() => void} Unsubscribe function
647
+ */
648
+ subscribe: (listener) => {
649
+ listeners.add(listener);
650
+ if (listenerRefCount === 0) {
651
+ document.addEventListener("scroll", handleScroll, { capture: true, passive: true });
652
+ document.addEventListener("pointermove", handlePointerMove, { capture: true, passive: true });
653
+ }
654
+ listenerRefCount += 1;
655
+ return () => {
656
+ listeners.delete(listener);
657
+ listenerRefCount -= 1;
658
+ if (listenerRefCount === 0) {
659
+ document.removeEventListener("scroll", handleScroll, { capture: true });
660
+ document.removeEventListener("pointermove", handlePointerMove, { capture: true });
661
+ // Tear down can race a mid-suppression unmount (e.g. the whole menu closing while the list
662
+ // is still scrolling). Clear the pending timeout and the root attribute here so neither can
663
+ // outlive every consumer.
664
+ clearSettleTimeout();
665
+ setSuppressed(false);
666
+ lastPointerPosition = null;
667
+ }
668
+ };
669
+ },
670
+ };
671
+
672
+ const subscribeNoop = (_onStoreChange) => () => undefined;
673
+ const alwaysFalse = () => false;
674
+ /**
675
+ * Wires a single `Popover`'s hover interaction up to the shared `hoverScrollGuard`: holds its
676
+ * hover-opens back for as long as scrolling anywhere on the page hasn't yet settled (see
677
+ * `hoverScrollGuard`'s own doc comment for the full root-cause rationale), and -- once it settles
678
+ * -- imperatively opens this `Popover` if the cursor turns out to be resting on it, bypassing any
679
+ * open delay.
680
+ *
681
+ * ### Why the open needs its own imperative check
682
+ * Scrolling can carry the cursor's hit-test target across a whole run of rows without the physical
683
+ * cursor ever moving, so by the time scrolling settles there is no fresh `pointerenter` left for
684
+ * `useHover` to react to on whichever row the cursor ends up over: entering already happened
685
+ * (suppressed) while the list was still moving, not as a new enter.
686
+ *
687
+ * ### Why `elementFromPoint`, not `:hover`
688
+ * The obvious way to ask "is the cursor over this element right now" is `element.matches(":hover")`
689
+ * -- but browsers only recompute that cached flag in response to a *new* real pointer event, not
690
+ * just because the element became hit-testable again. Scrolling is exactly what leaves no such event
691
+ * behind at the moment settling happens, so `:hover` reports stale information right when it's
692
+ * needed most. `document.elementFromPoint` performs a fresh, on-demand hit-test instead of reading a
693
+ * cached flag, so it agrees with reality even when `:hover` hasn't caught up yet. It's checked
694
+ * against the last real pointer position `hoverScrollGuard` tracked (see that module's doc comment)
695
+ * rather than this reference's own bounding box, so it naturally returns nothing (and this correctly
696
+ * no-ops) if another element -- e.g. a still-open popover -- now sits on top of it.
697
+ *
698
+ * @param options See `UseHoverScrollGuardOptions`
699
+ * @param options.enabled Whether this consumer should subscribe to the shared scroll guard
700
+ * @param options.hoverEnabled Whether this `Popover`'s own `activation.hover` is enabled at all
701
+ * @param options.openSiblingPinned See `usePopover`'s own `openSiblingPinned`
702
+ * @param options.isOpen Whether this `Popover` is already open
703
+ * @param options.popoverContext This `Popover`'s own Floating UI context
704
+ * @param options.referenceRef This `Popover`'s reference element ref
705
+ * @returns {boolean} Whether hover-opens should be held back right now -- fold into `useHover`'s
706
+ * own `enabled` option alongside this `Popover`'s other activation conditions
707
+ */
708
+ const useHoverScrollGuard = ({ enabled, hoverEnabled, openSiblingPinned, isOpen, popoverContext, referenceRef, }) => {
709
+ // Subscribe only while this Popover opts into the guard. The shared store lazily attaches
710
+ // document listeners on the first subscriber — gating here keeps plain Popovers/tooltips from
711
+ // pulling the page-wide suppression net in (ADR-0002).
712
+ const isHoverScrollSuppressed = useSyncExternalStore(enabled ? hoverScrollGuard.subscribe : subscribeNoop, enabled ? hoverScrollGuard.isSuppressed : alwaysFalse, alwaysFalse);
713
+ // Read fresh inside the transition effect below via `.current`, rather than listed as that
714
+ // effect's own dependencies -- it only actually needs to look at the *edge* of suppression
715
+ // turning off, not re-run this check on every unrelated render any of these happen to change on
716
+ // too (e.g. the floating position recomputing on every scroll frame while still suppressed).
717
+ const latestGateRef = useRef({ enabled, hoverEnabled, openSiblingPinned, isOpen, popoverContext, referenceRef });
718
+ useEffect(() => {
719
+ latestGateRef.current = { enabled, hoverEnabled, openSiblingPinned, isOpen, popoverContext, referenceRef };
720
+ }, [enabled, hoverEnabled, openSiblingPinned, isOpen, popoverContext, referenceRef]);
721
+ // Tracks whether the *previous* run of the effect below saw suppression active, so that effect
722
+ // can tell a genuine "just settled" transition apart from simply mounting into an already-clear
723
+ // state. Without this, every fresh mount whose `isHoverScrollSuppressed` happens to already be
724
+ // `false` (the common case -- most `Popover`s mount outside of any scroll gesture) would run the
725
+ // open-check below regardless, with no real settle having ever happened. That's most visible for
726
+ // a freshly-opened `MenuTree`: the cursor is still resting wherever the trigger button was
727
+ // clicked, which is often right where the first row now renders, so every row's guard would
728
+ // otherwise treat its own very first mount as if scrolling had just settled over it and hover-open
729
+ // itself instantly, bypassing the open delay entirely.
730
+ const wasSuppressedRef = useRef(isHoverScrollSuppressed);
731
+ useEffect(() => {
732
+ const wasSuppressed = wasSuppressedRef.current;
733
+ wasSuppressedRef.current = isHoverScrollSuppressed;
734
+ if (!enabled || isHoverScrollSuppressed || !wasSuppressed) {
735
+ return;
736
+ }
737
+ const { hoverEnabled: latestHoverEnabled, openSiblingPinned: latestOpenSiblingPinned, isOpen: latestIsOpen, popoverContext: latestPopoverContext, referenceRef: latestReferenceRef, } = latestGateRef.current;
738
+ if (!latestHoverEnabled || latestOpenSiblingPinned || latestIsOpen) {
739
+ return;
740
+ }
741
+ const referenceElement = latestReferenceRef.current;
742
+ const pointerPosition = hoverScrollGuard.getLastPointerPosition();
743
+ // jsdom (used by every non-browser test in this repo) doesn't implement `elementFromPoint` at
744
+ // all, unlike every real browser -- guarded rather than assumed so tests exercising this
745
+ // codepath incidentally (e.g. via `userEvent.hover` tracking a pointer position) don't crash.
746
+ if (referenceElement instanceof Element &&
747
+ pointerPosition !== null &&
748
+ typeof document.elementFromPoint === "function") {
749
+ const elementAtPointer = document.elementFromPoint(pointerPosition.x, pointerPosition.y);
750
+ if (elementAtPointer !== null && referenceElement.contains(elementAtPointer)) {
751
+ // No real triggering event to forward: scrolling settling isn't itself a pointer event, and
752
+ // by this point there may be no fresh `pointerenter` at all for this reference (see the doc
753
+ // comment above).
754
+ latestPopoverContext.onOpenChange(true, undefined, "hover");
755
+ }
756
+ }
757
+ }, [enabled, isHoverScrollSuppressed]);
758
+ return enabled && isHoverScrollSuppressed;
759
+ };
760
+
307
761
  const DEFAULT_ACTIVATION = { click: true, hover: false, keyboardHandlers: true };
308
762
  const DEFAULT_DISMISSAL = {
309
763
  enabled: true,
@@ -316,6 +770,60 @@ const DEFAULT_SIZING = {
316
770
  minHeight: "none",
317
771
  maxHeight: "none",
318
772
  };
773
+ const isVerticalPlacement = (resolvedPlacement) => resolvedPlacement.startsWith("top") || resolvedPlacement.startsWith("bottom");
774
+ /**
775
+ * The sole fallback for a nested horizontal submenu's `flip` (see `middleware` below): the mirrored
776
+ * placement on the opposite horizontal side, same alignment. Deliberately excludes any vertical
777
+ * placement -- a submenu that can't fit on either horizontal side should stay horizontal and
778
+ * cascade with overlap (via `shift({ crossAxis: true })` below) rather than flip above/below the
779
+ * parent panel. When that overlap would cover the trigger row, a follow-up middleware nudges the
780
+ * submenu above or below the trigger so the invoking item stays visible.
781
+ */
782
+ const getOppositeHorizontalPlacement = (placement) => {
783
+ switch (placement) {
784
+ case "right":
785
+ return "left";
786
+ case "right-start":
787
+ return "left-start";
788
+ case "right-end":
789
+ return "left-end";
790
+ case "left":
791
+ return "right";
792
+ case "left-start":
793
+ return "right-start";
794
+ case "left-end":
795
+ return "right-end";
796
+ default:
797
+ return placement;
798
+ }
799
+ };
800
+ /**
801
+ * The gap between a nested submenu and its parent panel, along whichever axis it opens on --
802
+ * shared so the vertical and horizontal nested-placement branches below stay in sync.
803
+ */
804
+ const NESTED_SUBMENU_GAP = 2;
805
+ /**
806
+ * Once a branch is already "engaged" (see `hasOpenSibling` below), how long the pointer must stop
807
+ * moving over a sibling row before it switches to it. Deliberately short -- long enough to filter
808
+ * out a row the cursor merely swept across while heading elsewhere, short enough that a genuine
809
+ * hover still feels instant, matching native menu-bar behavior.
810
+ */
811
+ const SIBLING_HOVER_SWITCH_REST_MS = 75;
812
+ /**
813
+ * When a nested submenu falls back to a vertical placement, slide it as far as
814
+ * possible along the cross axis (left or right) so it visually separates from the
815
+ * parent panel instead of sitting almost flush on top of it.
816
+ */
817
+ const getNestedVerticalAlignmentAxis = ({ rects }) => {
818
+ const refRect = rects.reference;
819
+ const floatingWidth = rects.floating.width;
820
+ const spaceRight = window.innerWidth - PADDING - refRect.x - floatingWidth;
821
+ const spaceLeft = refRect.x - PADDING;
822
+ if (spaceRight >= spaceLeft) {
823
+ return Math.max(0, spaceRight);
824
+ }
825
+ return -Math.max(0, spaceLeft);
826
+ };
319
827
  /**
320
828
  * The hook that powers the Popover component.
321
829
  * It should not be used directly, but rather through the Popover component.
@@ -323,67 +831,308 @@ const DEFAULT_SIZING = {
323
831
  * @param {PopoverProps} options The options for the popover
324
832
  * @returns {UsePopoverType} The data for the popover
325
833
  */
326
- const usePopover = ({ initialOpen = false, placement = "bottom", isModal, isOpen: controlledIsOpen, activation = DEFAULT_ACTIVATION, dismissal = DEFAULT_DISMISSAL, sizing = DEFAULT_SIZING, onOpenStateChange, id, className, "data-testid": dataTestId, }) => {
834
+ const usePopover = ({ initialOpen = false, placement = "bottom", isModal, isOpen: controlledIsOpen, activation = DEFAULT_ACTIVATION, dismissal = DEFAULT_DISMISSAL, sizing = DEFAULT_SIZING, onOpenStateChange, role = "dialog", id, className, "data-testid": dataTestId, }) => {
327
835
  const [uncontrolledIsOpen, setUncontrolledIsOpen] = useState(initialOpen);
328
836
  const [labelId, setLabelId] = useState();
329
837
  const [descriptionId, setDescriptionId] = useState();
330
838
  const isOpen = controlledIsOpen ?? uncontrolledIsOpen;
839
+ const { nodeId, parentId, isNested, tree } = useMenuTree();
840
+ const isInsideMenuTree = tree !== null && isNested;
841
+ const isHorizontallyPlaced = placement.startsWith("right") || placement.startsWith("left");
842
+ const middleware = useMemo(() => [
843
+ // `offset()` must run before `flip()`/`shift()` -- Floating UI evaluates those against the
844
+ // *current* computed position, so they only see the gap `offset()` adds (including the
845
+ // scrollbar clearance below) if it has already run. With the order reversed, `flip()` would
846
+ // decide whether a nested submenu fits using the reference's bare edge, then `offset()` could
847
+ // push it past the boundary `flip()` just approved. `flip()` resets the lifecycle whenever it
848
+ // changes the placement, so `offset()` below still re-runs (and re-resolves its
849
+ // placement-dependent branches) for whichever placement `flip()` settles on.
850
+ offset(placementState => {
851
+ const { placement: resolvedPlacement, elements } = placementState;
852
+ if (isInsideMenuTree && isVerticalPlacement(resolvedPlacement)) {
853
+ return {
854
+ mainAxis: NESTED_SUBMENU_GAP,
855
+ alignmentAxis: getNestedVerticalAlignmentAxis(placementState),
856
+ };
857
+ }
858
+ // A nested submenu's `elements.reference` is its trigger row, not the parent panel -- and
859
+ // that row's own edge is inset from the parent panel's true edge by the panel's padding
860
+ // (and, when its list is scrolling, by its scrollbar too). Clearing the parent panel's
861
+ // actual edge instead keeps the visual gap identical either way.
862
+ if (isInsideMenuTree && elements.reference instanceof HTMLElement) {
863
+ const parentPanel = getParentPanelElement(tree, parentId);
864
+ if (parentPanel !== null) {
865
+ return {
866
+ mainAxis: NESTED_SUBMENU_GAP + getNestedSubmenuClearance(parentPanel, elements.reference, resolvedPlacement),
867
+ };
868
+ }
869
+ }
870
+ return 8;
871
+ }),
872
+ isInsideMenuTree && isHorizontallyPlaced
873
+ ? flip({ crossAxis: false, fallbackPlacements: [getOppositeHorizontalPlacement(placement)] })
874
+ : flip({ fallbackPlacements: ["top", "bottom", "right", "left"] }),
875
+ shift(getShiftOptions({ isNestedHorizontal: isInsideMenuTree && isHorizontallyPlaced })),
876
+ {
877
+ // After horizontal cascade-with-overlap, keep the trigger row uncovered by sliding the
878
+ // submenu above or below it (see `getYToClearReference`). Side-by-side placement leaves
879
+ // the trigger clear already, so this is a no-op in that case.
880
+ name: "clearNestedTrigger",
881
+ fn({ x, y, rects }) {
882
+ if (!(isInsideMenuTree && isHorizontallyPlaced)) {
883
+ return {};
884
+ }
885
+ const nextY = getYToClearReference({
886
+ floatingX: x,
887
+ floatingY: y,
888
+ floatingWidth: rects.floating.width,
889
+ floatingHeight: rects.floating.height,
890
+ reference: rects.reference,
891
+ gap: NESTED_SUBMENU_GAP,
892
+ viewportHeight: window.innerHeight,
893
+ padding: PADDING,
894
+ });
895
+ return nextY === null ? {} : { y: nextY };
896
+ },
897
+ },
898
+ size({
899
+ apply({ availableWidth, availableHeight, elements }) {
900
+ const { floating, reference } = elements;
901
+ const resolvedSizing = typeof sizing === "function" ? sizing(DEFAULT_SIZING) : sizing;
902
+ const refRect = reference.getBoundingClientRect();
903
+ // Nested horizontal submenus must keep a viewport-sized width budget so `shift` can
904
+ // cascade with overlap when neither side fits -- see `getFloatingWidthBudget`.
905
+ const widthBudget = getFloatingWidthBudget({
906
+ availableWidth,
907
+ viewportWidth: window.innerWidth,
908
+ preferViewportBudget: isInsideMenuTree && isHorizontallyPlaced,
909
+ });
910
+ Object.assign(floating.style, {
911
+ maxWidth: getMaxWidthValue({
912
+ value: resolvedSizing.maxWidth,
913
+ referenceWidth: refRect.width,
914
+ availableWidth: widthBudget,
915
+ }),
916
+ minWidth: getMinWidthValue({
917
+ value: resolvedSizing.minWidth,
918
+ referenceWidth: refRect.width,
919
+ availableWidth: widthBudget,
920
+ }),
921
+ maxHeight: getMaxHeightValue({
922
+ value: resolvedSizing.maxHeight,
923
+ availableHeight,
924
+ }),
925
+ minHeight: getMinHeightValue({
926
+ value: resolvedSizing.minHeight,
927
+ }),
928
+ });
929
+ },
930
+ }),
931
+ ], [isInsideMenuTree, isHorizontallyPlaced, placement, sizing, tree, parentId]);
932
+ // Remembers *how* this node was last (re-)opened (see `MenuTreeOpenEvent.reason`) so it can be
933
+ // relayed to siblings on the `MENU_TREE_OPEN_EVENT` emit below. State, not a ref: Floating UI's
934
+ // `useClick` calls `onOpenChange(true, event, "click")` on every click of the reference *even
935
+ // while already open* (its "reaffirm" behavior for `stickIfOpen`) without that changing `isOpen`
936
+ // itself -- e.g. the row was already open from a hover, and the user then clicks it. That reason
937
+ // upgrade (hover -> click) needs its own render so the emit effect below re-runs and rebroadcasts
938
+ // it; a ref's mutation wouldn't be visible to that effect's dependency array.
939
+ const [openReason, setOpenReason] = useState(undefined);
331
940
  const popoverData = useFloating({
941
+ nodeId,
332
942
  placement,
333
943
  open: isOpen,
334
- onOpenChange: open => {
944
+ onOpenChange: (open, _event, reason) => {
335
945
  setUncontrolledIsOpen(open);
336
946
  onOpenStateChange?.(open);
947
+ // Reset to `undefined` on close (regardless of *how* it closed) rather than keeping the close
948
+ // reason around -- a later open that bypasses `onOpenChange` entirely (e.g. `MenuItem`'s
949
+ // `ArrowRight` keyboard shortcut, which sets the controlled `isOpen` state directly) should
950
+ // never be judged against a stale reason left over from a previous open/close cycle.
951
+ setOpenReason(open ? reason : undefined);
337
952
  },
338
953
  whileElementsMounted: autoUpdate,
339
- middleware: [
340
- offset(8),
341
- flip({ fallbackPlacements: ["top", "bottom", "right", "left"] }),
342
- shift({ padding: PADDING }),
343
- size({
344
- apply({ availableWidth, availableHeight, elements }) {
345
- const { floating, reference } = elements;
346
- const resolvedSizing = typeof sizing === "function" ? sizing(DEFAULT_SIZING) : sizing;
347
- const refRect = reference.getBoundingClientRect();
348
- Object.assign(floating.style, {
349
- maxWidth: getMaxWidthValue({
350
- value: resolvedSizing.maxWidth,
351
- referenceWidth: refRect.width,
352
- availableWidth,
353
- }),
354
- minWidth: getMinWidthValue({
355
- value: resolvedSizing.minWidth,
356
- referenceWidth: refRect.width,
357
- }),
358
- maxHeight: getMaxHeightValue({
359
- value: resolvedSizing.maxHeight,
360
- availableHeight,
361
- }),
362
- minHeight: getMinHeightValue({
363
- value: resolvedSizing.minHeight,
364
- }),
365
- });
366
- },
367
- }),
368
- ],
954
+ middleware,
369
955
  });
956
+ const updatePositionRef = useRef(popoverData.update);
957
+ // useFloating keeps the last resolved x/y/placement across close cycles. Painting the
958
+ // floating node at those stale coordinates before the next compute corrupts flip/shift
959
+ // overflow measurements (seen when reopening a submenu after a viewport resize).
960
+ const isPlacementReady = isOpen && popoverData.isPositioned;
961
+ useLayoutEffect(() => {
962
+ const updatePosition = popoverData.update;
963
+ updatePositionRef.current = updatePosition;
964
+ if (!isOpen) {
965
+ return;
966
+ }
967
+ updatePosition();
968
+ const frame = requestAnimationFrame(() => {
969
+ updatePositionRef.current();
970
+ });
971
+ return () => cancelAnimationFrame(frame);
972
+ }, [isOpen, popoverData.update]);
370
973
  const popoverContext = popoverData.context;
371
974
  const resolvedActivation = typeof activation === "function" ? activation(DEFAULT_ACTIVATION) : activation;
372
975
  const resolvedDismissal = typeof dismissal === "function" ? dismissal(DEFAULT_DISMISSAL) : dismissal;
976
+ const { outsidePress: configuredOutsidePress, ...restDismissal } = resolvedDismissal;
373
977
  const clickInteraction = useClick(popoverContext, {
374
978
  enabled: resolvedActivation.click,
375
979
  keyboardHandlers: resolvedActivation.keyboardHandlers,
376
980
  });
377
- const dismissInteraction = useDismiss(popoverContext, resolvedDismissal);
981
+ // Whether this node is part of a `MenuTree` at all (root or nested) -- as opposed to
982
+ // `isInsideMenuTree`, which is only true for *nested* members. A tree's root has no ancestor to
983
+ // defer to, but it's exactly as likely as a nested node to have a click land on one of its own
984
+ // (possibly deeply nested) open descendants, so it needs the same tree-aware outside-press check.
985
+ const isPartOfMenuTree = tree !== null;
986
+ const dismissInteraction = useDismiss(popoverContext, {
987
+ ...restDismissal,
988
+ outsidePress: event => {
989
+ if (typeof configuredOutsidePress === "function") {
990
+ if (!configuredOutsidePress(event)) {
991
+ return false;
992
+ }
993
+ }
994
+ else if (configuredOutsidePress === false) {
995
+ return false;
996
+ }
997
+ // A press anywhere inside the tree (this node's own content, a sibling row, or a nested
998
+ // descendant's floating panel) is never "outside" for *any* tree member -- root included.
999
+ // Without this, a root `Popover` falls back to Floating UI's own DOM-containment check,
1000
+ // which isn't reliable the instant a descendant opens/closes and reflows the tree's DOM
1001
+ // (e.g. a hover-delayed submenu finishing its open transition), and can spuriously collapse
1002
+ // the whole tree instead of just switching between sibling rows.
1003
+ if (isPartOfMenuTree) {
1004
+ return !isPressInsideMenuTree(event, tree);
1005
+ }
1006
+ return true;
1007
+ },
1008
+ // Nested in a MenuTree: Escape closes one level at a time (doesn't bubble to ancestors).
1009
+ // Outside-press bubbles only when the press is genuinely outside the tree; sibling-row
1010
+ // presses are filtered above so they don't collapse the root menu.
1011
+ bubbles: isInsideMenuTree ? { escapeKey: false, outsidePress: true } : undefined,
1012
+ });
378
1013
  const hoverOptions = typeof resolvedActivation.hover === "object" ? resolvedActivation.hover : undefined;
379
1014
  const hoverEnabled = hoverOptions ? (hoverOptions.active ?? true) : resolvedActivation.hover === true;
380
1015
  const hoverInteractable = hoverOptions?.interactable ?? true;
1016
+ const isHoverDelayed = hoverOptions?.delayed === true;
1017
+ // Once a sibling under the same parent has opened *via hover*, this branch of the tree is
1018
+ // already "engaged" -- hovering between sibling rows should switch instantly from then on,
1019
+ // matching native OS/browser menu-bar behavior, instead of re-running the accidental-open guard
1020
+ // delay for every single row. Without this, switching from an already-open sibling to this row
1021
+ // hits the full open delay on *every* hover, leaving neither submenu visible for that whole
1022
+ // window. Only tracked when this node actually uses a delayed hover-open -- other `Popover`s
1023
+ // don't pay for the extra tree subscription.
1024
+ const [hasOpenSibling, setHasOpenSibling] = useState(false);
1025
+ // A sibling opened by an explicit action (a click, or any reason other than a bare hover --
1026
+ // e.g. the `ArrowRight` keyboard shortcut) reflects deliberate user intent to inspect *that* row,
1027
+ // not "I'm passing through the list". Hovering a different row shouldn't steal it away the moment
1028
+ // the cursor happens to drift, so hover-to-open is disabled outright for the rest of the branch
1029
+ // until that click-opened sibling closes again (`MENU_TREE_CLOSE_EVENT` below clears this).
1030
+ const [openSiblingPinned, setOpenSiblingPinned] = useState(false);
1031
+ // Which sibling is currently responsible for `openSiblingPinned` being `true`, so a *different*
1032
+ // sibling closing doesn't clear it. Needed because clicking a sibling while another one is
1033
+ // already open (click-)pinned emits two tree events in sequence: this row's own
1034
+ // `MENU_TREE_OPEN_EVENT` (which pins everyone else), immediately followed by the previously-open
1035
+ // sibling's cascade `MENU_TREE_CLOSE_EVENT` once `MenuTreeSiblingCloseBoundary` closes it. Without
1036
+ // tracking the source, that stale close event would wipe out the pin the newer open just set. A
1037
+ // ref (not state): read and written only from within the event handlers below, never needs to
1038
+ // trigger a render itself.
1039
+ const pinnedBySiblingIdRef = useRef(null);
1040
+ useEffect(() => {
1041
+ if (!tree || !isHoverDelayed) {
1042
+ return;
1043
+ }
1044
+ const handleSiblingOpen = ({ nodeId: openedNodeId, parentId: openedParentId, reason, isMenuRow, }) => {
1045
+ if (!isMenuRow || openedNodeId === nodeId || openedParentId !== parentId) {
1046
+ return;
1047
+ }
1048
+ if (reason === "hover") {
1049
+ setHasOpenSibling(true);
1050
+ setOpenSiblingPinned(false);
1051
+ pinnedBySiblingIdRef.current = null;
1052
+ }
1053
+ else {
1054
+ setOpenSiblingPinned(true);
1055
+ pinnedBySiblingIdRef.current = openedNodeId;
1056
+ }
1057
+ };
1058
+ const handleSiblingClose = ({ nodeId: closedNodeId, parentId: closedParentId, isMenuRow, }) => {
1059
+ if (isMenuRow &&
1060
+ closedNodeId !== nodeId &&
1061
+ closedParentId === parentId &&
1062
+ pinnedBySiblingIdRef.current === closedNodeId) {
1063
+ setOpenSiblingPinned(false);
1064
+ pinnedBySiblingIdRef.current = null;
1065
+ }
1066
+ };
1067
+ tree.events.on(MENU_TREE_OPEN_EVENT, handleSiblingOpen);
1068
+ tree.events.on(MENU_TREE_CLOSE_EVENT, handleSiblingClose);
1069
+ return () => {
1070
+ tree.events.off(MENU_TREE_OPEN_EVENT, handleSiblingOpen);
1071
+ tree.events.off(MENU_TREE_CLOSE_EVENT, handleSiblingClose);
1072
+ };
1073
+ }, [tree, nodeId, parentId, isHoverDelayed]);
1074
+ // See `useHoverScrollGuard`'s doc comment for the full rationale: holds hover-opens back while a
1075
+ // scroll anywhere on the page is more recent than the last genuine pointer move, so a list
1076
+ // scrolling underneath a stationary cursor can't fire a cascade of phantom opens.
1077
+ // Scoped to delayed-hover *nested* MenuTree members so plain Popovers/tooltips stay unaffected
1078
+ // (ADR-0002 opt-in boundary).
1079
+ const isHoverScrollSuppressed = useHoverScrollGuard({
1080
+ enabled: isInsideMenuTree && isHoverDelayed,
1081
+ hoverEnabled,
1082
+ openSiblingPinned,
1083
+ isOpen,
1084
+ popoverContext,
1085
+ referenceRef: popoverData.refs.reference,
1086
+ });
381
1087
  const hoverInteraction = useHover$1(popoverContext, {
382
- enabled: hoverEnabled,
383
- delay: hoverOptions?.delayed === true ? { open: 400 } : undefined,
384
- handleClose: hoverEnabled === true && hoverInteractable === true ? safePolygon() : undefined,
1088
+ enabled: hoverEnabled && !openSiblingPinned && !isHoverScrollSuppressed,
1089
+ delay: isHoverDelayed && !hasOpenSibling ? { open: 400 } : undefined,
1090
+ // Once a sibling is already open, switching rows drops the upfront delay above to feel
1091
+ // instant -- but with no delay/`restMs` at all, a cursor merely *passing through* this row en
1092
+ // route to the already-open sibling's submenu (e.g. cutting diagonally across the list to
1093
+ // reach a flyout that opens further up or down) would flash this row open too, stealing the
1094
+ // flyout away from the row the user was actually heading toward. `restMs` only starts counting
1095
+ // once `mousemove` stops moving *significantly* within this row -- every further significant
1096
+ // move resets it -- so a fast pass-through never accumulates enough dwell time to open, while a
1097
+ // genuine stop (even a brief one) still switches near-instantly. Only meaningful in the
1098
+ // `hasOpenSibling` branch: `PopoverTrigger` force-exempts every `MenuTree` row from the
1099
+ // `blockPointerEvents` block below via `pointer-events-auto` (see its own comment) so rows stay
1100
+ // clickable while that block is active elsewhere in the branch -- which also means a row's
1101
+ // *own* hover keeps firing for a merely passing-through cursor unless gated here.
1102
+ restMs: isHoverDelayed && hasOpenSibling ? SIBLING_HOVER_SWITCH_REST_MS : undefined,
1103
+ // Floating UI's default `move: true` also opens on a bare `mousemove` over the reference (not
1104
+ // just `mouseenter`) -- meant for content that appears under an already-resting cursor. That's
1105
+ // not a real intent signal for a MenuTree row (a mousemove without a preceding mouseenter is
1106
+ // most often the browser positioning the cursor for an unrelated click), so it's disabled here
1107
+ // to avoid opening a sibling's submenu from incidental cursor movement. Scoped to MenuTree
1108
+ // members -- standalone (non-tree) Popovers keep today's exact behavior.
1109
+ move: isPartOfMenuTree ? false : undefined,
1110
+ // blockPointerEvents lets a nested row's submenu survive the cursor travelling diagonally
1111
+ // from the trigger to the panel instead of closing when it briefly leaves both elements.
1112
+ // Scoped to MenuTree members so standalone (non-tree) Popovers keep today's exact behavior.
1113
+ handleClose: hoverEnabled === true && hoverInteractable === true
1114
+ ? safePolygon({ blockPointerEvents: isInsideMenuTree })
1115
+ : undefined,
385
1116
  });
386
- const roleInteraction = useRole(popoverContext);
1117
+ const roleInteraction = useRole(popoverContext, { role });
1118
+ // Distinguishes a genuine open -> close transition from this node simply mounting closed (the
1119
+ // common case: every unrelated sibling elsewhere in the tree mounts with `isOpen === false`).
1120
+ // Without this, that initial "closed" render would emit `MENU_TREE_CLOSE_EVENT` regardless, and
1121
+ // any such unrelated sibling mounting while *this* node happens to be click-pinned open would
1122
+ // spuriously clear the pin.
1123
+ const wasOpenRef = useRef(isOpen);
1124
+ useEffect(() => {
1125
+ if (!tree) {
1126
+ return;
1127
+ }
1128
+ if (isOpen) {
1129
+ tree.events.emit(MENU_TREE_OPEN_EVENT, { nodeId, parentId, reason: openReason, isMenuRow: isHoverDelayed });
1130
+ }
1131
+ else if (wasOpenRef.current) {
1132
+ tree.events.emit(MENU_TREE_CLOSE_EVENT, { nodeId, parentId, isMenuRow: isHoverDelayed });
1133
+ }
1134
+ wasOpenRef.current = isOpen;
1135
+ }, [isOpen, openReason, tree, nodeId, parentId, isHoverDelayed]);
387
1136
  const combinedInteractions = useInteractions([
388
1137
  clickInteraction,
389
1138
  hoverInteraction,
@@ -394,19 +1143,21 @@ const usePopover = ({ initialOpen = false, placement = "bottom", isModal, isOpen
394
1143
  isOpen,
395
1144
  setIsOpen: setUncontrolledIsOpen,
396
1145
  ...omit(popoverData, ["middlewareData", "floatingStyles", "elements"]),
397
- x: popoverData.x,
398
- y: popoverData.y,
399
- isPositioned: popoverData.isPositioned,
1146
+ x: isPlacementReady ? popoverData.x : 0,
1147
+ y: isPlacementReady ? popoverData.y : 0,
1148
+ isPositioned: isPlacementReady,
400
1149
  refs: popoverData.refs,
401
1150
  update: popoverData.update,
402
- placement: popoverData.placement,
1151
+ placement: isPlacementReady ? popoverData.placement : placement,
403
1152
  strategy: popoverData.strategy,
404
1153
  ...combinedInteractions,
405
1154
  isModal,
1155
+ isNested,
406
1156
  labelId,
407
1157
  descriptionId,
408
1158
  setLabelId,
409
1159
  setDescriptionId,
1160
+ nodeId,
410
1161
  customProps: {
411
1162
  id,
412
1163
  className,
@@ -414,7 +1165,11 @@ const usePopover = ({ initialOpen = false, placement = "bottom", isModal, isOpen
414
1165
  },
415
1166
  }), [
416
1167
  isOpen,
1168
+ isPlacementReady,
1169
+ placement,
417
1170
  setUncontrolledIsOpen,
1171
+ nodeId,
1172
+ isNested,
418
1173
  combinedInteractions,
419
1174
  popoverData,
420
1175
  isModal,
@@ -427,6 +1182,14 @@ const usePopover = ({ initialOpen = false, placement = "bottom", isModal, isOpen
427
1182
  };
428
1183
 
429
1184
  const PopoverContext = createContext(null);
1185
+ /**
1186
+ * Like `usePopoverContext`, but returns `null` instead of throwing when called outside a `<Popover />`.
1187
+ * Used by components (e.g. `MenuContent`) that support being rendered either inside a `Popover` or fully
1188
+ * standalone.
1189
+ *
1190
+ * @returns {ContextType} The popover context, or `null` if there is no ancestor `Popover`
1191
+ */
1192
+ const useOptionalPopoverContext = () => useContext(PopoverContext);
430
1193
  /**
431
1194
  * A hook to get the popover context.
432
1195
  * It should only be used by the Popover components.
@@ -434,7 +1197,7 @@ const PopoverContext = createContext(null);
434
1197
  * @returns {ContextType} The popover context
435
1198
  */
436
1199
  const usePopoverContext = () => {
437
- const context = useContext(PopoverContext);
1200
+ const context = useOptionalPopoverContext();
438
1201
  if (context === null) {
439
1202
  throw new Error("Popover components must be wrapped in <Popover />");
440
1203
  }
@@ -496,9 +1259,9 @@ const usePopoverContext = () => {
496
1259
  */
497
1260
  const Popover = ({ children, isModal = false, ...restOptions }) => {
498
1261
  const popover = usePopover({ isModal, ...restOptions });
499
- return (jsx(PopoverContext.Provider, { value: popover, children: typeof children === "function"
500
- ? children({ isOpen: popover.isOpen, setIsOpen: popover.setIsOpen, placement: popover.placement })
501
- : children }));
1262
+ return (jsx(FloatingNode, { id: popover.nodeId, children: jsx(PopoverContext.Provider, { value: popover, children: typeof children === "function"
1263
+ ? children({ isOpen: popover.isOpen, setIsOpen: popover.setIsOpen, placement: popover.placement })
1264
+ : children }) }));
502
1265
  };
503
1266
 
504
1267
  /** @internal */
@@ -559,7 +1322,46 @@ const Portal = (props) => {
559
1322
  return jsx(FloatingPortal, { ...props, root: props.root ?? getDefaultPortalContainer() });
560
1323
  };
561
1324
 
562
- const cvaPopoverContainer = cvaMerge(["component-popover-border", "z-popover", "animate-fade-in-fast"]);
1325
+ const OVERFLOW_CLIPS = new Set(["auto", "scroll", "hidden", "clip"]);
1326
+ const clips = (overflow) => OVERFLOW_CLIPS.has(overflow);
1327
+ const rectsDisjoint = (a, b) => a.right <= b.left || a.left >= b.right || a.bottom <= b.top || a.top >= b.bottom;
1328
+ /**
1329
+ * Whether `element` is currently visible to the user -- not clipped out by any scrollable/clipping
1330
+ * ancestor (the browser fact `PopoverContent`'s `returnFocus` needs to respect: focusing a clipped
1331
+ * element still succeeds, and browsers then auto-scroll it into view, fighting whatever caused it
1332
+ * to be clipped in the first place -- see that module's doc comment) and not clipped by the
1333
+ * viewport itself.
1334
+ *
1335
+ * Deliberately synchronous (plain `getBoundingClientRect`/`getComputedStyle` reads) rather than an
1336
+ * `IntersectionObserver`, which only reports asynchronously on its own schedule and can't answer
1337
+ * "is it visible right now" at the exact moment a popover is closing.
1338
+ *
1339
+ * @param element The element to check
1340
+ * @returns {boolean} `false` if `element` (or any ancestor up to the viewport) clips it out
1341
+ */
1342
+ const isElementVisible = (element) => {
1343
+ const elementRect = element.getBoundingClientRect();
1344
+ if (elementRect.width === 0 && elementRect.height === 0) {
1345
+ return false;
1346
+ }
1347
+ const viewportRect = { left: 0, top: 0, right: window.innerWidth, bottom: window.innerHeight };
1348
+ if (rectsDisjoint(elementRect, viewportRect)) {
1349
+ return false;
1350
+ }
1351
+ let ancestor = element.parentElement;
1352
+ while (ancestor !== null) {
1353
+ const style = getComputedStyle(ancestor);
1354
+ if (clips(style.overflowX) || clips(style.overflowY)) {
1355
+ if (rectsDisjoint(elementRect, ancestor.getBoundingClientRect())) {
1356
+ return false;
1357
+ }
1358
+ }
1359
+ ancestor = ancestor.parentElement;
1360
+ }
1361
+ return true;
1362
+ };
1363
+
1364
+ const cvaPopoverContainer = cvaMerge(["z-popover", "animate-fade-in-fast"]);
563
1365
  const cvaPopoverTitleContainer = cvaMerge(["flex", "items-center", "px-2", "py-1"], {
564
1366
  variants: {
565
1367
  divider: {
@@ -570,6 +1372,25 @@ const cvaPopoverTitleContainer = cvaMerge(["flex", "items-center", "px-2", "py-1
570
1372
  });
571
1373
  const cvaPopoverTitleText = cvaMerge(["flex-1", "text-neutral-500"]);
572
1374
 
1375
+ /**
1376
+ * Wraps a `returnFocus` ref so Floating UI's live `ref.current` read at close time (see
1377
+ * `FloatingFocusManager`'s `getReturnElement`, which reads `.current` fresh inside its close
1378
+ * cleanup rather than caching it) transparently gets a visibility check first. A getter -- not a
1379
+ * value computed once -- is what makes this "live": consumers just keep the underlying ref pointed
1380
+ * at their normal target and never need to know it might be temporarily unsafe to focus.
1381
+ *
1382
+ * Falls back to Floating UI's own hidden, off-screen fallback element (via returning `null`,
1383
+ * exactly like an empty ref) whenever the target isn't currently visible -- e.g. a `MenuItem` row
1384
+ * that has scrolled out of its list. Without this, focusing a clipped element still succeeds, and
1385
+ * browsers then auto-scroll it into view, fighting whatever caused it to be clipped in the first
1386
+ * place.
1387
+ */
1388
+ const withVisibilityCheck = (sourceRef) => ({
1389
+ get current() {
1390
+ const target = sourceRef.current;
1391
+ return target !== null && isElementVisible(target) ? target : null;
1392
+ },
1393
+ });
573
1394
  /**
574
1395
  * PopoverContent displays the floating content inside a Popover.
575
1396
  * It renders in a portal and manages focus, positioning, and accessibility. Must be a child of a Popover.
@@ -608,7 +1429,11 @@ const cvaPopoverTitleText = cvaMerge(["flex-1", "text-neutral-500"]);
608
1429
  const PopoverContent = function PopoverContent({ className, "data-testid": dataTestId, children, portalId, initialFocus, returnFocus = true, ref: propRef, ...props }) {
609
1430
  const { context: floatingContext, customProps, ...context } = usePopoverContext();
610
1431
  const ref = useMergeRefs$1([context.refs.setFloating, propRef]);
611
- return (jsx(Portal, { id: portalId, children: context.isOpen === true ? (jsx(FloatingFocusManager, { closeOnFocusOut: false, context: floatingContext, guards: true, initialFocus: initialFocus, modal: context.isModal, order: ["reference", "content"], returnFocus: returnFocus, children: jsx("div", { "aria-describedby": context.descriptionId, "aria-labelledby": context.labelId, className: cvaPopoverContainer({ className: className ?? customProps.className }), "data-testid": dataTestId ?? customProps["data-testid"] ?? "popover-content", ref: ref, style: {
1432
+ // Only a ref-based `returnFocus` has a concrete target to check -- the `boolean` case defers to
1433
+ // Floating UI's own app-wide "previously focused element" tracking, which isn't exposed here to
1434
+ // check against.
1435
+ const resolvedReturnFocus = typeof returnFocus === "boolean" ? returnFocus : withVisibilityCheck(returnFocus);
1436
+ return (jsx(Portal, { id: portalId, children: context.isOpen === true ? (jsx(FloatingFocusManager, { closeOnFocusOut: false, context: floatingContext, guards: true, initialFocus: initialFocus, modal: context.isModal, order: ["reference", "content"], returnFocus: resolvedReturnFocus, children: jsx("div", { "aria-describedby": context.descriptionId, "aria-labelledby": context.labelId, className: cvaPopoverContainer({ className: className ?? customProps.className }), "data-testid": dataTestId ?? customProps["data-testid"] ?? "popover-content", ref: ref, style: {
612
1437
  position: context.strategy,
613
1438
  top: context.y,
614
1439
  left: context.x,
@@ -1112,6 +1937,12 @@ const PopoverTrigger = function PopoverTrigger({ children, renderButton = false,
1112
1937
  const context = usePopoverContext();
1113
1938
  const ref = useMergeRefs$1([context.refs.setReference, propRef]);
1114
1939
  const dataState = context.isOpen === true ? "open" : "closed";
1940
+ // While any `MenuTree` member is hover-open with `handleClose`'s `blockPointerEvents`, Floating
1941
+ // UI sets `document.body`'s pointer-events to `none` and only exempts *that* member's own
1942
+ // reference/floating pair -- every sibling row inherits `none` and silently swallows clicks for
1943
+ // as long as the block is active. Forcing `pointer-events: auto` here keeps every tree member's
1944
+ // own trigger clickable regardless of which sibling currently owns that block.
1945
+ const { tree } = useMenuTree();
1115
1946
  if (!renderButton && isValidElement(children)) {
1116
1947
  const referenceProps = context.getReferenceProps({
1117
1948
  ...props,
@@ -1120,9 +1951,14 @@ const PopoverTrigger = function PopoverTrigger({ children, renderButton = false,
1120
1951
  });
1121
1952
  const cloneProps = { ...referenceProps };
1122
1953
  cloneProps.ref = ref;
1954
+ if (tree !== null) {
1955
+ const childClassName = typeof children.props.className === "string" ? children.props.className : "";
1956
+ cloneProps.className = twMerge(childClassName, "pointer-events-auto");
1957
+ }
1123
1958
  return cloneElement(children, cloneProps);
1124
1959
  }
1125
- return (jsx(Button, { "data-state": dataState, ref: ref, type: "button", ...context.getReferenceProps(props), children: children }));
1960
+ const buttonProps = context.getReferenceProps(props);
1961
+ return (jsx(Button, { "data-state": dataState, ref: ref, type: "button", ...buttonProps, className: tree !== null ? twMerge(props.className, "pointer-events-auto") : props.className, children: children }));
1126
1962
  };
1127
1963
 
1128
1964
  const cvaText = cvaMerge(["text-black", "m-0", "relative", "text-sm", "font-normal"], {
@@ -6390,25 +7226,25 @@ const cvaMenuListMultiSelect = cvaMerge([
6390
7226
  const cvaMenuListItem = cvaMerge("max-w-full");
6391
7227
 
6392
7228
  /**
6393
- * MenuDivider renders a horizontal line to visually separate groups of items within a MenuList.
7229
+ * MenuDivider renders a horizontal line to visually separate groups of items within a MenuContent.
6394
7230
  *
6395
7231
  * ### When to use
6396
7232
  * Use MenuDivider between groups of related `MenuItem` elements to create logical sections within a menu.
6397
7233
  *
6398
7234
  * ### When not to use
6399
- * Do not use MenuDivider outside of a `MenuList`. For general-purpose dividers, use `Spacer` with `border`.
7235
+ * Do not use MenuDivider outside of a `MenuContent`. For general-purpose dividers, use `Spacer` with `border`.
6400
7236
  *
6401
7237
  * @example Menu with grouped sections
6402
7238
  * ```tsx
6403
- * import { MenuList, MenuItem, MenuDivider, Icon } from "@trackunit/react-components";
7239
+ * import { MenuContent, MenuItem, MenuDivider, Icon } from "@trackunit/react-components";
6404
7240
  *
6405
7241
  * const GroupedMenu = () => (
6406
- * <MenuList>
7242
+ * <MenuContent>
6407
7243
  * <MenuItem id="edit" label="Edit" prefix={<Icon name="PencilSquare" size="small" />} />
6408
7244
  * <MenuItem id="duplicate" label="Duplicate" prefix={<Icon name="DocumentDuplicate" size="small" />} />
6409
7245
  * <MenuDivider />
6410
7246
  * <MenuItem id="delete" label="Delete" variant="danger" prefix={<Icon name="Trash" size="small" />} />
6411
- * </MenuList>
7247
+ * </MenuContent>
6412
7248
  * );
6413
7249
  * ```
6414
7250
  * @returns {ReactElement} MenuDivider component
@@ -6430,7 +7266,38 @@ const cvaMenuItem = (props) => {
6430
7266
  focus: focused === true ? "focused" : "unfocused",
6431
7267
  }), className);
6432
7268
  };
6433
- const cvaMenuItemStyle = cvaMerge(["px-2", "h-auto", "flex", "flex-row", "items-center", "gap-x-2", "select-none", "rounded"], {
7269
+ const cvaMenuItemStyle = cvaMerge(
7270
+ // `min-w-0` overrides the flex/grid item default of `min-width: auto`, which otherwise refuses to
7271
+ // shrink this row below the intrinsic (unwrapped) width of its `truncate`d label -- without it, the
7272
+ // label's ellipsis truncation never kicks in and the ancestor menu list scrolls horizontally instead.
7273
+ //
7274
+ // `outline-hidden`: `MenuContent`'s roving-tabindex keyboard nav calls this element's own `.focus()`
7275
+ // on hover too (to keep keyboard nav in sync with the pointer -- see `onMouseMove` on `MenuItemProps`),
7276
+ // and Chromium's `:focus-visible` heuristic treats a script-triggered `.focus()` on a non-native
7277
+ // control as visible-by-default, so the browser's native ring was showing on every hover. The active
7278
+ // item is already indicated by `cvaInteractableItem`'s `hover:`/`focus-within:` background tint, so the
7279
+ // native ring is redundant (and, via hover, actively misleading) for both mouse and keyboard users.
7280
+ //
7281
+ // `data-[state=open]:…` / `data-hover-scroll-suppressed`: a submenu trigger's open state is held still
7282
+ // across scroll-induced phantom `:hover` flips (see `hoverScrollGuard`), but CSS `:hover` backgrounds
7283
+ // would still chase the cursor across sibling rows. Pin the open-row tint via `data-state` (set by
7284
+ // `PopoverTrigger`), and while the guard's root attribute is set, take non-open rows out of hit-testing
7285
+ // so their `:hover` styles can't fire either -- matching the open-state freeze without a React render
7286
+ // per scroll tick. `!` beats `PopoverTrigger`'s MenuTree `pointer-events-auto` exemption.
7287
+ [
7288
+ "px-2",
7289
+ "h-auto",
7290
+ "flex",
7291
+ "flex-row",
7292
+ "items-center",
7293
+ "gap-x-2",
7294
+ "select-none",
7295
+ "rounded",
7296
+ "min-w-0",
7297
+ "outline-hidden",
7298
+ "data-[state=open]:bg-neutral-600/5",
7299
+ "[:root[data-hover-scroll-suppressed]_&:not([data-state=open])]:!pointer-events-none",
7300
+ ], {
6434
7301
  variants: {
6435
7302
  fieldSize: {
6436
7303
  small: ["text-xs", "py-1.5"],
@@ -6442,6 +7309,7 @@ const cvaMenuItemStyle = cvaMerge(["px-2", "h-auto", "flex", "flex-row", "items-
6442
7309
  danger: [
6443
7310
  "text-danger-600",
6444
7311
  "hover:!bg-danger-100",
7312
+ "data-[state=open]:!bg-danger-100",
6445
7313
  "focus:!bg-danger-200",
6446
7314
  "hover:!text-danger-700",
6447
7315
  "focus:!text-danger-800",
@@ -6471,7 +7339,12 @@ const cvaMenuItemStyle = cvaMerge(["px-2", "h-auto", "flex", "flex-row", "items-
6471
7339
  disabled: false,
6472
7340
  },
6473
7341
  });
6474
- const cvaMenuItemLabel = cvaMerge(["flex-grow", "truncate", "text-black", "font-normal", "flex", "items-center"], {
7342
+ const cvaMenuItemLabel = cvaMerge(
7343
+ // No `truncate` here: `text-overflow: ellipsis` only applies to block containers, not flex containers,
7344
+ // and this wrapper is `flex` (to align prefix/label/description). The actual truncation lives on the
7345
+ // nested label `<span>` in `MenuItem.tsx`, which gets auto-blockified for being a flex item. `min-w-0`
7346
+ // (rather than `truncate`'s `overflow-hidden`) is what lets this wrapper shrink below its content size.
7347
+ ["flex-grow", "min-w-0", "text-black", "font-normal", "flex", "items-center"], {
6475
7348
  variants: {
6476
7349
  variant: {
6477
7350
  primary: [],
@@ -6537,21 +7410,34 @@ const cvaMenuItemSuffix = cvaMerge(["text-neutral-400", "text-sm", "flex", "item
6537
7410
  });
6538
7411
 
6539
7412
  /**
6540
- * MenuItem represents a single actionable item within a MenuList.
6541
- * It supports labels, icons (prefix/suffix), selected and focused states, and danger variants.
7413
+ * The row markup shared by both the submenu-less and submenu-bearing shapes of `MenuItem` -- pulled
7414
+ * out into its own component (rather than a JSX variable held in `MenuItem`) so each shape can
7415
+ * render it directly wherever it's needed instead of passing a pre-built element around.
7416
+ *
7417
+ * Purely presentational: every DOM-facing prop (`className`, `tabIndex`, event handlers, ...) is
7418
+ * expected to arrive already fully resolved and is forwarded onto the root `div` as-is via `...rest`.
7419
+ * This matters because when rendered inside `PopoverTrigger`, Floating UI clones this element to
7420
+ * inject its own reference props (`data-state`, click/keyboard activation, ...) -- resolving values
7421
+ * here instead would shadow that injection.
7422
+ */
7423
+ const MenuItemRow = ({ label, children, prefix, suffix, selected = false, disabled = false, variant = "primary", optionLabelDescription, optionPrefix, dataTestId, hasSubmenu, ...rest }) => (jsxs("div", { "aria-disabled": disabled, "data-testid": dataTestId ? `${dataTestId}-menu-item` : "menu-item", role: "menuitem", ...rest, children: [prefix !== null && prefix !== undefined ? (jsx("div", { className: cvaMenuItemPrefix({ selected, variant, disabled }), "data-testid": dataTestId ? `${dataTestId}-prefix` : "menu-item-prefix", children: prefix })) : null, children !== null && children !== undefined && typeof children !== "string" ? (children) : (jsxs("div", { className: cvaMenuItemLabel({ variant, disabled }), "data-testid": dataTestId ? `${dataTestId}-label` : "menu-item-label", children: [optionPrefix !== null && optionPrefix !== undefined ? optionPrefix : null, jsx("span", { className: "min-w-0 flex-1 truncate", children: children ?? label }), optionLabelDescription !== undefined && optionLabelDescription !== "" ? (jsxs("span", { className: "ml-1 text-neutral-400", children: ["(", optionLabelDescription, ")"] })) : null] })), suffix !== null && suffix !== undefined ? (jsx("div", { className: cvaMenuItemSuffix({ selected, variant, disabled }), "data-testid": dataTestId ? `${dataTestId}-suffix` : "menu-item-suffix", children: suffix })) : hasSubmenu ? (jsx("div", { className: cvaMenuItemSuffix({ selected, variant, disabled }), "data-testid": dataTestId ? `${dataTestId}-suffix` : "menu-item-suffix", children: jsx(Icon, { name: "ChevronRight", size: "small" }) })) : null] }));
7424
+ /**
7425
+ * MenuItem represents a single actionable item within a MenuContent.
7426
+ * It supports labels, icons (prefix/suffix), selected and focused states, danger variants, and — via the
7427
+ * `submenu` prop — nested submenus.
6542
7428
  *
6543
7429
  * ### When to use
6544
- * Use MenuItem inside a `MenuList` for individual actions (edit, delete, duplicate) or selectable options.
7430
+ * Use MenuItem inside a `MenuContent` for individual actions (edit, delete, duplicate) or selectable options.
6545
7431
  *
6546
7432
  * ### When not to use
6547
- * Do not use MenuItem outside of a `MenuList` context. For standalone clickable items, use `Button` or `ListItem`.
7433
+ * Do not use MenuItem outside of a `MenuContent` context. For standalone clickable items, use `Button` or `ListItem`.
6548
7434
  *
6549
7435
  * @example MenuItem with icon prefix
6550
7436
  * ```tsx
6551
- * import { MenuList, MenuItem, Icon } from "@trackunit/react-components";
7437
+ * import { MenuContent, MenuItem, Icon } from "@trackunit/react-components";
6552
7438
  *
6553
7439
  * const ActionMenu = () => (
6554
- * <MenuList>
7440
+ * <MenuContent>
6555
7441
  * <MenuItem
6556
7442
  * id="edit"
6557
7443
  * label="Edit asset"
@@ -6565,15 +7451,82 @@ const cvaMenuItemSuffix = cvaMerge(["text-neutral-400", "text-sm", "flex", "item
6565
7451
  * variant="danger"
6566
7452
  * onClick={() => console.log("Delete clicked")}
6567
7453
  * />
6568
- * </MenuList>
7454
+ * </MenuContent>
7455
+ * );
7456
+ * ```
7457
+ * @example MenuItem with a submenu
7458
+ * ```tsx
7459
+ * import { MenuContent, MenuItem } from "@trackunit/react-components";
7460
+ *
7461
+ * const StatusMenu = () => (
7462
+ * <MenuContent>
7463
+ * <MenuItem
7464
+ * label="Status"
7465
+ * submenu={
7466
+ * <MenuContent>
7467
+ * <MenuItem id="active" label="Active" />
7468
+ * <MenuItem id="idle" label="Idle" />
7469
+ * </MenuContent>
7470
+ * }
7471
+ * />
7472
+ * </MenuContent>
6569
7473
  * );
6570
7474
  * ```
6571
7475
  * @param {MenuItemProps} props - The props for the MenuItem component
6572
7476
  * @returns {ReactElement} MenuItem component
6573
7477
  */
6574
- const MenuItem = ({ className, "data-testid": dataTestId, label, children, selected = false, focused = false, prefix, suffix, disabled = false, onClick, stopPropagation = true, id, tabIndex, optionLabelDescription, optionPrefix, fieldSize = "medium", variant = "primary", style, ref, }) => {
7478
+ const MenuItem = ({ className, "data-testid": dataTestId, label, children, selected = false, focused = false, prefix, suffix, disabled = false, onClick, stopPropagation = true, id, tabIndex, optionLabelDescription, optionPrefix, fieldSize = "medium", variant = "primary", style, ref, submenu, submenuSizing, onFocus, onMouseMove, onPointerLeave, }) => {
7479
+ const [submenuOpen, setSubmenuOpen] = useState(false);
7480
+ const triggerRowRef = useRef(null);
7481
+ const mergedTriggerRef = useMergeRefs$1([ref, triggerRowRef]);
7482
+ // Where `PopoverContent` below returns focus to once the submenu closes -- normally the trigger
7483
+ // row itself (kept in sync below). `PopoverContent`'s own `returnFocus` is visibility-aware, so
7484
+ // this doesn't need to be cleared by hand right before the scroll-out auto-close further down --
7485
+ // see that prop's own doc comment for why.
7486
+ const returnFocusRef = useRef(null);
7487
+ // A submenu's trigger row commonly lives inside a scrollable `MenuContent` list. Without this, a
7488
+ // submenu opened from a row that later scrolls out of view stays open, floating disconnected from
7489
+ // any visible row -- and (since nothing else would ever close it) stays that way even once the row
7490
+ // scrolls back into view. `IntersectionObserver` (rather than e.g. the `Popover`'s own
7491
+ // `ancestorScroll` dismissal) is what lets this react to the row's actual visibility instead of
7492
+ // firing on every scroll tick of any ancestor, however small or unrelated to this row -- browsers
7493
+ // compute intersection against every ancestor's overflow-clip box, not just an explicit `root`, so
7494
+ // this fires as soon as *this* row's clipped out, even if some other list ancestor keeps scrolling.
7495
+ // Only observing while `submenuOpen` (rather than unconditionally) means the effect's own close
7496
+ // never re-triggers itself, which is also exactly what keeps the submenu from reopening on its own
7497
+ // once the row scrolls back into view -- there's no observer left running to notice that happen.
7498
+ useEffect(() => {
7499
+ if (!submenuOpen) {
7500
+ return;
7501
+ }
7502
+ const node = triggerRowRef.current;
7503
+ if (node === null) {
7504
+ return;
7505
+ }
7506
+ returnFocusRef.current = node;
7507
+ const observer = new IntersectionObserver(([entry]) => {
7508
+ if (entry !== undefined && !entry.isIntersecting) {
7509
+ // The row itself is what just scrolled out -- not a safe place to return focus to.
7510
+ // `PopoverContent`'s `returnFocus` checks the target's visibility itself right at close
7511
+ // time and falls back to its own hidden, off-screen element when it isn't visible, so
7512
+ // this doesn't need to clear `returnFocusRef` by hand first.
7513
+ setSubmenuOpen(false);
7514
+ }
7515
+ }, { threshold: 0 });
7516
+ observer.observe(node);
7517
+ return () => observer.disconnect();
7518
+ }, [submenuOpen]);
6575
7519
  /* Handle tab navigation */
6576
7520
  const handleKeyDown = (e) => {
7521
+ // Enter/Space are already handled by the internal Popover's `activation: { click: true }`
7522
+ // (`useClick`'s standard button-like keyboard semantics) -- only ArrowRight needs wiring up
7523
+ // by hand here, since arrow keys aren't part of click/button semantics.
7524
+ if (submenu !== undefined && disabled !== true && e.key === "ArrowRight") {
7525
+ e.preventDefault();
7526
+ e.stopPropagation();
7527
+ setSubmenuOpen(true);
7528
+ return;
7529
+ }
6577
7530
  if (e.key === "Enter" && onClick !== undefined && disabled !== true) {
6578
7531
  if (stopPropagation) {
6579
7532
  e.stopPropagation();
@@ -6582,37 +7535,82 @@ const MenuItem = ({ className, "data-testid": dataTestId, label, children, selec
6582
7535
  onClick(e);
6583
7536
  }
6584
7537
  };
6585
- return (jsxs("div", { "aria-disabled": disabled, className: cvaMenuItem({
6586
- selected,
6587
- fieldSize,
6588
- disabled,
6589
- className,
6590
- variant,
6591
- focused,
6592
- }), "data-testid": dataTestId ? `${dataTestId}-menu-item` : "menu-item", id: id, onClick: e => {
6593
- if (stopPropagation) {
6594
- e.stopPropagation();
6595
- }
6596
- onClick?.(e);
6597
- }, onKeyDown: handleKeyDown, ref: ref, role: "menuitem", style: style, tabIndex: disabled ? -1 : (tabIndex ?? 0), children: [prefix !== null && prefix !== undefined ? (jsx("div", { className: cvaMenuItemPrefix({ selected, variant, disabled }), "data-testid": dataTestId ? `${dataTestId}-prefix` : "menu-item-prefix", children: prefix })) : null, children !== null && children !== undefined && typeof children !== "string" ? (children) : (jsxs("div", { className: cvaMenuItemLabel({ variant, disabled }), "data-testid": dataTestId ? `${dataTestId}-label` : "menu-item-label", children: [optionPrefix !== null && optionPrefix !== undefined ? optionPrefix : null, children ?? label, optionLabelDescription !== undefined && optionLabelDescription !== "" ? (jsxs("span", { className: "ml-1 text-neutral-400", children: ["(", optionLabelDescription, ")"] })) : null] })), suffix !== null && suffix !== undefined ? (jsx("div", { className: cvaMenuItemSuffix({ selected, variant, disabled }), "data-testid": dataTestId ? `${dataTestId}-suffix` : "menu-item-suffix", children: suffix })) : null] }));
7538
+ const handleItemClick = e => {
7539
+ if (stopPropagation) {
7540
+ e.stopPropagation();
7541
+ }
7542
+ onClick?.(e);
7543
+ };
7544
+ const itemRowProps = {
7545
+ id,
7546
+ label,
7547
+ children,
7548
+ prefix,
7549
+ suffix,
7550
+ selected,
7551
+ disabled,
7552
+ variant,
7553
+ optionLabelDescription,
7554
+ optionPrefix,
7555
+ style,
7556
+ onFocus,
7557
+ onMouseMove,
7558
+ onPointerLeave,
7559
+ onClick: handleItemClick,
7560
+ onKeyDown: handleKeyDown,
7561
+ dataTestId,
7562
+ hasSubmenu: submenu !== undefined,
7563
+ className: cvaMenuItem({ selected, fieldSize, disabled, className, variant, focused }),
7564
+ tabIndex: disabled ? -1 : (tabIndex ?? 0),
7565
+ };
7566
+ if (submenu === undefined) {
7567
+ return jsx(MenuItemRow, { ...itemRowProps, ref: ref });
7568
+ }
7569
+ // Menu role lives on the nested `MenuContent` only — the Popover keeps its default `dialog`
7570
+ // floating role so SRs don't see nested `role="menu"`. `aria-haspopup="menu"` overrides
7571
+ // Floating UI's dialog haspopup so the trigger still advertises a submenu.
7572
+ return (jsxs(Popover, { activation: { click: !disabled, hover: disabled ? false : { delayed: true } }, isOpen: submenuOpen, onOpenStateChange: setSubmenuOpen, placement: "right-start", sizing: submenuSizing, children: [jsx(PopoverTrigger, { ref: mergedTriggerRef, children: jsx(MenuItemRow, { ...itemRowProps, "aria-expanded": submenuOpen, "aria-haspopup": "menu" }) }), jsx(PopoverContent, { initialFocus: 1, returnFocus: returnFocusRef, children: submenu })] }));
6598
7573
  };
6599
7574
 
6600
7575
  /**
6601
- * The MenuList is a popover menu that appears above all other content on the page. The menu offers a list of actions or functions that a user can access by clicking on a trigger.
7576
+ * Derives the plain-text label used for typeahead matching. Disabled items are excluded (`null`)
7577
+ * so typing never lands focus on an item the user can't act on.
7578
+ */
7579
+ const getTypeaheadLabel = (props) => {
7580
+ if (props.disabled === true) {
7581
+ return null;
7582
+ }
7583
+ if (typeof props.label === "string" && props.label !== "") {
7584
+ return props.label;
7585
+ }
7586
+ if (typeof props.children === "string") {
7587
+ return props.children;
7588
+ }
7589
+ return null;
7590
+ };
7591
+ /**
7592
+ * MenuContent (formerly MenuList) is a popover menu that appears above all other content on the page. It offers a
7593
+ * list of actions or functions that a user can access by clicking on a trigger, with full keyboard support:
7594
+ * roving-tabindex Up/Down navigation (wrapping), Home/End, typeahead, and — via `MenuItem`'s `submenu` prop —
7595
+ * nested submenu entry/exit.
7596
+ *
7597
+ * Typically rendered inside a `Popover` (directly, or as `PopoverContent`'s children), in which case it reads
7598
+ * the popover's floating context to power its keyboard navigation. Also works standalone (e.g. inside a
7599
+ * `Collapse`, with no ambient `Popover`), falling back to its own local, always-open floating context.
6602
7600
  *
6603
7601
  * **When to use**
6604
- * - Use the MenuList if you have limited space and need to display overflow actions in a list.
6605
- * - Use the MenuList for actions that are not essential to completing workflows.
6606
- * - Don't use the MenuList to display single or multi-select items within form components. For dropdowns within select components, use SelectDropdown (component not available yet).
7602
+ * - Use the MenuContent if you have limited space and need to display overflow actions in a list.
7603
+ * - Use the MenuContent for actions that are not essential to completing workflows.
7604
+ * - Don't use the MenuContent to display single or multi-select items within form components. For dropdowns within select components, use SelectDropdown (component not available yet).
6607
7605
  *
6608
- * @example MenuList with action items
7606
+ * @example MenuContent with action items
6609
7607
  * ```tsx
6610
- * import { MenuList, MenuItem, MoreMenu, Icon } from "@trackunit/react-components";
7608
+ * import { MenuContent, MenuItem, MoreMenu, Icon } from "@trackunit/react-components";
6611
7609
  *
6612
7610
  * const ActionsMenu = () => (
6613
7611
  * <MoreMenu>
6614
7612
  * {(close) => (
6615
- * <MenuList onClick={close}>
7613
+ * <MenuContent onClick={close}>
6616
7614
  * <MenuItem id="edit" prefix={<Icon name="PencilSquare" size="small" />}>
6617
7615
  * Edit
6618
7616
  * </MenuItem>
@@ -6622,14 +7620,14 @@ const MenuItem = ({ className, "data-testid": dataTestId, label, children, selec
6622
7620
  * <MenuItem id="delete" prefix={<Icon name="Trash" size="small" />} destructive>
6623
7621
  * Delete
6624
7622
  * </MenuItem>
6625
- * </MenuList>
7623
+ * </MenuContent>
6626
7624
  * )}
6627
7625
  * </MoreMenu>
6628
7626
  * );
6629
7627
  * ```
6630
- * @example Multi-select MenuList
7628
+ * @example Multi-select MenuContent
6631
7629
  * ```tsx
6632
- * import { MenuList, MenuItem, MoreMenu } from "@trackunit/react-components";
7630
+ * import { MenuContent, MenuItem, MoreMenu } from "@trackunit/react-components";
6633
7631
  * import { useState } from "react";
6634
7632
  *
6635
7633
  * const FilterMenu = () => {
@@ -6637,7 +7635,7 @@ const MenuItem = ({ className, "data-testid": dataTestId, label, children, selec
6637
7635
  *
6638
7636
  * return (
6639
7637
  * <MoreMenu label="Filter by status">
6640
- * <MenuList
7638
+ * <MenuContent
6641
7639
  * isMulti
6642
7640
  * selectedItems={selected}
6643
7641
  * onSelectionChange={setSelected}
@@ -6645,18 +7643,52 @@ const MenuItem = ({ className, "data-testid": dataTestId, label, children, selec
6645
7643
  * <MenuItem id="active">Active</MenuItem>
6646
7644
  * <MenuItem id="idle">Idle</MenuItem>
6647
7645
  * <MenuItem id="offline">Offline</MenuItem>
6648
- * </MenuList>
7646
+ * </MenuContent>
6649
7647
  * </MoreMenu>
6650
7648
  * );
6651
7649
  * };
6652
7650
  * ```
6653
- * @param {MenuListProps} props - The props for the MenuList component
6654
- * @returns {ReactElement} MenuList component
7651
+ * @param {MenuContentProps} props - The props for the MenuContent component
7652
+ * @returns {ReactElement} MenuContent component
6655
7653
  */
6656
- const MenuList = ({ "data-testid": dataTestId, className, children, isMulti = false, selectedItems: controlledSelectedItems, onSelectionChange, style, ref, ...args }) => {
6657
- const childrenArr = Children.toArray(children);
7654
+ const MenuContent = ({ "data-testid": dataTestId, className, listClassName, children, isMulti = false, selectedItems: controlledSelectedItems, onSelectionChange, style, ref, ...args }) => {
7655
+ const childrenArr = useMemo(() => Children.toArray(children), [children]);
6658
7656
  const [internalSelectedItems, setInternalSelectedItems] = useState(controlledSelectedItems ?? []);
6659
7657
  const selectedItems = controlledSelectedItems ?? internalSelectedItems;
7658
+ const [activeIndex, setActiveIndex] = useState(null);
7659
+ const listRef = useRef([]);
7660
+ const labelsRef = useRef([]);
7661
+ const ambientPopover = useOptionalPopoverContext();
7662
+ // Falls back to a local, always-open floating context when there is no ambient `Popover` (e.g. `MenuContent`
7663
+ // rendered standalone inside a `Collapse`) -- `useFloating` must still be called unconditionally per the
7664
+ // Rules of Hooks, so this is cheap and simply unused whenever `ambientPopover` is present. When ambient, the
7665
+ // enclosing `PopoverContent` already calls `context.refs.setFloating` on this same DOM node, so
7666
+ // `elements.floating` is already populated there; standalone, nothing else does that, so this component's own
7667
+ // root node must be wired up as the floating element itself for `useListNavigation`'s internal effects (which
7668
+ // gate on `elements.floating`) to run.
7669
+ const { context: standaloneContext, refs: standaloneRefs } = useFloating({ open: true });
7670
+ const context = ambientPopover?.context ?? standaloneContext;
7671
+ const floatingRef = useMergeRefs$1([ambientPopover ? null : standaloneRefs.setFloating, ref]);
7672
+ // Reads the ambient popover's own `isNested` (resolved by its `usePopover()` call *before* it
7673
+ // wraps this content in `<FloatingNode>`) rather than calling `useMenuTree()` again from here --
7674
+ // from this position, beneath that `FloatingNode`, `useMenuTree()` would resolve the ambient
7675
+ // parent id to the enclosing popover's *own* id and misreport every menu (root included) as
7676
+ // nested. Standalone (no ambient `Popover`) is never nested.
7677
+ const isNested = ambientPopover?.isNested ?? false;
7678
+ const listNavigation = useListNavigation(context, {
7679
+ listRef,
7680
+ activeIndex,
7681
+ onNavigate: setActiveIndex,
7682
+ loop: true,
7683
+ nested: isNested,
7684
+ });
7685
+ const typeahead = useTypeahead(context, {
7686
+ listRef: labelsRef,
7687
+ activeIndex,
7688
+ onMatch: setActiveIndex,
7689
+ resetMs: 500,
7690
+ });
7691
+ const { getFloatingProps, getItemProps } = useInteractions([listNavigation, typeahead]);
6660
7692
  const handleItemClick = useCallback((id) => {
6661
7693
  const newSelectedItems = isMulti
6662
7694
  ? selectedItems.includes(id)
@@ -6670,27 +7702,85 @@ const MenuList = ({ "data-testid": dataTestId, className, children, isMulti = fa
6670
7702
  setInternalSelectedItems(newSelectedItems);
6671
7703
  }
6672
7704
  }, [isMulti, selectedItems, onSelectionChange]);
6673
- return (jsx("div", { className: cvaMenu({ className, limitWidth: true }), "data-testid": dataTestId ? `${dataTestId}-menu-list` : "menu-list", onClick: args.onClick, ref: ref, role: "list", style: style, tabIndex: 0, children: jsx("div", { className: cvaMenuList(), children: childrenArr.map((menuItem, index) => {
6674
- if (isValidElement(menuItem)) {
6675
- const isSelected = (selectedItems.includes(menuItem.props.id ?? `${index}`) || menuItem.props.selected) ?? false;
6676
- return cloneElement(menuItem, {
6677
- ...menuItem.props,
6678
- key: index,
7705
+ // Only real `MenuItem`s participate in keyboard navigation. Arbitrary content (e.g. a `Search` input or a
7706
+ // list of checkboxes, via `Filter`'s children) can be rendered alongside them, but is excluded from the nav
7707
+ // list and rendered untouched -- `useListNavigation`/`useTypeahead` treat every registered item as a single
7708
+ // atomic focus target, an assumption that breaks down for a wrapper `<div>` holding several of its own
7709
+ // independently-focusable descendants (arrow keys skipping over it, its own click/focus handlers stealing
7710
+ // DOM focus from whatever's inside it). Computed as plain, memoized values (never touching listRef/labelsRef
7711
+ // here -- see the layout effect below, since refs must not be read/written during render).
7712
+ const { navIndicesByChildIndex, navLabels, firstEnabledNavIndex } = useMemo(() => {
7713
+ const indices = [];
7714
+ const labels = [];
7715
+ let itemCount = 0;
7716
+ let firstEnabledIndex = -1;
7717
+ childrenArr.forEach(child => {
7718
+ if (isValidElement(child) && child.type === MenuItem) {
7719
+ if (firstEnabledIndex === -1 && child.props.disabled !== true) {
7720
+ firstEnabledIndex = itemCount;
7721
+ }
7722
+ indices.push(itemCount);
7723
+ labels.push(getTypeaheadLabel(child.props));
7724
+ itemCount += 1;
7725
+ }
7726
+ else {
7727
+ indices.push(-1);
7728
+ }
7729
+ });
7730
+ return { navIndicesByChildIndex: indices, navLabels: labels, firstEnabledNavIndex: firstEnabledIndex };
7731
+ }, [childrenArr]);
7732
+ useLayoutEffect(() => {
7733
+ listRef.current.length = navLabels.length;
7734
+ labelsRef.current = navLabels;
7735
+ }, [navLabels]);
7736
+ // A focused descendant's own keydown would otherwise be swallowed by `useTypeahead` as a single-character
7737
+ // match attempt (see `getFloatingProps` below); this leaves native typing in nested content (e.g. a `Search`
7738
+ // input rendered as one of `MenuContent`'s non-`MenuItem` children) untouched.
7739
+ const handleKeyDownCapture = (event) => {
7740
+ if (isTypeableElement(event.target)) {
7741
+ event.stopPropagation();
7742
+ }
7743
+ };
7744
+ const createItemRefSetter = useCallback((itemNavIndex) => (node) => {
7745
+ listRef.current[itemNavIndex] = node;
7746
+ }, []);
7747
+ return (jsx("div", { className: cvaMenu({ className, limitWidth: true }), "data-testid": dataTestId ? `${dataTestId}-menu-list` : "menu-list", onKeyDownCapture: handleKeyDownCapture, ref: floatingRef, role: "menu", style: style, tabIndex: -1, ...getFloatingProps({ onClick: args.onClick }), children: jsx("div", { className: cvaMenuList({ className: listClassName }), children: childrenArr.map((menuItem, index) => {
7748
+ if (!isValidElement(menuItem)) {
7749
+ return null;
7750
+ }
7751
+ if (menuItem.type === MenuDivider) {
7752
+ return cloneElement(menuItem, { key: index });
7753
+ }
7754
+ // Arbitrary, non-`MenuItem` content (see the comment on `navIndicesByChildIndex` above) is rendered
7755
+ // untouched, participating in neither roving tabindex nor typeahead -- its own interactive
7756
+ // descendants (inputs, checkboxes, buttons, ...) keep their native focus/click/keydown behavior.
7757
+ if (menuItem.type !== MenuItem) {
7758
+ return cloneElement(menuItem, { key: index });
7759
+ }
7760
+ const itemNavIndex = navIndicesByChildIndex[index] ?? -1;
7761
+ const disabled = menuItem.props.disabled ?? false;
7762
+ const isSelected = (selectedItems.includes(menuItem.props.id ?? `${index}`) || menuItem.props.selected) ?? false;
7763
+ const isActive = activeIndex === itemNavIndex || (activeIndex === null && itemNavIndex === firstEnabledNavIndex);
7764
+ return cloneElement(menuItem, {
7765
+ ...menuItem.props,
7766
+ key: index,
7767
+ ...getItemProps({
6679
7768
  onClick: (event) => {
6680
7769
  menuItem.props.onClick?.(event);
6681
- if (menuItem.props.disabled !== true) {
7770
+ if (!disabled) {
6682
7771
  handleItemClick(menuItem.props.id ?? `${index}`);
6683
7772
  }
6684
7773
  },
6685
- className: isMulti && isSelected
6686
- ? cvaMenuListMultiSelect({ className: menuItem.props.className })
6687
- : cvaMenuListItem({ className: menuItem.props.className }),
6688
- selected: isSelected,
6689
- suffix: menuItem.props.suffix ??
6690
- (isMulti && isSelected ? jsx(Icon, { className: "text-primary-600 block", name: "Check", size: "medium" }) : null),
6691
- });
6692
- }
6693
- return null;
7774
+ }),
7775
+ tabIndex: disabled ? -1 : isActive ? 0 : -1,
7776
+ ref: createItemRefSetter(itemNavIndex),
7777
+ className: isMulti && isSelected
7778
+ ? cvaMenuListMultiSelect({ className: menuItem.props.className })
7779
+ : cvaMenuListItem({ className: menuItem.props.className }),
7780
+ selected: isSelected,
7781
+ suffix: menuItem.props.suffix ??
7782
+ (isMulti && isSelected ? jsx(Icon, { className: "text-primary-600 block", name: "Check", size: "medium" }) : null),
7783
+ });
6694
7784
  }) }) }));
6695
7785
  };
6696
7786
 
@@ -6698,7 +7788,7 @@ const cvaMoreMenu = cvaMerge(["p-0"]);
6698
7788
 
6699
7789
  /**
6700
7790
  * MoreMenu (kebab menu) renders a three-dot button that opens a popover with a list of actions.
6701
- * It is typically filled with a MenuList containing MenuItem elements.
7791
+ * It is typically filled with a MenuContent containing MenuItem elements.
6702
7792
  *
6703
7793
  * ### When to use
6704
7794
  * Use MoreMenu when you have overflow actions that don't fit in the main UI. Common for row-level actions in tables, card headers, or list items.
@@ -6708,30 +7798,30 @@ const cvaMoreMenu = cvaMerge(["p-0"]);
6708
7798
  *
6709
7799
  * @example Action items with render prop — Pass a function as `children` to receive the `close` callback and dismiss the menu after an action.
6710
7800
  * ```tsx
6711
- * import { MoreMenu, MenuList, MenuItem, Icon } from "@trackunit/react-components";
7801
+ * import { MoreMenu, MenuContent, MenuItem, Icon } from "@trackunit/react-components";
6712
7802
  *
6713
7803
  * const AssetActions = () => (
6714
7804
  * <MoreMenu>
6715
7805
  * {(close) => (
6716
- * <MenuList onClick={close}>
7806
+ * <MenuContent onClick={close}>
6717
7807
  * <MenuItem id="edit" label="Edit" prefix={<Icon name="PencilSquare" size="small" />} />
6718
7808
  * <MenuItem id="delete" label="Delete" variant="danger" prefix={<Icon name="Trash" size="small" />} />
6719
- * </MenuList>
7809
+ * </MenuContent>
6720
7810
  * )}
6721
7811
  * </MoreMenu>
6722
7812
  * );
6723
7813
  * ```
6724
7814
  * @example Custom trigger button — Use `customButton` to replace the default kebab icon with any element, like a labeled Button.
6725
7815
  * ```tsx
6726
- * import { MoreMenu, MenuList, MenuItem, Button } from "@trackunit/react-components";
7816
+ * import { MoreMenu, MenuContent, MenuItem, Button } from "@trackunit/react-components";
6727
7817
  *
6728
7818
  * const CustomTriggerMenu = () => (
6729
7819
  * <MoreMenu customButton={<Button variant="secondary" size="small">Actions</Button>}>
6730
7820
  * {(close) => (
6731
- * <MenuList onClick={close}>
7821
+ * <MenuContent onClick={close}>
6732
7822
  * <MenuItem id="export" label="Export" />
6733
7823
  * <MenuItem id="archive" label="Archive" />
6734
- * </MenuList>
7824
+ * </MenuContent>
6735
7825
  * )}
6736
7826
  * </MoreMenu>
6737
7827
  * );
@@ -6751,7 +7841,7 @@ const MoreMenu = ({ className, "data-testid": dataTestId, popoverProps, iconProp
6751
7841
  const actionMenuRef = useRef(null);
6752
7842
  const mergedRef = useMergeRefs([actionMenuRef, ref]);
6753
7843
  const resolvedButtonLabel = iconButtonProps.title ?? buttonLabel ?? t("moreMenu.buttonLabel");
6754
- return (jsx("div", { className: cvaMoreMenu({ className }), "data-testid": dataTestId ? dataTestId : undefined, ref: mergedRef, style: style, children: jsxs(Popover, { placement: "bottom-end", ...popoverProps, children: [jsx(PopoverTrigger, { children: customButton ?? (jsx(IconButton, { ...iconButtonProps, "data-testid": iconButtonProps["data-testid"] ?? "more-menu-icon", icon: jsx(Icon, { name: "EllipsisHorizontal", ...iconProps }), title: resolvedButtonLabel })) }), jsx(PopoverContent, { portalId: customPortalId, children: close => (typeof children === "function" ? children(close) : children) })] }) }));
7844
+ return (jsx("div", { className: cvaMoreMenu({ className }), "data-testid": dataTestId ? dataTestId : undefined, ref: mergedRef, style: style, children: jsx(MenuTree, { children: jsxs(Popover, { placement: "bottom-end", ...popoverProps, children: [jsx(PopoverTrigger, { children: customButton ?? (jsx(IconButton, { ...iconButtonProps, "data-testid": iconButtonProps["data-testid"] ?? "more-menu-icon", icon: jsx(Icon, { name: "EllipsisHorizontal", ...iconProps }), title: resolvedButtonLabel })) }), jsx(PopoverContent, { portalId: customPortalId, children: close => (typeof children === "function" ? children(close) : children) })] }) }) }));
6755
7845
  };
6756
7846
 
6757
7847
  const cvaNotice = cvaMerge(["flex", "items-center", "gap-1"]);
@@ -7113,7 +8203,7 @@ const PageHeaderSecondaryActions = ({ actions, hasPrimaryAction = false, groupAc
7113
8203
  return [danger, [...others, action]];
7114
8204
  }
7115
8205
  }, [[], []]);
7116
- return (jsx("div", { className: className, "data-testid": dataTestId, ref: ref, style: style, children: jsx(MoreMenu, { "data-testid": "secondary-actions-more-menu", iconButtonProps: { size: "small", variant: "secondary" }, children: close => (jsxs(MenuList, { className: "min-w-[160px]", children: [otherActions.map((action, index) => (jsx(ActionRenderer, { action: action, externalOnClick: close, isMenuItem: true }, `${action.actionText}-${index}`))), dangerActions.length > 0 ? jsx(MenuDivider, {}) : null, dangerActions.map((action, index) => (jsx(ActionRenderer, { action: action, externalOnClick: close, isMenuItem: true }, `${action.actionText}-${index}`)))] })) }) }));
8206
+ return (jsx("div", { className: className, "data-testid": dataTestId, ref: ref, style: style, children: jsx(MoreMenu, { "data-testid": "secondary-actions-more-menu", iconButtonProps: { size: "small", variant: "secondary" }, children: close => (jsxs(MenuContent, { className: "min-w-[160px]", children: [otherActions.map((action, index) => (jsx(ActionRenderer, { action: action, externalOnClick: close, isMenuItem: true }, `${action.actionText}-${index}`))), dangerActions.length > 0 ? jsx(MenuDivider, {}) : null, dangerActions.map((action, index) => (jsx(ActionRenderer, { action: action, externalOnClick: close, isMenuItem: true }, `${action.actionText}-${index}`)))] })) }) }));
7117
8207
  }
7118
8208
  // Otherwise, render them inline as buttons
7119
8209
  return (jsx("div", { className: twMerge("flex flex-row items-center gap-2", className), "data-testid": dataTestId, ref: ref, style: style, children: enabledActions
@@ -9086,6 +10176,25 @@ const useSheetMeasurements = ({ shouldRender, state, dockingEnabled, snapping, e
9086
10176
  ]);
9087
10177
  };
9088
10178
 
10179
+ /**
10180
+ * The layout-reserved width of `element`'s own vertical scrollbar (in pixels), or `0` when it
10181
+ * isn't currently reserving any (no overflow, or an overlay-style scrollbar that doesn't consume
10182
+ * layout space, e.g. macOS's default).
10183
+ *
10184
+ * `offsetWidth` includes the element's border and any reserved scrollbar; `clientWidth` excludes
10185
+ * both. Subtracting the (computed) border widths from that difference isolates the scrollbar
10186
+ * itself -- a naive `offsetWidth - clientWidth` would wrongly fold border width into the result.
10187
+ *
10188
+ * @param element The element to measure
10189
+ * @returns {number} The scrollbar's reserved width in pixels, or `0`
10190
+ */
10191
+ const getScrollbarWidth = (element) => {
10192
+ const style = window.getComputedStyle(element);
10193
+ const borderLeft = parseFloat(style.borderLeftWidth) || 0;
10194
+ const borderRight = parseFloat(style.borderRightWidth) || 0;
10195
+ return Math.max(0, element.offsetWidth - borderLeft - borderRight - element.clientWidth);
10196
+ };
10197
+
9089
10198
  /**
9090
10199
  * Blocks scrolling on the document and compensates for scrollbar width to prevent layout shift.
9091
10200
  * Uses scrollbar-gutter: stable to reserve space for the scrollbar when hiding overflow,
@@ -9104,10 +10213,7 @@ const blockDocumentScroll = () => {
9104
10213
  // Check html scrollbar: window.innerWidth includes scrollbar, clientWidth doesn't
9105
10214
  const htmlScrollbarWidth = window.innerWidth - html.clientWidth;
9106
10215
  // Check body scrollbar: offsetWidth includes border+scrollbar, clientWidth excludes both
9107
- const bodyStyle = window.getComputedStyle(body);
9108
- const bodyBorderLeft = parseInt(bodyStyle.borderLeftWidth) || 0;
9109
- const bodyBorderRight = parseInt(bodyStyle.borderRightWidth) || 0;
9110
- const bodyScrollbarWidth = body.offsetWidth - bodyBorderLeft - bodyBorderRight - body.clientWidth;
10216
+ const bodyScrollbarWidth = getScrollbarWidth(body);
9111
10217
  // Use whichever scrollbar is present
9112
10218
  const hasVisibleScrollbar = htmlScrollbarWidth > 0 || bodyScrollbarWidth > 0;
9113
10219
  // Store original values before modifying
@@ -9166,13 +10272,7 @@ const restoreDocumentScroll = (originalStyles) => {
9166
10272
  */
9167
10273
  const blockContainerScroll = (container) => {
9168
10274
  // Check if there's a visible scrollbar before we hide it
9169
- // offsetWidth includes border + scrollbar, clientWidth excludes both
9170
- // We need to subtract borders to isolate the scrollbar width
9171
- const style = window.getComputedStyle(container);
9172
- const borderLeft = parseInt(style.borderLeftWidth) || 0;
9173
- const borderRight = parseInt(style.borderRightWidth) || 0;
9174
- const scrollbarWidth = container.offsetWidth - borderLeft - borderRight - container.clientWidth;
9175
- const hasVisibleScrollbar = scrollbarWidth > 0;
10275
+ const hasVisibleScrollbar = getScrollbarWidth(container) > 0;
9176
10276
  const originalStyles = {
9177
10277
  container: {
9178
10278
  overflow: container.style.overflow,
@@ -10051,7 +11151,7 @@ const Sidebar = ({ childContainerClassName, children, breakpoint = "lg", classNa
10051
11151
  });
10052
11152
  }) }), overflowItemCount > 0 ? (jsx(MoreMenu, { iconButtonProps: {
10053
11153
  variant: "ghost-neutral",
10054
- }, ...moreMenuProps, className: moreMenuProps?.className, "data-testid": `${dataTestId}-more-menu`, children: close => (jsx(MenuList, { ...menuListProps, "data-testid": dataTestId, children: Children.map(children, child => {
11154
+ }, ...moreMenuProps, className: moreMenuProps?.className, "data-testid": `${dataTestId}-more-menu`, children: close => (jsx(MenuContent, { ...menuListProps, "data-testid": dataTestId, children: Children.map(children, child => {
10055
11155
  return itemOverflowMap[child.props.id] === true
10056
11156
  ? cloneElement(child, {
10057
11157
  onClick: e => {
@@ -11946,6 +13046,19 @@ const useStorageKey = (key, userId) => {
11946
13046
  return useMemo(() => (userId ? `${key}-${userId}` : key), [key, userId]);
11947
13047
  };
11948
13048
 
13049
+ /**
13050
+ * Raw localStorage access, kept in one place so this file's exemption from the
13051
+ * no-direct-storage-access rule is centralised rather than repeated at each call site.
13052
+ *
13053
+ * TODO: migrate to the useLocalStorage hook — this file was exempted when the
13054
+ * no-direct-storage-access rule was introduced.
13055
+ */
13056
+ /* eslint-disable no-restricted-globals */
13057
+ const rawLocalStorage = {
13058
+ read: (key) => localStorage.getItem(key),
13059
+ write: (key, value) => localStorage.setItem(key, value),
13060
+ remove: (key) => localStorage.removeItem(key),
13061
+ };
11949
13062
  /**
11950
13063
  * Generic persistence hook that loads state from a URL slot (search param
11951
13064
  * or hash fragment, depending on `urlLocation`) with localStorage fallback,
@@ -11969,6 +13082,11 @@ const useStorageKey = (key, userId) => {
11969
13082
  * `toUrlValue` + encoding). Duplicate writes where the state has not
11970
13083
  * changed (deep equality) are skipped.
11971
13084
  *
13085
+ * `clearState` removes the entry from localStorage and from the URL slot,
13086
+ * rather than writing an empty value. Use it when the state a consumer
13087
+ * derives has become empty, so no leftover entry is stored for a value the
13088
+ * user has reset. It is idempotent — clearing an absent entry is a no-op.
13089
+ *
11972
13090
  * @param options - Configuration for the persisted state.
11973
13091
  * @param options.key - Unique identifier used for both the URL slot and the localStorage key.
11974
13092
  * @param options.validate - Called with the decoded/parsed value; must return `TState` or `undefined`.
@@ -12083,9 +13201,7 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
12083
13201
  let storageState;
12084
13202
  if (localStorageEnabled) {
12085
13203
  try {
12086
- // TODO: migrate to useLocalStorage hook — this file was exempted when the no-direct-storage-access rule was introduced
12087
- // eslint-disable-next-line no-restricted-globals
12088
- const raw = localStorage.getItem(storageKey);
13204
+ const raw = rawLocalStorage.read(storageKey);
12089
13205
  if (raw) {
12090
13206
  const parsed = storageSerializer.deserialize(raw);
12091
13207
  storageState = validateState(parsed);
@@ -12220,8 +13336,7 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
12220
13336
  const serialized = serializeRef.current
12221
13337
  ? serializeRef.current(migrated)
12222
13338
  : storageSerializer.serialize(migrated);
12223
- // eslint-disable-next-line no-restricted-globals
12224
- localStorage.setItem(storageKey, serialized);
13339
+ rawLocalStorage.write(storageKey, serialized);
12225
13340
  }
12226
13341
  const urlValue = toUrlValueRef.current ? toUrlValueRef.current(migrated) : migrated;
12227
13342
  const encoded = encode(urlValue);
@@ -12235,14 +13350,20 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
12235
13350
  lastPersistedRef.current = state;
12236
13351
  if (localStorageEnabled) {
12237
13352
  const serialized = serializeRef.current ? serializeRef.current(state) : storageSerializer.serialize(state);
12238
- // eslint-disable-next-line no-restricted-globals
12239
- localStorage.setItem(storageKey, serialized);
13353
+ rawLocalStorage.write(storageKey, serialized);
12240
13354
  }
12241
13355
  const urlValue = toUrlValueRef.current ? toUrlValueRef.current(state) : state;
12242
13356
  const encoded = encode(urlValue);
12243
13357
  updateSearchParam(encoded);
12244
13358
  }, [storageKey, encode, localStorageEnabled, updateSearchParam]);
12245
- return useMemo(() => ({ initialState, persistState }), [initialState, persistState]);
13359
+ const clearState = useCallback(() => {
13360
+ lastPersistedRef.current = undefined;
13361
+ if (localStorageEnabled) {
13362
+ rawLocalStorage.remove(storageKey);
13363
+ }
13364
+ updateSearchParam(undefined);
13365
+ }, [localStorageEnabled, storageKey, updateSearchParam]);
13366
+ return useMemo(() => ({ initialState, persistState, clearState }), [initialState, persistState, clearState]);
12246
13367
  };
12247
13368
 
12248
13369
  const OVERSCAN = 10;
@@ -13444,4 +14565,4 @@ const useWindowActivity = ({ onFocus, onBlur, skip = false } = { onBlur: undefin
13444
14565
  */
13445
14566
  setupLibraryTranslations();
13446
14567
 
13447
- export { Alert, Badge, Breadcrumb, Button, Card, CardBody, CardFooter, CardHeader, Collapse, CompletionStatusIndicator, CopyableText, DEFAULT_SKELETON_PREFERENCE_CARD_PROPS, DetailsList, EmptyState, EmptyValue, ExternalLink, GridAreas, Heading, Highlight, HorizontalOverflowScroller, Icon, IconButton, Indicator, KPI, KPICard, KPICardSkeleton, KPISkeleton, LabeledValue, LabeledValueList, List, ListItem, MAX_HASH_LENGTH, MAX_URL_LENGTH, MenuDivider, MenuItem, MenuList, MoreMenu, Notice, PackageNameStoryComponent, Page, PageContent, PageHeader, PageHeaderKpiMetrics, PageHeaderSecondaryActions, PageHeaderTitle, Pagination, Polygon, Popover, PopoverContent, PopoverTitle, PopoverTrigger, Portal, PreferenceCard, PreferenceCardSkeleton, Prompt, ROLE_CARD, SHEET_TRANSITION_DURATION, SHEET_TRANSITION_DURATION_MS, SHEET_TRANSITION_EASING, SectionHeader, SegmentedValueBar, Sheet, Sidebar, SkeletonBlock, SkeletonLabel, SkeletonLines, Spacer, Spinner, StarButton, Tab, TabContent, TabList, Tabs, Tag, Text, ToggleGroup, Tooltip, TrendIndicator, TrendIndicators, ValueBar, ZStack, createGrid, cvaButton, cvaButtonPrefixSuffix, cvaButtonSpinner, cvaButtonSpinnerContainer, cvaClickable, cvaContainerStyles, cvaContentContainer, cvaContentWrapper, cvaDescriptionCard, cvaIconBackground, cvaIconButton, cvaImgStyles, cvaIndicator, cvaIndicatorIcon, cvaIndicatorIconBackground, cvaIndicatorLabel, cvaIndicatorPing, cvaInputContainer, cvaInteractableItem, cvaList, cvaListContainer, cvaListItem$1 as cvaListItem, cvaMenu, cvaMenuItem, cvaMenuItemLabel, cvaMenuItemPrefix, cvaMenuItemStyle, cvaMenuItemSuffix, cvaMenuList, cvaMenuListDivider, cvaMenuListItem, cvaMenuListMultiSelect, cvaPageHeader, cvaPageHeaderContainer, cvaPageHeaderHeading, cvaPreferenceCard, cvaTitleCard, cvaToggleGroup, cvaToggleGroupWithSlidingBackground, cvaToggleItem, cvaToggleItemContent, cvaToggleItemText, cvaZStackContainer, cvaZStackItem, defaultPageSize, docs, getDevicePixelRatio, getValueBarColorByValue, iconColorNames, iconPalette, noPagination, preferenceCardGrid, storageSerializer, useBidirectionalScroll, useClickOutside, useContainerBreakpoints, useContinuousTimeout, useCopyToClipboard, useCursorUrlSync, useCustomEncoding, useDebounce, useDevicePixelRatio, useElevatedReducer, useElevatedState, useGridAreas, useHashParamSync, useHover, useInfiniteScroll, useIsFirstRender, useIsFullscreen, useIsTextTruncated, useKeyboardShortcut, useList, useListItemHeight, useLocalStorage, useLocalStorageReducer, useMeasure, useMergeRefs, useModifierKey, useOverflowBorder, useOverflowItems, usePersistedState, usePopoverContext, usePrevious, usePrompt, useRandomCSSLengths, useRelayPagination, useResize, useScrollBlock, useScrollDetection, useSearchParamSync, useSelfUpdatingRef, useSessionStorage, useSessionStorageReducer, useSheet, useSheetSnap, useStorageKey, useTextSearch, useTimeout, useViewportBreakpoints, useWatch, useWindowActivity };
14568
+ export { Alert, Badge, Breadcrumb, Button, Card, CardBody, CardFooter, CardHeader, Collapse, CompletionStatusIndicator, CopyableText, DEFAULT_SKELETON_PREFERENCE_CARD_PROPS, DetailsList, EmptyState, EmptyValue, ExternalLink, GridAreas, Heading, Highlight, HorizontalOverflowScroller, Icon, IconButton, Indicator, KPI, KPICard, KPICardSkeleton, KPISkeleton, LabeledValue, LabeledValueList, List, ListItem, MAX_HASH_LENGTH, MAX_URL_LENGTH, MenuContent, MenuDivider, MenuItem, MenuTree, MoreMenu, Notice, PackageNameStoryComponent, Page, PageContent, PageHeader, PageHeaderKpiMetrics, PageHeaderSecondaryActions, PageHeaderTitle, Pagination, Polygon, Popover, PopoverContent, PopoverTitle, PopoverTrigger, Portal, PreferenceCard, PreferenceCardSkeleton, Prompt, ROLE_CARD, SHEET_TRANSITION_DURATION, SHEET_TRANSITION_DURATION_MS, SHEET_TRANSITION_EASING, SectionHeader, SegmentedValueBar, Sheet, Sidebar, SkeletonBlock, SkeletonLabel, SkeletonLines, Spacer, Spinner, StarButton, Tab, TabContent, TabList, Tabs, Tag, Text, ToggleGroup, Tooltip, TrendIndicator, TrendIndicators, ValueBar, ZStack, createGrid, cvaButton, cvaButtonPrefixSuffix, cvaButtonSpinner, cvaButtonSpinnerContainer, cvaClickable, cvaContainerStyles, cvaContentContainer, cvaContentWrapper, cvaDescriptionCard, cvaIconBackground, cvaIconButton, cvaImgStyles, cvaIndicator, cvaIndicatorIcon, cvaIndicatorIconBackground, cvaIndicatorLabel, cvaIndicatorPing, cvaInputContainer, cvaInteractableItem, cvaList, cvaListContainer, cvaListItem$1 as cvaListItem, cvaMenu, cvaMenuItem, cvaMenuItemLabel, cvaMenuItemPrefix, cvaMenuItemStyle, cvaMenuItemSuffix, cvaMenuList, cvaMenuListDivider, cvaMenuListItem, cvaMenuListMultiSelect, cvaPageHeader, cvaPageHeaderContainer, cvaPageHeaderHeading, cvaPreferenceCard, cvaTitleCard, cvaToggleGroup, cvaToggleGroupWithSlidingBackground, cvaToggleItem, cvaToggleItemContent, cvaToggleItemText, cvaZStackContainer, cvaZStackItem, defaultPageSize, docs, getDevicePixelRatio, getValueBarColorByValue, iconColorNames, iconPalette, noPagination, preferenceCardGrid, storageSerializer, useBidirectionalScroll, useClickOutside, useContainerBreakpoints, useContinuousTimeout, useCopyToClipboard, useCursorUrlSync, useCustomEncoding, useDebounce, useDevicePixelRatio, useElevatedReducer, useElevatedState, useGridAreas, useHashParamSync, useHover, useInfiniteScroll, useIsFirstRender, useIsFullscreen, useIsTextTruncated, useKeyboardShortcut, useList, useListItemHeight, useLocalStorage, useLocalStorageReducer, useMeasure, useMenuTree, useMergeRefs, useModifierKey, useOptionalPopoverContext, useOverflowBorder, useOverflowItems, usePersistedState, usePopoverContext, usePrevious, usePrompt, useRandomCSSLengths, useRelayPagination, useResize, useScrollBlock, useScrollDetection, useSearchParamSync, useSelfUpdatingRef, useSessionStorage, useSessionStorageReducer, useSheet, useSheetSnap, useStorageKey, useTextSearch, useTimeout, useViewportBreakpoints, useWatch, useWindowActivity };