@geotab/zenith 3.10.0 → 3.11.0-beta.1

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.
Files changed (83) hide show
  1. package/README.md +6 -0
  2. package/dist/index.css +8 -1
  3. package/dist/menu/components/controlledMenuList/controlledMenuList.d.ts +27 -0
  4. package/dist/menu/components/controlledMenuList/controlledMenuList.js +123 -0
  5. package/dist/menu/components/createControlledMenuList.d.ts +37 -0
  6. package/dist/menu/components/createControlledMenuList.js +55 -0
  7. package/dist/menu/components/createMenuItem.d.ts +67 -0
  8. package/dist/menu/components/createMenuItem.js +97 -0
  9. package/dist/menu/components/menuButton.js +8 -2
  10. package/dist/menu/components/menuItem.d.ts +1 -2
  11. package/dist/menu/components/menuItem.js +20 -74
  12. package/dist/menu/contexts/usePathContext.d.ts +2 -0
  13. package/dist/menu/contexts/usePathContext.js +9 -0
  14. package/dist/menu/controlledMenu.js +8 -175
  15. package/dist/menu/utils/buildMenuContent.d.ts +2 -0
  16. package/dist/menu/utils/buildMenuContent.js +38 -0
  17. package/dist/menu/utils/findContent.d.ts +2 -2
  18. package/dist/menu/utils/findContent.js +4 -3
  19. package/dist/menu/utils/getItemLabel.d.ts +2 -0
  20. package/dist/menu/utils/getItemLabel.js +8 -0
  21. package/dist/menu/utils/getSafeRel.d.ts +1 -0
  22. package/dist/menu/utils/getSafeRel.js +14 -0
  23. package/dist/menu/utils/isMenuItem.d.ts +2 -0
  24. package/dist/menu/utils/isMenuItem.js +13 -0
  25. package/dist/menu/utils/isSafeHref.d.ts +1 -0
  26. package/dist/menu/utils/isSafeHref.js +10 -0
  27. package/dist/menu/utils/normalizeSeparators.d.ts +2 -0
  28. package/dist/menu/utils/normalizeSeparators.js +23 -0
  29. package/dist/menu/utils/resolveKeys.d.ts +12 -0
  30. package/dist/menu/utils/resolveKeys.js +22 -0
  31. package/dist/menu/utils/useLastValidSheet.d.ts +7 -0
  32. package/dist/menu/utils/useLastValidSheet.js +30 -0
  33. package/dist/menu/utils/useMenuItemCore.d.ts +31 -0
  34. package/dist/menu/utils/useMenuItemCore.js +51 -0
  35. package/dist/menu/utils/useMenuItemKeyboardNav.d.ts +2 -0
  36. package/dist/menu/utils/useMenuItemKeyboardNav.js +15 -0
  37. package/dist/menu/utils/useMenuListKeyboardNav.d.ts +12 -0
  38. package/dist/menu/utils/useMenuListKeyboardNav.js +77 -0
  39. package/dist/menu/utils/useMenuPath.d.ts +6 -0
  40. package/dist/menu/utils/useMenuPath.js +35 -0
  41. package/dist/nav/navItem/navItem.js +6 -4
  42. package/dist/nav/navSection/navSection.js +7 -5
  43. package/esm/menu/components/controlledMenuList/controlledMenuList.d.ts +27 -0
  44. package/esm/menu/components/controlledMenuList/controlledMenuList.js +120 -0
  45. package/esm/menu/components/createControlledMenuList.d.ts +37 -0
  46. package/esm/menu/components/createControlledMenuList.js +51 -0
  47. package/esm/menu/components/createMenuItem.d.ts +67 -0
  48. package/esm/menu/components/createMenuItem.js +93 -0
  49. package/esm/menu/components/menuButton.js +8 -2
  50. package/esm/menu/components/menuItem.d.ts +1 -2
  51. package/esm/menu/components/menuItem.js +20 -74
  52. package/esm/menu/contexts/usePathContext.d.ts +2 -0
  53. package/esm/menu/contexts/usePathContext.js +5 -0
  54. package/esm/menu/controlledMenu.js +10 -177
  55. package/esm/menu/utils/buildMenuContent.d.ts +2 -0
  56. package/esm/menu/utils/buildMenuContent.js +34 -0
  57. package/esm/menu/utils/findContent.d.ts +2 -2
  58. package/esm/menu/utils/findContent.js +4 -3
  59. package/esm/menu/utils/getItemLabel.d.ts +2 -0
  60. package/esm/menu/utils/getItemLabel.js +4 -0
  61. package/esm/menu/utils/getSafeRel.d.ts +1 -0
  62. package/esm/menu/utils/getSafeRel.js +10 -0
  63. package/esm/menu/utils/isMenuItem.d.ts +2 -0
  64. package/esm/menu/utils/isMenuItem.js +9 -0
  65. package/esm/menu/utils/isSafeHref.d.ts +1 -0
  66. package/esm/menu/utils/isSafeHref.js +6 -0
  67. package/esm/menu/utils/normalizeSeparators.d.ts +2 -0
  68. package/esm/menu/utils/normalizeSeparators.js +19 -0
  69. package/esm/menu/utils/resolveKeys.d.ts +12 -0
  70. package/esm/menu/utils/resolveKeys.js +18 -0
  71. package/esm/menu/utils/useLastValidSheet.d.ts +7 -0
  72. package/esm/menu/utils/useLastValidSheet.js +26 -0
  73. package/esm/menu/utils/useMenuItemCore.d.ts +31 -0
  74. package/esm/menu/utils/useMenuItemCore.js +47 -0
  75. package/esm/menu/utils/useMenuItemKeyboardNav.d.ts +2 -0
  76. package/esm/menu/utils/useMenuItemKeyboardNav.js +11 -0
  77. package/esm/menu/utils/useMenuListKeyboardNav.d.ts +12 -0
  78. package/esm/menu/utils/useMenuListKeyboardNav.js +73 -0
  79. package/esm/menu/utils/useMenuPath.d.ts +6 -0
  80. package/esm/menu/utils/useMenuPath.js +31 -0
  81. package/esm/nav/navItem/navItem.js +6 -4
  82. package/esm/nav/navSection/navSection.js +7 -5
  83. package/package.json +1 -1
