@harborclient/sdk 1.0.68 → 1.0.69

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.
@@ -0,0 +1,49 @@
1
+ import type { JSX } from 'react';
2
+ import type { MenuItem } from './index.js';
3
+ interface Props {
4
+ /**
5
+ * Grouped flyout entries, mirroring `RowActionsMenu`'s `groups` shape.
6
+ */
7
+ groups: MenuItem[][];
8
+ /**
9
+ * Bounding rect of the parent row that anchors this flyout.
10
+ */
11
+ anchorRect: DOMRect;
12
+ /**
13
+ * Id applied to the flyout panel for `aria-controls` on the anchor row.
14
+ */
15
+ menuElementId: string;
16
+ /**
17
+ * Called when a leaf item is activated. The caller is responsible for
18
+ * closing the whole menu tree and invoking the item's `onSelect`.
19
+ */
20
+ onSelectItem: (item: MenuItem) => void;
21
+ /**
22
+ * Closes just this flyout and returns focus to the anchor row.
23
+ */
24
+ onRequestClose: () => void;
25
+ /**
26
+ * Closes the entire menu tree, e.g. when the user tabs out.
27
+ */
28
+ onCloseAll: () => void;
29
+ /**
30
+ * Forwarded so the parent can cancel a pending auto-close while the
31
+ * pointer is over the flyout panel itself.
32
+ */
33
+ onMouseEnter: () => void;
34
+ /**
35
+ * Forwarded so the parent can schedule closing this flyout once the
36
+ * pointer leaves the panel.
37
+ */
38
+ onMouseLeave: () => void;
39
+ }
40
+ /**
41
+ * Flyout panel opened beside a `RowActionsMenu` row that has a `submenu`.
42
+ * Portaled to `document.body` with fixed positioning, matching the main
43
+ * panel's styling and keyboard behavior (roving tabindex, typeahead,
44
+ * Home/End). `ArrowLeft` and `Tab` bubble a close request to the parent
45
+ * instead of being handled locally.
46
+ */
47
+ export declare function Submenu({ groups, anchorRect, menuElementId, onSelectItem, onRequestClose, onCloseAll, onMouseEnter, onMouseLeave }: Props): JSX.Element;
48
+ export {};
49
+ //# sourceMappingURL=Submenu.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Submenu.d.ts","sourceRoot":"","sources":["../../../src/components/RowActionsMenu/Submenu.tsx"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,GAAG,EAAiB,MAAM,OAAO,CAAC;AAgBhD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAK3C,UAAU,KAAK;IACb;;OAEG;IACH,MAAM,EAAE,QAAQ,EAAE,EAAE,CAAC;IAErB;;OAEG;IACH,UAAU,EAAE,OAAO,CAAC;IAEpB;;OAEG;IACH,aAAa,EAAE,MAAM,CAAC;IAEtB;;;OAGG;IACH,YAAY,EAAE,CAAC,IAAI,EAAE,QAAQ,KAAK,IAAI,CAAC;IAEvC;;OAEG;IACH,cAAc,EAAE,MAAM,IAAI,CAAC;IAE3B;;OAEG;IACH,UAAU,EAAE,MAAM,IAAI,CAAC;IAEvB;;;OAGG;IACH,YAAY,EAAE,MAAM,IAAI,CAAC;IAEzB;;;OAGG;IACH,YAAY,EAAE,MAAM,IAAI,CAAC;CAC1B;AAED;;;;;;GAMG;AACH,wBAAgB,OAAO,CAAC,EACtB,MAAM,EACN,UAAU,EACV,aAAa,EACb,YAAY,EACZ,cAAc,EACd,UAAU,EACV,YAAY,EACZ,YAAY,EACb,EAAE,KAAK,GAAG,GAAG,CAAC,OAAO,CA+MrB"}
@@ -0,0 +1,156 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "@harborclient/sdk/jsx-runtime";
2
+ import { faCheck } from '@fortawesome/free-solid-svg-icons';
3
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from '@harborclient/sdk/react';
4
+ import { FaIcon } from '../FaIcon/index.js';
5
+ import { MENU_MIN_WIDTH_PX, clampMenuPosition, getSubmenuAnchoredPosition } from '../menuPosition.js';
6
+ import { portalToBody } from '../portalToBody.js';
7
+ import { findAdjacentEnabledIndex, findEdgeEnabledIndex, isMenuItemEnabled, menuItemClass } from '../rowActionsMenuHelpers.js';
8
+ import { cn, resolveMenuTypeahead } from '../utils.js';
9
+ /** Milliseconds before an accumulated typeahead search resets. */
10
+ const TYPEAHEAD_TIMEOUT_MS = 500;
11
+ /**
12
+ * Flyout panel opened beside a `RowActionsMenu` row that has a `submenu`.
13
+ * Portaled to `document.body` with fixed positioning, matching the main
14
+ * panel's styling and keyboard behavior (roving tabindex, typeahead,
15
+ * Home/End). `ArrowLeft` and `Tab` bubble a close request to the parent
16
+ * instead of being handled locally.
17
+ */
18
+ export function Submenu({ groups, anchorRect, menuElementId, onSelectItem, onRequestClose, onCloseAll, onMouseEnter, onMouseLeave }) {
19
+ const panelRef = useRef(null);
20
+ const itemRefs = useRef([]);
21
+ const typeaheadBuffer = useRef('');
22
+ const typeaheadTimer = useRef(null);
23
+ const flatItems = useMemo(() => groups.flat(), [groups]);
24
+ const itemLabels = useMemo(() => flatItems.map((item) => item.label), [flatItems]);
25
+ const [focusedIndex, setFocusedIndex] = useState(() => findEdgeEnabledIndex(flatItems, false) ?? 0);
26
+ const [position, setPosition] = useState(() => getSubmenuAnchoredPosition(anchorRect, { width: MENU_MIN_WIDTH_PX, height: 0 }));
27
+ /**
28
+ * Clears the accumulated typeahead buffer.
29
+ */
30
+ const clearTypeahead = useCallback(() => {
31
+ typeaheadBuffer.current = '';
32
+ if (typeaheadTimer.current != null) {
33
+ window.clearTimeout(typeaheadTimer.current);
34
+ typeaheadTimer.current = null;
35
+ }
36
+ }, []);
37
+ /**
38
+ * Focuses a flyout item by index and updates roving tabindex state.
39
+ */
40
+ const focusItem = useCallback((index) => {
41
+ setFocusedIndex(index);
42
+ requestAnimationFrame(() => {
43
+ itemRefs.current[index]?.focus();
44
+ });
45
+ }, []);
46
+ /**
47
+ * Moves focus into the flyout as soon as it mounts.
48
+ */
49
+ useEffect(() => {
50
+ requestAnimationFrame(() => {
51
+ itemRefs.current[focusedIndex]?.focus();
52
+ });
53
+ // Intentionally runs once on mount; the flyout is remounted per anchor,
54
+ // so `focusedIndex` never needs to re-trigger this effect.
55
+ }, []);
56
+ /**
57
+ * Positions the flyout beside the anchor row once its size is known,
58
+ * flipping sides and clamping so it stays inside the viewport.
59
+ */
60
+ useLayoutEffect(() => {
61
+ const panelRect = panelRef.current?.getBoundingClientRect();
62
+ const size = {
63
+ width: panelRect?.width ?? MENU_MIN_WIDTH_PX,
64
+ height: panelRect?.height ?? 0
65
+ };
66
+ const requested = getSubmenuAnchoredPosition(anchorRect, size);
67
+ setPosition(size.height > 0 ? clampMenuPosition(requested, size) : requested);
68
+ }, [anchorRect, groups]);
69
+ /**
70
+ * Handles keyboard navigation within the flyout. `ArrowLeft` and `Tab`
71
+ * stop propagation and delegate closing to the parent, since the parent
72
+ * panel would otherwise also react to the bubbled event.
73
+ */
74
+ const handleKeyDown = (event) => {
75
+ if (event.key === 'Tab') {
76
+ event.preventDefault();
77
+ event.stopPropagation();
78
+ onCloseAll();
79
+ return;
80
+ }
81
+ if (event.key === 'ArrowLeft') {
82
+ event.preventDefault();
83
+ event.stopPropagation();
84
+ onRequestClose();
85
+ return;
86
+ }
87
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
88
+ const direction = event.key === 'ArrowDown' ? 1 : -1;
89
+ const nextIndex = findAdjacentEnabledIndex(flatItems, focusedIndex, direction);
90
+ if (nextIndex != null) {
91
+ event.preventDefault();
92
+ event.stopPropagation();
93
+ clearTypeahead();
94
+ focusItem(nextIndex);
95
+ }
96
+ return;
97
+ }
98
+ if (event.key === 'Home') {
99
+ const firstIndex = findEdgeEnabledIndex(flatItems, false);
100
+ if (firstIndex != null) {
101
+ event.preventDefault();
102
+ event.stopPropagation();
103
+ clearTypeahead();
104
+ focusItem(firstIndex);
105
+ }
106
+ return;
107
+ }
108
+ if (event.key === 'End') {
109
+ const lastIndex = findEdgeEnabledIndex(flatItems, true);
110
+ if (lastIndex != null) {
111
+ event.preventDefault();
112
+ event.stopPropagation();
113
+ clearTypeahead();
114
+ focusItem(lastIndex);
115
+ }
116
+ return;
117
+ }
118
+ const typeahead = resolveMenuTypeahead(itemLabels, focusedIndex, event.key, typeaheadBuffer.current);
119
+ if (typeahead) {
120
+ const candidate = flatItems[typeahead.index];
121
+ if (!candidate || !isMenuItemEnabled(candidate)) {
122
+ return;
123
+ }
124
+ event.preventDefault();
125
+ event.stopPropagation();
126
+ typeaheadBuffer.current = typeahead.buffer;
127
+ if (typeaheadTimer.current != null) {
128
+ window.clearTimeout(typeaheadTimer.current);
129
+ }
130
+ typeaheadTimer.current = window.setTimeout(() => {
131
+ typeaheadBuffer.current = '';
132
+ typeaheadTimer.current = null;
133
+ }, TYPEAHEAD_TIMEOUT_MS);
134
+ focusItem(typeahead.index);
135
+ }
136
+ };
137
+ return portalToBody(_jsx("div", { ref: panelRef, id: menuElementId, role: "menu", className: "hc-row-actions-menu-panel hc-row-actions-submenu-panel app-no-drag fixed z-50 min-w-[200px] rounded-md border border-separator bg-surface py-1 shadow-md", style: { left: position.x, top: position.y }, onKeyDown: handleKeyDown, onMouseEnter: onMouseEnter, onMouseLeave: onMouseLeave, children: groups.map((group, groupIndex) => {
138
+ let flatIndex = groups.slice(0, groupIndex).reduce((count, g) => count + g.length, 0);
139
+ return (_jsx("div", { className: groupIndex > 0
140
+ ? 'hc-row-actions-menu-group border-t border-separator'
141
+ : 'hc-row-actions-menu-group', children: group.map((item) => {
142
+ const itemIndex = flatIndex++;
143
+ const isCheckboxItem = item.checked !== undefined;
144
+ const isDisabled = item.disabled === true;
145
+ return (_jsxs("button", { ref: (el) => {
146
+ itemRefs.current[itemIndex] = isDisabled ? null : el;
147
+ }, type: "button", role: isCheckboxItem ? 'menuitemcheckbox' : 'menuitem', "aria-checked": isCheckboxItem ? item.checked : undefined, "aria-disabled": isDisabled || undefined, disabled: isDisabled, tabIndex: isDisabled ? -1 : itemIndex === focusedIndex ? 0 : -1, className: cn('hc-row-actions-menu-item', menuItemClass(item.variant, isDisabled)), onClick: (e) => {
148
+ e.stopPropagation();
149
+ if (isDisabled) {
150
+ return;
151
+ }
152
+ onSelectItem(item);
153
+ }, children: [isCheckboxItem ? (_jsx("span", { className: "hc-row-actions-menu-item-check inline-flex w-4 shrink-0 justify-center", "aria-hidden": true, children: item.checked ? _jsx(FaIcon, { icon: faCheck, className: "h-3 w-3" }) : null })) : null, _jsx("span", { className: "hc-row-actions-menu-item-label min-w-0", children: item.label })] }, item.label));
154
+ }) }, groupIndex));
155
+ }) }));
156
+ }
@@ -1,14 +1,36 @@
1
- import type { ComponentPropsWithoutRef, JSX } from 'react';
2
- export type MenuItem = {
1
+ import type { IconDefinition } from '@fortawesome/fontawesome-svg-core';
2
+ import type { ComponentPropsWithoutRef, JSX, ReactNode } from 'react';
3
+ import { type ButtonVariant } from '../Button/index.js';
4
+ interface MenuItemBase {
3
5
  label: string;
4
- onSelect: () => void;
5
6
  variant?: 'default' | 'danger';
6
7
  /**
7
8
  * When set, renders the item as a checkbox-style menu entry with a leading
8
9
  * checkmark slot. `true` shows the check; `false` reserves the slot for alignment.
9
10
  */
10
11
  checked?: boolean;
11
- };
12
+ /**
13
+ * When true, renders a non-interactive informational row excluded from keyboard
14
+ * focus and activation.
15
+ */
16
+ disabled?: boolean;
17
+ }
18
+ /**
19
+ * A single row in a `RowActionsMenu`. An item is either a leaf action that
20
+ * runs `onSelect`, or a parent that opens a `submenu` flyout beside it (shown
21
+ * with a trailing caret) — never both.
22
+ */
23
+ export type MenuItem = (MenuItemBase & {
24
+ onSelect: () => void;
25
+ submenu?: undefined;
26
+ }) | (MenuItemBase & {
27
+ onSelect?: undefined;
28
+ /**
29
+ * Grouped entries shown in a flyout beside this row when it is hovered,
30
+ * clicked, or activated with `ArrowRight`/`Enter`.
31
+ */
32
+ submenu: MenuItem[][];
33
+ });
12
34
  interface Props extends Omit<ComponentPropsWithoutRef<'div'>, 'children'> {
13
35
  /**
14
36
  * Grouped menu entries shown when the trigger is open. Each inner array is
@@ -27,10 +49,41 @@ interface Props extends Omit<ComponentPropsWithoutRef<'div'>, 'children'> {
27
49
  * Called when the user opens or closes a menu.
28
50
  */
29
51
  onOpenChange: (id: string | null) => void;
52
+ /**
53
+ * Visual style for the menu trigger button. Defaults to `icon` (hamburger).
54
+ */
55
+ triggerVariant?: ButtonVariant;
56
+ /**
57
+ * Optional label rendered beside the trigger icon. When set, the trigger
58
+ * switches from icon-only to a labeled control.
59
+ */
60
+ triggerLabel?: ReactNode;
61
+ /**
62
+ * Icon shown in the trigger. Defaults to the hamburger icon.
63
+ */
64
+ triggerIcon?: IconDefinition;
65
+ /**
66
+ * Accessible name for the trigger. Defaults to "Row actions" for icon-only
67
+ * triggers or `triggerLabel` when a label is provided.
68
+ */
69
+ triggerAriaLabel?: string;
70
+ /**
71
+ * Tooltip for the trigger button.
72
+ */
73
+ triggerTitle?: string;
74
+ /**
75
+ * Additional classes merged onto the trigger button.
76
+ */
77
+ triggerClassName?: string;
30
78
  }
