@trackunit/react-components 2.11.2 → 2.11.6
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 +152 -11
- package/index.esm.js +152 -12
- package/package.json +6 -6
- package/src/components/Menu/MenuContent/MenuContent.d.ts +14 -1
- package/src/components/Menu/MenuContent/MenuContentContext.d.ts +11 -0
- package/src/components/Menu/MenuContent/compactFloatingListRefs.d.ts +4 -3
- package/src/components/Menu/MenuContent/useMenuContentState.d.ts +2 -1
- package/src/components/Menu/MenuContent/useMenuSearchField.d.ts +61 -0
- package/src/index.d.ts +1 -0
- package/translation.cjs.js +2 -0
- package/translation.esm.js +2 -0
- package/translation10.cjs.js +2 -0
- package/translation10.esm.js +2 -0
- package/translation11.cjs.js +2 -0
- package/translation11.esm.js +2 -0
- package/translation12.cjs.js +2 -0
- package/translation12.esm.js +2 -0
- package/translation13.cjs.js +2 -0
- package/translation13.esm.js +2 -0
- package/translation14.cjs.js +2 -0
- package/translation14.esm.js +2 -0
- package/translation15.cjs.js +2 -0
- package/translation15.esm.js +2 -0
- package/translation16.cjs.js +2 -0
- package/translation16.esm.js +2 -0
- package/translation17.cjs.js +2 -0
- package/translation17.esm.js +2 -0
- package/translation2.cjs.js +2 -0
- package/translation2.esm.js +2 -0
- package/translation3.cjs.js +2 -0
- package/translation3.esm.js +2 -0
- package/translation4.cjs.js +2 -0
- package/translation4.esm.js +2 -0
- package/translation5.cjs.js +2 -0
- package/translation5.esm.js +2 -0
- package/translation6.cjs.js +2 -0
- package/translation6.esm.js +2 -0
- package/translation7.cjs.js +2 -0
- package/translation7.esm.js +2 -0
- package/translation8.cjs.js +2 -0
- package/translation8.esm.js +2 -0
- package/translation9.cjs.js +2 -0
- package/translation9.esm.js +2 -0
package/index.cjs.js
CHANGED
|
@@ -7364,15 +7364,16 @@ const compactFloatingListRefs = (elementsRef, labelsRef) => {
|
|
|
7364
7364
|
labels.length = lastLive + 1;
|
|
7365
7365
|
};
|
|
7366
7366
|
/**
|
|
7367
|
-
* Index of the first registered
|
|
7367
|
+
* Index of the first registered entry that is not `aria-disabled`. Used as the initial tab stop
|
|
7368
7368
|
* when `activeIndex` is still null.
|
|
7369
7369
|
*
|
|
7370
7370
|
* @param elementsRef The FloatingList elements array
|
|
7371
|
-
* @
|
|
7371
|
+
* @param skipIndex Registered index to ignore, used to look past a search field to the first real item
|
|
7372
|
+
* @returns {number} The first enabled index, or `0` when the list has no enabled entry
|
|
7372
7373
|
*/
|
|
7373
|
-
const firstEnabledListIndex = (elementsRef) => {
|
|
7374
|
-
const index = elementsRef.current.findIndex(element => {
|
|
7375
|
-
if (!element) {
|
|
7374
|
+
const firstEnabledListIndex = (elementsRef, skipIndex) => {
|
|
7375
|
+
const index = elementsRef.current.findIndex((element, elementIndex) => {
|
|
7376
|
+
if (!element || elementIndex === skipIndex) {
|
|
7376
7377
|
return false;
|
|
7377
7378
|
}
|
|
7378
7379
|
return element.getAttribute("aria-disabled") !== "true";
|
|
@@ -7385,7 +7386,7 @@ const UNMANAGED_SELECTION = [];
|
|
|
7385
7386
|
* Owns MenuContent's list registration, selection, and typeable-child key handling so the
|
|
7386
7387
|
* component file stays the shell + JSX.
|
|
7387
7388
|
*/
|
|
7388
|
-
const useMenuContentState = ({ children, isMulti, selectedItems: controlledSelectedItems, onSelectionChange, ref, }) => {
|
|
7389
|
+
const useMenuContentState = ({ children, isMulti, autoFocus, selectedItems: controlledSelectedItems, onSelectionChange, ref, }) => {
|
|
7389
7390
|
const selectionManaged = controlledSelectedItems !== undefined || onSelectionChange !== undefined;
|
|
7390
7391
|
const [internalSelectedItems, setInternalSelectedItems] = react.useState(controlledSelectedItems ?? []);
|
|
7391
7392
|
const selectedItems = selectionManaged ? (controlledSelectedItems ?? internalSelectedItems) : UNMANAGED_SELECTION;
|
|
@@ -7393,6 +7394,10 @@ const useMenuContentState = ({ children, isMulti, selectedItems: controlledSelec
|
|
|
7393
7394
|
const [firstEnabledIndex, setFirstEnabledIndex] = react.useState(0);
|
|
7394
7395
|
const listRef = react.useRef([]);
|
|
7395
7396
|
const labelsRef = react.useRef([]);
|
|
7397
|
+
const searchFieldIndexRef = react.useRef(null);
|
|
7398
|
+
const registerSearchField = react.useCallback((index) => {
|
|
7399
|
+
searchFieldIndexRef.current = index;
|
|
7400
|
+
}, []);
|
|
7396
7401
|
const ambientPopover = useOptionalPopoverContext();
|
|
7397
7402
|
// Falls back to a local, always-open floating context when there is no ambient `Popover` (e.g. `MenuContent`
|
|
7398
7403
|
// rendered standalone inside a `Collapse`) -- `useFloating` must still be called unconditionally per the
|
|
@@ -7416,6 +7421,10 @@ const useMenuContentState = ({ children, isMulti, selectedItems: controlledSelec
|
|
|
7416
7421
|
onNavigate: setActiveIndex,
|
|
7417
7422
|
loop: true,
|
|
7418
7423
|
nested: isNested,
|
|
7424
|
+
// Floating UI's own entry focus, which waits for the list to finish registering before it
|
|
7425
|
+
// picks the first enabled index. Off by default: most menus open from a trigger the user
|
|
7426
|
+
// should stay on.
|
|
7427
|
+
focusItemOnOpen: autoFocus,
|
|
7419
7428
|
});
|
|
7420
7429
|
const typeahead = react$1.useTypeahead(context, {
|
|
7421
7430
|
listRef: labelsRef,
|
|
@@ -7426,6 +7435,10 @@ const useMenuContentState = ({ children, isMulti, selectedItems: controlledSelec
|
|
|
7426
7435
|
const { getFloatingProps, getItemProps } = react$1.useInteractions([listNavigation, typeahead]);
|
|
7427
7436
|
const syncRegisteredList = react.useCallback(() => {
|
|
7428
7437
|
compactFloatingListRefs(listRef, labelsRef);
|
|
7438
|
+
// Deliberately *not* skipping the search field. `firstEnabledIndex` is the menu's single
|
|
7439
|
+
// roving tab stop, and a registered field should hold it: that is what keeps every row's
|
|
7440
|
+
// `tabIndex` at -1 while the user is typing, so Tab leaves for the menu's chrome instead of
|
|
7441
|
+
// stopping on a row. Only Home (below) looks past the field, because Home addresses items.
|
|
7429
7442
|
const nextFirstEnabled = firstEnabledListIndex(listRef);
|
|
7430
7443
|
setFirstEnabledIndex(current => (current === nextFirstEnabled ? current : nextFirstEnabled));
|
|
7431
7444
|
setActiveIndex(current => {
|
|
@@ -7471,24 +7484,62 @@ const useMenuContentState = ({ children, isMulti, selectedItems: controlledSelec
|
|
|
7471
7484
|
selectedItems,
|
|
7472
7485
|
handleItemClick,
|
|
7473
7486
|
syncRegisteredList,
|
|
7474
|
-
|
|
7487
|
+
setActiveIndex,
|
|
7488
|
+
registerSearchField,
|
|
7489
|
+
}), [
|
|
7490
|
+
activeIndex,
|
|
7491
|
+
firstEnabledIndex,
|
|
7492
|
+
getItemProps,
|
|
7493
|
+
isMulti,
|
|
7494
|
+
selectedItems,
|
|
7495
|
+
handleItemClick,
|
|
7496
|
+
syncRegisteredList,
|
|
7497
|
+
registerSearchField,
|
|
7498
|
+
]);
|
|
7475
7499
|
// Printable characters and Home/End/Left/Right stay native in nested inputs (e.g. FilterBar
|
|
7476
7500
|
// Search). ArrowDown is left alone so a typeable child can enter the registered list.
|
|
7477
7501
|
const handleKeyDownCapture = react.useCallback((event) => {
|
|
7478
7502
|
if (!utils.isTypeableElement(event.target)) {
|
|
7503
|
+
// Home/End address the *items*. A registered search field is a navigation stop but not an
|
|
7504
|
+
// item, and it renders above them, so Floating UI's Home -- the list's minimum index -- would
|
|
7505
|
+
// land in the input. Redirect to the first item instead, by moving `activeIndex` rather than
|
|
7506
|
+
// calling `focus()`, so list navigation stays the only thing that moves focus. End needs no
|
|
7507
|
+
// counterpart: the field is never the maximum index.
|
|
7508
|
+
//
|
|
7509
|
+
// The containment check is not redundant: a `MenuItem` submenu renders its own `MenuContent`
|
|
7510
|
+
// into a portal, and React still routes that subtree's synthetic events through the React
|
|
7511
|
+
// tree, so this handler sees a submenu's Home first and would claim it before the submenu
|
|
7512
|
+
// ever ran its own.
|
|
7513
|
+
if (event.key === "Home" &&
|
|
7514
|
+
searchFieldIndexRef.current !== null &&
|
|
7515
|
+
event.target instanceof Node &&
|
|
7516
|
+
event.currentTarget.contains(event.target)) {
|
|
7517
|
+
event.preventDefault();
|
|
7518
|
+
event.stopPropagation();
|
|
7519
|
+
const firstItemIndex = firstEnabledListIndex(listRef, searchFieldIndexRef.current);
|
|
7520
|
+
// `firstEnabledListIndex` falls back to 0 when nothing qualifies, which is the field's own
|
|
7521
|
+
// index once a query has filtered every row away. Leave the highlight alone rather than
|
|
7522
|
+
// pulling it into the input.
|
|
7523
|
+
if (firstItemIndex !== searchFieldIndexRef.current) {
|
|
7524
|
+
setActiveIndex(firstItemIndex);
|
|
7525
|
+
}
|
|
7526
|
+
}
|
|
7479
7527
|
return;
|
|
7480
7528
|
}
|
|
7481
7529
|
if (event.key === "ArrowDown") {
|
|
7482
7530
|
return;
|
|
7483
7531
|
}
|
|
7484
|
-
// ArrowUp is
|
|
7485
|
-
//
|
|
7532
|
+
// ArrowUp is swallowed only for an *unregistered* typeable child, where a wrap has no sensible
|
|
7533
|
+
// destination: the input is not in the list, so list navigation would move the menu's roving
|
|
7534
|
+
// tab stop to the last item behind the user's back. A field registered via `useMenuSearchField`
|
|
7535
|
+
// *is* in the list, at the index above the first row, so its ArrowUp is an ordinary wrap to the
|
|
7536
|
+
// last item -- symmetric with the ArrowDown wrap that already lands back in the field.
|
|
7486
7537
|
if (event.key.length === 1 ||
|
|
7487
7538
|
event.key === "Home" ||
|
|
7488
7539
|
event.key === "End" ||
|
|
7489
7540
|
event.key === "ArrowLeft" ||
|
|
7490
7541
|
event.key === "ArrowRight" ||
|
|
7491
|
-
event.key === "ArrowUp") {
|
|
7542
|
+
(event.key === "ArrowUp" && searchFieldIndexRef.current === null)) {
|
|
7492
7543
|
event.stopPropagation();
|
|
7493
7544
|
}
|
|
7494
7545
|
}, []);
|
|
@@ -7568,10 +7619,11 @@ const useMenuContentState = ({ children, isMulti, selectedItems: controlledSelec
|
|
|
7568
7619
|
* @param {MenuContentProps} props - The props for the MenuContent component
|
|
7569
7620
|
* @returns {ReactElement} MenuContent component
|
|
7570
7621
|
*/
|
|
7571
|
-
const MenuContent = ({ "data-testid": dataTestId, className, listClassName, children, isMulti = false, selectedItems, onSelectionChange, style, ref, "aria-label": ariaLabel, ...args }) => {
|
|
7622
|
+
const MenuContent = ({ "data-testid": dataTestId, className, listClassName, children, isMulti = false, autoFocus = false, selectedItems, onSelectionChange, style, ref, "aria-label": ariaLabel, ...args }) => {
|
|
7572
7623
|
const { floatingRef, getFloatingProps, handleKeyDownCapture, listRef, labelsRef, menuContext } = useMenuContentState({
|
|
7573
7624
|
children,
|
|
7574
7625
|
isMulti,
|
|
7626
|
+
autoFocus,
|
|
7575
7627
|
selectedItems,
|
|
7576
7628
|
onSelectionChange,
|
|
7577
7629
|
ref,
|
|
@@ -7579,6 +7631,94 @@ const MenuContent = ({ "data-testid": dataTestId, className, listClassName, chil
|
|
|
7579
7631
|
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 }) }) }) }));
|
|
7580
7632
|
};
|
|
7581
7633
|
|
|
7634
|
+
/**
|
|
7635
|
+
* Joins a search input to the nearest `MenuContent` as the stop *above* the first item, so Floating
|
|
7636
|
+
* UI's list navigation moves focus in and out of the list on its own: ArrowDown from the field enters
|
|
7637
|
+
* the list, and ArrowUp from the first item comes back to the field. The field is a full member of
|
|
7638
|
+
* the loop, so ArrowUp from it wraps to the last item and ArrowDown from the last item wraps back.
|
|
7639
|
+
*
|
|
7640
|
+
* Focus then has a single owner. Moving between an unregistered field and the list means calling
|
|
7641
|
+
* `.focus()` from a key handler, which races the `requestAnimationFrame` Floating UI asserts item
|
|
7642
|
+
* focus from: the field wins or loses depending on how far apart the two key presses land. That is
|
|
7643
|
+
* a flake rather than a behaviour, and it cannot be fixed from the outside (ADR-0005).
|
|
7644
|
+
*
|
|
7645
|
+
* Registering also keeps the rows off the sequential Tab path while the field holds focus: the menu's
|
|
7646
|
+
* single roving tab stop is the field itself, so Tab continues to the menu's chrome (reset buttons,
|
|
7647
|
+
* footers) instead of stopping on a row.
|
|
7648
|
+
*
|
|
7649
|
+
* Call it from a component rendered *inside* `MenuContent`: registration goes through the
|
|
7650
|
+
* `FloatingList` context that `MenuContent` opens around its children, so a caller that also renders
|
|
7651
|
+
* the `MenuContent` sits outside that context and registers nothing.
|
|
7652
|
+
*
|
|
7653
|
+
* Product code should render `Search` from `@trackunit/react-form-components` here, as
|
|
7654
|
+
* `FiltersMenuSearchField` does -- the native `<input>` below (and in this package's story and specs)
|
|
7655
|
+
* is only there because `@trackunit/react-components` cannot depend on `@trackunit/react-form-components`
|
|
7656
|
+
* without a dependency cycle.
|
|
7657
|
+
*
|
|
7658
|
+
* @returns {UseMenuSearchFieldResult} A getter for the search input's props
|
|
7659
|
+
* @example
|
|
7660
|
+
* ```tsx
|
|
7661
|
+
* const MenuSearch = () => {
|
|
7662
|
+
* const { getSearchFieldProps } = useMenuSearchField();
|
|
7663
|
+
* return <input aria-label="Search" type="text" {...getSearchFieldProps()} />;
|
|
7664
|
+
* };
|
|
7665
|
+
*
|
|
7666
|
+
* const MenuWithSearch = () => (
|
|
7667
|
+
* <MenuContent autoFocus>
|
|
7668
|
+
* <MenuSearch />
|
|
7669
|
+
* <MenuItem id="alpha" label="Alpha" />
|
|
7670
|
+
* </MenuContent>
|
|
7671
|
+
* );
|
|
7672
|
+
* ```
|
|
7673
|
+
*/
|
|
7674
|
+
const useMenuSearchField = () => {
|
|
7675
|
+
const menu = useOptionalMenuContentContext();
|
|
7676
|
+
// `label: null` keeps the field out of typeahead: it is a navigation stop, not a match target.
|
|
7677
|
+
const { ref: listItemRef, index } = react$1.useListItem({ label: null });
|
|
7678
|
+
const syncRegisteredList = menu?.syncRegisteredList;
|
|
7679
|
+
const registerSearchField = menu?.registerSearchField;
|
|
7680
|
+
const setActiveIndex = menu?.setActiveIndex;
|
|
7681
|
+
react.useLayoutEffect(() => {
|
|
7682
|
+
if (index < 0 || registerSearchField === undefined) {
|
|
7683
|
+
return;
|
|
7684
|
+
}
|
|
7685
|
+
registerSearchField(index);
|
|
7686
|
+
syncRegisteredList?.();
|
|
7687
|
+
return () => {
|
|
7688
|
+
registerSearchField(null);
|
|
7689
|
+
};
|
|
7690
|
+
}, [index, registerSearchField, syncRegisteredList]);
|
|
7691
|
+
// A consumer's ref only arrives when the getter is called, so it is held here and forwarded
|
|
7692
|
+
// through one callback whose identity never changes. Floating UI's `useMergeRefs` memoizes on the
|
|
7693
|
+
// refs themselves, which matters: `useListItem`'s ref changes identity when it learns its index,
|
|
7694
|
+
// and only re-running it writes the input into `elementsRef` at that index.
|
|
7695
|
+
const userRef = react.useRef(undefined);
|
|
7696
|
+
const forwardUserRef = react.useCallback(node => {
|
|
7697
|
+
const target = userRef.current;
|
|
7698
|
+
if (typeof target === "function") {
|
|
7699
|
+
target(node);
|
|
7700
|
+
return;
|
|
7701
|
+
}
|
|
7702
|
+
if (target !== null && target !== undefined) {
|
|
7703
|
+
target.current = node;
|
|
7704
|
+
}
|
|
7705
|
+
}, []);
|
|
7706
|
+
const ref = react$1.useMergeRefs([listItemRef, forwardUserRef]);
|
|
7707
|
+
const getSearchFieldProps = react.useCallback((userProps) => {
|
|
7708
|
+
userRef.current = userProps?.ref;
|
|
7709
|
+
return {
|
|
7710
|
+
ref,
|
|
7711
|
+
onFocus: (event) => {
|
|
7712
|
+
if (index >= 0) {
|
|
7713
|
+
setActiveIndex?.(index);
|
|
7714
|
+
}
|
|
7715
|
+
userProps?.onFocus?.(event);
|
|
7716
|
+
},
|
|
7717
|
+
};
|
|
7718
|
+
}, [index, ref, setActiveIndex]);
|
|
7719
|
+
return react.useMemo(() => ({ getSearchFieldProps }), [getSearchFieldProps]);
|
|
7720
|
+
};
|
|
7721
|
+
|
|
7582
7722
|
/**
|
|
7583
7723
|
* MenuDivider renders a horizontal line to visually separate groups of items within a MenuContent.
|
|
7584
7724
|
*
|
|
@@ -14922,6 +15062,7 @@ exports.useListItemHeight = useListItemHeight;
|
|
|
14922
15062
|
exports.useLocalStorage = useLocalStorage;
|
|
14923
15063
|
exports.useLocalStorageReducer = useLocalStorageReducer;
|
|
14924
15064
|
exports.useMeasure = useMeasure;
|
|
15065
|
+
exports.useMenuSearchField = useMenuSearchField;
|
|
14925
15066
|
exports.useMenuTree = useMenuTree;
|
|
14926
15067
|
exports.useMergeRefs = useMergeRefs;
|
|
14927
15068
|
exports.useModifierKey = useModifierKey;
|
package/index.esm.js
CHANGED
|
@@ -7363,15 +7363,16 @@ const compactFloatingListRefs = (elementsRef, labelsRef) => {
|
|
|
7363
7363
|
labels.length = lastLive + 1;
|
|
7364
7364
|
};
|
|
7365
7365
|
/**
|
|
7366
|
-
* Index of the first registered
|
|
7366
|
+
* Index of the first registered entry that is not `aria-disabled`. Used as the initial tab stop
|
|
7367
7367
|
* when `activeIndex` is still null.
|
|
7368
7368
|
*
|
|
7369
7369
|
* @param elementsRef The FloatingList elements array
|
|
7370
|
-
* @
|
|
7370
|
+
* @param skipIndex Registered index to ignore, used to look past a search field to the first real item
|
|
7371
|
+
* @returns {number} The first enabled index, or `0` when the list has no enabled entry
|
|
7371
7372
|
*/
|
|
7372
|
-
const firstEnabledListIndex = (elementsRef) => {
|
|
7373
|
-
const index = elementsRef.current.findIndex(element => {
|
|
7374
|
-
if (!element) {
|
|
7373
|
+
const firstEnabledListIndex = (elementsRef, skipIndex) => {
|
|
7374
|
+
const index = elementsRef.current.findIndex((element, elementIndex) => {
|
|
7375
|
+
if (!element || elementIndex === skipIndex) {
|
|
7375
7376
|
return false;
|
|
7376
7377
|
}
|
|
7377
7378
|
return element.getAttribute("aria-disabled") !== "true";
|
|
@@ -7384,7 +7385,7 @@ const UNMANAGED_SELECTION = [];
|
|
|
7384
7385
|
* Owns MenuContent's list registration, selection, and typeable-child key handling so the
|
|
7385
7386
|
* component file stays the shell + JSX.
|
|
7386
7387
|
*/
|
|
7387
|
-
const useMenuContentState = ({ children, isMulti, selectedItems: controlledSelectedItems, onSelectionChange, ref, }) => {
|
|
7388
|
+
const useMenuContentState = ({ children, isMulti, autoFocus, selectedItems: controlledSelectedItems, onSelectionChange, ref, }) => {
|
|
7388
7389
|
const selectionManaged = controlledSelectedItems !== undefined || onSelectionChange !== undefined;
|
|
7389
7390
|
const [internalSelectedItems, setInternalSelectedItems] = useState(controlledSelectedItems ?? []);
|
|
7390
7391
|
const selectedItems = selectionManaged ? (controlledSelectedItems ?? internalSelectedItems) : UNMANAGED_SELECTION;
|
|
@@ -7392,6 +7393,10 @@ const useMenuContentState = ({ children, isMulti, selectedItems: controlledSelec
|
|
|
7392
7393
|
const [firstEnabledIndex, setFirstEnabledIndex] = useState(0);
|
|
7393
7394
|
const listRef = useRef([]);
|
|
7394
7395
|
const labelsRef = useRef([]);
|
|
7396
|
+
const searchFieldIndexRef = useRef(null);
|
|
7397
|
+
const registerSearchField = useCallback((index) => {
|
|
7398
|
+
searchFieldIndexRef.current = index;
|
|
7399
|
+
}, []);
|
|
7395
7400
|
const ambientPopover = useOptionalPopoverContext();
|
|
7396
7401
|
// Falls back to a local, always-open floating context when there is no ambient `Popover` (e.g. `MenuContent`
|
|
7397
7402
|
// rendered standalone inside a `Collapse`) -- `useFloating` must still be called unconditionally per the
|
|
@@ -7415,6 +7420,10 @@ const useMenuContentState = ({ children, isMulti, selectedItems: controlledSelec
|
|
|
7415
7420
|
onNavigate: setActiveIndex,
|
|
7416
7421
|
loop: true,
|
|
7417
7422
|
nested: isNested,
|
|
7423
|
+
// Floating UI's own entry focus, which waits for the list to finish registering before it
|
|
7424
|
+
// picks the first enabled index. Off by default: most menus open from a trigger the user
|
|
7425
|
+
// should stay on.
|
|
7426
|
+
focusItemOnOpen: autoFocus,
|
|
7418
7427
|
});
|
|
7419
7428
|
const typeahead = useTypeahead(context, {
|
|
7420
7429
|
listRef: labelsRef,
|
|
@@ -7425,6 +7434,10 @@ const useMenuContentState = ({ children, isMulti, selectedItems: controlledSelec
|
|
|
7425
7434
|
const { getFloatingProps, getItemProps } = useInteractions([listNavigation, typeahead]);
|
|
7426
7435
|
const syncRegisteredList = useCallback(() => {
|
|
7427
7436
|
compactFloatingListRefs(listRef, labelsRef);
|
|
7437
|
+
// Deliberately *not* skipping the search field. `firstEnabledIndex` is the menu's single
|
|
7438
|
+
// roving tab stop, and a registered field should hold it: that is what keeps every row's
|
|
7439
|
+
// `tabIndex` at -1 while the user is typing, so Tab leaves for the menu's chrome instead of
|
|
7440
|
+
// stopping on a row. Only Home (below) looks past the field, because Home addresses items.
|
|
7428
7441
|
const nextFirstEnabled = firstEnabledListIndex(listRef);
|
|
7429
7442
|
setFirstEnabledIndex(current => (current === nextFirstEnabled ? current : nextFirstEnabled));
|
|
7430
7443
|
setActiveIndex(current => {
|
|
@@ -7470,24 +7483,62 @@ const useMenuContentState = ({ children, isMulti, selectedItems: controlledSelec
|
|
|
7470
7483
|
selectedItems,
|
|
7471
7484
|
handleItemClick,
|
|
7472
7485
|
syncRegisteredList,
|
|
7473
|
-
|
|
7486
|
+
setActiveIndex,
|
|
7487
|
+
registerSearchField,
|
|
7488
|
+
}), [
|
|
7489
|
+
activeIndex,
|
|
7490
|
+
firstEnabledIndex,
|
|
7491
|
+
getItemProps,
|
|
7492
|
+
isMulti,
|
|
7493
|
+
selectedItems,
|
|
7494
|
+
handleItemClick,
|
|
7495
|
+
syncRegisteredList,
|
|
7496
|
+
registerSearchField,
|
|
7497
|
+
]);
|
|
7474
7498
|
// Printable characters and Home/End/Left/Right stay native in nested inputs (e.g. FilterBar
|
|
7475
7499
|
// Search). ArrowDown is left alone so a typeable child can enter the registered list.
|
|
7476
7500
|
const handleKeyDownCapture = useCallback((event) => {
|
|
7477
7501
|
if (!isTypeableElement(event.target)) {
|
|
7502
|
+
// Home/End address the *items*. A registered search field is a navigation stop but not an
|
|
7503
|
+
// item, and it renders above them, so Floating UI's Home -- the list's minimum index -- would
|
|
7504
|
+
// land in the input. Redirect to the first item instead, by moving `activeIndex` rather than
|
|
7505
|
+
// calling `focus()`, so list navigation stays the only thing that moves focus. End needs no
|
|
7506
|
+
// counterpart: the field is never the maximum index.
|
|
7507
|
+
//
|
|
7508
|
+
// The containment check is not redundant: a `MenuItem` submenu renders its own `MenuContent`
|
|
7509
|
+
// into a portal, and React still routes that subtree's synthetic events through the React
|
|
7510
|
+
// tree, so this handler sees a submenu's Home first and would claim it before the submenu
|
|
7511
|
+
// ever ran its own.
|
|
7512
|
+
if (event.key === "Home" &&
|
|
7513
|
+
searchFieldIndexRef.current !== null &&
|
|
7514
|
+
event.target instanceof Node &&
|
|
7515
|
+
event.currentTarget.contains(event.target)) {
|
|
7516
|
+
event.preventDefault();
|
|
7517
|
+
event.stopPropagation();
|
|
7518
|
+
const firstItemIndex = firstEnabledListIndex(listRef, searchFieldIndexRef.current);
|
|
7519
|
+
// `firstEnabledListIndex` falls back to 0 when nothing qualifies, which is the field's own
|
|
7520
|
+
// index once a query has filtered every row away. Leave the highlight alone rather than
|
|
7521
|
+
// pulling it into the input.
|
|
7522
|
+
if (firstItemIndex !== searchFieldIndexRef.current) {
|
|
7523
|
+
setActiveIndex(firstItemIndex);
|
|
7524
|
+
}
|
|
7525
|
+
}
|
|
7478
7526
|
return;
|
|
7479
7527
|
}
|
|
7480
7528
|
if (event.key === "ArrowDown") {
|
|
7481
7529
|
return;
|
|
7482
7530
|
}
|
|
7483
|
-
// ArrowUp is
|
|
7484
|
-
//
|
|
7531
|
+
// ArrowUp is swallowed only for an *unregistered* typeable child, where a wrap has no sensible
|
|
7532
|
+
// destination: the input is not in the list, so list navigation would move the menu's roving
|
|
7533
|
+
// tab stop to the last item behind the user's back. A field registered via `useMenuSearchField`
|
|
7534
|
+
// *is* in the list, at the index above the first row, so its ArrowUp is an ordinary wrap to the
|
|
7535
|
+
// last item -- symmetric with the ArrowDown wrap that already lands back in the field.
|
|
7485
7536
|
if (event.key.length === 1 ||
|
|
7486
7537
|
event.key === "Home" ||
|
|
7487
7538
|
event.key === "End" ||
|
|
7488
7539
|
event.key === "ArrowLeft" ||
|
|
7489
7540
|
event.key === "ArrowRight" ||
|
|
7490
|
-
event.key === "ArrowUp") {
|
|
7541
|
+
(event.key === "ArrowUp" && searchFieldIndexRef.current === null)) {
|
|
7491
7542
|
event.stopPropagation();
|
|
7492
7543
|
}
|
|
7493
7544
|
}, []);
|
|
@@ -7567,10 +7618,11 @@ const useMenuContentState = ({ children, isMulti, selectedItems: controlledSelec
|
|
|
7567
7618
|
* @param {MenuContentProps} props - The props for the MenuContent component
|
|
7568
7619
|
* @returns {ReactElement} MenuContent component
|
|
7569
7620
|
*/
|
|
7570
|
-
const MenuContent = ({ "data-testid": dataTestId, className, listClassName, children, isMulti = false, selectedItems, onSelectionChange, style, ref, "aria-label": ariaLabel, ...args }) => {
|
|
7621
|
+
const MenuContent = ({ "data-testid": dataTestId, className, listClassName, children, isMulti = false, autoFocus = false, selectedItems, onSelectionChange, style, ref, "aria-label": ariaLabel, ...args }) => {
|
|
7571
7622
|
const { floatingRef, getFloatingProps, handleKeyDownCapture, listRef, labelsRef, menuContext } = useMenuContentState({
|
|
7572
7623
|
children,
|
|
7573
7624
|
isMulti,
|
|
7625
|
+
autoFocus,
|
|
7574
7626
|
selectedItems,
|
|
7575
7627
|
onSelectionChange,
|
|
7576
7628
|
ref,
|
|
@@ -7578,6 +7630,94 @@ const MenuContent = ({ "data-testid": dataTestId, className, listClassName, chil
|
|
|
7578
7630
|
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 }) }) }) }));
|
|
7579
7631
|
};
|
|
7580
7632
|
|
|
7633
|
+
/**
|
|
7634
|
+
* Joins a search input to the nearest `MenuContent` as the stop *above* the first item, so Floating
|
|
7635
|
+
* UI's list navigation moves focus in and out of the list on its own: ArrowDown from the field enters
|
|
7636
|
+
* the list, and ArrowUp from the first item comes back to the field. The field is a full member of
|
|
7637
|
+
* the loop, so ArrowUp from it wraps to the last item and ArrowDown from the last item wraps back.
|
|
7638
|
+
*
|
|
7639
|
+
* Focus then has a single owner. Moving between an unregistered field and the list means calling
|
|
7640
|
+
* `.focus()` from a key handler, which races the `requestAnimationFrame` Floating UI asserts item
|
|
7641
|
+
* focus from: the field wins or loses depending on how far apart the two key presses land. That is
|
|
7642
|
+
* a flake rather than a behaviour, and it cannot be fixed from the outside (ADR-0005).
|
|
7643
|
+
*
|
|
7644
|
+
* Registering also keeps the rows off the sequential Tab path while the field holds focus: the menu's
|
|
7645
|
+
* single roving tab stop is the field itself, so Tab continues to the menu's chrome (reset buttons,
|
|
7646
|
+
* footers) instead of stopping on a row.
|
|
7647
|
+
*
|
|
7648
|
+
* Call it from a component rendered *inside* `MenuContent`: registration goes through the
|
|
7649
|
+
* `FloatingList` context that `MenuContent` opens around its children, so a caller that also renders
|
|
7650
|
+
* the `MenuContent` sits outside that context and registers nothing.
|
|
7651
|
+
*
|
|
7652
|
+
* Product code should render `Search` from `@trackunit/react-form-components` here, as
|
|
7653
|
+
* `FiltersMenuSearchField` does -- the native `<input>` below (and in this package's story and specs)
|
|
7654
|
+
* is only there because `@trackunit/react-components` cannot depend on `@trackunit/react-form-components`
|
|
7655
|
+
* without a dependency cycle.
|
|
7656
|
+
*
|
|
7657
|
+
* @returns {UseMenuSearchFieldResult} A getter for the search input's props
|
|
7658
|
+
* @example
|
|
7659
|
+
* ```tsx
|
|
7660
|
+
* const MenuSearch = () => {
|
|
7661
|
+
* const { getSearchFieldProps } = useMenuSearchField();
|
|
7662
|
+
* return <input aria-label="Search" type="text" {...getSearchFieldProps()} />;
|
|
7663
|
+
* };
|
|
7664
|
+
*
|
|
7665
|
+
* const MenuWithSearch = () => (
|
|
7666
|
+
* <MenuContent autoFocus>
|
|
7667
|
+
* <MenuSearch />
|
|
7668
|
+
* <MenuItem id="alpha" label="Alpha" />
|
|
7669
|
+
* </MenuContent>
|
|
7670
|
+
* );
|
|
7671
|
+
* ```
|
|
7672
|
+
*/
|
|
7673
|
+
const useMenuSearchField = () => {
|
|
7674
|
+
const menu = useOptionalMenuContentContext();
|
|
7675
|
+
// `label: null` keeps the field out of typeahead: it is a navigation stop, not a match target.
|
|
7676
|
+
const { ref: listItemRef, index } = useListItem({ label: null });
|
|
7677
|
+
const syncRegisteredList = menu?.syncRegisteredList;
|
|
7678
|
+
const registerSearchField = menu?.registerSearchField;
|
|
7679
|
+
const setActiveIndex = menu?.setActiveIndex;
|
|
7680
|
+
useLayoutEffect(() => {
|
|
7681
|
+
if (index < 0 || registerSearchField === undefined) {
|
|
7682
|
+
return;
|
|
7683
|
+
}
|
|
7684
|
+
registerSearchField(index);
|
|
7685
|
+
syncRegisteredList?.();
|
|
7686
|
+
return () => {
|
|
7687
|
+
registerSearchField(null);
|
|
7688
|
+
};
|
|
7689
|
+
}, [index, registerSearchField, syncRegisteredList]);
|
|
7690
|
+
// A consumer's ref only arrives when the getter is called, so it is held here and forwarded
|
|
7691
|
+
// through one callback whose identity never changes. Floating UI's `useMergeRefs` memoizes on the
|
|
7692
|
+
// refs themselves, which matters: `useListItem`'s ref changes identity when it learns its index,
|
|
7693
|
+
// and only re-running it writes the input into `elementsRef` at that index.
|
|
7694
|
+
const userRef = useRef(undefined);
|
|
7695
|
+
const forwardUserRef = useCallback(node => {
|
|
7696
|
+
const target = userRef.current;
|
|
7697
|
+
if (typeof target === "function") {
|
|
7698
|
+
target(node);
|
|
7699
|
+
return;
|
|
7700
|
+
}
|
|
7701
|
+
if (target !== null && target !== undefined) {
|
|
7702
|
+
target.current = node;
|
|
7703
|
+
}
|
|
7704
|
+
}, []);
|
|
7705
|
+
const ref = useMergeRefs$1([listItemRef, forwardUserRef]);
|
|
7706
|
+
const getSearchFieldProps = useCallback((userProps) => {
|
|
7707
|
+
userRef.current = userProps?.ref;
|
|
7708
|
+
return {
|
|
7709
|
+
ref,
|
|
7710
|
+
onFocus: (event) => {
|
|
7711
|
+
if (index >= 0) {
|
|
7712
|
+
setActiveIndex?.(index);
|
|
7713
|
+
}
|
|
7714
|
+
userProps?.onFocus?.(event);
|
|
7715
|
+
},
|
|
7716
|
+
};
|
|
7717
|
+
}, [index, ref, setActiveIndex]);
|
|
7718
|
+
return useMemo(() => ({ getSearchFieldProps }), [getSearchFieldProps]);
|
|
7719
|
+
};
|
|
7720
|
+
|
|
7581
7721
|
/**
|
|
7582
7722
|
* MenuDivider renders a horizontal line to visually separate groups of items within a MenuContent.
|
|
7583
7723
|
*
|
|
@@ -14756,4 +14896,4 @@ const useWindowActivity = ({ onFocus, onBlur, skip = false } = { onBlur: undefin
|
|
|
14756
14896
|
*/
|
|
14757
14897
|
setupLibraryTranslations();
|
|
14758
14898
|
|
|
14759
|
-
export { Alert, Badge, Breadcrumb, Button, Card, CardBody, CardFooter, CardHeader, Collapse, CompletionStatusIndicator, CopyButton, CopyableText, DEFAULT_SKELETON_PREFERENCE_CARD_PROPS, DetailsList, EmptyState, EmptyValue, ExternalLink, GridAreas, Heading, Highlight, HorizontalOverflowScroller, Icon, IconButton, Indicator, KPI, KPICard, KPICardSkeleton, KPISkeleton, LabeledValue, LabeledValueList, List, ListItem, MAX_HASH_LENGTH, MAX_URL_LENGTH, MenuContent, MenuDivider, MenuItem, MenuTree, MoreMenu, Notice, PackageNameStoryComponent, Page, PageContent, PageHeader, PageHeaderKpiMetrics, PageHeaderSecondaryActions, PageHeaderTitle, Pagination, Polygon, Popover, PopoverContent, PopoverTitle, PopoverTrigger, Portal, PreferenceCard, PreferenceCardSkeleton, Prompt, ROLE_CARD, SHEET_TRANSITION_DURATION, SHEET_TRANSITION_DURATION_MS, SHEET_TRANSITION_EASING, SectionHeader, SegmentedValueBar, Sheet, Sidebar, SidebarContentLayout, SkeletonBlock, SkeletonLabel, SkeletonLines, Spacer, Spinner, StarButton, Tab, TabContent, TabList, Tabs, Tag, Text, ToggleGroup, Tooltip, TrendIndicator, TrendIndicators, ValueBar, ZStack, createGrid, cvaButton, cvaButtonPrefixSuffix, cvaButtonSpinner, cvaButtonSpinnerContainer, cvaClickable, cvaContainerStyles, cvaContentContainer, cvaContentWrapper, cvaDescriptionCard, cvaIconBackground, cvaIconButton, cvaImgStyles, cvaIndicator, cvaIndicatorIcon, cvaIndicatorIconBackground, cvaIndicatorLabel, cvaIndicatorPing, cvaInputContainer, cvaInteractableItem, cvaList, cvaListContainer, cvaListItem$1 as cvaListItem, cvaMenu, cvaMenuItem, cvaMenuItemLabel, cvaMenuItemPrefix, cvaMenuItemStyle, cvaMenuItemSuffix, cvaMenuList, cvaMenuListDivider, cvaMenuListItem, cvaMenuListMultiSelect, cvaPageHeader, cvaPageHeaderContainer, cvaPageHeaderHeading, cvaPreferenceCard, cvaTitleCard, cvaToggleGroup, cvaToggleGroupWithSlidingBackground, cvaToggleItem, cvaToggleItemContent, cvaToggleItemText, cvaZStackContainer, cvaZStackItem, defaultPageSize, docs, getDevicePixelRatio, getValueBarColorByValue, iconColorNames, iconPalette, noPagination, preferenceCardGrid, useBidirectionalScroll, useClickOutside, useContainerBreakpoints, useContinuousTimeout, useCopyToClipboard, useCursorUrlSync, useCustomEncoding, useDebounce, useDevicePixelRatio, useElevatedReducer, useElevatedState, useGridAreas, useHashParamSync, useHover, useInfiniteScroll, useIsFirstRender, useIsFullscreen, useIsTextTruncated, useKeyboardShortcut, useList, useListItemHeight, useLocalStorage, useLocalStorageReducer, useMeasure, useMenuTree, useMergeRefs, useModifierKey, useOptionalPopoverContext, useOverflowBorder, useOverflowItems, usePersistedState, usePopoverContext, usePrevious, usePrompt, useRandomCSSLengths, useRelayPagination, useResize, useScrollBlock, useScrollDetection, useSearchParamSync, useSelfUpdatingRef, useSessionStorage, useSessionStorageReducer, useSheet, useSheetSnap, useStorageKey, useTextSearch, useTimeout, useViewportBreakpoints, useWatch, useWindowActivity };
|
|
14899
|
+
export { Alert, Badge, Breadcrumb, Button, Card, CardBody, CardFooter, CardHeader, Collapse, CompletionStatusIndicator, CopyButton, CopyableText, DEFAULT_SKELETON_PREFERENCE_CARD_PROPS, DetailsList, EmptyState, EmptyValue, ExternalLink, GridAreas, Heading, Highlight, HorizontalOverflowScroller, Icon, IconButton, Indicator, KPI, KPICard, KPICardSkeleton, KPISkeleton, LabeledValue, LabeledValueList, List, ListItem, MAX_HASH_LENGTH, MAX_URL_LENGTH, MenuContent, MenuDivider, MenuItem, MenuTree, MoreMenu, Notice, PackageNameStoryComponent, Page, PageContent, PageHeader, PageHeaderKpiMetrics, PageHeaderSecondaryActions, PageHeaderTitle, Pagination, Polygon, Popover, PopoverContent, PopoverTitle, PopoverTrigger, Portal, PreferenceCard, PreferenceCardSkeleton, Prompt, ROLE_CARD, SHEET_TRANSITION_DURATION, SHEET_TRANSITION_DURATION_MS, SHEET_TRANSITION_EASING, SectionHeader, SegmentedValueBar, Sheet, Sidebar, SidebarContentLayout, SkeletonBlock, SkeletonLabel, SkeletonLines, Spacer, Spinner, StarButton, Tab, TabContent, TabList, Tabs, Tag, Text, ToggleGroup, Tooltip, TrendIndicator, TrendIndicators, ValueBar, ZStack, createGrid, cvaButton, cvaButtonPrefixSuffix, cvaButtonSpinner, cvaButtonSpinnerContainer, cvaClickable, cvaContainerStyles, cvaContentContainer, cvaContentWrapper, cvaDescriptionCard, cvaIconBackground, cvaIconButton, cvaImgStyles, cvaIndicator, cvaIndicatorIcon, cvaIndicatorIconBackground, cvaIndicatorLabel, cvaIndicatorPing, cvaInputContainer, cvaInteractableItem, cvaList, cvaListContainer, cvaListItem$1 as cvaListItem, cvaMenu, cvaMenuItem, cvaMenuItemLabel, cvaMenuItemPrefix, cvaMenuItemStyle, cvaMenuItemSuffix, cvaMenuList, cvaMenuListDivider, cvaMenuListItem, cvaMenuListMultiSelect, cvaPageHeader, cvaPageHeaderContainer, cvaPageHeaderHeading, cvaPreferenceCard, cvaTitleCard, cvaToggleGroup, cvaToggleGroupWithSlidingBackground, cvaToggleItem, cvaToggleItemContent, cvaToggleItemText, cvaZStackContainer, cvaZStackItem, defaultPageSize, docs, getDevicePixelRatio, getValueBarColorByValue, iconColorNames, iconPalette, noPagination, preferenceCardGrid, useBidirectionalScroll, useClickOutside, useContainerBreakpoints, useContinuousTimeout, useCopyToClipboard, useCursorUrlSync, useCustomEncoding, useDebounce, useDevicePixelRatio, useElevatedReducer, useElevatedState, useGridAreas, useHashParamSync, useHover, useInfiniteScroll, useIsFirstRender, useIsFullscreen, useIsTextTruncated, useKeyboardShortcut, useList, useListItemHeight, useLocalStorage, useLocalStorageReducer, useMeasure, useMenuSearchField, useMenuTree, useMergeRefs, useModifierKey, useOptionalPopoverContext, useOverflowBorder, useOverflowItems, usePersistedState, usePopoverContext, usePrevious, usePrompt, useRandomCSSLengths, useRelayPagination, useResize, useScrollBlock, useScrollDetection, useSearchParamSync, useSelfUpdatingRef, useSessionStorage, useSessionStorageReducer, useSheet, useSheetSnap, useStorageKey, useTextSearch, useTimeout, useViewportBreakpoints, useWatch, useWindowActivity };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trackunit/react-components",
|
|
3
|
-
"version": "2.11.
|
|
3
|
+
"version": "2.11.6",
|
|
4
4
|
"repository": "https://github.com/Trackunit/manager",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE.txt",
|
|
6
6
|
"migrations": "./migrations.json",
|
|
@@ -14,16 +14,16 @@
|
|
|
14
14
|
"@floating-ui/react": "^0.26.25",
|
|
15
15
|
"string-ts": "^2.0.0",
|
|
16
16
|
"tailwind-merge": "^2.0.0",
|
|
17
|
-
"@trackunit/ui-design-tokens": "1.15.
|
|
18
|
-
"@trackunit/css-class-variance-utilities": "1.14.
|
|
19
|
-
"@trackunit/shared-utils": "1.16.
|
|
20
|
-
"@trackunit/ui-icons": "1.14.
|
|
17
|
+
"@trackunit/ui-design-tokens": "1.15.17",
|
|
18
|
+
"@trackunit/css-class-variance-utilities": "1.14.27",
|
|
19
|
+
"@trackunit/shared-utils": "1.16.30",
|
|
20
|
+
"@trackunit/ui-icons": "1.14.26",
|
|
21
21
|
"es-toolkit": "^1.39.10",
|
|
22
22
|
"@tanstack/react-virtual": "^3.14.3",
|
|
23
23
|
"dequal": "^2.0.3",
|
|
24
24
|
"fflate": "^0.8.2",
|
|
25
25
|
"zod": "^3.25.76",
|
|
26
|
-
"@trackunit/i18n-library-translation": "2.4.
|
|
26
|
+
"@trackunit/i18n-library-translation": "2.4.38"
|
|
27
27
|
},
|
|
28
28
|
"peerDependencies": {
|
|
29
29
|
"react": "^19.0.0",
|
|
@@ -29,6 +29,19 @@ export interface MenuContentProps extends CommonProps, Styleable, Refable<HTMLDi
|
|
|
29
29
|
* Callback triggered when selected items change.
|
|
30
30
|
*/
|
|
31
31
|
onSelectionChange?: (selected: Array<string>) => void;
|
|
32
|
+
/**
|
|
33
|
+
* Move focus into the menu as soon as it opens, rather than leaving it on whatever opened it.
|
|
34
|
+
*
|
|
35
|
+
* The menu decides where "into the menu" is: a search field registered with `useMenuSearchField`
|
|
36
|
+
* if there is one, otherwise the first enabled item. Chrome that merely happens to come first in
|
|
37
|
+
* the DOM -- a reset button, a header link -- is never the entry point.
|
|
38
|
+
*
|
|
39
|
+
* Use it for menus the keyboard should land inside immediately (a filter panel the user opened to
|
|
40
|
+
* type in). Leave it off for action menus opened from a trigger the user should stay on.
|
|
41
|
+
*
|
|
42
|
+
* @default false
|
|
43
|
+
*/
|
|
44
|
+
autoFocus?: boolean;
|
|
32
45
|
/**
|
|
33
46
|
* Accessible name for the `role="menu"` node. Prefer this (or `aria-labelledby` via the trigger)
|
|
34
47
|
* when the menu is not named by surrounding chrome.
|
|
@@ -101,4 +114,4 @@ export interface MenuContentProps extends CommonProps, Styleable, Refable<HTMLDi
|
|
|
101
114
|
* @param {MenuContentProps} props - The props for the MenuContent component
|
|
102
115
|
* @returns {ReactElement} MenuContent component
|
|
103
116
|
*/
|
|
104
|
-
export declare const MenuContent: ({ "data-testid": dataTestId, className, listClassName, children, isMulti, selectedItems, onSelectionChange, style, ref, "aria-label": ariaLabel, ...args }: MenuContentProps) => ReactElement;
|
|
117
|
+
export declare const MenuContent: ({ "data-testid": dataTestId, className, listClassName, children, isMulti, autoFocus, selectedItems, onSelectionChange, style, ref, "aria-label": ariaLabel, ...args }: MenuContentProps) => ReactElement;
|
|
@@ -11,6 +11,17 @@ export interface MenuContentContextValue {
|
|
|
11
11
|
selectedItems: Array<string>;
|
|
12
12
|
handleItemClick: (id: string) => void;
|
|
13
13
|
syncRegisteredList: () => void;
|
|
14
|
+
/**
|
|
15
|
+
* Moves the menu's highlight to a registered index. Rows get this through `getItemProps`;
|
|
16
|
+
* a registered search field (`useMenuSearchField`) needs it directly, because it must not
|
|
17
|
+
* take Floating UI's hover props -- those pull focus out of the input on pointer leave.
|
|
18
|
+
*/
|
|
19
|
+
setActiveIndex: (index: number | null) => void;
|
|
20
|
+
/**
|
|
21
|
+
* Reports the registered index of the menu's search field, or `null` when it unregisters. The
|
|
22
|
+
* field is a navigation stop but not an item, which is the difference Home/End have to respect.
|
|
23
|
+
*/
|
|
24
|
+
registerSearchField: (index: number | null) => void;
|
|
14
25
|
}
|
|
15
26
|
declare const MenuContentContext: import("react").Context<MenuContentContextValue | null>;
|
|
16
27
|
/**
|
|
@@ -9,10 +9,11 @@ import { MutableRefObject } from "react";
|
|
|
9
9
|
*/
|
|
10
10
|
export declare const compactFloatingListRefs: (elementsRef: MutableRefObject<Array<HTMLElement | null>>, labelsRef: MutableRefObject<Array<string | null>>) => void;
|
|
11
11
|
/**
|
|
12
|
-
* Index of the first registered
|
|
12
|
+
* Index of the first registered entry that is not `aria-disabled`. Used as the initial tab stop
|
|
13
13
|
* when `activeIndex` is still null.
|
|
14
14
|
*
|
|
15
15
|
* @param elementsRef The FloatingList elements array
|
|
16
|
-
* @
|
|
16
|
+
* @param skipIndex Registered index to ignore, used to look past a search field to the first real item
|
|
17
|
+
* @returns {number} The first enabled index, or `0` when the list has no enabled entry
|
|
17
18
|
*/
|
|
18
|
-
export declare const firstEnabledListIndex: (elementsRef: MutableRefObject<Array<HTMLElement | null
|
|
19
|
+
export declare const firstEnabledListIndex: (elementsRef: MutableRefObject<Array<HTMLElement | null>>, skipIndex?: number | null) => number;
|
|
@@ -4,6 +4,7 @@ import { MenuContentContextValue } from "./MenuContentContext";
|
|
|
4
4
|
export interface UseMenuContentStateProps {
|
|
5
5
|
children: ReactNode;
|
|
6
6
|
isMulti: boolean;
|
|
7
|
+
autoFocus: boolean;
|
|
7
8
|
selectedItems?: Array<string>;
|
|
8
9
|
onSelectionChange?: (selected: Array<string>) => void;
|
|
9
10
|
ref?: Ref<HTMLDivElement>;
|
|
@@ -20,4 +21,4 @@ export interface UseMenuContentStateResult {
|
|
|
20
21
|
* Owns MenuContent's list registration, selection, and typeable-child key handling so the
|
|
21
22
|
* component file stays the shell + JSX.
|
|
22
23
|
*/
|
|
23
|
-
export declare const useMenuContentState: ({ children, isMulti, selectedItems: controlledSelectedItems, onSelectionChange, ref, }: UseMenuContentStateProps) => UseMenuContentStateResult;
|
|
24
|
+
export declare const useMenuContentState: ({ children, isMulti, autoFocus, selectedItems: controlledSelectedItems, onSelectionChange, ref, }: UseMenuContentStateProps) => UseMenuContentStateResult;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { FocusEventHandler, Ref, RefCallback } from "react";
|
|
2
|
+
export interface MenuSearchFieldUserProps {
|
|
3
|
+
/** The consumer's own ref, kept alongside the one that registers the input. */
|
|
4
|
+
ref?: Ref<HTMLElement>;
|
|
5
|
+
/** The consumer's own focus handler, called after the menu has moved its highlight. */
|
|
6
|
+
onFocus?: FocusEventHandler<HTMLElement>;
|
|
7
|
+
}
|
|
8
|
+
export interface MenuSearchFieldProps {
|
|
9
|
+
/** Puts the input in its place in the menu's keyboard order. */
|
|
10
|
+
ref: RefCallback<HTMLElement> | null;
|
|
11
|
+
/** Moves the menu's highlight off the items when the input is clicked or tabbed into. */
|
|
12
|
+
onFocus: FocusEventHandler<HTMLElement>;
|
|
13
|
+
}
|
|
14
|
+
export interface UseMenuSearchFieldResult {
|
|
15
|
+
/**
|
|
16
|
+
* Props for the search input. Pass your own `ref` / `onFocus` in and they are merged rather
|
|
17
|
+
* than replaced, so spread order cannot silently take the menu's registration away.
|
|
18
|
+
*/
|
|
19
|
+
getSearchFieldProps: (userProps?: MenuSearchFieldUserProps) => MenuSearchFieldProps;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Joins a search input to the nearest `MenuContent` as the stop *above* the first item, so Floating
|
|
23
|
+
* UI's list navigation moves focus in and out of the list on its own: ArrowDown from the field enters
|
|
24
|
+
* the list, and ArrowUp from the first item comes back to the field. The field is a full member of
|
|
25
|
+
* the loop, so ArrowUp from it wraps to the last item and ArrowDown from the last item wraps back.
|
|
26
|
+
*
|
|
27
|
+
* Focus then has a single owner. Moving between an unregistered field and the list means calling
|
|
28
|
+
* `.focus()` from a key handler, which races the `requestAnimationFrame` Floating UI asserts item
|
|
29
|
+
* focus from: the field wins or loses depending on how far apart the two key presses land. That is
|
|
30
|
+
* a flake rather than a behaviour, and it cannot be fixed from the outside (ADR-0005).
|
|
31
|
+
*
|
|
32
|
+
* Registering also keeps the rows off the sequential Tab path while the field holds focus: the menu's
|
|
33
|
+
* single roving tab stop is the field itself, so Tab continues to the menu's chrome (reset buttons,
|
|
34
|
+
* footers) instead of stopping on a row.
|
|
35
|
+
*
|
|
36
|
+
* Call it from a component rendered *inside* `MenuContent`: registration goes through the
|
|
37
|
+
* `FloatingList` context that `MenuContent` opens around its children, so a caller that also renders
|
|
38
|
+
* the `MenuContent` sits outside that context and registers nothing.
|
|
39
|
+
*
|
|
40
|
+
* Product code should render `Search` from `@trackunit/react-form-components` here, as
|
|
41
|
+
* `FiltersMenuSearchField` does -- the native `<input>` below (and in this package's story and specs)
|
|
42
|
+
* is only there because `@trackunit/react-components` cannot depend on `@trackunit/react-form-components`
|
|
43
|
+
* without a dependency cycle.
|
|
44
|
+
*
|
|
45
|
+
* @returns {UseMenuSearchFieldResult} A getter for the search input's props
|
|
46
|
+
* @example
|
|
47
|
+
* ```tsx
|
|
48
|
+
* const MenuSearch = () => {
|
|
49
|
+
* const { getSearchFieldProps } = useMenuSearchField();
|
|
50
|
+
* return <input aria-label="Search" type="text" {...getSearchFieldProps()} />;
|
|
51
|
+
* };
|
|
52
|
+
*
|
|
53
|
+
* const MenuWithSearch = () => (
|
|
54
|
+
* <MenuContent autoFocus>
|
|
55
|
+
* <MenuSearch />
|
|
56
|
+
* <MenuItem id="alpha" label="Alpha" />
|
|
57
|
+
* </MenuContent>
|
|
58
|
+
* );
|
|
59
|
+
* ```
|
|
60
|
+
*/
|
|
61
|
+
export declare const useMenuSearchField: () => UseMenuSearchFieldResult;
|
package/src/index.d.ts
CHANGED
|
@@ -55,6 +55,7 @@ export * from "./components/ListItem/ListItem";
|
|
|
55
55
|
export * from "./components/ListItem/useListItemHeight";
|
|
56
56
|
export * from "./components/Menu/MenuContent/MenuContent";
|
|
57
57
|
export * from "./components/Menu/MenuContent/MenuContent.variants";
|
|
58
|
+
export * from "./components/Menu/MenuContent/useMenuSearchField";
|
|
58
59
|
export * from "./components/Menu/MenuDivider/MenuDivider";
|
|
59
60
|
export * from "./components/Menu/MenuItem/MenuItem";
|
|
60
61
|
export * from "./components/Menu/MenuItem/MenuItem.variants";
|
package/translation.cjs.js
CHANGED
package/translation.esm.js
CHANGED
package/translation10.cjs.js
CHANGED
package/translation10.esm.js
CHANGED
package/translation11.cjs.js
CHANGED
package/translation11.esm.js
CHANGED
package/translation12.cjs.js
CHANGED
package/translation12.esm.js
CHANGED
package/translation13.cjs.js
CHANGED
package/translation13.esm.js
CHANGED
package/translation14.cjs.js
CHANGED
package/translation14.esm.js
CHANGED
package/translation15.cjs.js
CHANGED
package/translation15.esm.js
CHANGED
package/translation16.cjs.js
CHANGED
package/translation16.esm.js
CHANGED
package/translation17.cjs.js
CHANGED
package/translation17.esm.js
CHANGED
package/translation2.cjs.js
CHANGED
package/translation2.esm.js
CHANGED
package/translation3.cjs.js
CHANGED
package/translation3.esm.js
CHANGED
package/translation4.cjs.js
CHANGED
package/translation4.esm.js
CHANGED
package/translation5.cjs.js
CHANGED
package/translation5.esm.js
CHANGED
package/translation6.cjs.js
CHANGED
package/translation6.esm.js
CHANGED
package/translation7.cjs.js
CHANGED
package/translation7.esm.js
CHANGED
package/translation8.cjs.js
CHANGED
package/translation8.esm.js
CHANGED
package/translation9.cjs.js
CHANGED