@trackunit/react-components 2.10.24 → 2.10.27-alpha-7557f71cc7a.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
@@ -12,7 +12,7 @@ import IconSpriteOutline from '@trackunit/ui-icons/icons-sprite-outline.svg';
12
12
  import IconSpriteSolid from '@trackunit/ui-icons/icons-sprite-solid.svg';
13
13
  import { snakeCase, titleCase } from 'string-ts';
14
14
  import { cvaMerge } from '@trackunit/css-class-variance-utilities';
15
- 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
+ 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, FloatingList, useListItem } from '@floating-ui/react';
16
16
  import { Slot, Slottable } from '@radix-ui/react-slot';
17
17
  import { omit, isEqual } from 'es-toolkit';
18
18
  import { Link, useBlocker, useNavigate, useLocation, useRouter, useSearch } from '@tanstack/react-router';
@@ -7278,6 +7278,257 @@ const cvaMenuListMultiSelect = cvaMerge([
7278
7278
  ]);
7279
7279
  const cvaMenuListItem = cvaMerge("max-w-full");
7280
7280
 
7281
+ const MenuContentContext = createContext(null);
7282
+ /**
7283
+ * Like `useOptionalPopoverContext`, but for the nearest `MenuContent`.
7284
+ * `MenuItem` is used without a menu in places, so a missing ancestor must not throw.
7285
+ *
7286
+ * @returns {MenuContentContextValue | null} The menu context, or `null` when there is no ancestor `MenuContent`
7287
+ */
7288
+ const useOptionalMenuContentContext = () => useContext(MenuContentContext);
7289
+
7290
+ /**
7291
+ * FloatingList only grows `elementsRef` / `labelsRef`. On unmount, `useListItem` nulls the element
7292
+ * but writes the label back, so a removed row stays a live typeahead target on a dead slot.
7293
+ * Re-establish both arrays from currently mounted nodes after each commit.
7294
+ *
7295
+ * @param elementsRef The FloatingList elements array
7296
+ * @param labelsRef The FloatingList typeahead labels array, kept in lockstep with `elementsRef`
7297
+ */
7298
+ const compactFloatingListRefs = (elementsRef, labelsRef) => {
7299
+ const elements = elementsRef.current;
7300
+ const labels = labelsRef.current;
7301
+ const length = Math.max(elements.length, labels.length);
7302
+ for (let index = 0; index < length; index++) {
7303
+ if (!elements[index]) {
7304
+ labels[index] = null;
7305
+ }
7306
+ }
7307
+ let lastLive = -1;
7308
+ for (let index = 0; index < length; index++) {
7309
+ if (elements[index]) {
7310
+ lastLive = index;
7311
+ }
7312
+ }
7313
+ elements.length = lastLive + 1;
7314
+ labels.length = lastLive + 1;
7315
+ };
7316
+ /**
7317
+ * Index of the first registered item that is not `aria-disabled`. Used as the initial tab stop
7318
+ * when `activeIndex` is still null.
7319
+ *
7320
+ * @param elementsRef The FloatingList elements array
7321
+ * @returns {number} The first enabled index, or `0` when the list has no enabled item
7322
+ */
7323
+ const firstEnabledListIndex = (elementsRef) => {
7324
+ const index = elementsRef.current.findIndex(element => {
7325
+ if (!element) {
7326
+ return false;
7327
+ }
7328
+ return element.getAttribute("aria-disabled") !== "true";
7329
+ });
7330
+ return index === -1 ? 0 : index;
7331
+ };
7332
+
7333
+ const UNMANAGED_SELECTION = [];
7334
+ /**
7335
+ * Owns MenuContent's list registration, selection, and typeable-child key handling so the
7336
+ * component file stays the shell + JSX.
7337
+ */
7338
+ const useMenuContentState = ({ children, isMulti, selectedItems: controlledSelectedItems, onSelectionChange, ref, }) => {
7339
+ const selectionManaged = controlledSelectedItems !== undefined || onSelectionChange !== undefined;
7340
+ const [internalSelectedItems, setInternalSelectedItems] = useState(controlledSelectedItems ?? []);
7341
+ const selectedItems = selectionManaged ? (controlledSelectedItems ?? internalSelectedItems) : UNMANAGED_SELECTION;
7342
+ const [activeIndex, setActiveIndex] = useState(null);
7343
+ const [firstEnabledIndex, setFirstEnabledIndex] = useState(0);
7344
+ const listRef = useRef([]);
7345
+ const labelsRef = useRef([]);
7346
+ const ambientPopover = useOptionalPopoverContext();
7347
+ // Falls back to a local, always-open floating context when there is no ambient `Popover` (e.g. `MenuContent`
7348
+ // rendered standalone inside a `Collapse`) -- `useFloating` must still be called unconditionally per the
7349
+ // Rules of Hooks, so this is cheap and simply unused whenever `ambientPopover` is present. When ambient, the
7350
+ // enclosing `PopoverContent` already calls `context.refs.setFloating` on this same DOM node, so
7351
+ // `elements.floating` is already populated there; standalone, nothing else does that, so this component's own
7352
+ // root node must be wired up as the floating element itself for `useListNavigation`'s internal effects (which
7353
+ // gate on `elements.floating`) to run.
7354
+ const { context: standaloneContext, refs: standaloneRefs } = useFloating({ open: true });
7355
+ const context = ambientPopover?.context ?? standaloneContext;
7356
+ const floatingRef = useMergeRefs$1([ambientPopover ? null : standaloneRefs.setFloating, ref]);
7357
+ // Reads the ambient popover's own `isNested` (resolved by its `usePopover()` call *before* it
7358
+ // wraps this content in `<FloatingNode>`) rather than calling `useMenuTree()` again from here --
7359
+ // from this position, beneath that `FloatingNode`, `useMenuTree()` would resolve the ambient
7360
+ // parent id to the enclosing popover's *own* id and misreport every menu (root included) as
7361
+ // nested. Standalone (no ambient `Popover`) is never nested.
7362
+ const isNested = ambientPopover?.isNested ?? false;
7363
+ const listNavigation = useListNavigation(context, {
7364
+ listRef,
7365
+ activeIndex,
7366
+ onNavigate: setActiveIndex,
7367
+ loop: true,
7368
+ nested: isNested,
7369
+ });
7370
+ const typeahead = useTypeahead(context, {
7371
+ listRef: labelsRef,
7372
+ activeIndex,
7373
+ onMatch: setActiveIndex,
7374
+ resetMs: 500,
7375
+ });
7376
+ const { getFloatingProps, getItemProps } = useInteractions([listNavigation, typeahead]);
7377
+ const syncRegisteredList = useCallback(() => {
7378
+ compactFloatingListRefs(listRef, labelsRef);
7379
+ const nextFirstEnabled = firstEnabledListIndex(listRef);
7380
+ setFirstEnabledIndex(current => (current === nextFirstEnabled ? current : nextFirstEnabled));
7381
+ setActiveIndex(current => {
7382
+ if (current === null) {
7383
+ return current;
7384
+ }
7385
+ // Truncation sets `elements.length = lastLive + 1`, so an activeIndex past the new end
7386
+ // reads `undefined`, not `null`.
7387
+ const activeElement = listRef.current[current];
7388
+ if (activeElement === null || activeElement === undefined) {
7389
+ return null;
7390
+ }
7391
+ return current;
7392
+ });
7393
+ }, []);
7394
+ // Compact after this commit, not from a departing MenuItem's layout cleanup: React runs that
7395
+ // cleanup before it detaches the row's list ref, so compaction would still see a live slot.
7396
+ // Trailing removals do not shift sibling indices, so only this post-commit pass truncates them.
7397
+ useLayoutEffect(() => {
7398
+ syncRegisteredList();
7399
+ }, [children, syncRegisteredList]);
7400
+ const handleItemClick = useCallback((id) => {
7401
+ if (!selectionManaged) {
7402
+ return;
7403
+ }
7404
+ const newSelectedItems = isMulti
7405
+ ? selectedItems.includes(id)
7406
+ ? selectedItems.filter(item => item !== id)
7407
+ : [...selectedItems, id]
7408
+ : [id];
7409
+ if (onSelectionChange !== undefined) {
7410
+ onSelectionChange(newSelectedItems);
7411
+ }
7412
+ else {
7413
+ setInternalSelectedItems(newSelectedItems);
7414
+ }
7415
+ }, [isMulti, selectedItems, onSelectionChange, selectionManaged]);
7416
+ const menuContext = useMemo(() => ({
7417
+ activeIndex,
7418
+ firstEnabledIndex,
7419
+ getItemProps,
7420
+ isMulti,
7421
+ selectedItems,
7422
+ handleItemClick,
7423
+ syncRegisteredList,
7424
+ }), [activeIndex, firstEnabledIndex, getItemProps, isMulti, selectedItems, handleItemClick, syncRegisteredList]);
7425
+ // Printable characters and Home/End/Left/Right stay native in nested inputs (e.g. FilterBar
7426
+ // Search). ArrowDown is left alone so a typeable child can enter the registered list.
7427
+ const handleKeyDownCapture = useCallback((event) => {
7428
+ if (!isTypeableElement(event.target)) {
7429
+ return;
7430
+ }
7431
+ if (event.key === "ArrowDown") {
7432
+ return;
7433
+ }
7434
+ // ArrowUp is stopped so wrapping does not jump to the last item while the user is in Search.
7435
+ // Phase 2 (FilterBar) adds the reverse path: ArrowUp on the first row returns to Search.
7436
+ if (event.key.length === 1 ||
7437
+ event.key === "Home" ||
7438
+ event.key === "End" ||
7439
+ event.key === "ArrowLeft" ||
7440
+ event.key === "ArrowRight" ||
7441
+ event.key === "ArrowUp") {
7442
+ event.stopPropagation();
7443
+ }
7444
+ }, []);
7445
+ return useMemo(() => ({
7446
+ floatingRef,
7447
+ getFloatingProps,
7448
+ handleKeyDownCapture,
7449
+ listRef,
7450
+ labelsRef,
7451
+ menuContext,
7452
+ }), [floatingRef, getFloatingProps, handleKeyDownCapture, menuContext]);
7453
+ };
7454
+
7455
+ /**
7456
+ * MenuContent (formerly MenuList) is a popover menu that appears above all other content on the page. It offers a
7457
+ * list of actions or functions that a user can access by clicking on a trigger, with full keyboard support:
7458
+ * roving-tabindex Up/Down navigation (wrapping), Home/End, typeahead, and — via `MenuItem`'s `submenu` prop —
7459
+ * nested submenu entry/exit.
7460
+ *
7461
+ * Items join this menu themselves via `FloatingList` + `useListItem`, so wrapping components do not hide them
7462
+ * from the keyboard loop. Non-item children (search inputs, headings, dividers) render untouched.
7463
+ *
7464
+ * Typically rendered inside a `Popover` (directly, or as `PopoverContent`'s children), in which case it reads
7465
+ * the popover's floating context to power its keyboard navigation. Also works standalone (e.g. inside a
7466
+ * `Collapse`, with no ambient `Popover`), falling back to its own local, always-open floating context.
7467
+ *
7468
+ * **When to use**
7469
+ * - Use the MenuContent if you have limited space and need to display overflow actions in a list.
7470
+ * - Use the MenuContent for actions that are not essential to completing workflows.
7471
+ * - 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).
7472
+ *
7473
+ * @example MenuContent with action items
7474
+ * ```tsx
7475
+ * import { MenuContent, MenuItem, MoreMenu, Icon } from "@trackunit/react-components";
7476
+ *
7477
+ * const ActionsMenu = () => (
7478
+ * <MoreMenu>
7479
+ * {(close) => (
7480
+ * <MenuContent onClick={close}>
7481
+ * <MenuItem id="edit" prefix={<Icon name="PencilSquare" size="small" />}>
7482
+ * Edit
7483
+ * </MenuItem>
7484
+ * <MenuItem id="duplicate" prefix={<Icon name="DocumentDuplicate" size="small" />}>
7485
+ * Duplicate
7486
+ * </MenuItem>
7487
+ * <MenuItem id="delete" prefix={<Icon name="Trash" size="small" />} destructive>
7488
+ * Delete
7489
+ * </MenuItem>
7490
+ * </MenuContent>
7491
+ * )}
7492
+ * </MoreMenu>
7493
+ * );
7494
+ * ```
7495
+ * @example Multi-select MenuContent
7496
+ * ```tsx
7497
+ * import { MenuContent, MenuItem, MoreMenu } from "@trackunit/react-components";
7498
+ * import { useState } from "react";
7499
+ *
7500
+ * const FilterMenu = () => {
7501
+ * const [selected, setSelected] = useState<string[]>(["active"]);
7502
+ *
7503
+ * return (
7504
+ * <MoreMenu label="Filter by status">
7505
+ * <MenuContent
7506
+ * isMulti
7507
+ * selectedItems={selected}
7508
+ * onSelectionChange={setSelected}
7509
+ * >
7510
+ * <MenuItem id="active">Active</MenuItem>
7511
+ * <MenuItem id="idle">Idle</MenuItem>
7512
+ * <MenuItem id="offline">Offline</MenuItem>
7513
+ * </MenuContent>
7514
+ * </MoreMenu>
7515
+ * );
7516
+ * };
7517
+ * ```
7518
+ * @param {MenuContentProps} props - The props for the MenuContent component
7519
+ * @returns {ReactElement} MenuContent component
7520
+ */
7521
+ const MenuContent = ({ "data-testid": dataTestId, className, listClassName, children, isMulti = false, selectedItems, onSelectionChange, style, ref, "aria-label": ariaLabel, ...args }) => {
7522
+ const { floatingRef, getFloatingProps, handleKeyDownCapture, listRef, labelsRef, menuContext } = useMenuContentState({
7523
+ children,
7524
+ isMulti,
7525
+ selectedItems,
7526
+ onSelectionChange,
7527
+ ref,
7528
+ });
7529
+ return (jsx("div", { "aria-label": ariaLabel, 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: jsx(MenuContentContext.Provider, { value: menuContext, children: jsx(FloatingList, { elementsRef: listRef, labelsRef: labelsRef, children: children }) }) }) }));
7530
+ };
7531
+
7281
7532
  /**
7282
7533
  * MenuDivider renders a horizontal line to visually separate groups of items within a MenuContent.
7283
7534
  *
@@ -7481,6 +7732,182 @@ const cvaMenuItemSuffix = cvaMerge(["text-neutral-400", "text-sm", "flex", "item
7481
7732
  },
7482
7733
  });
7483
7734
 
7735
+ /**
7736
+ * A submenu's trigger row commonly lives inside a scrollable `MenuContent` list. Without this, a
7737
+ * submenu opened from a row that later scrolls out of view stays open, floating disconnected from
7738
+ * any visible row -- and (since nothing else would ever close it) stays that way even once the row
7739
+ * scrolls back into view. `IntersectionObserver` (rather than e.g. the `Popover`'s own
7740
+ * `ancestorScroll` dismissal) is what lets this react to the row's actual visibility instead of
7741
+ * firing on every scroll tick of any ancestor, however small or unrelated to this row -- browsers
7742
+ * compute intersection against every ancestor's overflow-clip box, not just an explicit `root`, so
7743
+ * this fires as soon as *this* row's clipped out, even if some other list ancestor keeps scrolling.
7744
+ * Only observing while `open` (rather than unconditionally) means the effect's own close
7745
+ * never re-triggers itself, which is also exactly what keeps the submenu from reopening on its own
7746
+ * once the row scrolls back into view -- there's no observer left running to notice that happen.
7747
+ */
7748
+ const useCloseWhenScrolledOut = ({ open, nodeRef, returnFocusRef, onClose, }) => {
7749
+ useEffect(() => {
7750
+ if (!open) {
7751
+ return;
7752
+ }
7753
+ const node = nodeRef.current;
7754
+ if (node === null) {
7755
+ return;
7756
+ }
7757
+ returnFocusRef.current = node;
7758
+ const observer = new IntersectionObserver(([entry]) => {
7759
+ if (entry !== undefined && !entry.isIntersecting) {
7760
+ // The row itself is what just scrolled out -- not a safe place to return focus to.
7761
+ // `PopoverContent`'s `returnFocus` checks the target's visibility itself right at close
7762
+ // time and falls back to its own hidden, off-screen element when it isn't visible, so
7763
+ // this doesn't need to clear `returnFocusRef` by hand first.
7764
+ onClose();
7765
+ }
7766
+ }, { threshold: 0 });
7767
+ observer.observe(node);
7768
+ return () => observer.disconnect();
7769
+ }, [open, nodeRef, onClose, returnFocusRef]);
7770
+ };
7771
+
7772
+ const getTypeaheadLabel = ({ disabled, label, children, }) => {
7773
+ if (disabled === true) {
7774
+ return null;
7775
+ }
7776
+ if (typeof label === "string" && label !== "") {
7777
+ return label;
7778
+ }
7779
+ if (typeof children === "string") {
7780
+ return children;
7781
+ }
7782
+ return null;
7783
+ };
7784
+ /**
7785
+ * When `activeIndex` is still null (nothing keyboard-navigated yet), the first *enabled*
7786
+ * registered item keeps `tabIndex={0}` so Tab can land in the list.
7787
+ */
7788
+ const isActiveInMenu = (menu, index, disabled) => {
7789
+ if (index < 0 || disabled) {
7790
+ return false;
7791
+ }
7792
+ if (menu.activeIndex === index) {
7793
+ return true;
7794
+ }
7795
+ return menu.activeIndex === null && index === menu.firstEnabledIndex;
7796
+ };
7797
+ /**
7798
+ * Registers this row with the nearest `MenuContent` (no-op when there isn't one) and resolves the
7799
+ * interaction / selection / tabIndex props the row needs. Submenu open state lives here so the
7800
+ * scroll-out closer can share it.
7801
+ */
7802
+ const useMenuItemBehavior = ({ className, label, children, selected, disabled, onClick, stopPropagation, id, tabIndex, ref, submenu, suffix, onFocus, onMouseMove, onPointerLeave, }) => {
7803
+ const [submenuOpen, setSubmenuOpen] = useState(false);
7804
+ const closeSubmenu = useCallback(() => setSubmenuOpen(false), []);
7805
+ const triggerRowRef = useRef(null);
7806
+ const menu = useOptionalMenuContentContext();
7807
+ // Always called: without a `FloatingList` ancestor this is a no-op, so standalone `MenuItem` still renders.
7808
+ const { ref: listItemRef, index } = useListItem({ label: getTypeaheadLabel({ disabled, label, children }) });
7809
+ const syncRegisteredList = menu?.syncRegisteredList;
7810
+ useLayoutEffect(() => {
7811
+ if (index < 0) {
7812
+ return;
7813
+ }
7814
+ syncRegisteredList?.();
7815
+ }, [index, syncRegisteredList, disabled]);
7816
+ const mergedTriggerRef = useMergeRefs$1([listItemRef, ref, triggerRowRef]);
7817
+ const mergedRowRef = useMergeRefs$1([listItemRef, ref]);
7818
+ // Where `PopoverContent` below returns focus to once the submenu closes -- normally the trigger
7819
+ // row itself (kept in sync below). `PopoverContent`'s own `returnFocus` is visibility-aware, so
7820
+ // this doesn't need to be cleared by hand right before the scroll-out auto-close further down --
7821
+ // see that prop's own doc comment for why.
7822
+ const returnFocusRef = useRef(null);
7823
+ useCloseWhenScrolledOut({
7824
+ open: submenuOpen,
7825
+ nodeRef: triggerRowRef,
7826
+ returnFocusRef,
7827
+ onClose: closeSubmenu,
7828
+ });
7829
+ const handleKeyDown = useCallback((e) => {
7830
+ // Enter/Space are already handled by the internal Popover's `activation: { click: true }`
7831
+ // (`useClick`'s standard button-like keyboard semantics) -- only ArrowRight needs wiring up
7832
+ // by hand here, since arrow keys aren't part of click/button semantics.
7833
+ if (submenu !== undefined && disabled !== true && e.key === "ArrowRight") {
7834
+ e.preventDefault();
7835
+ e.stopPropagation();
7836
+ setSubmenuOpen(true);
7837
+ return;
7838
+ }
7839
+ if (e.key === "Enter" && onClick !== undefined && disabled !== true) {
7840
+ if (stopPropagation) {
7841
+ e.stopPropagation();
7842
+ }
7843
+ // eslint-disable-next-line @trackunit/no-typescript-assertion
7844
+ onClick(e);
7845
+ }
7846
+ }, [disabled, onClick, stopPropagation, submenu]);
7847
+ const handleItemClick = useCallback(e => {
7848
+ if (stopPropagation) {
7849
+ e.stopPropagation();
7850
+ }
7851
+ onClick?.(e);
7852
+ if (menu !== null && disabled !== true) {
7853
+ menu.handleItemClick(id ?? `${index}`);
7854
+ }
7855
+ }, [disabled, id, index, menu, onClick, stopPropagation]);
7856
+ const isSelected = menu !== null ? Boolean(selected || menu.selectedItems.includes(id ?? `${index}`)) : selected;
7857
+ const listClassName = menu === null
7858
+ ? className
7859
+ : menu.isMulti && isSelected
7860
+ ? cvaMenuListMultiSelect({ className })
7861
+ : cvaMenuListItem({ className });
7862
+ const resolvedTabIndex = disabled
7863
+ ? -1
7864
+ : menu === null
7865
+ ? (tabIndex ?? 0)
7866
+ : isActiveInMenu(menu, index, disabled)
7867
+ ? 0
7868
+ : -1;
7869
+ // Consumer suffix wins; otherwise multi-select may inject a check. `undefined` (not `null`) is
7870
+ // required so `MenuItemRow` can still auto-render the submenu chevron when neither applies.
7871
+ const resolvedSuffix = useMemo(() => suffix ??
7872
+ (menu?.isMulti === true && isSelected ? (jsx(Icon, { className: "text-primary-600 block", name: "Check", size: "medium" })) : undefined), [suffix, menu?.isMulti, isSelected]);
7873
+ const interactionProps = useMemo(() => menu === null
7874
+ ? {
7875
+ onClick: handleItemClick,
7876
+ onFocus,
7877
+ onMouseMove,
7878
+ onPointerLeave,
7879
+ onKeyDown: handleKeyDown,
7880
+ }
7881
+ : menu.getItemProps({
7882
+ onClick: handleItemClick,
7883
+ onFocus,
7884
+ onMouseMove,
7885
+ onPointerLeave,
7886
+ onKeyDown: handleKeyDown,
7887
+ }), [handleItemClick, handleKeyDown, menu, onFocus, onMouseMove, onPointerLeave]);
7888
+ return useMemo(() => ({
7889
+ mergedRowRef,
7890
+ mergedTriggerRef,
7891
+ submenuOpen,
7892
+ setSubmenuOpen,
7893
+ returnFocusRef,
7894
+ isSelected,
7895
+ resolvedSuffix,
7896
+ listClassName,
7897
+ resolvedTabIndex,
7898
+ interactionProps,
7899
+ }), [
7900
+ mergedRowRef,
7901
+ mergedTriggerRef,
7902
+ submenuOpen,
7903
+ isSelected,
7904
+ resolvedSuffix,
7905
+ listClassName,
7906
+ resolvedTabIndex,
7907
+ interactionProps,
7908
+ ]);
7909
+ };
7910
+
7484
7911
  /**
7485
7912
  * The row markup shared by both the submenu-less and submenu-bearing shapes of `MenuItem` -- pulled
7486
7913
  * out into its own component (rather than a JSX variable held in `MenuItem`) so each shape can
@@ -7548,95 +7975,43 @@ const MenuItemRow = ({ label, children, prefix, suffix, selected = false, disabl
7548
7975
  * @returns {ReactElement} MenuItem component
7549
7976
  */