31
79
  /**
32
80
  * Hamburger-triggered dropdown for row-level actions (rename, delete, etc.).
81
+ *
82
+ * The menu panel is portaled to `document.body` with fixed positioning so it is
83
+ * not clipped by overflow-hidden sidebar or scroll containers. Items with a
84
+ * `submenu` open a nested flyout panel beside them on hover, click, or
85
+ * `ArrowRight`/`Enter`.
33
86
  */
34
- export declare function RowActionsMenu({ groups, menuId, openMenuId, onOpenChange, className, ...props }: Props): JSX.Element;
87
+ export declare function RowActionsMenu({ groups, menuId, openMenuId, onOpenChange, triggerVariant, triggerLabel, triggerIcon, triggerAriaLabel, triggerTitle, triggerClassName, className, ...props }: Props): JSX.Element;
35
88
  export {};
36
89
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/components/RowActionsMenu/index.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,wBAAwB,EAAE,GAAG,EAAiB,MAAM,OAAO,CAAC;AAK1E,MAAM,MAAM,QAAQ,GAAG;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,IAAI,CAAC;IACrB,OAAO,CAAC,EAAE,SAAS,GAAG,QAAQ,CAAC;IAE/B;;;OAGG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB,CAAC;AAEF,UAAU,KAAM,SAAQ,IAAI,CAAC,wBAAwB,CAAC,KAAK,CAAC,EAAE,UAAU,CAAC;IACvE;;;OAGG;IACH,MAAM,EAAE,QAAQ,EAAE,EAAE,CAAC;IAErB;;OAEG;IACH,MAAM,EAAE,MAAM,CAAC;IAEf;;OAEG;IACH,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAE1B;;OAEG;IACH,YAAY,EAAE,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAC;CAC3C;AAgBD;;GAEG;AACH,wBAAgB,cAAc,CAAC,EAC7B,MAAM,EACN,MAAM,EACN,UAAU,EACV,YAAY,EACZ,SAAS,EACT,GAAG,KAAK,EACT,EAAE,KAAK,GAAG,GAAG,CAAC,OAAO,CA0PrB"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/components/RowActionsMenu/index.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,mCAAmC,CAAC;AAUxE,OAAO,KAAK,EAAE,wBAAwB,EAAE,GAAG,EAAiB,SAAS,EAAE,MAAM,OAAO,CAAC;AACrF,OAAO,EAAU,KAAK,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAkBhE,UAAU,YAAY;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,SAAS,GAAG,QAAQ,CAAC;IAE/B;;;OAGG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAElB;;;OAGG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED;;;;GAIG;AACH,MAAM,MAAM,QAAQ,GAChB,CAAC,YAAY,GAAG;IAAE,QAAQ,EAAE,MAAM,IAAI,CAAC;IAAC,OAAO,CAAC,EAAE,SAAS,CAAA;CAAE,CAAC,GAC9D,CAAC,YAAY,GAAG;IACd,QAAQ,CAAC,EAAE,SAAS,CAAC;IAErB;;;OAGG;IACH,OAAO,EAAE,QAAQ,EAAE,EAAE,CAAC;CACvB,CAAC,CAAC;AAEP,UAAU,KAAM,SAAQ,IAAI,CAAC,wBAAwB,CAAC,KAAK,CAAC,EAAE,UAAU,CAAC;IACvE;;;OAGG;IACH,MAAM,EAAE,QAAQ,EAAE,EAAE,CAAC;IAErB;;OAEG;IACH,MAAM,EAAE,MAAM,CAAC;IAEf;;OAEG;IACH,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAE1B;;OAEG;IACH,YAAY,EAAE,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAC;IAE1C;;OAEG;IACH,cAAc,CAAC,EAAE,aAAa,CAAC;IAE/B;;;OAGG;IACH,YAAY,CAAC,EAAE,SAAS,CAAC;IAEzB;;OAEG;IACH,WAAW,CAAC,EAAE,cAAc,CAAC;IAE7B;;;OAGG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAE1B;;OAEG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IAEtB;;OAEG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAUD;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,EAC7B,MAAM,EACN,MAAM,EACN,UAAU,EACV,YAAY,EACZ,cAAc,EACd,YAAY,EACZ,WAAoB,EACpB,gBAAgB,EAChB,YAAY,EACZ,gBAAgB,EAChB,SAAS,EACT,GAAG,KAAK,EACT,EAAE,KAAK,GAAG,GAAG,CAAC,OAAO,CAkjBrB"}
@@ -1,34 +1,50 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "@harborclient/sdk/jsx-runtime";
2
- import { faBars, faCheck } from '@fortawesome/free-solid-svg-icons';
3
- import { useCallback, useEffect, useMemo, useRef, useState } from '@harborclient/sdk/react';
2
+ import { faBars, faCaretRight, faCheck } from '@fortawesome/free-solid-svg-icons';
3
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from '@harborclient/sdk/react';
4
4
  import { Button } from '../Button/index.js';
