@trackunit/react-components 2.5.4 → 2.6.2-alpha-770d9994af7.0

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