7550
7977
  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, }) => {
7551
- const [submenuOpen, setSubmenuOpen] = useState(false);
7552
- const triggerRowRef = useRef(null);
7553
- const mergedTriggerRef = useMergeRefs$1([ref, triggerRowRef]);
7554
- // Where `PopoverContent` below returns focus to once the submenu closes -- normally the trigger
7555
- // row itself (kept in sync below). `PopoverContent`'s own `returnFocus` is visibility-aware, so
7556
- // this doesn't need to be cleared by hand right before the scroll-out auto-close further down --
7557
- // see that prop's own doc comment for why.
7558
- const returnFocusRef = useRef(null);
7559
- // A submenu's trigger row commonly lives inside a scrollable `MenuContent` list. Without this, a
7560
- // submenu opened from a row that later scrolls out of view stays open, floating disconnected from
7561
- // any visible row -- and (since nothing else would ever close it) stays that way even once the row
7562
- // scrolls back into view. `IntersectionObserver` (rather than e.g. the `Popover`'s own
7563
- // `ancestorScroll` dismissal) is what lets this react to the row's actual visibility instead of
7564
- // firing on every scroll tick of any ancestor, however small or unrelated to this row -- browsers
7565
- // compute intersection against every ancestor's overflow-clip box, not just an explicit `root`, so
7566
- // this fires as soon as *this* row's clipped out, even if some other list ancestor keeps scrolling.
7567
- // Only observing while `submenuOpen` (rather than unconditionally) means the effect's own close
7568
- // never re-triggers itself, which is also exactly what keeps the submenu from reopening on its own
7569
- // once the row scrolls back into view -- there's no observer left running to notice that happen.
7570
- useEffect(() => {
7571
- if (!submenuOpen) {
7572
- return;
7573
- }
7574
- const node = triggerRowRef.current;
7575
- if (node === null) {
7576
- return;
7577
- }
7578
- returnFocusRef.current = node;
7579
- const observer = new IntersectionObserver(([entry]) => {
7580
- if (entry !== undefined && !entry.isIntersecting) {
7581
- // The row itself is what just scrolled out -- not a safe place to return focus to.
7582
- // `PopoverContent`'s `returnFocus` checks the target's visibility itself right at close
7583
- // time and falls back to its own hidden, off-screen element when it isn't visible, so
7584
- // this doesn't need to clear `returnFocusRef` by hand first.
7585
- setSubmenuOpen(false);
7586
- }
7587
- }, { threshold: 0 });
7588
- observer.observe(node);
7589
- return () => observer.disconnect();
7590
- }, [submenuOpen]);
7591
- /* Handle tab navigation */
7592
- const handleKeyDown = (e) => {
7593
- // Enter/Space are already handled by the internal Popover's `activation: { click: true }`
7594
- // (`useClick`'s standard button-like keyboard semantics) -- only ArrowRight needs wiring up
7595
- // by hand here, since arrow keys aren't part of click/button semantics.
7596
- if (submenu !== undefined && disabled !== true && e.key === "ArrowRight") {
7597
- e.preventDefault();
7598
- e.stopPropagation();
7599
- setSubmenuOpen(true);
7600
- return;
7601
- }
7602
- if (e.key === "Enter" && onClick !== undefined && disabled !== true) {
7603
- if (stopPropagation) {
7604
- e.stopPropagation();
7605
- }
7606
- // eslint-disable-next-line @trackunit/no-typescript-assertion
7607
- onClick(e);
7608
- }
7609
- };
7610
- const handleItemClick = e => {
7611
- if (stopPropagation) {
7612
- e.stopPropagation();
7613
- }
7614
- onClick?.(e);
7615
- };
7978
+ const { mergedRowRef, mergedTriggerRef, submenuOpen, setSubmenuOpen, returnFocusRef, isSelected, resolvedSuffix, listClassName, resolvedTabIndex, interactionProps, } = useMenuItemBehavior({
7979
+ className,
7980
+ label,
7981
+ children,
7982
+ selected,
7983
+ disabled,
7984
+ onClick,
7985
+ stopPropagation,
7986
+ id,
7987
+ tabIndex,
7988
+ ref,
7989
+ submenu,
7990
+ suffix,
7991
+ onFocus,
7992
+ onMouseMove,
7993
+ onPointerLeave,
7994
+ });
7616
7995
  const itemRowProps = {
7617
7996
  id,
7618
7997
  label,
7619
7998
  children,
7620
7999
  prefix,
7621
- suffix,
7622
- selected,
8000
+ suffix: resolvedSuffix,
8001
+ selected: isSelected,
7623
8002
  disabled,
7624
8003
  variant,
7625
8004
  optionLabelDescription,
7626
8005
  optionPrefix,
7627
8006
  style,
7628
- onFocus,
7629
- onMouseMove,
7630
- onPointerLeave,
7631
- onClick: handleItemClick,
7632
- onKeyDown: handleKeyDown,
7633
8007
  dataTestId,
7634
8008
  hasSubmenu: submenu !== undefined,
7635
- className: cvaMenuItem({ selected, fieldSize, disabled, className, variant, focused }),
7636
- tabIndex: disabled ? -1 : (tabIndex ?? 0),
8009
+ ...interactionProps,
8010
+ className: cvaMenuItem({ selected: isSelected, fieldSize, disabled, className: listClassName, variant, focused }),
8011
+ tabIndex: resolvedTabIndex,
7637
8012
  };
7638
8013
  if (submenu === undefined) {
7639
- return jsx(MenuItemRow, { ...itemRowProps, ref: ref });
8014
+ return jsx(MenuItemRow, { ...itemRowProps, ref: mergedRowRef });
7640
8015
  }
7641
8016
  // Menu role lives on the nested `MenuContent` only — the Popover keeps its default `dialog`
7642
8017
  // floating role so SRs don't see nested `role="menu"`. `aria-haspopup="menu"` overrides
@@ -7644,218 +8019,6 @@ const MenuItem = ({ className, "data-testid": dataTestId, label, children, selec
7644
8019
  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 })] }));