5
5
  import { FaIcon } from '../FaIcon/index.js';
6
- import { cn, resolveMenuTypeahead, resolveTabListKeyAction } from '../utils.js';
6
+ import { MENU_MIN_WIDTH_PX, clampMenuPosition, getTriggerAnchoredMenuPosition } from '../menuPosition.js';
7
+ import { portalToBody } from '../portalToBody.js';
8
+ import { findAdjacentEnabledIndex, findEdgeEnabledIndex, isMenuItemEnabled, menuItemClass } from '../rowActionsMenuHelpers.js';
9
+ import { cn, resolveMenuTypeahead } from '../utils.js';
10
+ import { Submenu } from './Submenu.js';
7
11
  const TYPEAHEAD_TIMEOUT_MS = 500;
8
- /**
9
- * Tailwind classes for a single menu item button.
10
- */
11
- function menuItemClass(variant) {
12
- const base = 'flex w-full cursor-pointer items-center gap-2 border-none bg-transparent px-3.5 py-1.5 text-left text-[16px] app-no-drag';
13
- return variant === 'danger'
14
- ? `${base} text-text hover:bg-danger/15 hover:text-danger`
15
- : `${base} text-text hover:bg-selection`;
16
- }
12
+ /** Delay before a hovered row's submenu opens, so passing over rows doesn't flash flyouts. */
13
+ const SUBMENU_OPEN_DELAY_MS = 120;
14
+ /** Delay before a submenu closes after the pointer leaves its row and panel. */
15
+ const SUBMENU_CLOSE_DELAY_MS = 200;
17
16
  /**
18
17
  * Hamburger-triggered dropdown for row-level actions (rename, delete, etc.).
18
+ *
19
+ * The menu panel is portaled to `document.body` with fixed positioning so it is
20
+ * not clipped by overflow-hidden sidebar or scroll containers. Items with a
21
+ * `submenu` open a nested flyout panel beside them on hover, click, or
22
+ * `ArrowRight`/`Enter`.
19
23
  */
20
- export function RowActionsMenu({ groups, menuId, openMenuId, onOpenChange, className, ...props }) {
24
+ export function RowActionsMenu({ groups, menuId, openMenuId, onOpenChange, triggerVariant, triggerLabel, triggerIcon = faBars, triggerAriaLabel, triggerTitle, triggerClassName, className, ...props }) {
21
25
  const isOpen = openMenuId === menuId;
22
26
  const menuElementId = `${menuId}-menu`;
27
+ const submenuElementId = `${menuId}-submenu`;
23
28
  const rootRef = useRef(null);
24
29
  const triggerRef = useRef(null);
30
+ const panelRef = useRef(null);
25
31
  const itemRefs = useRef([]);
26
32
  const typeaheadBuffer = useRef('');
27
33
  const typeaheadTimer = useRef(null);
28
34
  const wasOpenRef = useRef(isOpen);
35
+ const submenuOpenTimer = useRef(null);
36
+ const submenuCloseTimer = useRef(null);
29
37
  const [focusedIndex, setFocusedIndex] = useState(0);
38
+ const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 });
39
+ const [openSubmenuIndex, setOpenSubmenuIndex] = useState(null);
30
40
  const flatItems = useMemo(() => groups.flat(), [groups]);