package/README.md CHANGED
@@ -47,6 +47,12 @@ Zenith library provides components defined in Zenith Design System. It includes
47
47
 
48
48
  ## Change log
49
49
 
50
+ ### 3.11.0
51
+
52
+ - Add `ControlledMenuList` component and `createMenuItem`/`createControlledMenuList` factories for typed, path-based menu navigation with keyboard support
53
+ - Improve `NavItem` keyboard navigation
54
+ - Fix gap for `Nav` component
55
+
50
56
  ### 3.10.0
51
57
 
52
58
  - Add `wrap` and `vertical` props to `ListItem`
package/dist/index.css CHANGED
@@ -3433,6 +3433,11 @@ html:lang(ar) .zen-menu-button__action--drive-tablet {
3433
3433
  .zen-menu-item__content {
3434
3434
  list-style-type: none;
3435
3435
  }
3436
+ .zen-menu-item--horizontal {
3437
+ display: flex;
3438
+ flex-direction: row;
3439
+ gap: 8px;
3440
+ }
3436
3441
  .zen-popup {
3437
3442
  font-family: var(--main-font);
3438
3443
  font-size: 14px;
@@ -3480,6 +3485,8 @@ html:lang(ar) .zen-popup {
3480
3485
  }
3481
3486
  }
3482
3487
  .zen-menu-separator {
3488
+ border-block-start: 1px solid var(--borders-general);
3489
+ list-style-type: none;
3483
3490
  border-top: 1px solid var(--borders-general);
3484
3491
  height: 0;
3485
3492
  }
@@ -17799,7 +17806,7 @@ html:lang(ar) .zen-nav-item__title-text {
17799
17806
  .zen-nav-item__content-left {
17800
17807
  display: flex;
17801
17808
  align-items: center;
17802
- gap: 4px;
17809
+ gap: 8px;
17803
17810
  flex: 1 1 auto;
17804
17811
  min-width: 0;
17805
17812
  }
@@ -0,0 +1,27 @@
1
+ import { FC, ForwardRefExoticComponent, RefAttributes, ReactNode } from "react";
2
+ import { IMenuControlledItem } from "../menuItem";
3
+ import { IZenComponentProps } from "../../../commonHelpers/zenComponent";
4
+ import { IMenuSeparator } from "../menuSeparator";
5
+ import { TAlignment } from "../../../absolute/absolute";
6
+ export type TMenuListDirection = "vertical" | "horizontal";
7
+ export interface IControlledMenuList extends IZenComponentProps {
8
+ listClassName?: string;
9
+ ariaLabel?: string;
10
+ /** Default tooltip alignment injected into MenuItem children that don't specify their own. */
11
+ defaultTooltipAlignment?: TAlignment;
12
+ /** Default submenu alignment injected into MenuItem children that don't specify their own. */
13
+ defaultAlignment?: TAlignment;
14
+ /** Layout direction of the menu list. Defaults to "vertical". */
15
+ direction?: TMenuListDirection;
16
+ }
17
+ /** Internal props injected by ControlledMenu — not part of the public API. */
18
+ export interface IControlledMenuListInternal extends IControlledMenuList {
19
+ isOpen?: boolean;
20
+ setIsOpen?: (v: boolean) => void;
21
+ }
22
+ export declare const ControlledMenuList: ForwardRefExoticComponent<IControlledMenuList & {
23
+ children?: ReactNode;
24
+ } & RefAttributes<HTMLDivElement>> & {
25
+ Item: FC<IMenuControlledItem>;
26
+ Separator: FC<IMenuSeparator>;
27
+ };
@@ -0,0 +1,123 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ControlledMenuList = void 0;
4
+ const jsx_runtime_1 = require("react/jsx-runtime");
5
+ /* eslint-disable @typescript-eslint/naming-convention */
6
+ const react_1 = require("react");
7
+ const menuItem_1 = require("../menuItem");
8
+ const classNames_1 = require("../../../commonHelpers/classNames/classNames");
9
+ const findContent_1 = require("../../utils/findContent");
10
+ const getItemLabel_1 = require("../../utils/getItemLabel");
11
+ const normalizeSeparators_1 = require("../../utils/normalizeSeparators");
12
+ const useMenuPath_1 = require("../../utils/useMenuPath");
13
+ const useLastValidSheet_1 = require("../../utils/useLastValidSheet");
14
+ const useMenuListKeyboardNav_1 = require("../../utils/useMenuListKeyboardNav");
15
+ const menuButton_1 = require("../menuButton");
16
+ const iconArrowLeft_1 = require("../../../icons/iconArrowLeft");
17
+ const deviceType_1 = require("../../../commonHelpers/hooks/deviceType");
18
+ const useDeviceType_1 = require("../../../commonHelpers/hooks/useDeviceType");
19
+ const generateId_1 = require("../../../commonHelpers/generateId");
20
+ const pathProvider_1 = require("../../contexts/pathProvider");
21
+ const mobileSheet_1 = require("../../../mobileSheet/mobileSheet");
22
+ const menuSeparator_1 = require("../menuSeparator");
23
+ const ControlledMenuListBase = (0, react_1.forwardRef)(({ children, setIsOpen, isOpen, className = "", listClassName = "", ariaLabel, defaultTooltipAlignment, defaultAlignment, direction = "vertical" }, ref) => {
24
+ const [deviceType, setDeviceType] = (0, react_1.useState)(deviceType_1.DeviceType.Desktop);
25
+ const isMobile = deviceType === deviceType_1.DeviceType.Mobile;
26
+ (0, useDeviceType_1.useDeviceType)(setDeviceType);
27
+ // True when mounted inside ControlledMenu, which injects setIsOpen and isOpen.
28
+ // Standalone usage leaves both undefined.
29
+ const isEmbedded = setIsOpen !== undefined;
30
+ const { path, onOpenBranch, closeBranch, closeAll } = (0, useMenuPath_1.useMenuPath)(isOpen);
31
+ const navigatedViaKeyboardRef = (0, react_1.useRef)(false);
32
+ const keyboardActiveRef = (0, react_1.useRef)(false);
33
+ const internalRef = (0, react_1.useRef)(null);
34
+ const divRefCallback = (0, react_1.useCallback)((node) => {
35
+ internalRef.current = node;
36
+ if (typeof ref === "function") {
37
+ ref(node);
38
+ }
39
+ else if (ref) {
40
+ ref.current = node;
41
+ }
42
+ }, [ref]);
43
+ const effectiveSetIsOpen = (0, react_1.useCallback)((v) => {
44
+ setIsOpen === null || setIsOpen === void 0 ? void 0 : setIsOpen(v);
45
+ if (!v && !isEmbedded) {
46
+ closeAll();
47
+ }
48
+ }, [setIsOpen, closeAll, isEmbedded]);
49
+ const buildListItems = (0, react_1.useCallback)((childrenToProcess) => {
50
+ var _a;
51
+ const cont = [];
52
+ // If children is a Fragment, extract its children
53
+ let actualChildren = childrenToProcess;
54
+ if ((0, react_1.isValidElement)(childrenToProcess) && childrenToProcess.type === react_1.Fragment) {
55
+ actualChildren = (_a = childrenToProcess.props.children) !== null && _a !== void 0 ? _a : [];
56
+ }
57
+ react_1.Children.map(actualChildren, (child) => {
58
+ var _a, _b;
59
+ if (!child)
60
+ return;
61
+ if (typeof child === "string") {
62
+ cont.push((0, jsx_runtime_1.jsx)("li", { role: "presentation", className: (0, classNames_1.classNames)(["zen-menu-item__content"]), children: child }, (0, generateId_1.generateId)()));
63
+ return;
64
+ }
65
+ if ((0, react_1.isValidElement)(child) && (0, menuSeparator_1.isSeparator)(child)) {
66
+ cont.push((0, react_1.cloneElement)(child, { key: (_a = child.key) !== null && _a !== void 0 ? _a : (0, generateId_1.generateId)() }));
67
+ return;
68
+ }
69
+ if ((0, react_1.isValidElement)(child) && (0, menuItem_1.isMenuItem)(child)) {
70
+ const childProps = child.props;
71
+ cont.push((0, react_1.cloneElement)(child, {
72
+ isMobile,
73
+ key: (_b = childProps.id) !== null && _b !== void 0 ? _b : (0, generateId_1.generateId)(),
74
+ setIsOpen: effectiveSetIsOpen,
75
+ onClick: childProps.onClick,
76
+ tooltipAlignment: childProps.tooltipAlignment || defaultTooltipAlignment || undefined,
77
+ alignment: childProps.alignment || defaultAlignment || undefined
78
+ }));
79
+ return;
80
+ }
81
+ const childProps = child.props;
82
+ cont.push((0, jsx_runtime_1.jsx)("li", { className: (0, classNames_1.classNames)(["zen-menu-item__content"]), role: "presentation", children: child }, childProps.id || childProps["data-id"] || (0, generateId_1.generateId)()));
83
+ });
84
+ return (0, normalizeSeparators_1.normalizeSeparators)(cont);
85
+ }, [isMobile, effectiveSetIsOpen, defaultTooltipAlignment, defaultAlignment]);
86
+ // Embedded (ControlledMenu): switch content based on path so the outer MobileSheet
87
+ // shows nested items with the back button as a list row.
88
+ // Standalone: always show top-level content; nested navigation uses its own MobileSheet.
89
+ const [content, parent] = (0, react_1.useMemo)(() => {
90
+ if (isEmbedded && isMobile && path.length > 0) {
91
+ const el = (0, findContent_1.findContent)(children, menuItem_1.isMenuItem, path[path.length - 1]);
92
+ if (el && (0, react_1.isValidElement)(el)) {
93
+ return [buildListItems(el.props.children), el];
94
+ }
95
+ }
96
+ return [buildListItems(children), null];
97
+ }, [children, isMobile, path, isEmbedded, buildListItems]);
98
+ // Standalone mobile only: compute nested content for the inline MobileSheet.
99
+ const [nestedContent, nestedParent] = (0, react_1.useMemo)(() => {
100
+ if (isEmbedded || !isMobile || path.length === 0) {
101
+ return [null, null];
102
+ }
103
+ const el = (0, findContent_1.findContent)(children, menuItem_1.isMenuItem, path[path.length - 1]);
104
+ if (!el || !(0, react_1.isValidElement)(el)) {
105
+ return [null, null];
106
+ }
107
+ return [buildListItems(el.props.children), el];
108
+ }, [children, isMobile, path, isEmbedded, buildListItems]);
109
+ const isHorizontal = direction === "horizontal";
110
+ const { onKeyDown, onKeyDownVertical, onMouseDown } = (0, useMenuListKeyboardNav_1.useMenuListKeyboardNav)(keyboardActiveRef, navigatedViaKeyboardRef, isHorizontal);
111
+ const renderList = (listContent, backParent, listRef, applyDirection = true, keyDownHandler = onKeyDown) => ((0, jsx_runtime_1.jsx)("div", { ref: listRef, tabIndex: -1, onKeyDown: keyDownHandler, onMouseDown: onMouseDown, className: (0, classNames_1.classNames)(["zen-action-list", className]), children: (0, jsx_runtime_1.jsxs)("ul", { role: "menu", "aria-label": ariaLabel, className: (0, classNames_1.classNames)([
112
+ "zen-menu-item",
113
+ className,
114
+ listClassName,
115
+ applyDirection && direction === "horizontal" ? "zen-menu-item--horizontal" : ""
116
+ ]), children: [backParent ? ((0, jsx_runtime_1.jsx)(menuButton_1.MenuButton, { id: "root", name: (0, getItemLabel_1.getItemLabel)(backParent), icon: iconArrowLeft_1.IconArrowLeft, onClick: closeBranch, hasChildren: false, disabled: false }, "root")) : null, listContent] }) }));
117
+ const { sheetContent, sheetParent, sheetPathLength, sheetParentName } = (0, useLastValidSheet_1.useLastValidSheet)(nestedContent, nestedParent, path, children);
118
+ return ((0, jsx_runtime_1.jsxs)(pathProvider_1.PathProvider, { path: path, onOpenBranch: onOpenBranch, closeBranch: closeBranch, navigatedViaKeyboardRef: navigatedViaKeyboardRef, keyboardActiveRef: keyboardActiveRef, children: [renderList(content, parent, divRefCallback), !isEmbedded && isMobile && ((0, jsx_runtime_1.jsxs)(mobileSheet_1.MobileSheet, { label: sheetParentName, isOpen: path.length > 0, triggerRef: internalRef, onHidePanel: closeAll, onCloseClick: closeAll, children: [(0, jsx_runtime_1.jsx)(mobileSheet_1.MobileSheet.Title, { children: sheetParentName }), (0, jsx_runtime_1.jsx)(mobileSheet_1.MobileSheet.Content, { children: renderList(sheetContent, sheetPathLength > 1 ? sheetParent : null, undefined, false, onKeyDownVertical) })] }))] }));
119
+ });
120
+ ControlledMenuListBase.displayName = "ControlledMenuList";
121
+ exports.ControlledMenuList = ControlledMenuListBase;
122
+ exports.ControlledMenuList.Item = menuItem_1.MenuItem;
123
+ exports.ControlledMenuList.Separator = menuSeparator_1.MenuSeparator;
@@ -0,0 +1,37 @@
1
+ import { FC, ReactNode } from "react";
2
+ import { IControlledMenuListInternal } from "./controlledMenuList/controlledMenuList";
3
+ import { IMenuControlledItem } from "./menuItem";
4
+ import { IMenuSeparator } from "./menuSeparator";
5
+ export interface ICreateControlledMenuListRenderProps {
6
+ children: ReactNode;
7
+ }
8
+ type CreatedListComponent<T> = FC<T & IControlledMenuListInternal & {
9
+ children?: ReactNode;
10
+ }> & {
11
+ Item: FC<IMenuControlledItem>;
12
+ Separator: FC<IMenuSeparator>;
13
+ };
14
+ /**
15
+ * Factory that creates a typed ControlledMenuList component with custom props.
16
+ *
17
+ * The `renderContent` function receives all consumer props plus `children` (the built ControlledMenuList element),
18
+ * allowing the consumer to wrap the list with custom markup.
19
+ *
20
+ * @example
21
+ * interface INavSection { sectionTitle: string }
22
+ *
23
+ * const NavSection = createControlledMenuList<INavSection>(({ sectionTitle, children }) => (
24
+ * <div>
25
+ * <div className="section-header">{sectionTitle}</div>
26
+ * {children}
27
+ * </div>
28
+ * ));
29
+ *
30
+ * // Usage — accepts INavSection + IControlledMenuList props:
31
+ * <NavSection sectionTitle="Reports" setIsOpen={setIsOpen} isOpen={isOpen}>
32
+ * <NavSection.Item id="daily" name="Daily Reports" />
33
+ * <NavSection.Item id="weekly" name="Weekly Reports" />
34
+ * </NavSection>
35
+ */
36
+ export declare function createControlledMenuList<T extends object>(renderContent: (props: T & ICreateControlledMenuListRenderProps) => ReactNode): CreatedListComponent<T>;
37
+ export {};
@@ -0,0 +1,55 @@
1
+ "use strict";
2
+ var __rest = (this && this.__rest) || function (s, e) {
3
+ var t = {};
4
+ for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
5
+ t[p] = s[p];
6
+ if (s != null && typeof Object.getOwnPropertySymbols === "function")
7
+ for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
8
+ if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
9
+ t[p[i]] = s[p[i]];
10
+ }
11
+ return t;
12
+ };
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.createControlledMenuList = void 0;
15
+ const jsx_runtime_1 = require("react/jsx-runtime");
16
+ const controlledMenuList_1 = require("./controlledMenuList/controlledMenuList");
17
+ const menuItem_1 = require("./menuItem");
18
+ const menuSeparator_1 = require("./menuSeparator");
19
+ /**
20
+ * Factory that creates a typed ControlledMenuList component with custom props.
21
+ *
22
+ * The `renderContent` function receives all consumer props plus `children` (the built ControlledMenuList element),
23
+ * allowing the consumer to wrap the list with custom markup.
24
+ *
25
+ * @example
26
+ * interface INavSection { sectionTitle: string }
27
+ *
28
+ * const NavSection = createControlledMenuList<INavSection>(({ sectionTitle, children }) => (
29
+ * <div>
30
+ * <div className="section-header">{sectionTitle}</div>
31
+ * {children}
32
+ * </div>
33
+ * ));
34
+ *
35
+ * // Usage — accepts INavSection + IControlledMenuList props:
36
+ * <NavSection sectionTitle="Reports" setIsOpen={setIsOpen} isOpen={isOpen}>
37
+ * <NavSection.Item id="daily" name="Daily Reports" />
38
+ * <NavSection.Item id="weekly" name="Weekly Reports" />
39
+ * </NavSection>
40
+ */
41
+ function createControlledMenuList(renderContent) {
42
+ const CreatedList = allProps => {
43
+ const { setIsOpen, listClassName, isOpen, defaultTooltipAlignment, defaultAlignment, direction, className, children } = allProps, rest = __rest(allProps, ["setIsOpen", "listClassName", "isOpen", "defaultTooltipAlignment", "defaultAlignment", "direction", "className", "children"]);
44
+ // Cast required to pass internal props (isOpen, setIsOpen) not in the public interface.
45
+ const InternalList = controlledMenuList_1.ControlledMenuList;
46
+ const list = ((0, jsx_runtime_1.jsx)(InternalList, { setIsOpen: setIsOpen, listClassName: listClassName, isOpen: isOpen, defaultTooltipAlignment: defaultTooltipAlignment, defaultAlignment: defaultAlignment, direction: direction, className: className, children: children }));
47
+ return renderContent(Object.assign(Object.assign({}, rest), { children: list }));
48
+ };
49
+ CreatedList.displayName = "ControlledMenuList";
50
+ const result = CreatedList;
51
+ result.Item = menuItem_1.MenuItem;
52
+ result.Separator = menuSeparator_1.MenuSeparator;
53
+ return result;
54
+ }
55
+ exports.createControlledMenuList = createControlledMenuList;
@@ -0,0 +1,67 @@
1
+ import { ReactNode, ForwardRefExoticComponent, RefAttributes, ForwardedRef } from "react";
2
+ import { TAlignment } from "../../absolute/absolute";
3
+ import { IMenuItem } from "./menuItem";
4
+ import "./menuButton.less";
5
+ export interface ICreateMenuItemRenderProps {
6
+ hasChildren: boolean;
7
+ isOpen: boolean;
8
+ isMobile: boolean;
9
+ }
10
+ export interface ICreateMenuItemWrapperProps {
11
+ role: "presentation";
12
+ className: string;
13
+ tabIndex?: number;
14
+ }
15
+ /**
16
+ * Factory that creates a typed Menu item component with full MenuItem functionality
17
+ * (nested submenus, keyboard navigation, mobile sheet support), but with a
18
+ * consumer-defined typed interface instead of the fixed MenuItem props.
19
+ *
20
+ * The `renderTrigger` function receives all consumer props plus `hasChildren` and
21
+ * `isOpen` so the trigger content can reflect submenu state.
22
+ *
23
+ * @example
24
+ * interface INavItem { label: string; count?: number }
25
+ *
26
+ * const NavMenuItem = createMenuItem<INavItem>(({ label, count, hasChildren, isOpen }) => (
27
+ * <>
28
+ * <span>{label}</span>
29
+ * {count !== undefined && <Badge>{count}</Badge>}
30
+ * {hasChildren && <ChevronRight rotated={isOpen} />}
31
+ * </>
32
+ * ));
33
+ *
34
+ * // Usage — supports nesting exactly like Menu.Item:
35
+ * <Menu title="Nav">
36
+ * <NavMenuItem id="reports" label="Reports" count={3}>
37
+ * <Menu.Item id="charts" name="Charts" />
38
+ * <Menu.Item id="tables" name="Tables" />
39
+ * </NavMenuItem>
40
+ * </Menu>
41
+ *
42
+ * @example
43
+ * // renderWrapper: replace the default <li> with a custom host element
44
+ * <NavMenuItem
45
+ * id="settings"
46
+ * label="Settings"
47
+ * renderWrapper={(children, ref, defaultProps) => (
48
+ * <li ref={ref} {...defaultProps} data-testid="nav-settings">
49
+ * {children}
50
+ * </li>
51
+ * )}
52
+ * />
53
+ */
54
+ type PublicMenuItemProps<T> = T & Pick<IMenuItem, "id" | "children" | "className" | "alignment" | "disabled" | "onClick" | "link" | "target" | "rel"> & {
55
+ ariaLabel?: string;
56
+ onOpenChange?: (isOpen: boolean) => void;
57
+ renderWrapper?: (children: ReactNode, ref: ForwardedRef<HTMLLIElement>, defaultProps: ICreateMenuItemWrapperProps) => ReactNode;
58
+ tabIndex?: number;
59
+ tooltip?: ReactNode;
60
+ tooltipAlignment?: TAlignment;
61
+ };
62
+ export interface ICreateMenuItemOptions<T> {
63
+ /** Hook-compatible function called during render to derive a default tooltip from item props. Can call useContext. */
64
+ renderTooltip?: (props: T) => ReactNode | undefined;
65
+ }
66
+ export declare function createMenuItem<T extends object>(renderTrigger: (props: T & ICreateMenuItemRenderProps) => ReactNode, options?: ICreateMenuItemOptions<T>): ForwardRefExoticComponent<PublicMenuItemProps<T> & RefAttributes<HTMLLIElement>>;
67
+ export {};
@@ -0,0 +1,97 @@
1
+ "use strict";
2
+ var __rest = (this && this.__rest) || function (s, e) {
3
+ var t = {};
4
+ for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
5
+ t[p] = s[p];
6
+ if (s != null && typeof Object.getOwnPropertySymbols === "function")
7
+ for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
8
+ if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
9
+ t[p[i]] = s[p[i]];
10
+ }
11
+ return t;
12
+ };
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.createMenuItem = void 0;
15
+ const jsx_runtime_1 = require("react/jsx-runtime");
16
+ const react_1 = require("react");
17
+ const controlledPopup_1 = require("../../controlledPopup/controlledPopup");
18
+ const classNames_1 = require("../../commonHelpers/classNames/classNames");
19
+ const useMenuItemCore_1 = require("../utils/useMenuItemCore");
20
+ const tooltip_1 = require("../../tooltip/tooltip");
21
+ const isSafeHref_1 = require("../utils/isSafeHref");
22
+ const getSafeRel_1 = require("../utils/getSafeRel");
23
+ const useDriveClassName_1 = require("../../utils/theme/useDriveClassName");
24
+ function createMenuItem(renderTrigger, options) {
25
+ var _a;
26
+ // Defined at factory level so it is always the same function reference —
27
+ // safe to call unconditionally inside the component (rules of hooks).
28
+ const resolveDefaultTooltip = (_a = options === null || options === void 0 ? void 0 : options.renderTooltip) !== null && _a !== void 0 ? _a : (() => undefined);
29
+ const CreatedItem = (0, react_1.forwardRef)((allProps, containerRef) => {
30
+ const { id, children, className, alignment, disabled, isMobile = false, setIsOpen, ariaLabel, onClick, onOpenChange, link, target, rel, renderWrapper, tabIndex, tooltip, tooltipAlignment } = allProps, rest = __rest(allProps, ["id", "children", "className", "alignment", "disabled", "isMobile", "setIsOpen", "ariaLabel", "onClick", "onOpenChange", "link", "target", "rel", "renderWrapper", "tabIndex", "tooltip", "tooltipAlignment"]);
31
+ const linkEvents = (0, react_1.useMemo)(() => ({
32
+ onKeyDown: (e) => {
33
+ if (e.key === " ") {
34
+ e.preventDefault();
35
+ const linkEl = e.currentTarget;
36
+ linkEl.dataset.spaceDown = "1";
37
+ linkEl.classList.add("zen-menu-button__action--active");
38
+ }
39
+ },
40
+ onKeyUp: (e) => {
41
+ if (e.key === " ") {
42
+ e.preventDefault();
43
+ const linkEl = e.currentTarget;
44
+ linkEl.classList.remove("zen-menu-button__action--active");
45
+ if (linkEl.dataset.spaceDown) {
46
+ delete linkEl.dataset.spaceDown;
47
+ linkEl.click();
48
+ }
49
+ }
50
+ }
51
+ }), []);
52
+ const { ref, isOpen, hasChildren, content, openedViaKeyboard, contentAlignment, path, handleDesktopActionClick, handleTriggerClick, handleOpenChange } = (0, useMenuItemCore_1.useMenuItemCore)({ id, children, className, alignment, isMobile, setIsOpen, onClick, onOpenChange });
53
+ const driveClass = (0, useDriveClassName_1.useDriveClassName)("zen-menu-button__action");
54
+ const liDefaultProps = Object.assign({ role: "presentation", className: (0, classNames_1.classNames)(["zen-menu-button", className !== null && className !== void 0 ? className : ""]) }, (tabIndex !== undefined && { tabIndex }));
55
+ // Resolved at component top level so renderTooltip can safely call hooks (e.g. useContext).
56
+ const defaultTooltip = resolveDefaultTooltip(Object.assign(Object.assign({}, rest), { isMobile }));
57
+ const effectiveTooltip = tooltip !== null && tooltip !== void 0 ? tooltip : defaultTooltip;
58
+ const effectiveTooltipAlignment = tooltipAlignment;
59
+ const wrapButton = (button) => {
60
+ // On mobile, tooltip wraps the <li> instead of the button so Tooltip doesn't
61
+ // intercept the button's onClick and shows on tap instead.
62
+ // Items with children are excluded — wrapping <li> would fire both the tooltip and
63
+ // the submenu drill-in on the same tap.
64
+ const isMobileTooltip = isMobile && !!effectiveTooltip && !hasChildren;
65
+ const wrappedButton = effectiveTooltip && !isMobileTooltip ? ((0, jsx_runtime_1.jsx)(tooltip_1.Tooltip, { trigger: button, alignment: effectiveTooltipAlignment, children: effectiveTooltip })) : (button);
66
+ const liElement = renderWrapper ? (renderWrapper(wrappedButton, containerRef, liDefaultProps)) : ((0, jsx_runtime_1.jsx)("li", Object.assign({ ref: containerRef }, liDefaultProps, { tabIndex: tabIndex, children: wrappedButton })));
67
+ // Wrap li with Tooltip on mobile
68
+ return isMobileTooltip ? ((0, jsx_runtime_1.jsx)(tooltip_1.Tooltip, { trigger: liElement, alignment: effectiveTooltipAlignment, children: effectiveTooltip })) : (liElement);
69
+ };
70
+ // Leaf node — no submenu
71
+ if (!hasChildren) {
72
+ const triggerContent = renderTrigger(Object.assign(Object.assign({}, rest), { hasChildren: false, isOpen: false, isMobile }));
73
+ return wrapButton(link ? ((0, jsx_runtime_1.jsx)("a", Object.assign({ role: "menuitem", "aria-label": ariaLabel, "aria-disabled": disabled, href: disabled || !(0, isSafeHref_1.isSafeHref)(link) ? undefined : link, target: target, rel: (0, getSafeRel_1.getSafeRel)(rel, target), className: (0, classNames_1.classNames)(["zen-menu-button__action", "zen-caption", driveClass !== null && driveClass !== void 0 ? driveClass : ""]), onClick: handleDesktopActionClick }, linkEvents, { children: triggerContent }))) : ((0, jsx_runtime_1.jsx)("button", { ref: ref, type: "button", role: "menuitem", "aria-label": ariaLabel, disabled: !!disabled, className: (0, classNames_1.classNames)(["zen-menu-button__action", "zen-button", "zen-caption", driveClass !== null && driveClass !== void 0 ? driveClass : ""]), onClick: handleDesktopActionClick, children: triggerContent })));
74
+ }
75
+ // Mobile — button drills into sub-level via PathContext
76
+ if (isMobile) {
77
+ return wrapButton((0, jsx_runtime_1.jsx)("button", { ref: ref, type: "button", role: "menuitem", "aria-label": ariaLabel, disabled: !!disabled, className: (0, classNames_1.classNames)([
78
+ "zen-menu-button__action",
79
+ "zen-button",
80
+ "zen-caption",
81
+ "zen-menu-button__action--has-children",
82
+ driveClass !== null && driveClass !== void 0 ? driveClass : ""
83
+ ]), onClick: handleTriggerClick, children: renderTrigger(Object.assign(Object.assign({}, rest), { hasChildren: true, isOpen, isMobile })) }));
84
+ }
85
+ // Desktop with submenu
86
+ return ((0, jsx_runtime_1.jsxs)(react_1.Fragment, { children: [wrapButton((0, jsx_runtime_1.jsx)("button", { ref: ref, type: "button", role: "menuitem", "aria-label": ariaLabel, disabled: !!disabled, "aria-haspopup": "menu", "aria-expanded": isOpen, className: (0, classNames_1.classNames)([
87
+ "zen-menu-button__action",
88
+ "zen-button",
89
+ "zen-caption",
90
+ "zen-menu-button__action--has-children",
91
+ driveClass !== null && driveClass !== void 0 ? driveClass : ""
92
+ ]), onClick: handleTriggerClick, children: renderTrigger(Object.assign(Object.assign({}, rest), { hasChildren: true, isOpen, isMobile: false })) })), (0, jsx_runtime_1.jsx)(controlledPopup_1.ControlledPopup, { className: (0, classNames_1.classNames)([`zen-controlled-menu-submenu--${path.length}`]), useTrapFocusWithTrigger: openedViaKeyboard ? "on" : "withTrigger", alignment: contentAlignment, triggerRef: ref, isOpen: isOpen, onOpenChange: handleOpenChange, ariaLabel: ariaLabel, recalculateOnScroll: true, children: (0, jsx_runtime_1.jsx)("ul", { role: "menu", className: "zen-menu-item", children: content }) })] }));
93
+ });
94
+ CreatedItem.displayName = "MenuItem";
95
+ return CreatedItem;
96
+ }
97
+ exports.createMenuItem = createMenuItem;
@@ -8,6 +8,8 @@ const iconChevronRight_1 = require("../../icons/iconChevronRight");
8
8
  const useDriveClassName_1 = require("../../utils/theme/useDriveClassName");
