@trackunit/react-components 2.10.25 → 2.10.27-alpha-20b461ca062.0

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