31
41
  const itemLabels = useMemo(() => flatItems.map((item) => item.label), [flatItems]);
42
+ const hasEnabledItems = useMemo(() => flatItems.some((item) => isMenuItemEnabled(item)), [flatItems]);
43
+ const isLabeledTrigger = triggerLabel != null;
44
+ const resolvedTriggerVariant = triggerVariant ?? (isLabeledTrigger ? 'secondary' : 'icon');
45
+ const resolvedTriggerTitle = triggerTitle ?? (isLabeledTrigger ? undefined : 'Actions');
46
+ const resolvedTriggerAriaLabel = triggerAriaLabel ??
47
+ (typeof triggerLabel === 'string' ? triggerLabel : isLabeledTrigger ? 'Menu' : 'Row actions');
32
48
  /**
33
49
  * Clears the accumulated typeahead buffer.
34
50
  */
@@ -39,16 +55,102 @@ export function RowActionsMenu({ groups, menuId, openMenuId, onOpenChange, class
39
55
  typeaheadTimer.current = null;
40
56
  }
41
57
  }, []);
58
+ /**
59
+ * Cancels any pending submenu open/close timers.
60
+ */
61
+ const clearSubmenuTimers = useCallback(() => {
62
+ if (submenuOpenTimer.current != null) {
63
+ window.clearTimeout(submenuOpenTimer.current);
64
+ submenuOpenTimer.current = null;
65
+ }
66
+ if (submenuCloseTimer.current != null) {
67
+ window.clearTimeout(submenuCloseTimer.current);
68
+ submenuCloseTimer.current = null;
69
+ }
70
+ }, []);
42
71
  /**
43
72
  * Closes the menu and returns focus to the trigger button.
44
73
  */
45
74
  const closeMenu = useCallback(() => {
46
75
  clearTypeahead();
76
+ clearSubmenuTimers();
47
77
  onOpenChange(null);
48
78
  requestAnimationFrame(() => {
49
79
  triggerRef.current?.focus();
50
80
  });
51
- }, [clearTypeahead, onOpenChange]);
81
+ }, [clearSubmenuTimers, clearTypeahead, onOpenChange]);
82
+ /**
83
+ * Opens the submenu belonging to the item at `index` immediately, canceling
84
+ * any pending open/close timers.
85
+ */
86
+ const openSubmenuAt = useCallback((index) => {
87
+ clearSubmenuTimers();
88
+ setOpenSubmenuIndex(index);
89
+ }, [clearSubmenuTimers]);
90
+ /**
91
+ * Closes the currently open submenu and returns focus to its parent row.
92
+ */
93
+ const closeSubmenuAndRefocus = useCallback(() => {
94
+ clearSubmenuTimers();
95
+ setOpenSubmenuIndex((current) => {
96
+ if (current != null) {
97
+ requestAnimationFrame(() => {
98
+ itemRefs.current[current]?.focus();
99
+ });
100
+ }
101
+ return null;
102
+ });
103
+ }, [clearSubmenuTimers]);
104
+ /**
105
+ * Schedules opening the submenu at `index` after a short hover-intent
106
+ * delay, so moving the pointer across sibling rows doesn't flash flyouts.
107
+ */
108
+ const scheduleOpenSubmenu = useCallback((index) => {
109
+ clearSubmenuTimers();
110
+ submenuOpenTimer.current = window.setTimeout(() => {
111
+ submenuOpenTimer.current = null;
112
+ setOpenSubmenuIndex(index);
113
+ }, SUBMENU_OPEN_DELAY_MS);
114
+ }, [clearSubmenuTimers]);
115
+ /**
116
+ * Schedules closing the open submenu after a short delay, giving the
117
+ * pointer time to travel from the row into the flyout panel.
118
+ */
119
+ const scheduleCloseSubmenu = useCallback(() => {
120
+ if (submenuOpenTimer.current != null) {
121
+ window.clearTimeout(submenuOpenTimer.current);
122
+ submenuOpenTimer.current = null;
123
+ }
124
+ submenuCloseTimer.current = window.setTimeout(() => {
125
+ submenuCloseTimer.current = null;
126
+ setOpenSubmenuIndex(null);
127
+ }, SUBMENU_CLOSE_DELAY_MS);
128
+ }, []);
129
+ /**
130
+ * Handles pointer hover over a row: opens that row's submenu (immediately
131
+ * if a different submenu is already open, otherwise after a short delay),
132
+ * or schedules closing the open submenu when hovering a row without one.
133
+ */
134
+ const handleItemMouseEnter = useCallback((item, itemIndex) => {
135
+ if (item.submenu) {
136
+ if (openSubmenuIndex === itemIndex) {
137
+ clearSubmenuTimers();
138
+ }
139
+ else if (openSubmenuIndex != null) {
140
+ openSubmenuAt(itemIndex);
141
+ }
142
+ else {
143
+ scheduleOpenSubmenu(itemIndex);
144
+ }
145
+ return;
146
+ }
147
+ if (openSubmenuIndex != null) {
148
+ scheduleCloseSubmenu();
149
+ }
150
+ else {
151
+ clearSubmenuTimers();
152
+ }
153
+ }, [clearSubmenuTimers, openSubmenuAt, openSubmenuIndex, scheduleCloseSubmenu, scheduleOpenSubmenu]);
52
154
  /**
53
155
  * Focuses a menu item by index and updates roving tabindex state.
54
156
  */
@@ -62,11 +164,35 @@ export function RowActionsMenu({ groups, menuId, openMenuId, onOpenChange, class
62
164
  * Opens the menu and focuses the first or last item.
63
165
  */
64
166
  const openMenu = useCallback((focusLast = false) => {
65
- if (flatItems.length === 0)
167
+ if (!hasEnabledItems)
66
168
  return;
67
- setFocusedIndex(focusLast ? flatItems.length - 1 : 0);
169
+ const edgeIndex = findEdgeEnabledIndex(flatItems, focusLast);
170
+ if (edgeIndex == null)
171
+ return;
172
+ setFocusedIndex(edgeIndex);
68
173
  onOpenChange(menuId);
69
- }, [flatItems.length, menuId, onOpenChange]);
174
+ }, [flatItems, hasEnabledItems, menuId, onOpenChange]);
175
+ /**
176
+ * Updates fixed menu coordinates from the trigger and measured panel size.
177
+ */
178
+ const updateMenuPosition = useCallback(() => {
179
+ const trigger = triggerRef.current;
180
+ if (!trigger) {
181
+ return;
182
+ }
183
+ const triggerRect = trigger.getBoundingClientRect();
184
+ const panelRect = panelRef.current?.getBoundingClientRect();
185
+ const menuSize = {
186
+ width: panelRect?.width ?? MENU_MIN_WIDTH_PX,
187
+ height: panelRect?.height ?? 0
188
+ };
189
+ const requested = getTriggerAnchoredMenuPosition(triggerRect, menuSize);
190
+ if (menuSize.height > 0) {
191
+ setMenuPosition(clampMenuPosition(requested, menuSize));
192
+ return;
193
+ }
194
+ setMenuPosition(requested);
195
+ }, []);
70
196
  /**
71
197
  * Moves focus into the menu after it opens and item refs are mounted.
72
198
  */
