@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 +450 -287
- package/index.esm.js +451 -288
- package/migrations/entry.js.map +1 -0
- package/migrations/utils/jsx-utils.js.map +1 -0
- package/migrations/v2-0-0/breadcrumb-remove-deprecated-props.js.map +1 -0
- package/migrations/v2-0-0/cardheader-onclickclose-to-actions.js.map +1 -0
- package/migrations/v2-0-0/iconbutton-add-required-name.js.map +1 -0
- package/migrations/v2-0-0/kpi-tooltiplabel-to-wrapper.js.map +1 -0
- package/migrations/v2-0-0/notice-tooltiplabel-to-wrapper.js.map +1 -0
- package/migrations/v2-0-0/pagination-add-required-titles.js.map +1 -0
- package/migrations/v2-0-0/tag-add-removetaglabel.js.map +1 -0
- package/migrations/v2-0-0/togglegroup-remove-item-style.js.map +1 -0
- package/migrations/v3-0-0/menulist-rename-to-menucontent.js.map +1 -0
- package/package.json +6 -6
- package/src/components/Menu/MenuContent/MenuContent.d.ts +9 -1
- package/src/components/Menu/MenuContent/MenuContentContext.d.ts +23 -0
- package/src/components/Menu/MenuContent/compactFloatingListRefs.d.ts +18 -0
- package/src/components/Menu/MenuContent/useMenuContentState.d.ts +23 -0
- package/src/components/Menu/MenuItem/MenuItem.d.ts +4 -3
- package/src/components/Menu/MenuItem/useCloseWhenScrolledOut.d.ts +21 -0
- package/src/components/Menu/MenuItem/useMenuItemBehavior.d.ts +38 -0
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
|
|
7552
|
-
|
|
7553
|
-
|
|
7554
|
-
|
|
7555
|
-
|
|
7556
|
-
|
|
7557
|
-
|
|
7558
|
-
|
|
7559
|
-
|
|
7560
|
-
|
|
7561
|
-
|
|
7562
|
-
|
|
7563
|
-
|
|
7564
|
-
|
|
7565
|
-
|
|
7566
|
-
|
|
7567
|
-
|
|
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
|
-
|
|
7636
|
-
|
|
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:
|
|
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
|
/**
|