7645
8020
  };
7646
8021
 
7647
- /**
7648
- * Derives the plain-text label used for typeahead matching. Disabled items are excluded (`null`)
7649
- * so typing never lands focus on an item the user can't act on.
7650
- */
7651
- const getTypeaheadLabel = (props) => {
7652
- if (props.disabled === true) {
7653
- return null;
7654
- }
7655
- if (typeof props.label === "string" && props.label !== "") {
7656
- return props.label;
7657
- }
7658
- if (typeof props.children === "string") {
7659
- return props.children;
7660
- }
7661
- return null;
7662
- };
7663
- /**
7664
- * MenuContent (formerly MenuList) is a popover menu that appears above all other content on the page. It offers a
7665
- * list of actions or functions that a user can access by clicking on a trigger, with full keyboard support:
7666
- * roving-tabindex Up/Down navigation (wrapping), Home/End, typeahead, and — via `MenuItem`'s `submenu` prop —
7667
- * nested submenu entry/exit.
7668
- *
7669
- * Typically rendered inside a `Popover` (directly, or as `PopoverContent`'s children), in which case it reads
7670
- * the popover's floating context to power its keyboard navigation. Also works standalone (e.g. inside a
7671
- * `Collapse`, with no ambient `Popover`), falling back to its own local, always-open floating context.
7672
- *
7673
- * **When to use**
7674
- * - Use the MenuContent if you have limited space and need to display overflow actions in a list.
7675
- * - Use the MenuContent for actions that are not essential to completing workflows.
7676
- * - 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).
7677
- *
7678
- * @example MenuContent with action items
7679
- * ```tsx
7680
- * import { MenuContent, MenuItem, MoreMenu, Icon } from "@trackunit/react-components";
7681
- *
7682
- * const ActionsMenu = () => (
7683
- * <MoreMenu>
7684
- * {(close) => (
7685
- * <MenuContent onClick={close}>
7686
- * <MenuItem id="edit" prefix={<Icon name="PencilSquare" size="small" />}>
7687
- * Edit
7688
- * </MenuItem>
7689
- * <MenuItem id="duplicate" prefix={<Icon name="DocumentDuplicate" size="small" />}>
7690
- * Duplicate
7691
- * </MenuItem>
7692
- * <MenuItem id="delete" prefix={<Icon name="Trash" size="small" />} destructive>
7693
- * Delete
7694
- * </MenuItem>
7695
- * </MenuContent>
7696
- * )}
7697
- * </MoreMenu>
7698
- * );
7699
- * ```
7700
- * @example Multi-select MenuContent
7701
- * ```tsx
7702
- * import { MenuContent, MenuItem, MoreMenu } from "@trackunit/react-components";
7703
- * import { useState } from "react";
7704
- *
7705
- * const FilterMenu = () => {
7706
- * const [selected, setSelected] = useState<string[]>(["active"]);
7707
- *
7708
- * return (
7709
- * <MoreMenu label="Filter by status">
7710
- * <MenuContent
7711
- * isMulti
7712
- * selectedItems={selected}
7713
- * onSelectionChange={setSelected}
7714
- * >
7715
- * <MenuItem id="active">Active</MenuItem>
7716
- * <MenuItem id="idle">Idle</MenuItem>
7717
- * <MenuItem id="offline">Offline</MenuItem>
7718
- * </MenuContent>
7719
- * </MoreMenu>
7720
- * );
7721
- * };
7722
- * ```
7723
- * @param {MenuContentProps} props - The props for the MenuContent component
7724
- * @returns {ReactElement} MenuContent component
7725
- */
7726
- const MenuContent = ({ "data-testid": dataTestId, className, listClassName, children, isMulti = false, selectedItems: controlledSelectedItems, onSelectionChange, style, ref, ...args }) => {
7727
- const childrenArr = useMemo(() => Children.toArray(children), [children]);
7728
- const [internalSelectedItems, setInternalSelectedItems] = useState(controlledSelectedItems ?? []);
7729
- const selectedItems = controlledSelectedItems ?? internalSelectedItems;
7730
- const [activeIndex, setActiveIndex] = useState(null);
7731
- const listRef = useRef([]);
7732
- const labelsRef = useRef([]);
7733
- const ambientPopover = useOptionalPopoverContext();
7734
- // Falls back to a local, always-open floating context when there is no ambient `Popover` (e.g. `MenuContent`
7735
- // rendered standalone inside a `Collapse`) -- `useFloating` must still be called unconditionally per the
7736
- // Rules of Hooks, so this is cheap and simply unused whenever `ambientPopover` is present. When ambient, the
7737
- // enclosing `PopoverContent` already calls `context.refs.setFloating` on this same DOM node, so
7738
- // `elements.floating` is already populated there; standalone, nothing else does that, so this component's own
7739
- // root node must be wired up as the floating element itself for `useListNavigation`'s internal effects (which
7740
- // gate on `elements.floating`) to run.
7741
- const { context: standaloneContext, refs: standaloneRefs } = useFloating({ open: true });
7742
- const context = ambientPopover?.context ?? standaloneContext;
7743
- const floatingRef = useMergeRefs$1([ambientPopover ? null : standaloneRefs.setFloating, ref]);
7744
- // Reads the ambient popover's own `isNested` (resolved by its `usePopover()` call *before* it
7745
- // wraps this content in `<FloatingNode>`) rather than calling `useMenuTree()` again from here --
7746
- // from this position, beneath that `FloatingNode`, `useMenuTree()` would resolve the ambient
7747
- // parent id to the enclosing popover's *own* id and misreport every menu (root included) as
7748
- // nested. Standalone (no ambient `Popover`) is never nested.
7749
- const isNested = ambientPopover?.isNested ?? false;
7750
- const listNavigation = useListNavigation(context, {
7751
- listRef,
7752
- activeIndex,
7753
- onNavigate: setActiveIndex,
7754
- loop: true,
7755
- nested: isNested,
7756
- });
7757
- const typeahead = useTypeahead(context, {
7758
- listRef: labelsRef,
7759
- activeIndex,
7760
- onMatch: setActiveIndex,
7761
- resetMs: 500,
7762
- });
7763
- const { getFloatingProps, getItemProps } = useInteractions([listNavigation, typeahead]);
7764
- const handleItemClick = useCallback((id) => {
7765
- const newSelectedItems = isMulti
7766
- ? selectedItems.includes(id)
7767
- ? selectedItems.filter(item => item !== id)
7768
- : [...selectedItems, id]
7769
- : [id];
7770
- if (onSelectionChange !== undefined) {
7771
- onSelectionChange(newSelectedItems);
7772
- }
7773
- else {
7774
- setInternalSelectedItems(newSelectedItems);
7775
- }
7776
- }, [isMulti, selectedItems, onSelectionChange]);
7777
- // Only real `MenuItem`s participate in keyboard navigation. Arbitrary content (e.g. a `Search` input or a
7778
- // list of checkboxes, via `Filter`'s children) can be rendered alongside them, but is excluded from the nav
7779
- // list and rendered untouched -- `useListNavigation`/`useTypeahead` treat every registered item as a single
7780
- // atomic focus target, an assumption that breaks down for a wrapper `<div>` holding several of its own
7781
- // independently-focusable descendants (arrow keys skipping over it, its own click/focus handlers stealing
7782
- // DOM focus from whatever's inside it). Computed as plain, memoized values (never touching listRef/labelsRef
7783
- // here -- see the layout effect below, since refs must not be read/written during render).
7784
- const { navIndicesByChildIndex, navLabels, firstEnabledNavIndex } = useMemo(() => {
7785
- const indices = [];
7786
- const labels = [];
7787
- let itemCount = 0;
7788
- let firstEnabledIndex = -1;
7789
- childrenArr.forEach(child => {
7790
- if (isValidElement(child) && child.type === MenuItem) {
7791
- if (firstEnabledIndex === -1 && child.props.disabled !== true) {
7792
- firstEnabledIndex = itemCount;
7793
- }
7794
- indices.push(itemCount);
7795
- labels.push(getTypeaheadLabel(child.props));
7796
- itemCount += 1;
7797
- }
7798
- else {
7799
- indices.push(-1);
7800
- }
7801
- });
7802
- return { navIndicesByChildIndex: indices, navLabels: labels, firstEnabledNavIndex: firstEnabledIndex };
7803
- }, [childrenArr]);
7804
- useLayoutEffect(() => {
7805
- listRef.current.length = navLabels.length;
7806
- labelsRef.current = navLabels;
7807
- }, [navLabels]);
7808
- // A focused descendant's own keydown would otherwise be swallowed by `useTypeahead` as a single-character
7809
- // match attempt (see `getFloatingProps` below); this leaves native typing in nested content (e.g. a `Search`
7810
- // input rendered as one of `MenuContent`'s non-`MenuItem` children) untouched.
7811
- const handleKeyDownCapture = (event) => {
7812
- if (isTypeableElement(event.target)) {
7813
- event.stopPropagation();
7814
- }
7815
- };
7816
- const createItemRefSetter = useCallback((itemNavIndex) => (node) => {
7817
- listRef.current[itemNavIndex] = node;
7818
- }, []);
7819
- 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) => {
7820
- if (!isValidElement(menuItem)) {
7821
- return null;
7822
- }
7823
- if (menuItem.type === MenuDivider) {
7824
- return cloneElement(menuItem, { key: index });
7825
- }
7826
- // Arbitrary, non-`MenuItem` content (see the comment on `navIndicesByChildIndex` above) is rendered
7827
- // untouched, participating in neither roving tabindex nor typeahead -- its own interactive
7828
- // descendants (inputs, checkboxes, buttons, ...) keep their native focus/click/keydown behavior.
7829
- if (menuItem.type !== MenuItem) {
7830
- return cloneElement(menuItem, { key: index });
7831
- }
7832
- const itemNavIndex = navIndicesByChildIndex[index] ?? -1;
7833
- const disabled = menuItem.props.disabled ?? false;
7834
- const isSelected = (selectedItems.includes(menuItem.props.id ?? `${index}`) || menuItem.props.selected) ?? false;
7835
- const isActive = activeIndex === itemNavIndex || (activeIndex === null && itemNavIndex === firstEnabledNavIndex);
7836
- return cloneElement(menuItem, {
7837
- ...menuItem.props,
7838
- key: index,
7839
- ...getItemProps({
7840
- onClick: (event) => {
7841
- menuItem.props.onClick?.(event);
7842
- if (!disabled) {
7843
- handleItemClick(menuItem.props.id ?? `${index}`);
7844
- }
7845
- },
7846
- }),
7847
- tabIndex: disabled ? -1 : isActive ? 0 : -1,
7848
- ref: createItemRefSetter(itemNavIndex),
7849
- className: isMulti && isSelected
7850
- ? cvaMenuListMultiSelect({ className: menuItem.props.className })
7851
- : cvaMenuListItem({ className: menuItem.props.className }),
7852
- selected: isSelected,
7853
- suffix: menuItem.props.suffix ??
7854
- (isMulti && isSelected ? jsx(Icon, { className: "text-primary-600 block", name: "Check", size: "medium" }) : null),
7855
- });
7856
- }) }) }));
7857
- };
7858
-
7859
8022
  const cvaMoreMenu = cvaMerge(["p-0"]);
7860
8023
 
7861
8024
  /**