@@ -79,30 +205,66 @@ export function RowActionsMenu({ groups, menuId, openMenuId, onOpenChange, class
79
205
  wasOpenRef.current = isOpen;
80
206
  }, [focusedIndex, isOpen]);
81
207
  /**
82
- * Resets item refs when the menu closes.
208
+ * Resets item refs and submenu state when the menu closes.
83
209
  */
84
210
  useEffect(() => {
85
211
  if (!isOpen) {
86
212
  itemRefs.current = [];
87
213
  setFocusedIndex(0);
214
+ setOpenSubmenuIndex(null);
88
215
  clearTypeahead();
216
+ clearSubmenuTimers();
89
217
  }
90
- }, [clearTypeahead, isOpen]);
218
+ }, [clearSubmenuTimers, clearTypeahead, isOpen]);
91
219
  /**
92
- * Closes the menu on outside click or Escape while it is open.
220
+ * Re-clamps the portaled menu after mount once panel dimensions are known.
221
+ */
222
+ useLayoutEffect(() => {
223
+ if (!isOpen) {
224
+ return;
225
+ }
226
+ updateMenuPosition();
227
+ }, [groups, isOpen, updateMenuPosition]);
228
+ /**
229
+ * Tracks trigger movement while the menu is open so fixed coordinates stay aligned.
230
+ */
231
+ useEffect(() => {
232
+ if (!isOpen) {
233
+ return;
234
+ }
235
+ updateMenuPosition();
236
+ window.addEventListener('scroll', updateMenuPosition, true);
237
+ window.addEventListener('resize', updateMenuPosition);
238
+ return () => {
239
+ window.removeEventListener('scroll', updateMenuPosition, true);
240
+ window.removeEventListener('resize', updateMenuPosition);
241
+ };
242
+ }, [isOpen, updateMenuPosition]);
243
+ /**
244
+ * Closes the menu on outside click, and closes the open submenu (or, if
245
+ * none is open, the whole menu) on Escape, while the menu is open.
93
246
  */
94
247
  useEffect(() => {
95
248
  if (!isOpen)
96
249
  return;
97
250
  const handleMouseDown = (e) => {
98
- if (rootRef.current && !rootRef.current.contains(e.target)) {
99
- closeMenu();
251
+ const target = e.target;
252
+ if (rootRef.current?.contains(target) ||
253
+ panelRef.current?.contains(target) ||
254
+ document.getElementById(submenuElementId)?.contains(target)) {
255
+ return;
100
256
  }
257
+ closeMenu();
101
258
  };
102
259
  const handleKeyDown = (e) => {
103
260
  if (e.key === 'Escape') {
104
261
  e.preventDefault();
105
- closeMenu();
262
+ if (openSubmenuIndex != null) {
263
+ closeSubmenuAndRefocus();
264
+ }
265
+ else {
266
+ closeMenu();
267
+ }
106
268
  }
107
269
  };
108
270
  document.addEventListener('mousedown', handleMouseDown);
@@ -111,7 +273,7 @@ export function RowActionsMenu({ groups, menuId, openMenuId, onOpenChange, class
111
273
  document.removeEventListener('mousedown', handleMouseDown);
112
274
  document.removeEventListener('keydown', handleKeyDown);
113
275
  };
114
- }, [closeMenu, isOpen]);
276
+ }, [closeMenu, closeSubmenuAndRefocus, isOpen, openSubmenuIndex, submenuElementId]);
115
277
  /**
116
278
  * Handles keyboard interaction on the menu trigger when closed.
117
279
  */
@@ -129,24 +291,59 @@ export function RowActionsMenu({ groups, menuId, openMenuId, onOpenChange, class
129
291
  }
130
292
  };
131
293
  /**
132
- * Handles keyboard navigation within the open menu.
294
+ * Handles keyboard navigation within the open menu. Arrow/Home/End/typeahead
295
+ * are ignored while a submenu has focus, since `Submenu` stops propagation
296
+ * for the keys it handles itself.
133
297
  */