9
9
  const useDrive_1 = require("../../utils/theme/useDrive");
10
10
  const getMenuButtonState_1 = require("../utils/getMenuButtonState");
11
+ const isSafeHref_1 = require("../utils/isSafeHref");
12
+ const getSafeRel_1 = require("../utils/getSafeRel");
11
13
  const MenuButton = ({ id, onClick, hasChildren, disabled, icon, name, link, target, rel, className = "", active = null, ref }) => {
12
14
  const { hasState, isActive } = (0, getMenuButtonState_1.getMenuButtonState)(active, disabled);
13
15
  const driveMenuButtonActionClasses = (0, useDriveClassName_1.useDriveClassName)("zen-menu-button__action");
@@ -17,6 +19,7 @@ const MenuButton = ({ id, onClick, hasChildren, disabled, icon, name, link, targ
17
19
  if (e.key === " ") {
18
20
  e.preventDefault();
19
21
  const linkEl = e.target;
22
+ linkEl.dataset.spaceDown = "1";
20
23
  linkEl.classList.add("zen-menu-button__action--active");
21
24
  }
22
25
  },
@@ -25,7 +28,10 @@ const MenuButton = ({ id, onClick, hasChildren, disabled, icon, name, link, targ
25
28
  e.preventDefault();
26
29
  const linkEl = e.target;
27
30
  linkEl.classList.remove("zen-menu-button__action--active");
28
- linkEl.click();
31
+ if (linkEl.dataset.spaceDown) {
32
+ delete linkEl.dataset.spaceDown;
33
+ linkEl.click();
34
+ }
29
35
  }
30
36
  }
31
37
  }), []);
