@trackunit/react-components 2.5.4 → 2.6.3-alpha-0874090e5e0.0

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