134
298
  const handleMenuKeyDown = (event) => {
135
- if (flatItems.length === 0)
299
+ if (!hasEnabledItems)
136
300
  return;
137
301
  if (event.key === 'Tab') {
138
302
  closeMenu();
139
303
  return;
140
304
  }
141
- const arrowIndex = resolveTabListKeyAction(event.key, focusedIndex, flatItems.length);
142
- if (arrowIndex !== null) {
143
- event.preventDefault();
144
- clearTypeahead();
145
- focusItem(arrowIndex);
305
+ if (event.key === 'ArrowRight') {
306
+ const candidate = flatItems[focusedIndex];
307
+ if (candidate?.submenu) {
308
+ event.preventDefault();
309
+ openSubmenuAt(focusedIndex);
310
+ }
311
+ return;
312
+ }
313
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
314
+ const direction = event.key === 'ArrowDown' ? 1 : -1;
315
+ const nextIndex = findAdjacentEnabledIndex(flatItems, focusedIndex, direction);
316
+ if (nextIndex != null) {
317
+ event.preventDefault();
318
+ clearTypeahead();
319
+ focusItem(nextIndex);
320
+ }
321
+ return;
322
+ }
323
+ if (event.key === 'Home') {
324
+ const firstIndex = findEdgeEnabledIndex(flatItems, false);
325
+ if (firstIndex != null) {
326
+ event.preventDefault();
327
+ clearTypeahead();
328
+ focusItem(firstIndex);
329
+ }
330
+ return;
331
+ }
332
+ if (event.key === 'End') {
333
+ const lastIndex = findEdgeEnabledIndex(flatItems, true);
334
+ if (lastIndex != null) {
335
+ event.preventDefault();
336
+ clearTypeahead();
337
+ focusItem(lastIndex);
338
+ }
146
339
  return;
147
340
  }
148
341
  const typeahead = resolveMenuTypeahead(itemLabels, focusedIndex, event.key, typeaheadBuffer.current);
149
342
  if (typeahead) {
343
+ const candidate = flatItems[typeahead.index];
344
+ if (!candidate || !isMenuItemEnabled(candidate)) {
345
+ return;
346
+ }
150
347
  event.preventDefault();
151
348
  typeaheadBuffer.current = typeahead.buffer;
152
349
  if (typeaheadTimer.current != null) {
@@ -159,28 +356,69 @@ export function RowActionsMenu({ groups, menuId, openMenuId, onOpenChange, class
159
356
  focusItem(typeahead.index);
160
357
  }
161
358
  };
162
- return (_jsxs("div", { ref: rootRef, ...props, className: cn('hc-row-actions-menu relative shrink-0', className), children: [_jsx(Button, { innerRef: triggerRef, type: "button", variant: "icon", className: "hc-row-actions-menu-trigger", title: "Actions", "aria-label": "Row actions", "aria-haspopup": "menu", "aria-expanded": isOpen, "aria-controls": isOpen ? menuElementId : undefined, onClick: (e) => {
163
- e.stopPropagation();
164
- if (isOpen) {
165
- closeMenu();
166
- }
167
- else {
168
- openMenu(false);
169
- }
170
- }, onKeyDown: handleTriggerKeyDown, children: _jsx(FaIcon, { icon: faBars, className: "h-3.5 w-3.5" }) }), isOpen && (_jsx("div", { id: menuElementId, role: "menu", className: "hc-row-actions-menu-panel app-no-drag absolute top-full right-0 z-10 mt-0.5 min-w-[200px] rounded-md border border-separator bg-surface py-1 shadow-md", onKeyDown: handleMenuKeyDown, children: groups.map((group, groupIndex) => {
171
- let flatIndex = groups.slice(0, groupIndex).reduce((count, g) => count + g.length, 0);
172
- return (_jsx("div", { className: groupIndex > 0
173
- ? 'hc-row-actions-menu-group border-t border-separator'
174
- : 'hc-row-actions-menu-group', children: group.map((item) => {
175
- const itemIndex = flatIndex++;
176
- const isCheckboxItem = item.checked !== undefined;
177
- return (_jsxs("button", { ref: (el) => {
178
- itemRefs.current[itemIndex] = el;
179
- }, type: "button", role: isCheckboxItem ? 'menuitemcheckbox' : 'menuitem', "aria-checked": isCheckboxItem ? item.checked : undefined, tabIndex: itemIndex === focusedIndex ? 0 : -1, className: cn('hc-row-actions-menu-item', menuItemClass(item.variant)), onClick: (e) => {
180
- e.stopPropagation();
181
- closeMenu();
182
- item.onSelect();
183
- }, children: [isCheckboxItem ? (_jsx("span", { className: "hc-row-actions-menu-item-check inline-flex w-4 shrink-0 justify-center", "aria-hidden": true, children: item.checked ? _jsx(FaIcon, { icon: faCheck, className: "h-3 w-3" }) : null })) : null, _jsx("span", { className: "hc-row-actions-menu-item-label min-w-0", children: item.label })] }, item.label));
184
- }) }, groupIndex));
185
- }) }))] }));
359
+ const openSubmenuItem = openSubmenuIndex != null ? flatItems[openSubmenuIndex] : undefined;
360
+ const openSubmenuAnchorRect = openSubmenuIndex != null ? itemRefs.current[openSubmenuIndex]?.getBoundingClientRect() : null;
361
+ const menuPanel = isOpen ? (_jsx("div", { ref: panelRef, id: menuElementId, role: "menu", className: "hc-row-actions-menu-panel app-no-drag fixed z-50 min-w-[200px] rounded-md border border-separator bg-surface py-1 shadow-md", style: { left: menuPosition.x, top: menuPosition.y }, onKeyDown: handleMenuKeyDown, onMouseLeave: () => {
362
+ if (openSubmenuIndex != null) {
363
+ scheduleCloseSubmenu();
364
+ }
365
+ }, children: groups.map((group, groupIndex) => {
366
+ let flatIndex = groups.slice(0, groupIndex).reduce((count, g) => count + g.length, 0);
367
+ return (_jsx("div", { className: groupIndex > 0
368
+ ? 'hc-row-actions-menu-group border-t border-separator'
369
+ : 'hc-row-actions-menu-group', children: group.map((item) => {
370
+ const itemIndex = flatIndex++;
371
+ const isCheckboxItem = item.checked !== undefined;
372
+ const isDisabled = item.disabled === true;
373
+ const hasSubmenu = item.submenu !== undefined;
374
+ return (_jsxs("button", { ref: (el) => {
375
+ itemRefs.current[itemIndex] = isDisabled ? null : el;
376
+ }, type: "button", role: isCheckboxItem ? 'menuitemcheckbox' : 'menuitem', "aria-checked": isCheckboxItem ? item.checked : undefined, "aria-disabled": isDisabled || undefined, "aria-haspopup": hasSubmenu ? 'menu' : undefined, "aria-expanded": hasSubmenu ? openSubmenuIndex === itemIndex : undefined, "aria-controls": hasSubmenu && openSubmenuIndex === itemIndex ? submenuElementId : undefined, disabled: isDisabled, tabIndex: isDisabled ? -1 : itemIndex === focusedIndex ? 0 : -1, className: cn('hc-row-actions-menu-item', menuItemClass(item.variant, isDisabled)), onMouseEnter: () => {
377
+ if (!isDisabled) {
378
+ handleItemMouseEnter(item, itemIndex);
379
+ }
380
+ }, onClick: (e) => {
381
+ e.stopPropagation();
382
+ if (isDisabled) {
383
+ return;
384
+ }
385
+ if (item.submenu) {
386
+ if (openSubmenuIndex === itemIndex) {
387
+ closeSubmenuAndRefocus();
388
+ }
389
+ else {
390
+ openSubmenuAt(itemIndex);
391
+ }
392
+ return;
393
+ }
394
+ closeMenu();
395
+ item.onSelect();
396
+ }, children: [isCheckboxItem ? (_jsx("span", { className: "hc-row-actions-menu-item-check inline-flex w-4 shrink-0 justify-center", "aria-hidden": true, children: item.checked ? _jsx(FaIcon, { icon: faCheck, className: "h-3 w-3" }) : null })) : null, _jsx("span", { className: "hc-row-actions-menu-item-label min-w-0", children: item.label }), hasSubmenu ? (_jsx(FaIcon, { icon: faCaretRight, className: "ml-auto h-3 w-3 shrink-0", "aria-hidden": true })) : null] }, item.label));
397
+ }) }, groupIndex));
398
+ }) })) : null;
399
+ const submenuPanel = openSubmenuItem?.submenu && openSubmenuAnchorRect ? (_jsx(Submenu, { groups: openSubmenuItem.submenu, anchorRect: openSubmenuAnchorRect, menuElementId: submenuElementId, onSelectItem: (item) => {
400
+ closeMenu();
401
+ item.onSelect?.();
402
+ }, onRequestClose: closeSubmenuAndRefocus, onCloseAll: closeMenu, onMouseEnter: clearSubmenuTimers, onMouseLeave: scheduleCloseSubmenu })) : null;
403
+ const labeledTriggerVariant = resolvedTriggerVariant === 'icon' || resolvedTriggerVariant === 'iconDanger'
404
+ ? 'secondary'
405
+ : resolvedTriggerVariant;
406
+ const triggerButton = isLabeledTrigger ? (_jsxs(Button, { innerRef: triggerRef, type: "button", variant: labeledTriggerVariant, className: cn('hc-row-actions-menu-trigger', triggerClassName), title: resolvedTriggerTitle, "aria-haspopup": "menu", "aria-expanded": isOpen, "aria-controls": isOpen ? menuElementId : undefined, onClick: (e) => {
407
+ e.stopPropagation();
408
+ if (isOpen) {
409
+ closeMenu();
410
+ }
411
+ else {
412
+ openMenu(false);
413
+ }
414
+ }, onKeyDown: handleTriggerKeyDown, children: [_jsx(FaIcon, { icon: triggerIcon, className: "h-3.5 w-3.5", "aria-hidden": true }), triggerLabel] })) : (_jsx(Button, { innerRef: triggerRef, type: "button", variant: "icon", className: cn('hc-row-actions-menu-trigger', triggerClassName), title: resolvedTriggerTitle, "aria-label": resolvedTriggerAriaLabel, "aria-haspopup": "menu", "aria-expanded": isOpen, "aria-controls": isOpen ? menuElementId : undefined, onClick: (e) => {
415
+ e.stopPropagation();
416
+ if (isOpen) {
417
+ closeMenu();
418
+ }
419
+ else {
420
+ openMenu(false);
421
+ }
422
+ }, onKeyDown: handleTriggerKeyDown, children: _jsx(FaIcon, { icon: triggerIcon, className: "h-3.5 w-3.5" }) }));
423
+ return (_jsxs("div", { ref: rootRef, ...props, className: cn('hc-row-actions-menu shrink-0', className), children: [triggerButton, menuPanel ? portalToBody(menuPanel) : null, submenuPanel] }));
186
424
  }
@@ -1,15 +1,6 @@
1
1
  import type { JSX } from 'react';
2
2
  import type { MenuItem } from '../RowActionsMenu/index.js';
3
- interface Position {
4
- /**
5
- * Viewport X coordinate where the menu was opened.
6
- */
7
- x: number;
8
- /**
9
- * Viewport Y coordinate where the menu was opened.
10
- */
11
- y: number;
12
- }
3
+ import { type MenuPosition } from '../menuPosition.js';
13
4
  interface Props {
14
5
  /**
15
6
  * Grouped menu entries. Each inner array is one visual group separated by a divider.
@@ -18,7 +9,7 @@ interface Props {
18
9
  /**
19
10
  * Cursor position used to anchor the menu panel.
20
11
  */
21
- position: Position;
12
+ position: MenuPosition;
22
13
  /**
23
14
  * Called when the menu should close without selecting an item.
24
15
  */
@@ -1 +1 @@
1
- {"version":3,"file":"TabContextMenu.d.ts","sourceRoot":"","sources":["../../../src/components/TabBar/TabContextMenu.tsx"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,GAAG,EAAiB,MAAM,OAAO,CAAC;AAChD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,4BAA4B,CAAC;AAI3D,UAAU,QAAQ;IAChB;;OAEG;IACH,CAAC,EAAE,MAAM,CAAC;IAEV;;OAEG;IACH,CAAC,EAAE,MAAM,CAAC;CACX;AAED,UAAU,KAAK;IACb;;OAEG;IACH,MAAM,EAAE,QAAQ,EAAE,EAAE,CAAC;IAErB;;OAEG;IACH,QAAQ,EAAE,QAAQ,CAAC;IAEnB;;OAEG;IACH,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AAgCD;;GAEG;AACH,wBAAgB,cAAc,CAAC,EAAE,MAAM,EAAE,QAAQ,EAAE,OAAO,EAAE,EAAE,KAAK,GAAG,GAAG,CAAC,OAAO,GAAG,IAAI,CAkJvF"}
1
+ {"version":3,"file":"TabContextMenu.d.ts","sourceRoot":"","sources":["../../../src/components/TabBar/TabContextMenu.tsx"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,GAAG,EAAiB,MAAM,OAAO,CAAC;AAChD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,4BAA4B,CAAC;AAC3D,OAAO,EAAE,KAAK,YAAY,EAAqB,MAAM,oBAAoB,CAAC;AAI1E,UAAU,KAAK;IACb;;OAEG;IACH,MAAM,EAAE,QAAQ,EAAE,EAAE,CAAC;IAErB;;OAEG;IACH,QAAQ,EAAE,YAAY,CAAC;IAEvB;;OAEG;IACH,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AAgBD;;GAEG;AACH,wBAAgB,cAAc,CAAC,EAAE,MAAM,EAAE,QAAQ,EAAE,OAAO,EAAE,EAAE,KAAK,GAAG,GAAG,CAAC,OAAO,GAAG,IAAI,CAkJvF"}
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx } from "@harborclient/sdk/jsx-runtime";
2
2
  import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from '@harborclient/sdk/react';
3
+ import { clampMenuPosition } from '../menuPosition.js';
3
4
  import { portalToBody } from '../portalToBody.js';
4
5
  import { cn, resolveTabListKeyAction } from '../utils.js';
5
6
  /**
@@ -13,21 +14,6 @@ function menuItemClass(variant) {
13
14
  ? `${base} text-text hover:bg-danger/15 hover:text-danger`
14
15
  : `${base} text-text hover:bg-selection`;
15
16
  }
16
- /**
17
- * Clamps a menu position so the panel stays fully inside the viewport.
18
- *
19
- * @param position - Requested top-left coordinates.
20
- * @param size - Measured menu width and height.
21
- */
22
- function clampMenuPosition(position, size) {
23
- const margin = 8;
24
- const maxX = Math.max(margin, window.innerWidth - size.width - margin);
25
- const maxY = Math.max(margin, window.innerHeight - size.height - margin);
26
- return {
27
- x: Math.min(Math.max(position.x, margin), maxX),
28
- y: Math.min(Math.max(position.y, margin), maxY)
29
- };
30
- }
31
17
  /**
32
18
  * Cursor-positioned context menu for tab bars, rendered in a portal at the click point.
33
19
  */
@@ -126,7 +112,7 @@ export function TabContextMenu({ groups, position, onClose }) {
126
112
  }, type: "button", role: "menuitem", tabIndex: itemIndex === focusedIndex ? 0 : -1, className: cn('hc-tab-context-menu-item', menuItemClass(item.variant)), onClick: (event) => {
127
113
  event.stopPropagation();
128
114
  closeMenu();
129
- item.onSelect();
115
+ item.onSelect?.();
130
116
  }, children: item.label }, item.label));
131
117
  }) }, groupIndex));