@@ -49,7 +55,7 @@ const MenuButton = ({ id, onClick, hasChildren, disabled, icon, name, link, targ
49
55
  "zen-caption",
50
56
  disabled ? "zen-menu-button__action--disabled" : "",
51
57
  driveMenuButtonActionClasses || ""
52
- ]), href: disabled ? undefined : link, "aria-disabled": disabled, onClick: onClickHandler, target: target, rel: rel }, linkEvents, { children: [!!icon &&
58
+ ]), href: disabled || !(0, isSafeHref_1.isSafeHref)(link) ? undefined : link, "aria-disabled": disabled, onClick: onClickHandler, target: target, rel: (0, getSafeRel_1.getSafeRel)(rel, target) }, linkEvents, { children: [!!icon &&
53
59
  (0, react_1.createElement)(icon, {
54
60
  size: isDrive ? "huge" : "large",
55
61
  className: "zen-caption__pre-content"
@@ -2,6 +2,7 @@ import { FC, ReactElement } from "react";
2
2
  import { IMenuButton } from "./menuButton";
3
3
  import "./menuItem.less";
4
4
  import { TAlignment } from "../../absolute/absolute";
5
+ export { isMenuItem } from "../utils/isMenuItem";
5
6
  interface IMenuItemInternal {
6
7
  isMobile?: boolean;
7
8
  setIsOpen?: (v: boolean) => void;
@@ -12,6 +13,4 @@ export interface IMenuControlledItem extends IMenuItem {
12
13
  export interface IMenuItem extends IMenuButton {
13
14
  alignment?: TAlignment;
14
15
  }
15
- export declare const isMenuItem: (element: ReactElement | undefined) => boolean;
16
16
  export declare const MenuItem: FC<Omit<IMenuItem & IMenuControlledItem & IMenuItemInternal, "hasChildren">>;
17
- export {};
@@ -2,37 +2,30 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.MenuItem = exports.isMenuItem = void 0;
4
4
  const jsx_runtime_1 = require("react/jsx-runtime");
5
- /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument, @typescript-eslint/naming-convention */
5
+ /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument */
6
6
  const react_1 = require("react");
7
7
  const menuButton_1 = require("./menuButton");
8
- const controlledMenu_1 = require("../controlledMenu");
9
8
  const controlledPopup_1 = require("../../controlledPopup/controlledPopup");
10
- const pathContext_1 = require("../contexts/pathContext");
11
9
  const classNames_1 = require("../../commonHelpers/classNames/classNames");
12
- const generateId_1 = require("../../commonHelpers/generateId");
13
- const headerContext_1 = require("../../header/headerContext");
14
- const menuSeparator_1 = require("./menuSeparator");
15
- const isMenuItem = (element) => {
16
- if (!element || !element.type) {
17
- return false;
18
- }
19
- if (element.type === controlledMenu_1.ControlledMenu.Item) {
20
- return true;
21
- }
22
- if ((typeof element.type === "object" || typeof element.type === "function") && "displayName" in element.type) {
23
- return element.type.displayName === "MenuItem";
24
- }
25
- return false;
26
- };
27
- exports.isMenuItem = isMenuItem;
10
+ const useMenuItemCore_1 = require("../utils/useMenuItemCore");
11
+ var isMenuItem_1 = require("../utils/isMenuItem");
12
+ Object.defineProperty(exports, "isMenuItem", { enumerable: true, get: function () { return isMenuItem_1.isMenuItem; } });
28
13
  const MenuItem = ({ id, children, name, icon, disabled, onClick, link, target, rel, isMobile = false, setIsOpen, trigger, className, active, alignment }) => {
29
- const aligmentContext = (0, react_1.useContext)(headerContext_1.MenuAlignmentContext);
30
- const contentAlignment = alignment || aligmentContext.alignment || "right-top";
31
- const { path, onOpenBranch, closeBranch, navigatedViaKeyboardRef } = (0, react_1.useContext)(pathContext_1.PathContext);
32
- const memoizedDesktopActionOnClick = (0, react_1.useCallback)((itemId, e) => {
14
+ const { ref, isOpen, hasChildren, content, openedViaKeyboard, contentAlignment, path, onOpenBranch, handleOpenChange } = (0, useMenuItemCore_1.useMenuItemCore)({
15
+ id,
16
+ children,
17
+ className,
18
+ alignment,
19
+ isMobile,
20
+ setIsOpen,
21
+ onClick
22
+ });
23
+ // MenuButton.onClick signature is (id, e) — adapt the hook's (e)-only handler
24
+ const memoizedDesktopActionOnClick = (0, react_1.useCallback)((_, e) => {
33
25
  setIsOpen === null || setIsOpen === void 0 ? void 0 : setIsOpen(false);
34
- onClick === null || onClick === void 0 ? void 0 : onClick(itemId, e);
35
- }, [setIsOpen, onClick]);
26
+ onClick === null || onClick === void 0 ? void 0 : onClick(id, e);
27
+ }, [setIsOpen, onClick, id]);
28
+ // MenuItem-specific callbacks (not provided by hook)
36
29
  const memoizedMobileActionOnClick = (0, react_1.useCallback)((itemId, e) => {
37
30
  onOpenBranch(id);
38
31
  !link && (onClick === null || onClick === void 0 ? void 0 : onClick(itemId, e));
@@ -50,54 +43,7 @@ const MenuItem = ({ id, children, name, icon, disabled, onClick, link, target, r
50
43
  }
51
44
  onClick === null || onClick === void 0 ? void 0 : onClick(id, e);
52
45
  }, [onClick, onOpenBranch, id, trigger]);
53
- const memoizedOnOpenChange = (0, react_1.useCallback)(() => {
54
- closeBranch();
55
- }, [closeBranch]);
56
- const ref = (0, react_1.useRef)(null);
57
- const content = (0, react_1.useMemo)(() => {
58
- const cont = [];
59
- react_1.Children.map(children, (child) => {
60
- if (!child) {
61
- return;
62
- }
63
- if (typeof child === "string") {
64
- cont.push((0, jsx_runtime_1.jsx)("li", { className: (0, classNames_1.classNames)(["zen-menu-item__content", className !== null && className !== void 0 ? className : ""]), role: "presentation", children: child }, (0, generateId_1.generateId)()));
65
- return;
66
- }
67
- if ((0, react_1.isValidElement)(child) && (0, menuSeparator_1.isSeparator)(child)) {
68
- const clone = (0, react_1.cloneElement)(child, {
69
- key: child.props.key || (0, generateId_1.generateId)()
70
- });
71
- cont.push(clone);
72
- return;
73
- }
74
- if ((0, exports.isMenuItem)(child)) {
75
- const childProps = child.props;
76
- const clone = (0, react_1.cloneElement)(child, {
77
- isMobile,
78
- key: childProps.id,
79
- setIsOpen
80
- });
81
- cont.push(clone);
82
- return;
83
- }
84
- const childProps = child.props;
85
- cont.push((0, jsx_runtime_1.jsx)("li", { className: (0, classNames_1.classNames)(["zen-menu-item__content", className !== null && className !== void 0 ? className : ""]), role: "presentation", children: child }, childProps.id || childProps["data-id"] || (0, generateId_1.generateId)()));
86
- });
87
- return cont;
88
- }, [children, isMobile, setIsOpen, className]);
89
- const isOpen = (0, react_1.useMemo)(() => path.includes(id), [path, id]);
90
- // Track previous isOpen state to detect when submenu opens
91
- const wasOpenRef = (0, react_1.useRef)(false);
92
- const localOpenedViaKeyboardRef = (0, react_1.useRef)(false);
93
- // Capture keyboard navigation state synchronously when isOpen transitions to true
94
- if (isOpen && !wasOpenRef.current && navigatedViaKeyboardRef) {
95
- localOpenedViaKeyboardRef.current = navigatedViaKeyboardRef.current;
96
- navigatedViaKeyboardRef.current = false; // Reset for next navigation
97
- }
98
- wasOpenRef.current = isOpen;
99
- const openedViaKeyboard = localOpenedViaKeyboardRef.current;
100
- if (content.length === 0) {
46
+ if (!hasChildren) {
101
47
  return ((0, jsx_runtime_1.jsx)(menuButton_1.MenuButton, { id: id, name: name, icon: icon, disabled: disabled, link: link, target: target, rel: rel, onClick: memoizedDesktopActionOnClick, className: className, active: active, hasChildren: false }, id));
102
48
  }
103
49
  if (isMobile) {
@@ -113,7 +59,7 @@ const MenuItem = ({ id, children, name, icon, disabled, onClick, link, target, r
113
59
  else {
114
60
  popupTrigger = ((0, jsx_runtime_1.jsx)(menuButton_1.MenuButton, { id: id, ref: ref, name: name, icon: icon, disabled: disabled, hasChildren: true, onClick: memoizedTriggerOnClick, active: active }, id));
115
61
  }
116
- return ((0, jsx_runtime_1.jsxs)(react_1.Fragment, { children: [popupTrigger, (0, jsx_runtime_1.jsx)(controlledPopup_1.ControlledPopup, { className: (0, classNames_1.classNames)([`zen-controlled-menu-submenu--${path.length}`]), useTrapFocusWithTrigger: openedViaKeyboard ? "on" : "withTrigger", alignment: contentAlignment, triggerRef: ref, isOpen: isOpen, onOpenChange: memoizedOnOpenChange, ariaLabel: popupTrigger.props.name, recalculateOnScroll: true, children: (0, jsx_runtime_1.jsx)("ul", { role: "menu", className: "zen-menu-item", children: content }) })] }, id));
62
+ return ((0, jsx_runtime_1.jsxs)(react_1.Fragment, { children: [popupTrigger, (0, jsx_runtime_1.jsx)(controlledPopup_1.ControlledPopup, { className: (0, classNames_1.classNames)([`zen-controlled-menu-submenu--${path.length}`]), useTrapFocusWithTrigger: openedViaKeyboard ? "on" : "withTrigger", alignment: contentAlignment, triggerRef: ref, isOpen: isOpen, onOpenChange: handleOpenChange, ariaLabel: popupTrigger.props.name, recalculateOnScroll: true, children: (0, jsx_runtime_1.jsx)("ul", { role: "menu", className: "zen-menu-item", children: content }) })] }, id));
117
63
  };
118
64
  exports.MenuItem = MenuItem;
119
65
  exports.MenuItem.displayName = "MenuItem";
@@ -0,0 +1,2 @@
1
+ import { IPathContext } from "./pathContext";
2
+ export declare function usePathContext(): IPathContext;