132
118
  }) }));
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Viewport coordinates for a fixed-position menu panel.
3
+ */
4
+ export interface MenuPosition {
5
+ /** Left edge in viewport pixels. */
6
+ x: number;
7
+ /** Top edge in viewport pixels. */
8
+ y: number;
9
+ }
10
+ /**
11
+ * Measured menu panel dimensions.
12
+ */
13
+ export interface MenuSize {
14
+ /** Panel width in pixels. */
15
+ width: number;
16
+ /** Panel height in pixels. */
17
+ height: number;
18
+ }
19
+ /** Minimum gap between a menu panel and the viewport edge. */
20
+ export declare const MENU_VIEWPORT_MARGIN_PX = 8;
21
+ /** Gap between a trigger button and its dropdown panel. */
22
+ export declare const MENU_TRIGGER_OFFSET_PX = 2;
23
+ /** Default minimum menu width used before the panel is measured. */
24
+ export declare const MENU_MIN_WIDTH_PX = 200;
25
+ /**
26
+ * Clamps a menu position so the panel stays fully inside the viewport.
27
+ *
28
+ * @param position - Requested top-left coordinates.
29
+ * @param size - Measured menu width and height.
30
+ */
31
+ export declare function clampMenuPosition(position: MenuPosition, size: MenuSize): MenuPosition;
32
+ /**
33
+ * Computes menu coordinates anchored below and right-aligned to a trigger button.
34
+ *
35
+ * @param triggerRect - Trigger bounding rect in viewport coordinates.
36
+ * @param menuSize - Measured or estimated menu dimensions.
37
+ */
38
+ export declare function getTriggerAnchoredMenuPosition(triggerRect: DOMRect, menuSize: MenuSize): MenuPosition;
39
+ /**
40
+ * Computes flyout coordinates anchored beside a parent menu row, preferring
41
+ * the right side and flipping to the left when the right side would overflow
42
+ * the viewport.
43
+ *
44
+ * @param anchorRect - Bounding rect of the parent row that owns the flyout.
45
+ * @param menuSize - Measured or estimated flyout dimensions.
46
+ */
47
+ export declare function getSubmenuAnchoredPosition(anchorRect: DOMRect, menuSize: MenuSize): MenuPosition;
48
+ //# sourceMappingURL=menuPosition.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"menuPosition.d.ts","sourceRoot":"","sources":["../../src/components/menuPosition.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,oCAAoC;IACpC,CAAC,EAAE,MAAM,CAAC;IAEV,mCAAmC;IACnC,CAAC,EAAE,MAAM,CAAC;CACX;AAED;;GAEG;AACH,MAAM,WAAW,QAAQ;IACvB,6BAA6B;IAC7B,KAAK,EAAE,MAAM,CAAC;IAEd,8BAA8B;IAC9B,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,8DAA8D;AAC9D,eAAO,MAAM,uBAAuB,IAAI,CAAC;AAEzC,2DAA2D;AAC3D,eAAO,MAAM,sBAAsB,IAAI,CAAC;AAExC,oEAAoE;AACpE,eAAO,MAAM,iBAAiB,MAAM,CAAC;AAErC;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,YAAY,EAAE,IAAI,EAAE,QAAQ,GAAG,YAAY,CAQtF;AAED;;;;;GAKG;AACH,wBAAgB,8BAA8B,CAC5C,WAAW,EAAE,OAAO,EACpB,QAAQ,EAAE,QAAQ,GACjB,YAAY,CAKd;AAED;;;;;;;GAOG;AACH,wBAAgB,0BAA0B,CAAC,UAAU,EAAE,OAAO,EAAE,QAAQ,EAAE,QAAQ,GAAG,YAAY,CAUhG"}
@@ -0,0 +1,49 @@
1
+ /** Minimum gap between a menu panel and the viewport edge. */
2
+ export const MENU_VIEWPORT_MARGIN_PX = 8;
3
+ /** Gap between a trigger button and its dropdown panel. */
4
+ export const MENU_TRIGGER_OFFSET_PX = 2;
5
+ /** Default minimum menu width used before the panel is measured. */
6
+ export const MENU_MIN_WIDTH_PX = 200;
7
+ /**
8
+ * Clamps a menu position so the panel stays fully inside the viewport.
9
+ *
10
+ * @param position - Requested top-left coordinates.
11
+ * @param size - Measured menu width and height.
12
+ */
13
+ export function clampMenuPosition(position, size) {
14
+ const margin = MENU_VIEWPORT_MARGIN_PX;
15
+ const maxX = Math.max(margin, window.innerWidth - size.width - margin);
16
+ const maxY = Math.max(margin, window.innerHeight - size.height - margin);
17
+ return {
18
+ x: Math.min(Math.max(position.x, margin), maxX),
19
+ y: Math.min(Math.max(position.y, margin), maxY)
20
+ };
21
+ }
22
+ /**
23
+ * Computes menu coordinates anchored below and right-aligned to a trigger button.
24
+ *
25
+ * @param triggerRect - Trigger bounding rect in viewport coordinates.
26
+ * @param menuSize - Measured or estimated menu dimensions.
27
+ */
28
+ export function getTriggerAnchoredMenuPosition(triggerRect, menuSize) {
29
+ return {
30
+ x: triggerRect.right - menuSize.width,
31
+ y: triggerRect.bottom + MENU_TRIGGER_OFFSET_PX
32
+ };
33
+ }
34
+ /**
35
+ * Computes flyout coordinates anchored beside a parent menu row, preferring
36
+ * the right side and flipping to the left when the right side would overflow
37
+ * the viewport.
38
+ *
39
+ * @param anchorRect - Bounding rect of the parent row that owns the flyout.
40
+ * @param menuSize - Measured or estimated flyout dimensions.
41
+ */
42
+ export function getSubmenuAnchoredPosition(anchorRect, menuSize) {
43
+ const fitsOnRight = anchorRect.right + MENU_TRIGGER_OFFSET_PX + menuSize.width + MENU_VIEWPORT_MARGIN_PX <=
44
+ window.innerWidth;
45
+ const x = fitsOnRight
46
+ ? anchorRect.right + MENU_TRIGGER_OFFSET_PX
47
+ : anchorRect.left - menuSize.width - MENU_TRIGGER_OFFSET_PX;
48
+ return { x, y: anchorRect.top };
49
+ }
@@ -1,4 +1,21 @@
1
1
  import type { MenuItem } from './RowActionsMenu/index.js';
2
+ /**
3
+ * Tailwind classes for a single menu item button, shared by the main
4
+ * `RowActionsMenu` panel and its flyout `Submenu` panels.
5
+ */
6
+ export declare function menuItemClass(variant: MenuItem['variant'], disabled?: boolean): string;
7
+ /**
8
+ * Returns whether a menu item can receive focus or activate an action.
9
+ */
10
+ export declare function isMenuItemEnabled(item: MenuItem): boolean;
11
+ /**
12
+ * Finds the next enabled flat item index in the requested direction.
13
+ */
14
+ export declare function findAdjacentEnabledIndex(items: MenuItem[], fromIndex: number, direction: -1 | 1): number | null;
15
+ /**
16
+ * Returns the first or last enabled flat item index.
17
+ */
18
+ export declare function findEdgeEnabledIndex(items: MenuItem[], fromEnd: boolean): number | null;
2
19
  /**
3
20
  * Builds an optional reorder group for move-up/move-down row actions.
4
21
  *
@@ -1 +1 @@
1
- {"version":3,"file":"rowActionsMenuHelpers.d.ts","sourceRoot":"","sources":["../../src/components/rowActionsMenuHelpers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,2BAA2B,CAAC;AAE1D;;;;;;;;;GASG;AACH,wBAAgB,qBAAqB,CACnC,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,CAAC,SAAS,EAAE,IAAI,GAAG,MAAM,KAAK,IAAI,GACzC,QAAQ,EAAE,EAAE,CAsBd"}
1
+ {"version":3,"file":"rowActionsMenuHelpers.d.ts","sourceRoot":"","sources":["../../src/components/rowActionsMenuHelpers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,2BAA2B,CAAC;AAE1D;;;GAGG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,QAAQ,CAAC,SAAS,CAAC,EAAE,QAAQ,CAAC,EAAE,OAAO,GAAG,MAAM,CAatF;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,QAAQ,GAAG,OAAO,CAEzD;AAED;;GAEG;AACH,wBAAgB,wBAAwB,CACtC,KAAK,EAAE,QAAQ,EAAE,EACjB,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,CAAC,CAAC,GAAG,CAAC,GAChB,MAAM,GAAG,IAAI,CAWf;AAED;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,QAAQ,EAAE,EAAE,OAAO,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAWvF;AAED;;;;;;;;;GASG;AACH,wBAAgB,qBAAqB,CACnC,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,CAAC,SAAS,EAAE,IAAI,GAAG,MAAM,KAAK,IAAI,GACzC,QAAQ,EAAE,EAAE,CAsBd"}
@@ -1,3 +1,50 @@
1
+ /**
2
+ * Tailwind classes for a single menu item button, shared by the main
3
+ * `RowActionsMenu` panel and its flyout `Submenu` panels.
4
+ */
5
+ export function menuItemClass(variant, disabled) {
6
+ const base = 'flex w-full items-center gap-2 border-none bg-transparent px-3.5 py-1.5 text-left text-[16px] app-no-drag';
7
+ if (disabled) {
8
+ return `${base} cursor-not-allowed text-muted opacity-60`;
9
+ }
10
+ const interactive = `${base} cursor-pointer`;
11
+ return variant === 'danger'
12
+ ? `${interactive} text-text hover:bg-danger/15 hover:text-danger`
13
+ : `${interactive} text-text hover:bg-selection`;
14
+ }
15
+ /**
16
+ * Returns whether a menu item can receive focus or activate an action.
17
+ */
18
+ export function isMenuItemEnabled(item) {
19
+ return !item.disabled;
20
+ }
21
+ /**
22
+ * Finds the next enabled flat item index in the requested direction.
23
+ */
24
+ export function findAdjacentEnabledIndex(items, fromIndex, direction) {
25
+ let index = fromIndex + direction;
26
+ while (index >= 0 && index < items.length) {
27
+ if (isMenuItemEnabled(items[index])) {
28
+ return index;
29
+ }
30
+ index += direction;
31
+ }
32
+ return null;
33
+ }
34
+ /**
35
+ * Returns the first or last enabled flat item index.
36
+ */
37
+ export function findEdgeEnabledIndex(items, fromEnd) {
38
+ if (fromEnd) {
39
+ for (let index = items.length - 1; index >= 0; index -= 1) {
40
+ if (isMenuItemEnabled(items[index])) {
41
+ return index;
42
+ }
43
+ }
44
+ return null;
45
+ }
46
+ return items.findIndex((item) => isMenuItemEnabled(item));
47
+ }
1
48
  /**
2
49
  * Builds an optional reorder group for move-up/move-down row actions.
3
50
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborclient/sdk",
3
- "version": "1.0.68",
3
+ "version": "1.0.69",
4
4
  "description": "TypeScript SDK for HarborClient development.",
5
5
  "keywords": [
6
6
  "harborclient",