@trackunit/react-components 2.5.4 → 2.6.3-alpha-0874090e5e0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.cjs.js +1237 -133
- package/index.esm.js +1237 -136
- package/migrations/v3-0-0/menulist-rename-to-menucontent.js +105 -0
- package/migrations.json +5 -0
- package/package.json +6 -6
- package/src/components/Menu/MenuContent/MenuContent.d.ts +96 -0
- package/src/components/Menu/MenuDivider/MenuDivider.d.ts +5 -5
- package/src/components/Menu/MenuItem/MenuItem.d.ts +57 -10
- package/src/components/Menu/MoreMenu/MoreMenu.d.ts +7 -7
- package/src/components/Popover/MenuTree.d.ts +130 -0
- package/src/components/Popover/Popover.d.ts +8 -0
- package/src/components/Popover/PopoverContent.d.ts +13 -8
- package/src/components/Popover/PopoverNestedClearance.d.ts +56 -0
- package/src/components/Popover/PopoverSizing.d.ts +54 -2
- package/src/components/Popover/PopoverTypes.d.ts +21 -0
- package/src/components/Popover/hoverScrollGuard.d.ts +85 -0
- package/src/components/Popover/isElementVisible.d.ts +15 -0
- package/src/components/Popover/useHoverScrollGuard.d.ts +54 -0
- package/src/components/Popover/usePopover.d.ts +1 -1
- package/src/components/Sidebar/Sidebar.d.ts +3 -3
- package/src/hooks/getScrollbarWidth.d.ts +13 -0
- package/src/index.d.ts +4 -2
- package/src/components/Menu/MenuList/MenuList.d.ts +0 -83
- /package/src/components/Menu/{MenuList/MenuList.variants.d.ts → MenuContent/MenuContent.variants.d.ts} +0 -0
package/index.cjs.js
CHANGED
|
@@ -18,6 +18,7 @@ var reactSlot = require('@radix-ui/react-slot');
|
|
|
18
18
|
var esToolkit = require('es-toolkit');
|
|
19
19
|
var reactRouter = require('@tanstack/react-router');
|
|
20
20
|
var reactVirtual = require('@tanstack/react-virtual');
|
|
21
|
+
var utils = require('@floating-ui/react/utils');
|
|
21
22
|
var reactHelmetAsync = require('react-helmet-async');
|
|
22
23
|
var reactTabs = require('@radix-ui/react-tabs');
|
|
23
24
|
var fflate = require('fflate');
|
|
@@ -213,7 +214,212 @@ const Icon = ({ name, size = "medium", className, "data-testid": dataTestId, col
|
|
|
213
214
|
return (jsxRuntime.jsx("span", { ...rest, "aria-describedby": ariaDescribedBy, "aria-hidden": ariaHidden, "aria-label": ariaLabel ? ariaLabel : stringTs.titleCase(iconName), "aria-labelledby": ariaLabelledBy, className: cvaIcon({ color, size, fontSize, className }), "data-testid": dataTestId, id: iconContainerId, onClick: onClick, ref: ref, style: style, children: jsxRuntime.jsx("svg", { "aria-labelledby": iconContainerId, "data-testid": dataTestId ? `${dataTestId}-${iconName}` : iconName, role: "img", viewBox: correctViewBox, children: jsxRuntime.jsx("use", { href: href[correctIconType], ref: useTagRef }) }) }));
|
|
214
215
|
};
|
|
215
216
|
|
|
217
|
+
/**
|
|
218
|
+
* Emitted on the tree's event emitter whenever a node transitions to open, so `MenuTree` can close
|
|
219
|
+
* that node's siblings. See `usePopover`'s emit side and `MenuTree`'s listener below.
|
|
220
|
+
*/
|
|
221
|
+
const MENU_TREE_OPEN_EVENT = "menuopen";
|
|
222
|
+
/**
|
|
223
|
+
* Emitted whenever a node transitions to closed, so siblings can tell when a `reason: "click"` open
|
|
224
|
+
* (see `MenuTreeOpenEvent`) has been dismissed and stop treating that branch as click-pinned. See
|
|
225
|
+
* `usePopover`'s emit side and its `openSiblingPinned` listener.
|
|
226
|
+
*/
|
|
227
|
+
const MENU_TREE_CLOSE_EVENT = "menuclose";
|
|
228
|
+
/**
|
|
229
|
+
* Returns whether a pointer event landed on any `MenuTree` member's trigger or floating surface.
|
|
230
|
+
* Used by nested `Popover`s to avoid treating a click on a sibling row (or the parent panel) as an
|
|
231
|
+
* outside press that dismisses the whole branch.
|
|
232
|
+
*/
|
|
233
|
+
const isPressInsideMenuTree = (event, tree) => {
|
|
234
|
+
const { target } = event;
|
|
235
|
+
if (!(target instanceof Node)) {
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
return tree.nodesRef.current.some(node => {
|
|
239
|
+
const floating = node.context?.refs.floating.current;
|
|
240
|
+
const reference = node.context?.refs.reference.current;
|
|
241
|
+
return ((floating instanceof Element && floating.contains(target)) ||
|
|
242
|
+
(reference instanceof Element && reference.contains(target)));
|
|
243
|
+
});
|
|
244
|
+
};
|
|
245
|
+
/**
|
|
246
|
+
* Reads (and joins) the nearest `MenuTree` boundary, if any.
|
|
247
|
+
*
|
|
248
|
+
* Returns explicit tree state so fully custom nested-menu UI can be built without going through
|
|
249
|
+
* `Popover`/`PopoverTrigger`/`PopoverContent`'s implicit prop-cloning. `Popover` is one consumer of
|
|
250
|
+
* this hook, not the only way to participate in a `MenuTree`.
|
|
251
|
+
*
|
|
252
|
+
* Safe to call outside a `<MenuTree>`: `parentId`/`tree` are `null` and `isNested` is `false`.
|
|
253
|
+
*
|
|
254
|
+
* @returns {UseMenuTreeType} Explicit tree state for the calling node
|
|
255
|
+
*/
|
|
256
|
+
const useMenuTree = () => {
|
|
257
|
+
const nodeId = react$1.useFloatingNodeId();
|
|
258
|
+
const parentId = react$1.useFloatingParentNodeId();
|
|
259
|
+
const tree = react$1.useFloatingTree();
|
|
260
|
+
return react.useMemo(() => ({ nodeId, parentId, isNested: parentId !== null, tree }), [nodeId, parentId, tree]);
|
|
261
|
+
};
|
|
262
|
+
const MenuTreeSiblingCloseBoundary = ({ children }) => {
|
|
263
|
+
const tree = react$1.useFloatingTree();
|
|
264
|
+
react.useEffect(() => {
|
|
265
|
+
if (!tree) {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
const closeSiblings = ({ nodeId, parentId }) => {
|
|
269
|
+
tree.nodesRef.current
|
|
270
|
+
.filter(node => node.id !== nodeId && node.parentId === parentId && node.context?.open === true)
|
|
271
|
+
.forEach(node => node.context?.onOpenChange(false));
|
|
272
|
+
};
|
|
273
|
+
tree.events.on(MENU_TREE_OPEN_EVENT, closeSiblings);
|
|
274
|
+
return () => {
|
|
275
|
+
tree.events.off(MENU_TREE_OPEN_EVENT, closeSiblings);
|
|
276
|
+
};
|
|
277
|
+
}, [tree]);
|
|
278
|
+
return jsxRuntime.jsx(jsxRuntime.Fragment, { children: children });
|
|
279
|
+
};
|
|
280
|
+
/**
|
|
281
|
+
* Opt-in tree-coordination boundary for a family of `Popover`s: sibling-close-on-open, dismiss
|
|
282
|
+
* bubbling (Escape closes one level at a time; outside-press is scoped per node so sibling rows
|
|
283
|
+
* don't collapse the root), and hover safe-polygon between nested rows. Every `Popover` rendered
|
|
284
|
+
* automatically joins it — there is no per-instance flag to set. A `Popover` outside any `MenuTree`
|
|
285
|
+
* is unaffected and behaves exactly as it does today.
|
|
286
|
+
*
|
|
287
|
+
* Only the immediate siblings of a newly-opened node (nodes sharing the same parent) are closed.
|
|
288
|
+
* Closing a node cascades to close its own open subtree as a structural consequence of
|
|
289
|
+
* `PopoverContent` unmounting when its `Popover` closes, not extra logic here.
|
|
290
|
+
*
|
|
291
|
+
* ### When to use
|
|
292
|
+
* Wrap a family of nested `Popover`s (e.g. a menu bar or a filter bar's flyouts) that should
|
|
293
|
+
* coordinate open/close state with each other.
|
|
294
|
+
*
|
|
295
|
+
* ### When not to use
|
|
296
|
+
* Do not wrap unrelated `Popover`s that merely happen to render inside each other's React tree —
|
|
297
|
+
* they will incorrectly join tree coordination and can be closed by sibling opens.
|
|
298
|
+
*
|
|
299
|
+
* ### Click-opened rows vs. sibling hovers
|
|
300
|
+
* `hover: { delayed: true }` on a nested `Popover` (used below, instead of a plain `hover: true`)
|
|
301
|
+
* keeps a row opened by a click open while the cursor passes over its siblings, matching what
|
|
302
|
+
* `MenuItem`'s `submenu` prop already does internally. See the "Advanced" section of the
|
|
303
|
+
* `Components/Menu/Nested Menu` docs page for the full explanation.
|
|
304
|
+
*
|
|
305
|
+
* @example
|
|
306
|
+
* ```tsx
|
|
307
|
+
* import { MenuTree, Popover, PopoverContent, PopoverTrigger } from "@trackunit/react-components";
|
|
308
|
+
*
|
|
309
|
+
* const NestedMenu = () => (
|
|
310
|
+
* <MenuTree>
|
|
311
|
+
* <Popover placement="bottom-start">
|
|
312
|
+
* <PopoverTrigger>Open</PopoverTrigger>
|
|
313
|
+
* <PopoverContent>
|
|
314
|
+
* <Popover activation={{ click: true, hover: { delayed: true } }} placement="right-start">
|
|
315
|
+
* <PopoverTrigger>Row A</PopoverTrigger>
|
|
316
|
+
* <PopoverContent>Row A's flyout</PopoverContent>
|
|
317
|
+
* </Popover>
|
|
318
|
+
* <Popover activation={{ click: true, hover: { delayed: true } }} placement="right-start">
|
|
319
|
+
* <PopoverTrigger>Row B</PopoverTrigger>
|
|
320
|
+
* <PopoverContent>Row B's flyout</PopoverContent>
|
|
321
|
+
* </Popover>
|
|
322
|
+
* </PopoverContent>
|
|
323
|
+
* </Popover>
|
|
324
|
+
* </MenuTree>
|
|
325
|
+
* );
|
|
326
|
+
* ```
|
|
327
|
+
* @param {object} props The props for MenuTree
|
|
328
|
+
* @param {ReactNode} props.children The `Popover`s (and anything else) to coordinate
|
|
329
|
+
* @returns {ReactElement} A `FloatingTree` provider wired up for sibling-close coordination
|
|
330
|
+
*/
|
|
331
|
+
const MenuTree = ({ children }) => {
|
|
332
|
+
return (jsxRuntime.jsx(react$1.FloatingTree, { children: jsxRuntime.jsx(MenuTreeSiblingCloseBoundary, { children: children }) }));
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* The parent Popover's own floating panel element, found via the `MenuTree`'s Floating UI tree
|
|
337
|
+
* bookkeeping rather than by walking the DOM -- this is the exact element `PopoverContent` set as
|
|
338
|
+
* `context.refs.floating.current` when it rendered, so its rect is the parent panel's true visual
|
|
339
|
+
* boundary (border, shadow, and any internal scrollbar all already baked in), regardless of how
|
|
340
|
+
* deeply the current node's own trigger row is nested inside it.
|
|
341
|
+
*
|
|
342
|
+
* @param tree The `MenuTree`'s Floating UI tree, or `null` outside a `MenuTree`
|
|
343
|
+
* @param parentId The nearest ancestor node's id from `useMenuTree`, or `null` at the tree's root
|
|
344
|
+
* @returns {HTMLElement | null} The parent's floating panel element, or `null` if there isn't one
|
|
345
|
+
*/
|
|
346
|
+
const getParentPanelElement = (tree, parentId) => {
|
|
347
|
+
if (tree === null || parentId === null) {
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
return tree.nodesRef.current.find(node => node.id === parentId)?.context?.refs.floating.current ?? null;
|
|
351
|
+
};
|
|
352
|
+
/**
|
|
353
|
+
* Extra horizontal offset (in pixels) a `right`/`left`-placed submenu needs so it clears its parent
|
|
354
|
+
* Popover panel's true edge, rather than sitting flush with (or overlapping) whatever is at the edge
|
|
355
|
+
* of the specific row that triggered it.
|
|
356
|
+
*
|
|
357
|
+
* A submenu's `elements.reference` is the trigger row it opened from, not the parent panel itself --
|
|
358
|
+
* and that row's own edge is inset from the panel's true visual boundary by the panel's padding and,
|
|
359
|
+
* when the panel's list happens to be scrolling, by its scrollbar too. Measuring against the parent
|
|
360
|
+
* panel's actual rendered edge (via `getParentPanelElement` above) gets both of those for free and
|
|
361
|
+
* keeps the visual gap identical whether or not the parent list happens to be scrolling -- rather
|
|
362
|
+
* than re-deriving "how much padding/scrollbar is in the way" and landing on a different visual gap
|
|
363
|
+
* depending on the parent's internal layout.
|
|
364
|
+
*
|
|
365
|
+
* @param parentPanelElement The parent Popover's own floating panel element
|
|
366
|
+
* @param referenceElement The trigger element the submenu is anchored to
|
|
367
|
+
* @param resolvedPlacement Floating UI's resolved placement (e.g. `"right-start"`)
|
|
368
|
+
* @returns {number} Additional pixels to add to the base `offset()` `mainAxis` value
|
|
369
|
+
*/
|
|
370
|
+
const getNestedSubmenuClearance = (parentPanelElement, referenceElement, resolvedPlacement) => {
|
|
371
|
+
const panelRect = parentPanelElement.getBoundingClientRect();
|
|
372
|
+
const referenceRect = referenceElement.getBoundingClientRect();
|
|
373
|
+
if (resolvedPlacement.startsWith("right")) {
|
|
374
|
+
return Math.max(0, panelRect.right - referenceRect.right);
|
|
375
|
+
}
|
|
376
|
+
if (resolvedPlacement.startsWith("left")) {
|
|
377
|
+
return Math.max(0, referenceRect.left - panelRect.left);
|
|
378
|
+
}
|
|
379
|
+
return 0;
|
|
380
|
+
};
|
|
381
|
+
const rectsOverlap = (a, b) => !(a.x + a.width <= b.x || a.x >= b.x + b.width || a.y + a.height <= b.y || a.y >= b.y + b.height);
|
|
382
|
+
/**
|
|
383
|
+
* When a nested submenu has shifted horizontally over its parent (cascade-with-overlap), it must
|
|
384
|
+
* not cover the trigger row that opened it. Returns a new `y` that places the floating panel
|
|
385
|
+
* entirely below or above the reference -- whichever side has more room -- or `null` when the
|
|
386
|
+
* panels do not overlap and no adjustment is needed.
|
|
387
|
+
*/
|
|
388
|
+
const getYToClearReference = ({ floatingX, floatingY, floatingWidth, floatingHeight, reference, gap, viewportHeight, padding, }) => {
|
|
389
|
+
const floating = { x: floatingX, y: floatingY, width: floatingWidth, height: floatingHeight };
|
|
390
|
+
if (!rectsOverlap(floating, reference)) {
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
const spaceBelow = viewportHeight - padding - (reference.y + reference.height);
|
|
394
|
+
const spaceAbove = reference.y - padding;
|
|
395
|
+
if (spaceBelow >= spaceAbove) {
|
|
396
|
+
return reference.y + reference.height + gap;
|
|
397
|
+
}
|
|
398
|
+
return reference.y - floatingHeight - gap;
|
|
399
|
+
};
|
|
400
|
+
|
|
216
401
|
const PADDING = 12;
|
|
402
|
+
/**
|
|
403
|
+
* Width budget passed into `getMaxWidthValue` / `getMinWidthValue` from `size()`'s `apply()`.
|
|
404
|
+
*
|
|
405
|
+
* For nested horizontal submenus, Floating UI's side-relative `availableWidth` is only the
|
|
406
|
+
* remaining strip beside the parent panel. Capping to that shrinks the panel until `shift` no
|
|
407
|
+
* longer sees overflow -- so the intentional "cascade with overlap" path never runs, and any
|
|
408
|
+
* CSS min-width on the content (e.g. `MenuContent`'s `min-w-[200px]`) then overflows the
|
|
409
|
+
* wrapper and the page.
|
|
410
|
+
*
|
|
411
|
+
* Using the viewport width as the budget keeps the panel at its preferred size; `flip` still
|
|
412
|
+
* tries both sides, and `shift` slides it over the parent when neither side fits.
|
|
413
|
+
*/
|
|
414
|
+
const getFloatingWidthBudget = ({ availableWidth, viewportWidth, preferViewportBudget, }) => (preferViewportBudget ? viewportWidth : availableWidth);
|
|
415
|
+
/**
|
|
416
|
+
* Options for Floating UI's `shift()` middleware.
|
|
417
|
+
*
|
|
418
|
+
* For `right`/`left` placement, `shift`'s default only moves along the alignment (vertical) axis.
|
|
419
|
+
* Nested horizontal submenus need `crossAxis: true` so they can slide horizontally over the parent
|
|
420
|
+
* when neither side has room -- the "cascade with overlap" path paired with `getFloatingWidthBudget`.
|
|
421
|
+
*/
|
|
422
|
+
const getShiftOptions = ({ isNestedHorizontal, }) => isNestedHorizontal ? { padding: PADDING, crossAxis: true } : { padding: PADDING };
|
|
217
423
|
/**
|
|
218
424
|
* Converts a width size value into a CSS dimension value for max constraints
|
|
219
425
|
*
|
|
@@ -240,13 +446,20 @@ const getMaxWidthValue = ({ value, referenceWidth, availableWidth }) => {
|
|
|
240
446
|
}
|
|
241
447
|
};
|
|
242
448
|
/**
|
|
243
|
-
* Converts a width size value into a CSS dimension value for min constraints
|
|
449
|
+
* Converts a width size value into a CSS dimension value for min constraints.
|
|
450
|
+
*
|
|
451
|
+
* A numeric `value` is clamped to `availableWidth` so a caller's desired minimum can never force the
|
|
452
|
+
* floating panel wider than the viewport space `size()` already determined was safe -- without this, a
|
|
453
|
+
* fixed minWidth would win a losing fight against `getMaxWidthValue`'s cap (CSS resolves `min-width` over
|
|
454
|
+
* `max-width` on conflict) and the panel would overflow past its container/viewport edge instead of
|
|
455
|
+
* shrinking to fit.
|
|
244
456
|
*
|
|
245
457
|
* @param params - The parameters object
|
|
246
458
|
* @param params.value - The size value: number for pixels, "trigger-width" to match trigger, "none" for no constraint
|
|
247
459
|
* @param params.referenceWidth - The width of the trigger element in pixels
|
|
460
|
+
* @param params.availableWidth - The available width in the viewport
|
|
248
461
|
*/
|
|
249
|
-
const getMinWidthValue = ({ value, referenceWidth, }) => {
|
|
462
|
+
const getMinWidthValue = ({ value, referenceWidth, availableWidth }) => {
|
|
250
463
|
switch (value) {
|
|
251
464
|
case "trigger-width": {
|
|
252
465
|
return `${referenceWidth}px`;
|
|
@@ -257,7 +470,7 @@ const getMinWidthValue = ({ value, referenceWidth, }) => {
|
|
|
257
470
|
}
|
|
258
471
|
default: {
|
|
259
472
|
if (typeof value === "number") {
|
|
260
|
-
return `${value}px`;
|
|
473
|
+
return `${Math.max(0, Math.min(value, availableWidth - PADDING * 2))}px`;
|
|
261
474
|
}
|
|
262
475
|
throw new Error(`${value} is not known`);
|
|
263
476
|
}
|
|
@@ -305,6 +518,248 @@ const getMinHeightValue = ({ value }) => {
|
|
|
305
518
|
}
|
|
306
519
|
};
|
|
307
520
|
|
|
521
|
+
/**
|
|
522
|
+
* Module-level (not React state) tracking of whether hover-driven `Popover` opening should be
|
|
523
|
+
* held back right now, because scrolling is either happening or has not yet settled.
|
|
524
|
+
*
|
|
525
|
+
* ### Root cause this guards against
|
|
526
|
+
* Scrolling any container recomputes the browser's pointer hit-test target on every frame, which
|
|
527
|
+
* fires real `pointerenter`/`pointerleave` pairs on whichever element now sits under an
|
|
528
|
+
* otherwise-stationary cursor -- indistinguishable, at the DOM level, from the user actually
|
|
529
|
+
* hovering that element. A hover-activated `Popover` reacting to those phantom events opens every
|
|
530
|
+
* row a scrolling list happens to carry under the cursor (most visible with `MenuItem`'s nested
|
|
531
|
+
* submenus in a scrolling list, but the same browser behavior affects any hover-activated
|
|
532
|
+
* `Popover`, e.g. a `Tooltip` over a scrolling table row).
|
|
533
|
+
*
|
|
534
|
+
* ### Why "settled", not "next pointer move"
|
|
535
|
+
* An earlier version of this guard cleared suppression on the next genuine `pointermove`, so a
|
|
536
|
+
* still-scrolling list would keep flipping back and forth between "suppressed" and "open the row
|
|
537
|
+
* the cursor happens to be over this frame" the moment the user so much as twitched the mouse
|
|
538
|
+
* mid-scroll. That's backwards from how this is meant to feel: while the list is moving, whatever
|
|
539
|
+
* was hovered before the scroll started should just stay put, and only once the scroll actually
|
|
540
|
+
* comes to rest should the row the cursor now stably rests over take over. Debouncing off the
|
|
541
|
+
* `scroll` event itself -- clearing suppression only after a lull longer than
|
|
542
|
+
* {@link SCROLL_SETTLE_DELAY_MS} with no further scroll events -- gets exactly that: suppression
|
|
543
|
+
* spans the whole scroll gesture (however bumpy), not just individual frames of it.
|
|
544
|
+
*
|
|
545
|
+
* ### Why not intercept/cancel the phantom event instead
|
|
546
|
+
* `mouseenter`/`pointerenter` don't natively bubble, so React simulates their capture/bubble
|
|
547
|
+
* dispatch internally -- not a stable surface to hook into from outside, and Floating UI's
|
|
548
|
+
* `useHover` owns that event handling anyway (it also drives `handleClose`/`safePolygon`, which
|
|
549
|
+
* still needs to work normally). Gating hover on "no scroll event anywhere in the last
|
|
550
|
+
* {@link SCROLL_SETTLE_DELAY_MS}ms" gets the same real-world result without any of that.
|
|
551
|
+
*
|
|
552
|
+
* ### Why page-wide listeners, not scoped to a particular scroll container
|
|
553
|
+
* A `scroll` event reaches a capturing-phase listener on `document` regardless of which element
|
|
554
|
+
* scrolled or whether the event itself bubbles, so a single pair of listeners here -- shared by
|
|
555
|
+
* every *opted-in* hover instance on the page, rather than each one attaching its own -- is both
|
|
556
|
+
* simpler and cheaper than resolving each `Popover`'s own scrollable ancestors. Subscription is
|
|
557
|
+
* gated in `usePopover` to delayed-hover nested `MenuTree` members only (ADR-0002), so plain
|
|
558
|
+
* Popovers/tooltips never attach the listeners or inherit suppression. The remaining trade-off is
|
|
559
|
+
* that scrolling *anywhere* briefly holds back hover-opens for those opted-in menu rows, not just
|
|
560
|
+
* near the scrolling container; acceptable since the guard clears itself shortly after scrolling
|
|
561
|
+
* settles, wherever the cursor then happens to rest.
|
|
562
|
+
*
|
|
563
|
+
* ### Visual hover highlight (CSS)
|
|
564
|
+
* The same scroll-induced `:hover` flips that would open phantom submenus also restyle every row
|
|
565
|
+
* the cursor passes over via CSS `:hover` backgrounds -- even while this guard holds the *open*
|
|
566
|
+
* state still. Consumers that want the highlight to freeze with the open state (notably
|
|
567
|
+
* `MenuItem`) key off {@link HOVER_SCROLL_SUPPRESSED_ATTR} on `<html>`, which this module mirrors
|
|
568
|
+
* onto the document in lockstep with suppression, so the highlight can follow open-state rules
|
|
569
|
+
* without re-rendering every row on each scroll tick.
|
|
570
|
+
*
|
|
571
|
+
* ### Why this also tracks the last real pointer position
|
|
572
|
+
* Once scrolling settles, `useHoverScrollGuard` needs to know which element the cursor now rests
|
|
573
|
+
* over so it can open that one. The obvious answer -- ask the browser via `:hover` -- turns out
|
|
574
|
+
* not to work: browsers only recompute an element's cached `:hover` state in response to a new
|
|
575
|
+
* real pointer event, not just because a re-render or attribute change made it hit-testable again.
|
|
576
|
+
* Since scrolling itself is exactly what carried the cursor's hit-test target across a run of rows
|
|
577
|
+
* without the physical mouse ever moving, there's typically no fresh pointer event left to trigger
|
|
578
|
+
* that recomputation right when settling happens -- so `:hover` stays stuck reporting whatever it
|
|
579
|
+
* last had, easily confirmed by comparing it against `document.elementFromPoint` at the same
|
|
580
|
+
* coordinates, which performs a fresh, on-demand hit-test instead of reading a cached flag. Tracking
|
|
581
|
+
* real client coordinates here (via a `pointermove` listener, sharing this module's lifecycle) lets
|
|
582
|
+
* `useHoverScrollGuard` run that same fresh `elementFromPoint` check itself once settled, rather
|
|
583
|
+
* than trusting a pseudo-class the browser hasn't gotten around to updating yet.
|
|
584
|
+
*/
|
|
585
|
+
const HOVER_SCROLL_SUPPRESSED_ATTR = "data-hover-scroll-suppressed";
|
|
586
|
+
/**
|
|
587
|
+
* How long a lull in `scroll` events must last before scrolling is considered "settled" and
|
|
588
|
+
* suppression clears. Long enough to swallow the gap between individual momentum-scroll frames
|
|
589
|
+
* (which can easily exceed a single animation frame), short enough that the now-stable hover
|
|
590
|
+
* still feels immediate once the list actually stops.
|
|
591
|
+
*/
|
|
592
|
+
const SCROLL_SETTLE_DELAY_MS = 100;
|
|
593
|
+
let isSuppressed = false;
|
|
594
|
+
let settleTimeoutId = null;
|
|
595
|
+
const listeners = new Set();
|
|
596
|
+
let listenerRefCount = 0;
|
|
597
|
+
/** Last real pointer position seen anywhere on the page, in viewport (`clientX`/`clientY`) coordinates. */
|
|
598
|
+
let lastPointerPosition = null;
|
|
599
|
+
const notify = () => {
|
|
600
|
+
listeners.forEach(listener => listener());
|
|
601
|
+
};
|
|
602
|
+
const setSuppressed = (next) => {
|
|
603
|
+
if (isSuppressed === next) {
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
isSuppressed = next;
|
|
607
|
+
if (next) {
|
|
608
|
+
document.documentElement.setAttribute(HOVER_SCROLL_SUPPRESSED_ATTR, "");
|
|
609
|
+
}
|
|
610
|
+
else {
|
|
611
|
+
document.documentElement.removeAttribute(HOVER_SCROLL_SUPPRESSED_ATTR);
|
|
612
|
+
}
|
|
613
|
+
notify();
|
|
614
|
+
};
|
|
615
|
+
const clearSettleTimeout = () => {
|
|
616
|
+
if (settleTimeoutId !== null) {
|
|
617
|
+
clearTimeout(settleTimeoutId);
|
|
618
|
+
settleTimeoutId = null;
|
|
619
|
+
}
|
|
620
|
+
};
|
|
621
|
+
const handleScroll = () => {
|
|
622
|
+
setSuppressed(true);
|
|
623
|
+
// Every scroll event pushes the settle deadline back out -- suppression only clears once a full
|
|
624
|
+
// `SCROLL_SETTLE_DELAY_MS` lull passes with no further scrolling, however long the gesture itself
|
|
625
|
+
// (or its momentum tail) runs.
|
|
626
|
+
clearSettleTimeout();
|
|
627
|
+
settleTimeoutId = setTimeout(() => {
|
|
628
|
+
settleTimeoutId = null;
|
|
629
|
+
setSuppressed(false);
|
|
630
|
+
}, SCROLL_SETTLE_DELAY_MS);
|
|
631
|
+
};
|
|
632
|
+
const handlePointerMove = (event) => {
|
|
633
|
+
lastPointerPosition = { x: event.clientX, y: event.clientY };
|
|
634
|
+
};
|
|
635
|
+
const hoverScrollGuard = {
|
|
636
|
+
isSuppressed: () => isSuppressed,
|
|
637
|
+
/**
|
|
638
|
+
* The last real pointer position seen anywhere on the page, or `null` if no `pointermove` has
|
|
639
|
+
* happened yet (e.g. scrolling driven by keyboard/touch before any mouse input). See this
|
|
640
|
+
* module's doc comment for why consumers should use this over reading `:hover` directly.
|
|
641
|
+
*/
|
|
642
|
+
getLastPointerPosition: () => lastPointerPosition,
|
|
643
|
+
/**
|
|
644
|
+
* Subscribes to changes in suppression state, lazily attaching the shared `scroll` listener on
|
|
645
|
+
* first subscriber and tearing it down once the last one unsubscribes.
|
|
646
|
+
*
|
|
647
|
+
* @param listener Called whenever suppression state flips
|
|
648
|
+
* @returns {() => void} Unsubscribe function
|
|
649
|
+
*/
|
|
650
|
+
subscribe: (listener) => {
|
|
651
|
+
listeners.add(listener);
|
|
652
|
+
if (listenerRefCount === 0) {
|
|
653
|
+
document.addEventListener("scroll", handleScroll, { capture: true, passive: true });
|
|
654
|
+
document.addEventListener("pointermove", handlePointerMove, { capture: true, passive: true });
|
|
655
|
+
}
|
|
656
|
+
listenerRefCount += 1;
|
|
657
|
+
return () => {
|
|
658
|
+
listeners.delete(listener);
|
|
659
|
+
listenerRefCount -= 1;
|
|
660
|
+
if (listenerRefCount === 0) {
|
|
661
|
+
document.removeEventListener("scroll", handleScroll, { capture: true });
|
|
662
|
+
document.removeEventListener("pointermove", handlePointerMove, { capture: true });
|
|
663
|
+
// Tear down can race a mid-suppression unmount (e.g. the whole menu closing while the list
|
|
664
|
+
// is still scrolling). Clear the pending timeout and the root attribute here so neither can
|
|
665
|
+
// outlive every consumer.
|
|
666
|
+
clearSettleTimeout();
|
|
667
|
+
setSuppressed(false);
|
|
668
|
+
lastPointerPosition = null;
|
|
669
|
+
}
|
|
670
|
+
};
|
|
671
|
+
},
|
|
672
|
+
};
|
|
673
|
+
|
|
674
|
+
const subscribeNoop = (_onStoreChange) => () => undefined;
|
|
675
|
+
const alwaysFalse = () => false;
|
|
676
|
+
/**
|
|
677
|
+
* Wires a single `Popover`'s hover interaction up to the shared `hoverScrollGuard`: holds its
|
|
678
|
+
* hover-opens back for as long as scrolling anywhere on the page hasn't yet settled (see
|
|
679
|
+
* `hoverScrollGuard`'s own doc comment for the full root-cause rationale), and -- once it settles
|
|
680
|
+
* -- imperatively opens this `Popover` if the cursor turns out to be resting on it, bypassing any
|
|
681
|
+
* open delay.
|
|
682
|
+
*
|
|
683
|
+
* ### Why the open needs its own imperative check
|
|
684
|
+
* Scrolling can carry the cursor's hit-test target across a whole run of rows without the physical
|
|
685
|
+
* cursor ever moving, so by the time scrolling settles there is no fresh `pointerenter` left for
|
|
686
|
+
* `useHover` to react to on whichever row the cursor ends up over: entering already happened
|
|
687
|
+
* (suppressed) while the list was still moving, not as a new enter.
|
|
688
|
+
*
|
|
689
|
+
* ### Why `elementFromPoint`, not `:hover`
|
|
690
|
+
* The obvious way to ask "is the cursor over this element right now" is `element.matches(":hover")`
|
|
691
|
+
* -- but browsers only recompute that cached flag in response to a *new* real pointer event, not
|
|
692
|
+
* just because the element became hit-testable again. Scrolling is exactly what leaves no such event
|
|
693
|
+
* behind at the moment settling happens, so `:hover` reports stale information right when it's
|
|
694
|
+
* needed most. `document.elementFromPoint` performs a fresh, on-demand hit-test instead of reading a
|
|
695
|
+
* cached flag, so it agrees with reality even when `:hover` hasn't caught up yet. It's checked
|
|
696
|
+
* against the last real pointer position `hoverScrollGuard` tracked (see that module's doc comment)
|
|
697
|
+
* rather than this reference's own bounding box, so it naturally returns nothing (and this correctly
|
|
698
|
+
* no-ops) if another element -- e.g. a still-open popover -- now sits on top of it.
|
|
699
|
+
*
|
|
700
|
+
* @param options See `UseHoverScrollGuardOptions`
|
|
701
|
+
* @param options.enabled Whether this consumer should subscribe to the shared scroll guard
|
|
702
|
+
* @param options.hoverEnabled Whether this `Popover`'s own `activation.hover` is enabled at all
|
|
703
|
+
* @param options.openSiblingPinned See `usePopover`'s own `openSiblingPinned`
|
|
704
|
+
* @param options.isOpen Whether this `Popover` is already open
|
|
705
|
+
* @param options.popoverContext This `Popover`'s own Floating UI context
|
|
706
|
+
* @param options.referenceRef This `Popover`'s reference element ref
|
|
707
|
+
* @returns {boolean} Whether hover-opens should be held back right now -- fold into `useHover`'s
|
|
708
|
+
* own `enabled` option alongside this `Popover`'s other activation conditions
|
|
709
|
+
*/
|
|
710
|
+
const useHoverScrollGuard = ({ enabled, hoverEnabled, openSiblingPinned, isOpen, popoverContext, referenceRef, }) => {
|
|
711
|
+
// Subscribe only while this Popover opts into the guard. The shared store lazily attaches
|
|
712
|
+
// document listeners on the first subscriber — gating here keeps plain Popovers/tooltips from
|
|
713
|
+
// pulling the page-wide suppression net in (ADR-0002).
|
|
714
|
+
const isHoverScrollSuppressed = react.useSyncExternalStore(enabled ? hoverScrollGuard.subscribe : subscribeNoop, enabled ? hoverScrollGuard.isSuppressed : alwaysFalse, alwaysFalse);
|
|
715
|
+
// Read fresh inside the transition effect below via `.current`, rather than listed as that
|
|
716
|
+
// effect's own dependencies -- it only actually needs to look at the *edge* of suppression
|
|
717
|
+
// turning off, not re-run this check on every unrelated render any of these happen to change on
|
|
718
|
+
// too (e.g. the floating position recomputing on every scroll frame while still suppressed).
|
|
719
|
+
const latestGateRef = react.useRef({ enabled, hoverEnabled, openSiblingPinned, isOpen, popoverContext, referenceRef });
|
|
720
|
+
react.useEffect(() => {
|
|
721
|
+
latestGateRef.current = { enabled, hoverEnabled, openSiblingPinned, isOpen, popoverContext, referenceRef };
|
|
722
|
+
}, [enabled, hoverEnabled, openSiblingPinned, isOpen, popoverContext, referenceRef]);
|
|
723
|
+
// Tracks whether the *previous* run of the effect below saw suppression active, so that effect
|
|
724
|
+
// can tell a genuine "just settled" transition apart from simply mounting into an already-clear
|
|
725
|
+
// state. Without this, every fresh mount whose `isHoverScrollSuppressed` happens to already be
|
|
726
|
+
// `false` (the common case -- most `Popover`s mount outside of any scroll gesture) would run the
|
|
727
|
+
// open-check below regardless, with no real settle having ever happened. That's most visible for
|
|
728
|
+
// a freshly-opened `MenuTree`: the cursor is still resting wherever the trigger button was
|
|
729
|
+
// clicked, which is often right where the first row now renders, so every row's guard would
|
|
730
|
+
// otherwise treat its own very first mount as if scrolling had just settled over it and hover-open
|
|
731
|
+
// itself instantly, bypassing the open delay entirely.
|
|
732
|
+
const wasSuppressedRef = react.useRef(isHoverScrollSuppressed);
|
|
733
|
+
react.useEffect(() => {
|
|
734
|
+
const wasSuppressed = wasSuppressedRef.current;
|
|
735
|
+
wasSuppressedRef.current = isHoverScrollSuppressed;
|
|
736
|
+
if (!enabled || isHoverScrollSuppressed || !wasSuppressed) {
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
const { hoverEnabled: latestHoverEnabled, openSiblingPinned: latestOpenSiblingPinned, isOpen: latestIsOpen, popoverContext: latestPopoverContext, referenceRef: latestReferenceRef, } = latestGateRef.current;
|
|
740
|
+
if (!latestHoverEnabled || latestOpenSiblingPinned || latestIsOpen) {
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
const referenceElement = latestReferenceRef.current;
|
|
744
|
+
const pointerPosition = hoverScrollGuard.getLastPointerPosition();
|
|
745
|
+
// jsdom (used by every non-browser test in this repo) doesn't implement `elementFromPoint` at
|
|
746
|
+
// all, unlike every real browser -- guarded rather than assumed so tests exercising this
|
|
747
|
+
// codepath incidentally (e.g. via `userEvent.hover` tracking a pointer position) don't crash.
|
|
748
|
+
if (referenceElement instanceof Element &&
|
|
749
|
+
pointerPosition !== null &&
|
|
750
|
+
typeof document.elementFromPoint === "function") {
|
|
751
|
+
const elementAtPointer = document.elementFromPoint(pointerPosition.x, pointerPosition.y);
|
|
752
|
+
if (elementAtPointer !== null && referenceElement.contains(elementAtPointer)) {
|
|
753
|
+
// No real triggering event to forward: scrolling settling isn't itself a pointer event, and
|
|
754
|
+
// by this point there may be no fresh `pointerenter` at all for this reference (see the doc
|
|
755
|
+
// comment above).
|
|
756
|
+
latestPopoverContext.onOpenChange(true, undefined, "hover");
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}, [enabled, isHoverScrollSuppressed]);
|
|
760
|
+
return enabled && isHoverScrollSuppressed;
|
|
761
|
+
};
|
|
762
|
+
|
|
308
763
|
const DEFAULT_ACTIVATION = { click: true, hover: false, keyboardHandlers: true };
|
|
309
764
|
const DEFAULT_DISMISSAL = {
|
|
310
765
|
enabled: true,
|
|
@@ -317,6 +772,60 @@ const DEFAULT_SIZING = {
|
|
|
317
772
|
minHeight: "none",
|
|
318
773
|
maxHeight: "none",
|
|
319
774
|
};
|
|
775
|
+
const isVerticalPlacement = (resolvedPlacement) => resolvedPlacement.startsWith("top") || resolvedPlacement.startsWith("bottom");
|
|
776
|
+
/**
|
|
777
|
+
* The sole fallback for a nested horizontal submenu's `flip` (see `middleware` below): the mirrored
|
|
778
|
+
* placement on the opposite horizontal side, same alignment. Deliberately excludes any vertical
|
|
779
|
+
* placement -- a submenu that can't fit on either horizontal side should stay horizontal and
|
|
780
|
+
* cascade with overlap (via `shift({ crossAxis: true })` below) rather than flip above/below the
|
|
781
|
+
* parent panel. When that overlap would cover the trigger row, a follow-up middleware nudges the
|
|
782
|
+
* submenu above or below the trigger so the invoking item stays visible.
|
|
783
|
+
*/
|
|
784
|
+
const getOppositeHorizontalPlacement = (placement) => {
|
|
785
|
+
switch (placement) {
|
|
786
|
+
case "right":
|
|
787
|
+
return "left";
|
|
788
|
+
case "right-start":
|
|
789
|
+
return "left-start";
|
|
790
|
+
case "right-end":
|
|
791
|
+
return "left-end";
|
|
792
|
+
case "left":
|
|
793
|
+
return "right";
|
|
794
|
+
case "left-start":
|
|
795
|
+
return "right-start";
|
|
796
|
+
case "left-end":
|
|
797
|
+
return "right-end";
|
|
798
|
+
default:
|
|
799
|
+
return placement;
|
|
800
|
+
}
|
|
801
|
+
};
|
|
802
|
+
/**
|
|
803
|
+
* The gap between a nested submenu and its parent panel, along whichever axis it opens on --
|
|
804
|
+
* shared so the vertical and horizontal nested-placement branches below stay in sync.
|
|
805
|
+
*/
|
|
806
|
+
const NESTED_SUBMENU_GAP = 2;
|
|
807
|
+
/**
|
|
808
|
+
* Once a branch is already "engaged" (see `hasOpenSibling` below), how long the pointer must stop
|
|
809
|
+
* moving over a sibling row before it switches to it. Deliberately short -- long enough to filter
|
|
810
|
+
* out a row the cursor merely swept across while heading elsewhere, short enough that a genuine
|
|
811
|
+
* hover still feels instant, matching native menu-bar behavior.
|
|
812
|
+
*/
|
|
813
|
+
const SIBLING_HOVER_SWITCH_REST_MS = 75;
|
|
814
|
+
/**
|
|
815
|
+
* When a nested submenu falls back to a vertical placement, slide it as far as
|
|
816
|
+
* possible along the cross axis (left or right) so it visually separates from the
|
|
817
|
+
* parent panel instead of sitting almost flush on top of it.
|
|
818
|
+
*/
|
|
819
|
+
const getNestedVerticalAlignmentAxis = ({ rects }) => {
|
|
820
|
+
const refRect = rects.reference;
|
|
821
|
+
const floatingWidth = rects.floating.width;
|
|
822
|
+
const spaceRight = window.innerWidth - PADDING - refRect.x - floatingWidth;
|
|
823
|
+
const spaceLeft = refRect.x - PADDING;
|
|
824
|
+
if (spaceRight >= spaceLeft) {
|
|
825
|
+
return Math.max(0, spaceRight);
|
|
826
|
+
}
|
|
827
|
+
return -Math.max(0, spaceLeft);
|
|
828
|
+
};
|
|
320
829
|
/**
|
|
321
830
|
* The hook that powers the Popover component.
|
|
322
831
|
* It should not be used directly, but rather through the Popover component.
|
|
@@ -324,67 +833,308 @@ const DEFAULT_SIZING = {
|
|
|
324
833
|
* @param {PopoverProps} options The options for the popover
|
|
325
834
|
* @returns {UsePopoverType} The data for the popover
|
|
326
835
|
*/
|
|
327
|
-
const usePopover = ({ initialOpen = false, placement = "bottom", isModal, isOpen: controlledIsOpen, activation = DEFAULT_ACTIVATION, dismissal = DEFAULT_DISMISSAL, sizing = DEFAULT_SIZING, onOpenStateChange, id, className, "data-testid": dataTestId, }) => {
|
|
836
|
+
const usePopover = ({ initialOpen = false, placement = "bottom", isModal, isOpen: controlledIsOpen, activation = DEFAULT_ACTIVATION, dismissal = DEFAULT_DISMISSAL, sizing = DEFAULT_SIZING, onOpenStateChange, role = "dialog", id, className, "data-testid": dataTestId, }) => {
|
|
328
837
|
const [uncontrolledIsOpen, setUncontrolledIsOpen] = react.useState(initialOpen);
|
|
329
838
|
const [labelId, setLabelId] = react.useState();
|
|
330
839
|
const [descriptionId, setDescriptionId] = react.useState();
|
|
331
840
|
const isOpen = controlledIsOpen ?? uncontrolledIsOpen;
|
|
841
|
+
const { nodeId, parentId, isNested, tree } = useMenuTree();
|
|
842
|
+
const isInsideMenuTree = tree !== null && isNested;
|
|
843
|
+
const isHorizontallyPlaced = placement.startsWith("right") || placement.startsWith("left");
|
|
844
|
+
const middleware = react.useMemo(() => [
|
|
845
|
+
// `offset()` must run before `flip()`/`shift()` -- Floating UI evaluates those against the
|
|
846
|
+
// *current* computed position, so they only see the gap `offset()` adds (including the
|
|
847
|
+
// scrollbar clearance below) if it has already run. With the order reversed, `flip()` would
|
|
848
|
+
// decide whether a nested submenu fits using the reference's bare edge, then `offset()` could
|
|
849
|
+
// push it past the boundary `flip()` just approved. `flip()` resets the lifecycle whenever it
|
|
850
|
+
// changes the placement, so `offset()` below still re-runs (and re-resolves its
|
|
851
|
+
// placement-dependent branches) for whichever placement `flip()` settles on.
|
|
852
|
+
react$1.offset(placementState => {
|
|
853
|
+
const { placement: resolvedPlacement, elements } = placementState;
|
|
854
|
+
if (isInsideMenuTree && isVerticalPlacement(resolvedPlacement)) {
|
|
855
|
+
return {
|
|
856
|
+
mainAxis: NESTED_SUBMENU_GAP,
|
|
857
|
+
alignmentAxis: getNestedVerticalAlignmentAxis(placementState),
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
// A nested submenu's `elements.reference` is its trigger row, not the parent panel -- and
|
|
861
|
+
// that row's own edge is inset from the parent panel's true edge by the panel's padding
|
|
862
|
+
// (and, when its list is scrolling, by its scrollbar too). Clearing the parent panel's
|
|
863
|
+
// actual edge instead keeps the visual gap identical either way.
|
|
864
|
+
if (isInsideMenuTree && elements.reference instanceof HTMLElement) {
|
|
865
|
+
const parentPanel = getParentPanelElement(tree, parentId);
|
|
866
|
+
if (parentPanel !== null) {
|
|
867
|
+
return {
|
|
868
|
+
mainAxis: NESTED_SUBMENU_GAP + getNestedSubmenuClearance(parentPanel, elements.reference, resolvedPlacement),
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
return 8;
|
|
873
|
+
}),
|
|
874
|
+
isInsideMenuTree && isHorizontallyPlaced
|
|
875
|
+
? react$1.flip({ crossAxis: false, fallbackPlacements: [getOppositeHorizontalPlacement(placement)] })
|
|
876
|
+
: react$1.flip({ fallbackPlacements: ["top", "bottom", "right", "left"] }),
|
|
877
|
+
react$1.shift(getShiftOptions({ isNestedHorizontal: isInsideMenuTree && isHorizontallyPlaced })),
|
|
878
|
+
{
|
|
879
|
+
// After horizontal cascade-with-overlap, keep the trigger row uncovered by sliding the
|
|
880
|
+
// submenu above or below it (see `getYToClearReference`). Side-by-side placement leaves
|
|
881
|
+
// the trigger clear already, so this is a no-op in that case.
|
|
882
|
+
name: "clearNestedTrigger",
|
|
883
|
+
fn({ x, y, rects }) {
|
|
884
|
+
if (!(isInsideMenuTree && isHorizontallyPlaced)) {
|
|
885
|
+
return {};
|
|
886
|
+
}
|
|
887
|
+
const nextY = getYToClearReference({
|
|
888
|
+
floatingX: x,
|
|
889
|
+
floatingY: y,
|
|
890
|
+
floatingWidth: rects.floating.width,
|
|
891
|
+
floatingHeight: rects.floating.height,
|
|
892
|
+
reference: rects.reference,
|
|
893
|
+
gap: NESTED_SUBMENU_GAP,
|
|
894
|
+
viewportHeight: window.innerHeight,
|
|
895
|
+
padding: PADDING,
|
|
896
|
+
});
|
|
897
|
+
return nextY === null ? {} : { y: nextY };
|
|
898
|
+
},
|
|
899
|
+
},
|
|
900
|
+
react$1.size({
|
|
901
|
+
apply({ availableWidth, availableHeight, elements }) {
|
|
902
|
+
const { floating, reference } = elements;
|
|
903
|
+
const resolvedSizing = typeof sizing === "function" ? sizing(DEFAULT_SIZING) : sizing;
|
|
904
|
+
const refRect = reference.getBoundingClientRect();
|
|
905
|
+
// Nested horizontal submenus must keep a viewport-sized width budget so `shift` can
|
|
906
|
+
// cascade with overlap when neither side fits -- see `getFloatingWidthBudget`.
|
|
907
|
+
const widthBudget = getFloatingWidthBudget({
|
|
908
|
+
availableWidth,
|
|
909
|
+
viewportWidth: window.innerWidth,
|
|
910
|
+
preferViewportBudget: isInsideMenuTree && isHorizontallyPlaced,
|
|
911
|
+
});
|
|
912
|
+
Object.assign(floating.style, {
|
|
913
|
+
maxWidth: getMaxWidthValue({
|
|
914
|
+
value: resolvedSizing.maxWidth,
|
|
915
|
+
referenceWidth: refRect.width,
|
|
916
|
+
availableWidth: widthBudget,
|
|
917
|
+
}),
|
|
918
|
+
minWidth: getMinWidthValue({
|
|
919
|
+
value: resolvedSizing.minWidth,
|
|
920
|
+
referenceWidth: refRect.width,
|
|
921
|
+
availableWidth: widthBudget,
|
|
922
|
+
}),
|
|
923
|
+
maxHeight: getMaxHeightValue({
|
|
924
|
+
value: resolvedSizing.maxHeight,
|
|
925
|
+
availableHeight,
|
|
926
|
+
}),
|
|
927
|
+
minHeight: getMinHeightValue({
|
|
928
|
+
value: resolvedSizing.minHeight,
|
|
929
|
+
}),
|
|
930
|
+
});
|
|
931
|
+
},
|
|
932
|
+
}),
|
|
933
|
+
], [isInsideMenuTree, isHorizontallyPlaced, placement, sizing, tree, parentId]);
|
|
934
|
+
// Remembers *how* this node was last (re-)opened (see `MenuTreeOpenEvent.reason`) so it can be
|
|
935
|
+
// relayed to siblings on the `MENU_TREE_OPEN_EVENT` emit below. State, not a ref: Floating UI's
|
|
936
|
+
// `useClick` calls `onOpenChange(true, event, "click")` on every click of the reference *even
|
|
937
|
+
// while already open* (its "reaffirm" behavior for `stickIfOpen`) without that changing `isOpen`
|
|
938
|
+
// itself -- e.g. the row was already open from a hover, and the user then clicks it. That reason
|
|
939
|
+
// upgrade (hover -> click) needs its own render so the emit effect below re-runs and rebroadcasts
|
|
940
|
+
// it; a ref's mutation wouldn't be visible to that effect's dependency array.
|
|
941
|
+
const [openReason, setOpenReason] = react.useState(undefined);
|
|
332
942
|
const popoverData = react$1.useFloating({
|
|
943
|
+
nodeId,
|
|
333
944
|
placement,
|
|
334
945
|
open: isOpen,
|
|
335
|
-
onOpenChange: open => {
|
|
946
|
+
onOpenChange: (open, _event, reason) => {
|
|
336
947
|
setUncontrolledIsOpen(open);
|
|
337
948
|
onOpenStateChange?.(open);
|
|
949
|
+
// Reset to `undefined` on close (regardless of *how* it closed) rather than keeping the close
|
|
950
|
+
// reason around -- a later open that bypasses `onOpenChange` entirely (e.g. `MenuItem`'s
|
|
951
|
+
// `ArrowRight` keyboard shortcut, which sets the controlled `isOpen` state directly) should
|
|
952
|
+
// never be judged against a stale reason left over from a previous open/close cycle.
|
|
953
|
+
setOpenReason(open ? reason : undefined);
|
|
338
954
|
},
|
|
339
955
|
whileElementsMounted: react$1.autoUpdate,
|
|
340
|
-
middleware
|
|
341
|
-
react$1.offset(8),
|
|
342
|
-
react$1.flip({ fallbackPlacements: ["top", "bottom", "right", "left"] }),
|
|
343
|
-
react$1.shift({ padding: PADDING }),
|
|
344
|
-
react$1.size({
|
|
345
|
-
apply({ availableWidth, availableHeight, elements }) {
|
|
346
|
-
const { floating, reference } = elements;
|
|
347
|
-
const resolvedSizing = typeof sizing === "function" ? sizing(DEFAULT_SIZING) : sizing;
|
|
348
|
-
const refRect = reference.getBoundingClientRect();
|
|
349
|
-
Object.assign(floating.style, {
|
|
350
|
-
maxWidth: getMaxWidthValue({
|
|
351
|
-
value: resolvedSizing.maxWidth,
|
|
352
|
-
referenceWidth: refRect.width,
|
|
353
|
-
availableWidth,
|
|
354
|
-
}),
|
|
355
|
-
minWidth: getMinWidthValue({
|
|
356
|
-
value: resolvedSizing.minWidth,
|
|
357
|
-
referenceWidth: refRect.width,
|
|
358
|
-
}),
|
|
359
|
-
maxHeight: getMaxHeightValue({
|
|
360
|
-
value: resolvedSizing.maxHeight,
|
|
361
|
-
availableHeight,
|
|
362
|
-
}),
|
|
363
|
-
minHeight: getMinHeightValue({
|
|
364
|
-
value: resolvedSizing.minHeight,
|
|
365
|
-
}),
|
|
366
|
-
});
|
|
367
|
-
},
|
|
368
|
-
}),
|
|
369
|
-
],
|
|
956
|
+
middleware,
|
|
370
957
|
});
|
|
958
|
+
const updatePositionRef = react.useRef(popoverData.update);
|
|
959
|
+
// useFloating keeps the last resolved x/y/placement across close cycles. Painting the
|
|
960
|
+
// floating node at those stale coordinates before the next compute corrupts flip/shift
|
|
961
|
+
// overflow measurements (seen when reopening a submenu after a viewport resize).
|
|
962
|
+
const isPlacementReady = isOpen && popoverData.isPositioned;
|
|
963
|
+
react.useLayoutEffect(() => {
|
|
964
|
+
const updatePosition = popoverData.update;
|
|
965
|
+
updatePositionRef.current = updatePosition;
|
|
966
|
+
if (!isOpen) {
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
updatePosition();
|
|
970
|
+
const frame = requestAnimationFrame(() => {
|
|
971
|
+
updatePositionRef.current();
|
|
972
|
+
});
|
|
973
|
+
return () => cancelAnimationFrame(frame);
|
|
974
|
+
}, [isOpen, popoverData.update]);
|
|
371
975
|
const popoverContext = popoverData.context;
|
|
372
976
|
const resolvedActivation = typeof activation === "function" ? activation(DEFAULT_ACTIVATION) : activation;
|
|
373
977
|
const resolvedDismissal = typeof dismissal === "function" ? dismissal(DEFAULT_DISMISSAL) : dismissal;
|
|
978
|
+
const { outsidePress: configuredOutsidePress, ...restDismissal } = resolvedDismissal;
|
|
374
979
|
const clickInteraction = react$1.useClick(popoverContext, {
|
|
375
980
|
enabled: resolvedActivation.click,
|
|
376
981
|
keyboardHandlers: resolvedActivation.keyboardHandlers,
|
|
377
982
|
});
|
|
378
|
-
|
|
983
|
+
// Whether this node is part of a `MenuTree` at all (root or nested) -- as opposed to
|
|
984
|
+
// `isInsideMenuTree`, which is only true for *nested* members. A tree's root has no ancestor to
|
|
985
|
+
// defer to, but it's exactly as likely as a nested node to have a click land on one of its own
|
|
986
|
+
// (possibly deeply nested) open descendants, so it needs the same tree-aware outside-press check.
|
|
987
|
+
const isPartOfMenuTree = tree !== null;
|
|
988
|
+
const dismissInteraction = react$1.useDismiss(popoverContext, {
|
|
989
|
+
...restDismissal,
|
|
990
|
+
outsidePress: event => {
|
|
991
|
+
if (typeof configuredOutsidePress === "function") {
|
|
992
|
+
if (!configuredOutsidePress(event)) {
|
|
993
|
+
return false;
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
else if (configuredOutsidePress === false) {
|
|
997
|
+
return false;
|
|
998
|
+
}
|
|
999
|
+
// A press anywhere inside the tree (this node's own content, a sibling row, or a nested
|
|
1000
|
+
// descendant's floating panel) is never "outside" for *any* tree member -- root included.
|
|
1001
|
+
// Without this, a root `Popover` falls back to Floating UI's own DOM-containment check,
|
|
1002
|
+
// which isn't reliable the instant a descendant opens/closes and reflows the tree's DOM
|
|
1003
|
+
// (e.g. a hover-delayed submenu finishing its open transition), and can spuriously collapse
|
|
1004
|
+
// the whole tree instead of just switching between sibling rows.
|
|
1005
|
+
if (isPartOfMenuTree) {
|
|
1006
|
+
return !isPressInsideMenuTree(event, tree);
|
|
1007
|
+
}
|
|
1008
|
+
return true;
|
|
1009
|
+
},
|
|
1010
|
+
// Nested in a MenuTree: Escape closes one level at a time (doesn't bubble to ancestors).
|
|
1011
|
+
// Outside-press bubbles only when the press is genuinely outside the tree; sibling-row
|
|
1012
|
+
// presses are filtered above so they don't collapse the root menu.
|
|
1013
|
+
bubbles: isInsideMenuTree ? { escapeKey: false, outsidePress: true } : undefined,
|
|
1014
|
+
});
|
|
379
1015
|
const hoverOptions = typeof resolvedActivation.hover === "object" ? resolvedActivation.hover : undefined;
|
|
380
1016
|
const hoverEnabled = hoverOptions ? (hoverOptions.active ?? true) : resolvedActivation.hover === true;
|
|
381
1017
|
const hoverInteractable = hoverOptions?.interactable ?? true;
|
|
1018
|
+
const isHoverDelayed = hoverOptions?.delayed === true;
|
|
1019
|
+
// Once a sibling under the same parent has opened *via hover*, this branch of the tree is
|
|
1020
|
+
// already "engaged" -- hovering between sibling rows should switch instantly from then on,
|
|
1021
|
+
// matching native OS/browser menu-bar behavior, instead of re-running the accidental-open guard
|
|
1022
|
+
// delay for every single row. Without this, switching from an already-open sibling to this row
|
|
1023
|
+
// hits the full open delay on *every* hover, leaving neither submenu visible for that whole
|
|
1024
|
+
// window. Only tracked when this node actually uses a delayed hover-open -- other `Popover`s
|
|
1025
|
+
// don't pay for the extra tree subscription.
|
|
1026
|
+
const [hasOpenSibling, setHasOpenSibling] = react.useState(false);
|
|
1027
|
+
// A sibling opened by an explicit action (a click, or any reason other than a bare hover --
|
|
1028
|
+
// e.g. the `ArrowRight` keyboard shortcut) reflects deliberate user intent to inspect *that* row,
|
|
1029
|
+
// not "I'm passing through the list". Hovering a different row shouldn't steal it away the moment
|
|
1030
|
+
// the cursor happens to drift, so hover-to-open is disabled outright for the rest of the branch
|
|
1031
|
+
// until that click-opened sibling closes again (`MENU_TREE_CLOSE_EVENT` below clears this).
|
|
1032
|
+
const [openSiblingPinned, setOpenSiblingPinned] = react.useState(false);
|
|
1033
|
+
// Which sibling is currently responsible for `openSiblingPinned` being `true`, so a *different*
|
|
1034
|
+
// sibling closing doesn't clear it. Needed because clicking a sibling while another one is
|
|
1035
|
+
// already open (click-)pinned emits two tree events in sequence: this row's own
|
|
1036
|
+
// `MENU_TREE_OPEN_EVENT` (which pins everyone else), immediately followed by the previously-open
|
|
1037
|
+
// sibling's cascade `MENU_TREE_CLOSE_EVENT` once `MenuTreeSiblingCloseBoundary` closes it. Without
|
|
1038
|
+
// tracking the source, that stale close event would wipe out the pin the newer open just set. A
|
|
1039
|
+
// ref (not state): read and written only from within the event handlers below, never needs to
|
|
1040
|
+
// trigger a render itself.
|
|
1041
|
+
const pinnedBySiblingIdRef = react.useRef(null);
|
|
1042
|
+
react.useEffect(() => {
|
|
1043
|
+
if (!tree || !isHoverDelayed) {
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
const handleSiblingOpen = ({ nodeId: openedNodeId, parentId: openedParentId, reason, isMenuRow, }) => {
|
|
1047
|
+
if (!isMenuRow || openedNodeId === nodeId || openedParentId !== parentId) {
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
if (reason === "hover") {
|
|
1051
|
+
setHasOpenSibling(true);
|
|
1052
|
+
setOpenSiblingPinned(false);
|
|
1053
|
+
pinnedBySiblingIdRef.current = null;
|
|
1054
|
+
}
|
|
1055
|
+
else {
|
|
1056
|
+
setOpenSiblingPinned(true);
|
|
1057
|
+
pinnedBySiblingIdRef.current = openedNodeId;
|
|
1058
|
+
}
|
|
1059
|
+
};
|
|
1060
|
+
const handleSiblingClose = ({ nodeId: closedNodeId, parentId: closedParentId, isMenuRow, }) => {
|
|
1061
|
+
if (isMenuRow &&
|
|
1062
|
+
closedNodeId !== nodeId &&
|
|
1063
|
+
closedParentId === parentId &&
|
|
1064
|
+
pinnedBySiblingIdRef.current === closedNodeId) {
|
|
1065
|
+
setOpenSiblingPinned(false);
|
|
1066
|
+
pinnedBySiblingIdRef.current = null;
|
|
1067
|
+
}
|
|
1068
|
+
};
|
|
1069
|
+
tree.events.on(MENU_TREE_OPEN_EVENT, handleSiblingOpen);
|
|
1070
|
+
tree.events.on(MENU_TREE_CLOSE_EVENT, handleSiblingClose);
|
|
1071
|
+
return () => {
|
|
1072
|
+
tree.events.off(MENU_TREE_OPEN_EVENT, handleSiblingOpen);
|
|
1073
|
+
tree.events.off(MENU_TREE_CLOSE_EVENT, handleSiblingClose);
|
|
1074
|
+
};
|
|
1075
|
+
}, [tree, nodeId, parentId, isHoverDelayed]);
|
|
1076
|
+
// See `useHoverScrollGuard`'s doc comment for the full rationale: holds hover-opens back while a
|
|
1077
|
+
// scroll anywhere on the page is more recent than the last genuine pointer move, so a list
|
|
1078
|
+
// scrolling underneath a stationary cursor can't fire a cascade of phantom opens.
|
|
1079
|
+
// Scoped to delayed-hover *nested* MenuTree members so plain Popovers/tooltips stay unaffected
|
|
1080
|
+
// (ADR-0002 opt-in boundary).
|
|
1081
|
+
const isHoverScrollSuppressed = useHoverScrollGuard({
|
|
1082
|
+
enabled: isInsideMenuTree && isHoverDelayed,
|
|
1083
|
+
hoverEnabled,
|
|
1084
|
+
openSiblingPinned,
|
|
1085
|
+
isOpen,
|
|
1086
|
+
popoverContext,
|
|
1087
|
+
referenceRef: popoverData.refs.reference,
|
|
1088
|
+
});
|
|
382
1089
|
const hoverInteraction = react$1.useHover(popoverContext, {
|
|
383
|
-
enabled: hoverEnabled,
|
|
384
|
-
delay:
|
|
385
|
-
|
|
1090
|
+
enabled: hoverEnabled && !openSiblingPinned && !isHoverScrollSuppressed,
|
|
1091
|
+
delay: isHoverDelayed && !hasOpenSibling ? { open: 400 } : undefined,
|
|
1092
|
+
// Once a sibling is already open, switching rows drops the upfront delay above to feel
|
|
1093
|
+
// instant -- but with no delay/`restMs` at all, a cursor merely *passing through* this row en
|
|
1094
|
+
// route to the already-open sibling's submenu (e.g. cutting diagonally across the list to
|
|
1095
|
+
// reach a flyout that opens further up or down) would flash this row open too, stealing the
|
|
1096
|
+
// flyout away from the row the user was actually heading toward. `restMs` only starts counting
|
|
1097
|
+
// once `mousemove` stops moving *significantly* within this row -- every further significant
|
|
1098
|
+
// move resets it -- so a fast pass-through never accumulates enough dwell time to open, while a
|
|
1099
|
+
// genuine stop (even a brief one) still switches near-instantly. Only meaningful in the
|
|
1100
|
+
// `hasOpenSibling` branch: `PopoverTrigger` force-exempts every `MenuTree` row from the
|
|
1101
|
+
// `blockPointerEvents` block below via `pointer-events-auto` (see its own comment) so rows stay
|
|
1102
|
+
// clickable while that block is active elsewhere in the branch -- which also means a row's
|
|
1103
|
+
// *own* hover keeps firing for a merely passing-through cursor unless gated here.
|
|
1104
|
+
restMs: isHoverDelayed && hasOpenSibling ? SIBLING_HOVER_SWITCH_REST_MS : undefined,
|
|
1105
|
+
// Floating UI's default `move: true` also opens on a bare `mousemove` over the reference (not
|
|
1106
|
+
// just `mouseenter`) -- meant for content that appears under an already-resting cursor. That's
|
|
1107
|
+
// not a real intent signal for a MenuTree row (a mousemove without a preceding mouseenter is
|
|
1108
|
+
// most often the browser positioning the cursor for an unrelated click), so it's disabled here
|
|
1109
|
+
// to avoid opening a sibling's submenu from incidental cursor movement. Scoped to MenuTree
|
|
1110
|
+
// members -- standalone (non-tree) Popovers keep today's exact behavior.
|
|
1111
|
+
move: isPartOfMenuTree ? false : undefined,
|
|
1112
|
+
// blockPointerEvents lets a nested row's submenu survive the cursor travelling diagonally
|
|
1113
|
+
// from the trigger to the panel instead of closing when it briefly leaves both elements.
|
|
1114
|
+
// Scoped to MenuTree members so standalone (non-tree) Popovers keep today's exact behavior.
|
|
1115
|
+
handleClose: hoverEnabled === true && hoverInteractable === true
|
|
1116
|
+
? react$1.safePolygon({ blockPointerEvents: isInsideMenuTree })
|
|
1117
|
+
: undefined,
|
|
386
1118
|
});
|
|
387
|
-
const roleInteraction = react$1.useRole(popoverContext);
|
|
1119
|
+
const roleInteraction = react$1.useRole(popoverContext, { role });
|
|
1120
|
+
// Distinguishes a genuine open -> close transition from this node simply mounting closed (the
|
|
1121
|
+
// common case: every unrelated sibling elsewhere in the tree mounts with `isOpen === false`).
|
|
1122
|
+
// Without this, that initial "closed" render would emit `MENU_TREE_CLOSE_EVENT` regardless, and
|
|
1123
|
+
// any such unrelated sibling mounting while *this* node happens to be click-pinned open would
|
|
1124
|
+
// spuriously clear the pin.
|
|
1125
|
+
const wasOpenRef = react.useRef(isOpen);
|
|
1126
|
+
react.useEffect(() => {
|
|
1127
|
+
if (!tree) {
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
if (isOpen) {
|
|
1131
|
+
tree.events.emit(MENU_TREE_OPEN_EVENT, { nodeId, parentId, reason: openReason, isMenuRow: isHoverDelayed });
|
|
1132
|
+
}
|
|
1133
|
+
else if (wasOpenRef.current) {
|
|
1134
|
+
tree.events.emit(MENU_TREE_CLOSE_EVENT, { nodeId, parentId, isMenuRow: isHoverDelayed });
|
|
1135
|
+
}
|
|
1136
|
+
wasOpenRef.current = isOpen;
|
|
1137
|
+
}, [isOpen, openReason, tree, nodeId, parentId, isHoverDelayed]);
|
|
388
1138
|
const combinedInteractions = react$1.useInteractions([
|
|
389
1139
|
clickInteraction,
|
|
390
1140
|
hoverInteraction,
|
|
@@ -395,19 +1145,21 @@ const usePopover = ({ initialOpen = false, placement = "bottom", isModal, isOpen
|
|
|
395
1145
|
isOpen,
|
|
396
1146
|
setIsOpen: setUncontrolledIsOpen,
|
|
397
1147
|
...esToolkit.omit(popoverData, ["middlewareData", "floatingStyles", "elements"]),
|
|
398
|
-
x: popoverData.x,
|
|
399
|
-
y: popoverData.y,
|
|
400
|
-
isPositioned:
|
|
1148
|
+
x: isPlacementReady ? popoverData.x : 0,
|
|
1149
|
+
y: isPlacementReady ? popoverData.y : 0,
|
|
1150
|
+
isPositioned: isPlacementReady,
|
|
401
1151
|
refs: popoverData.refs,
|
|
402
1152
|
update: popoverData.update,
|
|
403
|
-
placement: popoverData.placement,
|
|
1153
|
+
placement: isPlacementReady ? popoverData.placement : placement,
|
|
404
1154
|
strategy: popoverData.strategy,
|
|
405
1155
|
...combinedInteractions,
|
|
406
1156
|
isModal,
|
|
1157
|
+
isNested,
|
|
407
1158
|
labelId,
|
|
408
1159
|
descriptionId,
|
|
409
1160
|
setLabelId,
|
|
410
1161
|
setDescriptionId,
|
|
1162
|
+
nodeId,
|
|
411
1163
|
customProps: {
|
|
412
1164
|
id,
|
|
413
1165
|
className,
|
|
@@ -415,7 +1167,11 @@ const usePopover = ({ initialOpen = false, placement = "bottom", isModal, isOpen
|
|
|
415
1167
|
},
|
|
416
1168
|
}), [
|
|
417
1169
|
isOpen,
|
|
1170
|
+
isPlacementReady,
|
|
1171
|
+
placement,
|
|
418
1172
|
setUncontrolledIsOpen,
|
|
1173
|
+
nodeId,
|
|
1174
|
+
isNested,
|
|
419
1175
|
combinedInteractions,
|
|
420
1176
|
popoverData,
|
|
421
1177
|
isModal,
|
|
@@ -428,6 +1184,14 @@ const usePopover = ({ initialOpen = false, placement = "bottom", isModal, isOpen
|
|
|
428
1184
|
};
|
|
429
1185
|
|
|
430
1186
|
const PopoverContext = react.createContext(null);
|
|
1187
|
+
/**
|
|
1188
|
+
* Like `usePopoverContext`, but returns `null` instead of throwing when called outside a `<Popover />`.
|
|
1189
|
+
* Used by components (e.g. `MenuContent`) that support being rendered either inside a `Popover` or fully
|
|
1190
|
+
* standalone.
|
|
1191
|
+
*
|
|
1192
|
+
* @returns {ContextType} The popover context, or `null` if there is no ancestor `Popover`
|
|
1193
|
+
*/
|
|
1194
|
+
const useOptionalPopoverContext = () => react.useContext(PopoverContext);
|
|
431
1195
|
/**
|
|
432
1196
|
* A hook to get the popover context.
|
|
433
1197
|
* It should only be used by the Popover components.
|
|
@@ -435,7 +1199,7 @@ const PopoverContext = react.createContext(null);
|
|
|
435
1199
|
* @returns {ContextType} The popover context
|
|
436
1200
|
*/
|
|
437
1201
|
const usePopoverContext = () => {
|
|
438
|
-
const context =
|
|
1202
|
+
const context = useOptionalPopoverContext();
|
|
439
1203
|
if (context === null) {
|
|
440
1204
|
throw new Error("Popover components must be wrapped in <Popover />");
|
|
441
1205
|
}
|
|
@@ -497,9 +1261,9 @@ const usePopoverContext = () => {
|
|
|
497
1261
|
*/
|
|
498
1262
|
const Popover = ({ children, isModal = false, ...restOptions }) => {
|
|
499
1263
|
const popover = usePopover({ isModal, ...restOptions });
|
|
500
|
-
return (jsxRuntime.jsx(PopoverContext.Provider, { value: popover, children: typeof children === "function"
|
|
501
|
-
|
|
502
|
-
|
|
1264
|
+
return (jsxRuntime.jsx(react$1.FloatingNode, { id: popover.nodeId, children: jsxRuntime.jsx(PopoverContext.Provider, { value: popover, children: typeof children === "function"
|
|
1265
|
+
? children({ isOpen: popover.isOpen, setIsOpen: popover.setIsOpen, placement: popover.placement })
|
|
1266
|
+
: children }) }));
|
|
503
1267
|
};
|
|
504
1268
|
|
|
505
1269
|
/** @internal */
|
|
@@ -560,7 +1324,46 @@ const Portal = (props) => {
|
|
|
560
1324
|
return jsxRuntime.jsx(react$1.FloatingPortal, { ...props, root: props.root ?? getDefaultPortalContainer() });
|
|
561
1325
|
};
|
|
562
1326
|
|
|
563
|
-
const
|
|
1327
|
+
const OVERFLOW_CLIPS = new Set(["auto", "scroll", "hidden", "clip"]);
|
|
1328
|
+
const clips = (overflow) => OVERFLOW_CLIPS.has(overflow);
|
|
1329
|
+
const rectsDisjoint = (a, b) => a.right <= b.left || a.left >= b.right || a.bottom <= b.top || a.top >= b.bottom;
|
|
1330
|
+
/**
|
|
1331
|
+
* Whether `element` is currently visible to the user -- not clipped out by any scrollable/clipping
|
|
1332
|
+
* ancestor (the browser fact `PopoverContent`'s `returnFocus` needs to respect: focusing a clipped
|
|
1333
|
+
* element still succeeds, and browsers then auto-scroll it into view, fighting whatever caused it
|
|
1334
|
+
* to be clipped in the first place -- see that module's doc comment) and not clipped by the
|
|
1335
|
+
* viewport itself.
|
|
1336
|
+
*
|
|
1337
|
+
* Deliberately synchronous (plain `getBoundingClientRect`/`getComputedStyle` reads) rather than an
|
|
1338
|
+
* `IntersectionObserver`, which only reports asynchronously on its own schedule and can't answer
|
|
1339
|
+
* "is it visible right now" at the exact moment a popover is closing.
|
|
1340
|
+
*
|
|
1341
|
+
* @param element The element to check
|
|
1342
|
+
* @returns {boolean} `false` if `element` (or any ancestor up to the viewport) clips it out
|
|
1343
|
+
*/
|
|
1344
|
+
const isElementVisible = (element) => {
|
|
1345
|
+
const elementRect = element.getBoundingClientRect();
|
|
1346
|
+
if (elementRect.width === 0 && elementRect.height === 0) {
|
|
1347
|
+
return false;
|
|
1348
|
+
}
|
|
1349
|
+
const viewportRect = { left: 0, top: 0, right: window.innerWidth, bottom: window.innerHeight };
|
|
1350
|
+
if (rectsDisjoint(elementRect, viewportRect)) {
|
|
1351
|
+
return false;
|
|
1352
|
+
}
|
|
1353
|
+
let ancestor = element.parentElement;
|
|
1354
|
+
while (ancestor !== null) {
|
|
1355
|
+
const style = getComputedStyle(ancestor);
|
|
1356
|
+
if (clips(style.overflowX) || clips(style.overflowY)) {
|
|
1357
|
+
if (rectsDisjoint(elementRect, ancestor.getBoundingClientRect())) {
|
|
1358
|
+
return false;
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
ancestor = ancestor.parentElement;
|
|
1362
|
+
}
|
|
1363
|
+
return true;
|
|
1364
|
+
};
|
|
1365
|
+
|
|
1366
|
+
const cvaPopoverContainer = cssClassVarianceUtilities.cvaMerge(["z-popover", "animate-fade-in-fast"]);
|
|
564
1367
|
const cvaPopoverTitleContainer = cssClassVarianceUtilities.cvaMerge(["flex", "items-center", "px-2", "py-1"], {
|
|
565
1368
|
variants: {
|
|
566
1369
|
divider: {
|
|
@@ -571,6 +1374,25 @@ const cvaPopoverTitleContainer = cssClassVarianceUtilities.cvaMerge(["flex", "it
|
|
|
571
1374
|
});
|
|
572
1375
|
const cvaPopoverTitleText = cssClassVarianceUtilities.cvaMerge(["flex-1", "text-neutral-500"]);
|
|
573
1376
|
|
|
1377
|
+
/**
|
|
1378
|
+
* Wraps a `returnFocus` ref so Floating UI's live `ref.current` read at close time (see
|
|
1379
|
+
* `FloatingFocusManager`'s `getReturnElement`, which reads `.current` fresh inside its close
|
|
1380
|
+
* cleanup rather than caching it) transparently gets a visibility check first. A getter -- not a
|
|
1381
|
+
* value computed once -- is what makes this "live": consumers just keep the underlying ref pointed
|
|
1382
|
+
* at their normal target and never need to know it might be temporarily unsafe to focus.
|
|
1383
|
+
*
|
|
1384
|
+
* Falls back to Floating UI's own hidden, off-screen fallback element (via returning `null`,
|
|
1385
|
+
* exactly like an empty ref) whenever the target isn't currently visible -- e.g. a `MenuItem` row
|
|
1386
|
+
* that has scrolled out of its list. Without this, focusing a clipped element still succeeds, and
|
|
1387
|
+
* browsers then auto-scroll it into view, fighting whatever caused it to be clipped in the first
|
|
1388
|
+
* place.
|
|
1389
|
+
*/
|
|
1390
|
+
const withVisibilityCheck = (sourceRef) => ({
|
|
1391
|
+
get current() {
|
|
1392
|
+
const target = sourceRef.current;
|
|
1393
|
+
return target !== null && isElementVisible(target) ? target : null;
|
|
1394
|
+
},
|
|
1395
|
+
});
|
|
574
1396
|
/**
|
|
575
1397
|
* PopoverContent displays the floating content inside a Popover.
|
|
576
1398
|
* It renders in a portal and manages focus, positioning, and accessibility. Must be a child of a Popover.
|
|
@@ -609,7 +1431,11 @@ const cvaPopoverTitleText = cssClassVarianceUtilities.cvaMerge(["flex-1", "text-
|
|
|
609
1431
|
const PopoverContent = function PopoverContent({ className, "data-testid": dataTestId, children, portalId, initialFocus, returnFocus = true, ref: propRef, ...props }) {
|
|
610
1432
|
const { context: floatingContext, customProps, ...context } = usePopoverContext();
|
|
611
1433
|
const ref = react$1.useMergeRefs([context.refs.setFloating, propRef]);
|
|
612
|
-
|
|
1434
|
+
// Only a ref-based `returnFocus` has a concrete target to check -- the `boolean` case defers to
|
|
1435
|
+
// Floating UI's own app-wide "previously focused element" tracking, which isn't exposed here to
|
|
1436
|
+
// check against.
|
|
1437
|
+
const resolvedReturnFocus = typeof returnFocus === "boolean" ? returnFocus : withVisibilityCheck(returnFocus);
|
|
1438
|
+
return (jsxRuntime.jsx(Portal, { id: portalId, children: context.isOpen === true ? (jsxRuntime.jsx(react$1.FloatingFocusManager, { closeOnFocusOut: false, context: floatingContext, guards: true, initialFocus: initialFocus, modal: context.isModal, order: ["reference", "content"], returnFocus: resolvedReturnFocus, children: jsxRuntime.jsx("div", { "aria-describedby": context.descriptionId, "aria-labelledby": context.labelId, className: cvaPopoverContainer({ className: className ?? customProps.className }), "data-testid": dataTestId ?? customProps["data-testid"] ?? "popover-content", ref: ref, style: {
|
|
613
1439
|
position: context.strategy,
|
|
614
1440
|
top: context.y,
|
|
615
1441
|
left: context.x,
|
|
@@ -1113,6 +1939,12 @@ const PopoverTrigger = function PopoverTrigger({ children, renderButton = false,
|
|
|
1113
1939
|
const context = usePopoverContext();
|
|
1114
1940
|
const ref = react$1.useMergeRefs([context.refs.setReference, propRef]);
|
|
1115
1941
|
const dataState = context.isOpen === true ? "open" : "closed";
|
|
1942
|
+
// While any `MenuTree` member is hover-open with `handleClose`'s `blockPointerEvents`, Floating
|
|
1943
|
+
// UI sets `document.body`'s pointer-events to `none` and only exempts *that* member's own
|
|
1944
|
+
// reference/floating pair -- every sibling row inherits `none` and silently swallows clicks for
|
|
1945
|
+
// as long as the block is active. Forcing `pointer-events: auto` here keeps every tree member's
|
|
1946
|
+
// own trigger clickable regardless of which sibling currently owns that block.
|
|
1947
|
+
const { tree } = useMenuTree();
|
|
1116
1948
|
if (!renderButton && react.isValidElement(children)) {
|
|
1117
1949
|
const referenceProps = context.getReferenceProps({
|
|
1118
1950
|
...props,
|
|
@@ -1121,9 +1953,14 @@ const PopoverTrigger = function PopoverTrigger({ children, renderButton = false,
|
|
|
1121
1953
|
});
|
|
1122
1954
|
const cloneProps = { ...referenceProps };
|
|
1123
1955
|
cloneProps.ref = ref;
|
|
1956
|
+
if (tree !== null) {
|
|
1957
|
+
const childClassName = typeof children.props.className === "string" ? children.props.className : "";
|
|
1958
|
+
cloneProps.className = tailwindMerge.twMerge(childClassName, "pointer-events-auto");
|
|
1959
|
+
}
|
|
1124
1960
|
return react.cloneElement(children, cloneProps);
|
|
1125
1961
|
}
|
|
1126
|
-
|
|
1962
|
+
const buttonProps = context.getReferenceProps(props);
|
|
1963
|
+
return (jsxRuntime.jsx(Button, { "data-state": dataState, ref: ref, type: "button", ...buttonProps, className: tree !== null ? tailwindMerge.twMerge(props.className, "pointer-events-auto") : props.className, children: children }));
|
|
1127
1964
|
};
|
|
1128
1965
|
|
|
1129
1966
|
const cvaText = cssClassVarianceUtilities.cvaMerge(["text-black", "m-0", "relative", "text-sm", "font-normal"], {
|
|
@@ -6391,25 +7228,25 @@ const cvaMenuListMultiSelect = cssClassVarianceUtilities.cvaMerge([
|
|
|
6391
7228
|
const cvaMenuListItem = cssClassVarianceUtilities.cvaMerge("max-w-full");
|
|
6392
7229
|
|
|
6393
7230
|
/**
|
|
6394
|
-
* MenuDivider renders a horizontal line to visually separate groups of items within a
|
|
7231
|
+
* MenuDivider renders a horizontal line to visually separate groups of items within a MenuContent.
|
|
6395
7232
|
*
|
|
6396
7233
|
* ### When to use
|
|
6397
7234
|
* Use MenuDivider between groups of related `MenuItem` elements to create logical sections within a menu.
|
|
6398
7235
|
*
|
|
6399
7236
|
* ### When not to use
|
|
6400
|
-
* Do not use MenuDivider outside of a `
|
|
7237
|
+
* Do not use MenuDivider outside of a `MenuContent`. For general-purpose dividers, use `Spacer` with `border`.
|
|
6401
7238
|
*
|
|
6402
7239
|
* @example Menu with grouped sections
|
|
6403
7240
|
* ```tsx
|
|
6404
|
-
* import {
|
|
7241
|
+
* import { MenuContent, MenuItem, MenuDivider, Icon } from "@trackunit/react-components";
|
|
6405
7242
|
*
|
|
6406
7243
|
* const GroupedMenu = () => (
|
|
6407
|
-
* <
|
|
7244
|
+
* <MenuContent>
|
|
6408
7245
|
* <MenuItem id="edit" label="Edit" prefix={<Icon name="PencilSquare" size="small" />} />
|
|
6409
7246
|
* <MenuItem id="duplicate" label="Duplicate" prefix={<Icon name="DocumentDuplicate" size="small" />} />
|
|
6410
7247
|
* <MenuDivider />
|
|
6411
7248
|
* <MenuItem id="delete" label="Delete" variant="danger" prefix={<Icon name="Trash" size="small" />} />
|
|
6412
|
-
* </
|
|
7249
|
+
* </MenuContent>
|
|
6413
7250
|
* );
|
|
6414
7251
|
* ```
|
|
6415
7252
|
* @returns {ReactElement} MenuDivider component
|
|
@@ -6431,7 +7268,38 @@ const cvaMenuItem = (props) => {
|
|
|
6431
7268
|
focus: focused === true ? "focused" : "unfocused",
|
|
6432
7269
|
}), className);
|
|
6433
7270
|
};
|
|
6434
|
-
const cvaMenuItemStyle = cssClassVarianceUtilities.cvaMerge(
|
|
7271
|
+
const cvaMenuItemStyle = cssClassVarianceUtilities.cvaMerge(
|
|
7272
|
+
// `min-w-0` overrides the flex/grid item default of `min-width: auto`, which otherwise refuses to
|
|
7273
|
+
// shrink this row below the intrinsic (unwrapped) width of its `truncate`d label -- without it, the
|
|
7274
|
+
// label's ellipsis truncation never kicks in and the ancestor menu list scrolls horizontally instead.
|
|
7275
|
+
//
|
|
7276
|
+
// `outline-hidden`: `MenuContent`'s roving-tabindex keyboard nav calls this element's own `.focus()`
|
|
7277
|
+
// on hover too (to keep keyboard nav in sync with the pointer -- see `onMouseMove` on `MenuItemProps`),
|
|
7278
|
+
// and Chromium's `:focus-visible` heuristic treats a script-triggered `.focus()` on a non-native
|
|
7279
|
+
// control as visible-by-default, so the browser's native ring was showing on every hover. The active
|
|
7280
|
+
// item is already indicated by `cvaInteractableItem`'s `hover:`/`focus-within:` background tint, so the
|
|
7281
|
+
// native ring is redundant (and, via hover, actively misleading) for both mouse and keyboard users.
|
|
7282
|
+
//
|
|
7283
|
+
// `data-[state=open]:…` / `data-hover-scroll-suppressed`: a submenu trigger's open state is held still
|
|
7284
|
+
// across scroll-induced phantom `:hover` flips (see `hoverScrollGuard`), but CSS `:hover` backgrounds
|
|
7285
|
+
// would still chase the cursor across sibling rows. Pin the open-row tint via `data-state` (set by
|
|
7286
|
+
// `PopoverTrigger`), and while the guard's root attribute is set, take non-open rows out of hit-testing
|
|
7287
|
+
// so their `:hover` styles can't fire either -- matching the open-state freeze without a React render
|
|
7288
|
+
// per scroll tick. `!` beats `PopoverTrigger`'s MenuTree `pointer-events-auto` exemption.
|
|
7289
|
+
[
|
|
7290
|
+
"px-2",
|
|
7291
|
+
"h-auto",
|
|
7292
|
+
"flex",
|
|
7293
|
+
"flex-row",
|
|
7294
|
+
"items-center",
|
|
7295
|
+
"gap-x-2",
|
|
7296
|
+
"select-none",
|
|
7297
|
+
"rounded",
|
|
7298
|
+
"min-w-0",
|
|
7299
|
+
"outline-hidden",
|
|
7300
|
+
"data-[state=open]:bg-neutral-600/5",
|
|
7301
|
+
"[:root[data-hover-scroll-suppressed]_&:not([data-state=open])]:!pointer-events-none",
|
|
7302
|
+
], {
|
|
6435
7303
|
variants: {
|
|
6436
7304
|
fieldSize: {
|
|
6437
7305
|
small: ["text-xs", "py-1.5"],
|
|
@@ -6443,6 +7311,7 @@ const cvaMenuItemStyle = cssClassVarianceUtilities.cvaMerge(["px-2", "h-auto", "
|
|
|
6443
7311
|
danger: [
|
|
6444
7312
|
"text-danger-600",
|
|
6445
7313
|
"hover:!bg-danger-100",
|
|
7314
|
+
"data-[state=open]:!bg-danger-100",
|
|
6446
7315
|
"focus:!bg-danger-200",
|
|
6447
7316
|
"hover:!text-danger-700",
|
|
6448
7317
|
"focus:!text-danger-800",
|
|
@@ -6472,7 +7341,12 @@ const cvaMenuItemStyle = cssClassVarianceUtilities.cvaMerge(["px-2", "h-auto", "
|
|
|
6472
7341
|
disabled: false,
|
|
6473
7342
|
},
|
|
6474
7343
|
});
|
|
6475
|
-
const cvaMenuItemLabel = cssClassVarianceUtilities.cvaMerge(
|
|
7344
|
+
const cvaMenuItemLabel = cssClassVarianceUtilities.cvaMerge(
|
|
7345
|
+
// No `truncate` here: `text-overflow: ellipsis` only applies to block containers, not flex containers,
|
|
7346
|
+
// and this wrapper is `flex` (to align prefix/label/description). The actual truncation lives on the
|
|
7347
|
+
// nested label `<span>` in `MenuItem.tsx`, which gets auto-blockified for being a flex item. `min-w-0`
|
|
7348
|
+
// (rather than `truncate`'s `overflow-hidden`) is what lets this wrapper shrink below its content size.
|
|
7349
|
+
["flex-grow", "min-w-0", "text-black", "font-normal", "flex", "items-center"], {
|
|
6476
7350
|
variants: {
|
|
6477
7351
|
variant: {
|
|
6478
7352
|
primary: [],
|
|
@@ -6538,21 +7412,34 @@ const cvaMenuItemSuffix = cssClassVarianceUtilities.cvaMerge(["text-neutral-400"
|
|
|
6538
7412
|
});
|
|
6539
7413
|
|
|
6540
7414
|
/**
|
|
6541
|
-
*
|
|
6542
|
-
*
|
|
7415
|
+
* The row markup shared by both the submenu-less and submenu-bearing shapes of `MenuItem` -- pulled
|
|
7416
|
+
* out into its own component (rather than a JSX variable held in `MenuItem`) so each shape can
|
|
7417
|
+
* render it directly wherever it's needed instead of passing a pre-built element around.
|
|
7418
|
+
*
|
|
7419
|
+
* Purely presentational: every DOM-facing prop (`className`, `tabIndex`, event handlers, ...) is
|
|
7420
|
+
* expected to arrive already fully resolved and is forwarded onto the root `div` as-is via `...rest`.
|
|
7421
|
+
* This matters because when rendered inside `PopoverTrigger`, Floating UI clones this element to
|
|
7422
|
+
* inject its own reference props (`data-state`, click/keyboard activation, ...) -- resolving values
|
|
7423
|
+
* here instead would shadow that injection.
|
|
7424
|
+
*/
|
|
7425
|
+
const MenuItemRow = ({ label, children, prefix, suffix, selected = false, disabled = false, variant = "primary", optionLabelDescription, optionPrefix, dataTestId, hasSubmenu, ...rest }) => (jsxRuntime.jsxs("div", { "aria-disabled": disabled, "data-testid": dataTestId ? `${dataTestId}-menu-item` : "menu-item", role: "menuitem", ...rest, children: [prefix !== null && prefix !== undefined ? (jsxRuntime.jsx("div", { className: cvaMenuItemPrefix({ selected, variant, disabled }), "data-testid": dataTestId ? `${dataTestId}-prefix` : "menu-item-prefix", children: prefix })) : null, children !== null && children !== undefined && typeof children !== "string" ? (children) : (jsxRuntime.jsxs("div", { className: cvaMenuItemLabel({ variant, disabled }), "data-testid": dataTestId ? `${dataTestId}-label` : "menu-item-label", children: [optionPrefix !== null && optionPrefix !== undefined ? optionPrefix : null, jsxRuntime.jsx("span", { className: "min-w-0 flex-1 truncate", children: children ?? label }), optionLabelDescription !== undefined && optionLabelDescription !== "" ? (jsxRuntime.jsxs("span", { className: "ml-1 text-neutral-400", children: ["(", optionLabelDescription, ")"] })) : null] })), suffix !== null && suffix !== undefined ? (jsxRuntime.jsx("div", { className: cvaMenuItemSuffix({ selected, variant, disabled }), "data-testid": dataTestId ? `${dataTestId}-suffix` : "menu-item-suffix", children: suffix })) : hasSubmenu ? (jsxRuntime.jsx("div", { className: cvaMenuItemSuffix({ selected, variant, disabled }), "data-testid": dataTestId ? `${dataTestId}-suffix` : "menu-item-suffix", children: jsxRuntime.jsx(Icon, { name: "ChevronRight", size: "small" }) })) : null] }));
|
|
7426
|
+
/**
|
|
7427
|
+
* MenuItem represents a single actionable item within a MenuContent.
|
|
7428
|
+
* It supports labels, icons (prefix/suffix), selected and focused states, danger variants, and — via the
|
|
7429
|
+
* `submenu` prop — nested submenus.
|
|
6543
7430
|
*
|
|
6544
7431
|
* ### When to use
|
|
6545
|
-
* Use MenuItem inside a `
|
|
7432
|
+
* Use MenuItem inside a `MenuContent` for individual actions (edit, delete, duplicate) or selectable options.
|
|
6546
7433
|
*
|
|
6547
7434
|
* ### When not to use
|
|
6548
|
-
* Do not use MenuItem outside of a `
|
|
7435
|
+
* Do not use MenuItem outside of a `MenuContent` context. For standalone clickable items, use `Button` or `ListItem`.
|
|
6549
7436
|
*
|
|
6550
7437
|
* @example MenuItem with icon prefix
|
|
6551
7438
|
* ```tsx
|
|
6552
|
-
* import {
|
|
7439
|
+
* import { MenuContent, MenuItem, Icon } from "@trackunit/react-components";
|
|
6553
7440
|
*
|
|
6554
7441
|
* const ActionMenu = () => (
|
|
6555
|
-
* <
|
|
7442
|
+
* <MenuContent>
|
|
6556
7443
|
* <MenuItem
|
|
6557
7444
|
* id="edit"
|
|
6558
7445
|
* label="Edit asset"
|
|
@@ -6566,15 +7453,82 @@ const cvaMenuItemSuffix = cssClassVarianceUtilities.cvaMerge(["text-neutral-400"
|
|
|
6566
7453
|
* variant="danger"
|
|
6567
7454
|
* onClick={() => console.log("Delete clicked")}
|
|
6568
7455
|
* />
|
|
6569
|
-
* </
|
|
7456
|
+
* </MenuContent>
|
|
7457
|
+
* );
|
|
7458
|
+
* ```
|
|
7459
|
+
* @example MenuItem with a submenu
|
|
7460
|
+
* ```tsx
|
|
7461
|
+
* import { MenuContent, MenuItem } from "@trackunit/react-components";
|
|
7462
|
+
*
|
|
7463
|
+
* const StatusMenu = () => (
|
|
7464
|
+
* <MenuContent>
|
|
7465
|
+
* <MenuItem
|
|
7466
|
+
* label="Status"
|
|
7467
|
+
* submenu={
|
|
7468
|
+
* <MenuContent>
|
|
7469
|
+
* <MenuItem id="active" label="Active" />
|
|
7470
|
+
* <MenuItem id="idle" label="Idle" />
|
|
7471
|
+
* </MenuContent>
|
|
7472
|
+
* }
|
|
7473
|
+
* />
|
|
7474
|
+
* </MenuContent>
|
|
6570
7475
|
* );
|
|
6571
7476
|
* ```
|
|
6572
7477
|
* @param {MenuItemProps} props - The props for the MenuItem component
|
|
6573
7478
|
* @returns {ReactElement} MenuItem component
|
|
6574
7479
|
*/
|
|
6575
|
-
const MenuItem = ({ className, "data-testid": dataTestId, label, children, selected = false, focused = false, prefix, suffix, disabled = false, onClick, stopPropagation = true, id, tabIndex, optionLabelDescription, optionPrefix, fieldSize = "medium", variant = "primary", style, ref, }) => {
|
|
7480
|
+
const MenuItem = ({ className, "data-testid": dataTestId, label, children, selected = false, focused = false, prefix, suffix, disabled = false, onClick, stopPropagation = true, id, tabIndex, optionLabelDescription, optionPrefix, fieldSize = "medium", variant = "primary", style, ref, submenu, submenuSizing, onFocus, onMouseMove, onPointerLeave, }) => {
|
|
7481
|
+
const [submenuOpen, setSubmenuOpen] = react.useState(false);
|
|
7482
|
+
const triggerRowRef = react.useRef(null);
|
|
7483
|
+
const mergedTriggerRef = react$1.useMergeRefs([ref, triggerRowRef]);
|
|
7484
|
+
// Where `PopoverContent` below returns focus to once the submenu closes -- normally the trigger
|
|
7485
|
+
// row itself (kept in sync below). `PopoverContent`'s own `returnFocus` is visibility-aware, so
|
|
7486
|
+
// this doesn't need to be cleared by hand right before the scroll-out auto-close further down --
|
|
7487
|
+
// see that prop's own doc comment for why.
|
|
7488
|
+
const returnFocusRef = react.useRef(null);
|
|
7489
|
+
// A submenu's trigger row commonly lives inside a scrollable `MenuContent` list. Without this, a
|
|
7490
|
+
// submenu opened from a row that later scrolls out of view stays open, floating disconnected from
|
|
7491
|
+
// any visible row -- and (since nothing else would ever close it) stays that way even once the row
|
|
7492
|
+
// scrolls back into view. `IntersectionObserver` (rather than e.g. the `Popover`'s own
|
|
7493
|
+
// `ancestorScroll` dismissal) is what lets this react to the row's actual visibility instead of
|
|
7494
|
+
// firing on every scroll tick of any ancestor, however small or unrelated to this row -- browsers
|
|
7495
|
+
// compute intersection against every ancestor's overflow-clip box, not just an explicit `root`, so
|
|
7496
|
+
// this fires as soon as *this* row's clipped out, even if some other list ancestor keeps scrolling.
|
|
7497
|
+
// Only observing while `submenuOpen` (rather than unconditionally) means the effect's own close
|
|
7498
|
+
// never re-triggers itself, which is also exactly what keeps the submenu from reopening on its own
|
|
7499
|
+
// once the row scrolls back into view -- there's no observer left running to notice that happen.
|
|
7500
|
+
react.useEffect(() => {
|
|
7501
|
+
if (!submenuOpen) {
|
|
7502
|
+
return;
|
|
7503
|
+
}
|
|
7504
|
+
const node = triggerRowRef.current;
|
|
7505
|
+
if (node === null) {
|
|
7506
|
+
return;
|
|
7507
|
+
}
|
|
7508
|
+
returnFocusRef.current = node;
|
|
7509
|
+
const observer = new IntersectionObserver(([entry]) => {
|
|
7510
|
+
if (entry !== undefined && !entry.isIntersecting) {
|
|
7511
|
+
// The row itself is what just scrolled out -- not a safe place to return focus to.
|
|
7512
|
+
// `PopoverContent`'s `returnFocus` checks the target's visibility itself right at close
|
|
7513
|
+
// time and falls back to its own hidden, off-screen element when it isn't visible, so
|
|
7514
|
+
// this doesn't need to clear `returnFocusRef` by hand first.
|
|
7515
|
+
setSubmenuOpen(false);
|
|
7516
|
+
}
|
|
7517
|
+
}, { threshold: 0 });
|
|
7518
|
+
observer.observe(node);
|
|
7519
|
+
return () => observer.disconnect();
|
|
7520
|
+
}, [submenuOpen]);
|
|
6576
7521
|
/* Handle tab navigation */
|
|
6577
7522
|
const handleKeyDown = (e) => {
|
|
7523
|
+
// Enter/Space are already handled by the internal Popover's `activation: { click: true }`
|
|
7524
|
+
// (`useClick`'s standard button-like keyboard semantics) -- only ArrowRight needs wiring up
|
|
7525
|
+
// by hand here, since arrow keys aren't part of click/button semantics.
|
|
7526
|
+
if (submenu !== undefined && disabled !== true && e.key === "ArrowRight") {
|
|
7527
|
+
e.preventDefault();
|
|
7528
|
+
e.stopPropagation();
|
|
7529
|
+
setSubmenuOpen(true);
|
|
7530
|
+
return;
|
|
7531
|
+
}
|
|
6578
7532
|
if (e.key === "Enter" && onClick !== undefined && disabled !== true) {
|
|
6579
7533
|
if (stopPropagation) {
|
|
6580
7534
|
e.stopPropagation();
|
|
@@ -6583,37 +7537,82 @@ const MenuItem = ({ className, "data-testid": dataTestId, label, children, selec
|
|
|
6583
7537
|
onClick(e);
|
|
6584
7538
|
}
|
|
6585
7539
|
};
|
|
6586
|
-
|
|
6587
|
-
|
|
6588
|
-
|
|
6589
|
-
|
|
6590
|
-
|
|
6591
|
-
|
|
6592
|
-
|
|
6593
|
-
|
|
6594
|
-
|
|
6595
|
-
|
|
6596
|
-
|
|
6597
|
-
|
|
6598
|
-
|
|
7540
|
+
const handleItemClick = e => {
|
|
7541
|
+
if (stopPropagation) {
|
|
7542
|
+
e.stopPropagation();
|
|
7543
|
+
}
|
|
7544
|
+
onClick?.(e);
|
|
7545
|
+
};
|
|
7546
|
+
const itemRowProps = {
|
|
7547
|
+
id,
|
|
7548
|
+
label,
|
|
7549
|
+
children,
|
|
7550
|
+
prefix,
|
|
7551
|
+
suffix,
|
|
7552
|
+
selected,
|
|
7553
|
+
disabled,
|
|
7554
|
+
variant,
|
|
7555
|
+
optionLabelDescription,
|
|
7556
|
+
optionPrefix,
|
|
7557
|
+
style,
|
|
7558
|
+
onFocus,
|
|
7559
|
+
onMouseMove,
|
|
7560
|
+
onPointerLeave,
|
|
7561
|
+
onClick: handleItemClick,
|
|
7562
|
+
onKeyDown: handleKeyDown,
|
|
7563
|
+
dataTestId,
|
|
7564
|
+
hasSubmenu: submenu !== undefined,
|
|
7565
|
+
className: cvaMenuItem({ selected, fieldSize, disabled, className, variant, focused }),
|
|
7566
|
+
tabIndex: disabled ? -1 : (tabIndex ?? 0),
|
|
7567
|
+
};
|
|
7568
|
+
if (submenu === undefined) {
|
|
7569
|
+
return jsxRuntime.jsx(MenuItemRow, { ...itemRowProps, ref: ref });
|
|
7570
|
+
}
|
|
7571
|
+
// Menu role lives on the nested `MenuContent` only — the Popover keeps its default `dialog`
|
|
7572
|
+
// floating role so SRs don't see nested `role="menu"`. `aria-haspopup="menu"` overrides
|
|
7573
|
+
// Floating UI's dialog haspopup so the trigger still advertises a submenu.
|
|
7574
|
+
return (jsxRuntime.jsxs(Popover, { activation: { click: !disabled, hover: disabled ? false : { delayed: true } }, isOpen: submenuOpen, onOpenStateChange: setSubmenuOpen, placement: "right-start", sizing: submenuSizing, children: [jsxRuntime.jsx(PopoverTrigger, { ref: mergedTriggerRef, children: jsxRuntime.jsx(MenuItemRow, { ...itemRowProps, "aria-expanded": submenuOpen, "aria-haspopup": "menu" }) }), jsxRuntime.jsx(PopoverContent, { initialFocus: 1, returnFocus: returnFocusRef, children: submenu })] }));
|
|
6599
7575
|
};
|
|
6600
7576
|
|
|
6601
7577
|
/**
|
|
6602
|
-
*
|
|
7578
|
+
* Derives the plain-text label used for typeahead matching. Disabled items are excluded (`null`)
|
|
7579
|
+
* so typing never lands focus on an item the user can't act on.
|
|
7580
|
+
*/
|
|
7581
|
+
const getTypeaheadLabel = (props) => {
|
|
7582
|
+
if (props.disabled === true) {
|
|
7583
|
+
return null;
|
|
7584
|
+
}
|
|
7585
|
+
if (typeof props.label === "string" && props.label !== "") {
|
|
7586
|
+
return props.label;
|
|
7587
|
+
}
|
|
7588
|
+
if (typeof props.children === "string") {
|
|
7589
|
+
return props.children;
|
|
7590
|
+
}
|
|
7591
|
+
return null;
|
|
7592
|
+
};
|
|
7593
|
+
/**
|
|
7594
|
+
* MenuContent (formerly MenuList) is a popover menu that appears above all other content on the page. It offers a
|
|
7595
|
+
* list of actions or functions that a user can access by clicking on a trigger, with full keyboard support:
|
|
7596
|
+
* roving-tabindex Up/Down navigation (wrapping), Home/End, typeahead, and — via `MenuItem`'s `submenu` prop —
|
|
7597
|
+
* nested submenu entry/exit.
|
|
7598
|
+
*
|
|
7599
|
+
* Typically rendered inside a `Popover` (directly, or as `PopoverContent`'s children), in which case it reads
|
|
7600
|
+
* the popover's floating context to power its keyboard navigation. Also works standalone (e.g. inside a
|
|
7601
|
+
* `Collapse`, with no ambient `Popover`), falling back to its own local, always-open floating context.
|
|
6603
7602
|
*
|
|
6604
7603
|
* **When to use**
|
|
6605
|
-
* - Use the
|
|
6606
|
-
* - Use the
|
|
6607
|
-
* - Don't use the
|
|
7604
|
+
* - Use the MenuContent if you have limited space and need to display overflow actions in a list.
|
|
7605
|
+
* - Use the MenuContent for actions that are not essential to completing workflows.
|
|
7606
|
+
* - Don't use the MenuContent to display single or multi-select items within form components. For dropdowns within select components, use SelectDropdown (component not available yet).
|
|
6608
7607
|
*
|
|
6609
|
-
* @example
|
|
7608
|
+
* @example MenuContent with action items
|
|
6610
7609
|
* ```tsx
|
|
6611
|
-
* import {
|
|
7610
|
+
* import { MenuContent, MenuItem, MoreMenu, Icon } from "@trackunit/react-components";
|
|
6612
7611
|
*
|
|
6613
7612
|
* const ActionsMenu = () => (
|
|
6614
7613
|
* <MoreMenu>
|
|
6615
7614
|
* {(close) => (
|
|
6616
|
-
* <
|
|
7615
|
+
* <MenuContent onClick={close}>
|
|
6617
7616
|
* <MenuItem id="edit" prefix={<Icon name="PencilSquare" size="small" />}>
|
|
6618
7617
|
* Edit
|
|
6619
7618
|
* </MenuItem>
|
|
@@ -6623,14 +7622,14 @@ const MenuItem = ({ className, "data-testid": dataTestId, label, children, selec
|
|
|
6623
7622
|
* <MenuItem id="delete" prefix={<Icon name="Trash" size="small" />} destructive>
|
|
6624
7623
|
* Delete
|
|
6625
7624
|
* </MenuItem>
|
|
6626
|
-
* </
|
|
7625
|
+
* </MenuContent>
|
|
6627
7626
|
* )}
|
|
6628
7627
|
* </MoreMenu>
|
|
6629
7628
|
* );
|
|
6630
7629
|
* ```
|
|
6631
|
-
* @example Multi-select
|
|
7630
|
+
* @example Multi-select MenuContent
|
|
6632
7631
|
* ```tsx
|
|
6633
|
-
* import {
|
|
7632
|
+
* import { MenuContent, MenuItem, MoreMenu } from "@trackunit/react-components";
|
|
6634
7633
|
* import { useState } from "react";
|
|
6635
7634
|
*
|
|
6636
7635
|
* const FilterMenu = () => {
|
|
@@ -6638,7 +7637,7 @@ const MenuItem = ({ className, "data-testid": dataTestId, label, children, selec
|
|
|
6638
7637
|
*
|
|
6639
7638
|
* return (
|
|
6640
7639
|
* <MoreMenu label="Filter by status">
|
|
6641
|
-
* <
|
|
7640
|
+
* <MenuContent
|
|
6642
7641
|
* isMulti
|
|
6643
7642
|
* selectedItems={selected}
|
|
6644
7643
|
* onSelectionChange={setSelected}
|
|
@@ -6646,18 +7645,52 @@ const MenuItem = ({ className, "data-testid": dataTestId, label, children, selec
|
|
|
6646
7645
|
* <MenuItem id="active">Active</MenuItem>
|
|
6647
7646
|
* <MenuItem id="idle">Idle</MenuItem>
|
|
6648
7647
|
* <MenuItem id="offline">Offline</MenuItem>
|
|
6649
|
-
* </
|
|
7648
|
+
* </MenuContent>
|
|
6650
7649
|
* </MoreMenu>
|
|
6651
7650
|
* );
|
|
6652
7651
|
* };
|
|
6653
7652
|
* ```
|
|
6654
|
-
* @param {
|
|
6655
|
-
* @returns {ReactElement}
|
|
7653
|
+
* @param {MenuContentProps} props - The props for the MenuContent component
|
|
7654
|
+
* @returns {ReactElement} MenuContent component
|
|
6656
7655
|
*/
|
|
6657
|
-
const
|
|
6658
|
-
const childrenArr = react.Children.toArray(children);
|
|
7656
|
+
const MenuContent = ({ "data-testid": dataTestId, className, listClassName, children, isMulti = false, selectedItems: controlledSelectedItems, onSelectionChange, style, ref, ...args }) => {
|
|
7657
|
+
const childrenArr = react.useMemo(() => react.Children.toArray(children), [children]);
|
|
6659
7658
|
const [internalSelectedItems, setInternalSelectedItems] = react.useState(controlledSelectedItems ?? []);
|
|
6660
7659
|
const selectedItems = controlledSelectedItems ?? internalSelectedItems;
|
|
7660
|
+
const [activeIndex, setActiveIndex] = react.useState(null);
|
|
7661
|
+
const listRef = react.useRef([]);
|
|
7662
|
+
const labelsRef = react.useRef([]);
|
|
7663
|
+
const ambientPopover = useOptionalPopoverContext();
|
|
7664
|
+
// Falls back to a local, always-open floating context when there is no ambient `Popover` (e.g. `MenuContent`
|
|
7665
|
+
// rendered standalone inside a `Collapse`) -- `useFloating` must still be called unconditionally per the
|
|
7666
|
+
// Rules of Hooks, so this is cheap and simply unused whenever `ambientPopover` is present. When ambient, the
|
|
7667
|
+
// enclosing `PopoverContent` already calls `context.refs.setFloating` on this same DOM node, so
|
|
7668
|
+
// `elements.floating` is already populated there; standalone, nothing else does that, so this component's own
|
|
7669
|
+
// root node must be wired up as the floating element itself for `useListNavigation`'s internal effects (which
|
|
7670
|
+
// gate on `elements.floating`) to run.
|
|
7671
|
+
const { context: standaloneContext, refs: standaloneRefs } = react$1.useFloating({ open: true });
|
|
7672
|
+
const context = ambientPopover?.context ?? standaloneContext;
|
|
7673
|
+
const floatingRef = react$1.useMergeRefs([ambientPopover ? null : standaloneRefs.setFloating, ref]);
|
|
7674
|
+
// Reads the ambient popover's own `isNested` (resolved by its `usePopover()` call *before* it
|
|
7675
|
+
// wraps this content in `<FloatingNode>`) rather than calling `useMenuTree()` again from here --
|
|
7676
|
+
// from this position, beneath that `FloatingNode`, `useMenuTree()` would resolve the ambient
|
|
7677
|
+
// parent id to the enclosing popover's *own* id and misreport every menu (root included) as
|
|
7678
|
+
// nested. Standalone (no ambient `Popover`) is never nested.
|
|
7679
|
+
const isNested = ambientPopover?.isNested ?? false;
|
|
7680
|
+
const listNavigation = react$1.useListNavigation(context, {
|
|
7681
|
+
listRef,
|
|
7682
|
+
activeIndex,
|
|
7683
|
+
onNavigate: setActiveIndex,
|
|
7684
|
+
loop: true,
|
|
7685
|
+
nested: isNested,
|
|
7686
|
+
});
|
|
7687
|
+
const typeahead = react$1.useTypeahead(context, {
|
|
7688
|
+
listRef: labelsRef,
|
|
7689
|
+
activeIndex,
|
|
7690
|
+
onMatch: setActiveIndex,
|
|
7691
|
+
resetMs: 500,
|
|
7692
|
+
});
|
|
7693
|
+
const { getFloatingProps, getItemProps } = react$1.useInteractions([listNavigation, typeahead]);
|
|
6661
7694
|
const handleItemClick = react.useCallback((id) => {
|
|
6662
7695
|
const newSelectedItems = isMulti
|
|
6663
7696
|
? selectedItems.includes(id)
|
|
@@ -6671,27 +7704,85 @@ const MenuList = ({ "data-testid": dataTestId, className, children, isMulti = fa
|
|
|
6671
7704
|
setInternalSelectedItems(newSelectedItems);
|
|
6672
7705
|
}
|
|
6673
7706
|
}, [isMulti, selectedItems, onSelectionChange]);
|
|
6674
|
-
|
|
6675
|
-
|
|
6676
|
-
|
|
6677
|
-
|
|
6678
|
-
|
|
6679
|
-
|
|
7707
|
+
// Only real `MenuItem`s participate in keyboard navigation. Arbitrary content (e.g. a `Search` input or a
|
|
7708
|
+
// list of checkboxes, via `Filter`'s children) can be rendered alongside them, but is excluded from the nav
|
|
7709
|
+
// list and rendered untouched -- `useListNavigation`/`useTypeahead` treat every registered item as a single
|
|
7710
|
+
// atomic focus target, an assumption that breaks down for a wrapper `<div>` holding several of its own
|
|
7711
|
+
// independently-focusable descendants (arrow keys skipping over it, its own click/focus handlers stealing
|
|
7712
|
+
// DOM focus from whatever's inside it). Computed as plain, memoized values (never touching listRef/labelsRef
|
|
7713
|
+
// here -- see the layout effect below, since refs must not be read/written during render).
|
|
7714
|
+
const { navIndicesByChildIndex, navLabels, firstEnabledNavIndex } = react.useMemo(() => {
|
|
7715
|
+
const indices = [];
|
|
7716
|
+
const labels = [];
|
|
7717
|
+
let itemCount = 0;
|
|
7718
|
+
let firstEnabledIndex = -1;
|
|
7719
|
+
childrenArr.forEach(child => {
|
|
7720
|
+
if (react.isValidElement(child) && child.type === MenuItem) {
|
|
7721
|
+
if (firstEnabledIndex === -1 && child.props.disabled !== true) {
|
|
7722
|
+
firstEnabledIndex = itemCount;
|
|
7723
|
+
}
|
|
7724
|
+
indices.push(itemCount);
|
|
7725
|
+
labels.push(getTypeaheadLabel(child.props));
|
|
7726
|
+
itemCount += 1;
|
|
7727
|
+
}
|
|
7728
|
+
else {
|
|
7729
|
+
indices.push(-1);
|
|
7730
|
+
}
|
|
7731
|
+
});
|
|
7732
|
+
return { navIndicesByChildIndex: indices, navLabels: labels, firstEnabledNavIndex: firstEnabledIndex };
|
|
7733
|
+
}, [childrenArr]);
|
|
7734
|
+
react.useLayoutEffect(() => {
|
|
7735
|
+
listRef.current.length = navLabels.length;
|
|
7736
|
+
labelsRef.current = navLabels;
|
|
7737
|
+
}, [navLabels]);
|
|
7738
|
+
// A focused descendant's own keydown would otherwise be swallowed by `useTypeahead` as a single-character
|
|
7739
|
+
// match attempt (see `getFloatingProps` below); this leaves native typing in nested content (e.g. a `Search`
|
|
7740
|
+
// input rendered as one of `MenuContent`'s non-`MenuItem` children) untouched.
|
|
7741
|
+
const handleKeyDownCapture = (event) => {
|
|
7742
|
+
if (utils.isTypeableElement(event.target)) {
|
|
7743
|
+
event.stopPropagation();
|
|
7744
|
+
}
|
|
7745
|
+
};
|
|
7746
|
+
const createItemRefSetter = react.useCallback((itemNavIndex) => (node) => {
|
|
7747
|
+
listRef.current[itemNavIndex] = node;
|
|
7748
|
+
}, []);
|
|
7749
|
+
return (jsxRuntime.jsx("div", { className: cvaMenu({ className, limitWidth: true }), "data-testid": dataTestId ? `${dataTestId}-menu-list` : "menu-list", onKeyDownCapture: handleKeyDownCapture, ref: floatingRef, role: "menu", style: style, tabIndex: -1, ...getFloatingProps({ onClick: args.onClick }), children: jsxRuntime.jsx("div", { className: cvaMenuList({ className: listClassName }), children: childrenArr.map((menuItem, index) => {
|
|
7750
|
+
if (!react.isValidElement(menuItem)) {
|
|
7751
|
+
return null;
|
|
7752
|
+
}
|
|
7753
|
+
if (menuItem.type === MenuDivider) {
|
|
7754
|
+
return react.cloneElement(menuItem, { key: index });
|
|
7755
|
+
}
|
|
7756
|
+
// Arbitrary, non-`MenuItem` content (see the comment on `navIndicesByChildIndex` above) is rendered
|
|
7757
|
+
// untouched, participating in neither roving tabindex nor typeahead -- its own interactive
|
|
7758
|
+
// descendants (inputs, checkboxes, buttons, ...) keep their native focus/click/keydown behavior.
|
|
7759
|
+
if (menuItem.type !== MenuItem) {
|
|
7760
|
+
return react.cloneElement(menuItem, { key: index });
|
|
7761
|
+
}
|
|
7762
|
+
const itemNavIndex = navIndicesByChildIndex[index] ?? -1;
|
|
7763
|
+
const disabled = menuItem.props.disabled ?? false;
|
|
7764
|
+
const isSelected = (selectedItems.includes(menuItem.props.id ?? `${index}`) || menuItem.props.selected) ?? false;
|
|
7765
|
+
const isActive = activeIndex === itemNavIndex || (activeIndex === null && itemNavIndex === firstEnabledNavIndex);
|
|
7766
|
+
return react.cloneElement(menuItem, {
|
|
7767
|
+
...menuItem.props,
|
|
7768
|
+
key: index,
|
|
7769
|
+
...getItemProps({
|
|
6680
7770
|
onClick: (event) => {
|
|
6681
7771
|
menuItem.props.onClick?.(event);
|
|
6682
|
-
if (
|
|
7772
|
+
if (!disabled) {
|
|
6683
7773
|
handleItemClick(menuItem.props.id ?? `${index}`);
|
|
6684
7774
|
}
|
|
6685
7775
|
},
|
|
6686
|
-
|
|
6687
|
-
|
|
6688
|
-
|
|
6689
|
-
|
|
6690
|
-
|
|
6691
|
-
|
|
6692
|
-
|
|
6693
|
-
|
|
6694
|
-
|
|
7776
|
+
}),
|
|
7777
|
+
tabIndex: disabled ? -1 : isActive ? 0 : -1,
|
|
7778
|
+
ref: createItemRefSetter(itemNavIndex),
|
|
7779
|
+
className: isMulti && isSelected
|
|
7780
|
+
? cvaMenuListMultiSelect({ className: menuItem.props.className })
|
|
7781
|
+
: cvaMenuListItem({ className: menuItem.props.className }),
|
|
7782
|
+
selected: isSelected,
|
|
7783
|
+
suffix: menuItem.props.suffix ??
|
|
7784
|
+
(isMulti && isSelected ? jsxRuntime.jsx(Icon, { className: "text-primary-600 block", name: "Check", size: "medium" }) : null),
|
|
7785
|
+
});
|
|
6695
7786
|
}) }) }));
|
|
6696
7787
|
};
|
|
6697
7788
|
|
|
@@ -6699,7 +7790,7 @@ const cvaMoreMenu = cssClassVarianceUtilities.cvaMerge(["p-0"]);
|
|
|
6699
7790
|
|
|
6700
7791
|
/**
|
|
6701
7792
|
* MoreMenu (kebab menu) renders a three-dot button that opens a popover with a list of actions.
|
|
6702
|
-
* It is typically filled with a
|
|
7793
|
+
* It is typically filled with a MenuContent containing MenuItem elements.
|
|
6703
7794
|
*
|
|
6704
7795
|
* ### When to use
|
|
6705
7796
|
* Use MoreMenu when you have overflow actions that don't fit in the main UI. Common for row-level actions in tables, card headers, or list items.
|
|
@@ -6709,30 +7800,30 @@ const cvaMoreMenu = cssClassVarianceUtilities.cvaMerge(["p-0"]);
|
|
|
6709
7800
|
*
|
|
6710
7801
|
* @example Action items with render prop — Pass a function as `children` to receive the `close` callback and dismiss the menu after an action.
|
|
6711
7802
|
* ```tsx
|
|
6712
|
-
* import { MoreMenu,
|
|
7803
|
+
* import { MoreMenu, MenuContent, MenuItem, Icon } from "@trackunit/react-components";
|
|
6713
7804
|
*
|
|
6714
7805
|
* const AssetActions = () => (
|
|
6715
7806
|
* <MoreMenu>
|
|
6716
7807
|
* {(close) => (
|
|
6717
|
-
* <
|
|
7808
|
+
* <MenuContent onClick={close}>
|
|
6718
7809
|
* <MenuItem id="edit" label="Edit" prefix={<Icon name="PencilSquare" size="small" />} />
|
|
6719
7810
|
* <MenuItem id="delete" label="Delete" variant="danger" prefix={<Icon name="Trash" size="small" />} />
|
|
6720
|
-
* </
|
|
7811
|
+
* </MenuContent>
|
|
6721
7812
|
* )}
|
|
6722
7813
|
* </MoreMenu>
|
|
6723
7814
|
* );
|
|
6724
7815
|
* ```
|
|
6725
7816
|
* @example Custom trigger button — Use `customButton` to replace the default kebab icon with any element, like a labeled Button.
|
|
6726
7817
|
* ```tsx
|
|
6727
|
-
* import { MoreMenu,
|
|
7818
|
+
* import { MoreMenu, MenuContent, MenuItem, Button } from "@trackunit/react-components";
|
|
6728
7819
|
*
|
|
6729
7820
|
* const CustomTriggerMenu = () => (
|
|
6730
7821
|
* <MoreMenu customButton={<Button variant="secondary" size="small">Actions</Button>}>
|
|
6731
7822
|
* {(close) => (
|
|
6732
|
-
* <
|
|
7823
|
+
* <MenuContent onClick={close}>
|
|
6733
7824
|
* <MenuItem id="export" label="Export" />
|
|
6734
7825
|
* <MenuItem id="archive" label="Archive" />
|
|
6735
|
-
* </
|
|
7826
|
+
* </MenuContent>
|
|
6736
7827
|
* )}
|
|
6737
7828
|
* </MoreMenu>
|
|
6738
7829
|
* );
|
|
@@ -6752,7 +7843,7 @@ const MoreMenu = ({ className, "data-testid": dataTestId, popoverProps, iconProp
|
|
|
6752
7843
|
const actionMenuRef = react.useRef(null);
|
|
6753
7844
|
const mergedRef = useMergeRefs([actionMenuRef, ref]);
|
|
6754
7845
|
const resolvedButtonLabel = iconButtonProps.title ?? buttonLabel ?? t("moreMenu.buttonLabel");
|
|
6755
|
-
return (jsxRuntime.jsx("div", { className: cvaMoreMenu({ className }), "data-testid": dataTestId ? dataTestId : undefined, ref: mergedRef, style: style, children: jsxRuntime.jsxs(Popover, { placement: "bottom-end", ...popoverProps, children: [jsxRuntime.jsx(PopoverTrigger, { children: customButton ?? (jsxRuntime.jsx(IconButton, { ...iconButtonProps, "data-testid": iconButtonProps["data-testid"] ?? "more-menu-icon", icon: jsxRuntime.jsx(Icon, { name: "EllipsisHorizontal", ...iconProps }), title: resolvedButtonLabel })) }), jsxRuntime.jsx(PopoverContent, { portalId: customPortalId, children: close => (typeof children === "function" ? children(close) : children) })] }) }));
|
|
7846
|
+
return (jsxRuntime.jsx("div", { className: cvaMoreMenu({ className }), "data-testid": dataTestId ? dataTestId : undefined, ref: mergedRef, style: style, children: jsxRuntime.jsx(MenuTree, { children: jsxRuntime.jsxs(Popover, { placement: "bottom-end", ...popoverProps, children: [jsxRuntime.jsx(PopoverTrigger, { children: customButton ?? (jsxRuntime.jsx(IconButton, { ...iconButtonProps, "data-testid": iconButtonProps["data-testid"] ?? "more-menu-icon", icon: jsxRuntime.jsx(Icon, { name: "EllipsisHorizontal", ...iconProps }), title: resolvedButtonLabel })) }), jsxRuntime.jsx(PopoverContent, { portalId: customPortalId, children: close => (typeof children === "function" ? children(close) : children) })] }) }) }));
|
|
6756
7847
|
};
|
|
6757
7848
|
|
|
6758
7849
|
const cvaNotice = cssClassVarianceUtilities.cvaMerge(["flex", "items-center", "gap-1"]);
|
|
@@ -7114,7 +8205,7 @@ const PageHeaderSecondaryActions = ({ actions, hasPrimaryAction = false, groupAc
|
|
|
7114
8205
|
return [danger, [...others, action]];
|
|
7115
8206
|
}
|
|
7116
8207
|
}, [[], []]);
|
|
7117
|
-
return (jsxRuntime.jsx("div", { className: className, "data-testid": dataTestId, ref: ref, style: style, children: jsxRuntime.jsx(MoreMenu, { "data-testid": "secondary-actions-more-menu", iconButtonProps: { size: "small", variant: "secondary" }, children: close => (jsxRuntime.jsxs(
|
|
8208
|
+
return (jsxRuntime.jsx("div", { className: className, "data-testid": dataTestId, ref: ref, style: style, children: jsxRuntime.jsx(MoreMenu, { "data-testid": "secondary-actions-more-menu", iconButtonProps: { size: "small", variant: "secondary" }, children: close => (jsxRuntime.jsxs(MenuContent, { className: "min-w-[160px]", children: [otherActions.map((action, index) => (jsxRuntime.jsx(ActionRenderer, { action: action, externalOnClick: close, isMenuItem: true }, `${action.actionText}-${index}`))), dangerActions.length > 0 ? jsxRuntime.jsx(MenuDivider, {}) : null, dangerActions.map((action, index) => (jsxRuntime.jsx(ActionRenderer, { action: action, externalOnClick: close, isMenuItem: true }, `${action.actionText}-${index}`)))] })) }) }));
|
|
7118
8209
|
}
|
|
7119
8210
|
// Otherwise, render them inline as buttons
|
|
7120
8211
|
return (jsxRuntime.jsx("div", { className: tailwindMerge.twMerge("flex flex-row items-center gap-2", className), "data-testid": dataTestId, ref: ref, style: style, children: enabledActions
|
|
@@ -9087,6 +10178,25 @@ const useSheetMeasurements = ({ shouldRender, state, dockingEnabled, snapping, e
|
|
|
9087
10178
|
]);
|
|
9088
10179
|
};
|
|
9089
10180
|
|
|
10181
|
+
/**
|
|
10182
|
+
* The layout-reserved width of `element`'s own vertical scrollbar (in pixels), or `0` when it
|
|
10183
|
+
* isn't currently reserving any (no overflow, or an overlay-style scrollbar that doesn't consume
|
|
10184
|
+
* layout space, e.g. macOS's default).
|
|
10185
|
+
*
|
|
10186
|
+
* `offsetWidth` includes the element's border and any reserved scrollbar; `clientWidth` excludes
|
|
10187
|
+
* both. Subtracting the (computed) border widths from that difference isolates the scrollbar
|
|
10188
|
+
* itself -- a naive `offsetWidth - clientWidth` would wrongly fold border width into the result.
|
|
10189
|
+
*
|
|
10190
|
+
* @param element The element to measure
|
|
10191
|
+
* @returns {number} The scrollbar's reserved width in pixels, or `0`
|
|
10192
|
+
*/
|
|
10193
|
+
const getScrollbarWidth = (element) => {
|
|
10194
|
+
const style = window.getComputedStyle(element);
|
|
10195
|
+
const borderLeft = parseFloat(style.borderLeftWidth) || 0;
|
|
10196
|
+
const borderRight = parseFloat(style.borderRightWidth) || 0;
|
|
10197
|
+
return Math.max(0, element.offsetWidth - borderLeft - borderRight - element.clientWidth);
|
|
10198
|
+
};
|
|
10199
|
+
|
|
9090
10200
|
/**
|
|
9091
10201
|
* Blocks scrolling on the document and compensates for scrollbar width to prevent layout shift.
|
|
9092
10202
|
* Uses scrollbar-gutter: stable to reserve space for the scrollbar when hiding overflow,
|
|
@@ -9105,10 +10215,7 @@ const blockDocumentScroll = () => {
|
|
|
9105
10215
|
// Check html scrollbar: window.innerWidth includes scrollbar, clientWidth doesn't
|
|
9106
10216
|
const htmlScrollbarWidth = window.innerWidth - html.clientWidth;
|
|
9107
10217
|
// Check body scrollbar: offsetWidth includes border+scrollbar, clientWidth excludes both
|
|
9108
|
-
const
|
|
9109
|
-
const bodyBorderLeft = parseInt(bodyStyle.borderLeftWidth) || 0;
|
|
9110
|
-
const bodyBorderRight = parseInt(bodyStyle.borderRightWidth) || 0;
|
|
9111
|
-
const bodyScrollbarWidth = body.offsetWidth - bodyBorderLeft - bodyBorderRight - body.clientWidth;
|
|
10218
|
+
const bodyScrollbarWidth = getScrollbarWidth(body);
|
|
9112
10219
|
// Use whichever scrollbar is present
|
|
9113
10220
|
const hasVisibleScrollbar = htmlScrollbarWidth > 0 || bodyScrollbarWidth > 0;
|
|
9114
10221
|
// Store original values before modifying
|
|
@@ -9167,13 +10274,7 @@ const restoreDocumentScroll = (originalStyles) => {
|
|
|
9167
10274
|
*/
|
|
9168
10275
|
const blockContainerScroll = (container) => {
|
|
9169
10276
|
// Check if there's a visible scrollbar before we hide it
|
|
9170
|
-
|
|
9171
|
-
// We need to subtract borders to isolate the scrollbar width
|
|
9172
|
-
const style = window.getComputedStyle(container);
|
|
9173
|
-
const borderLeft = parseInt(style.borderLeftWidth) || 0;
|
|
9174
|
-
const borderRight = parseInt(style.borderRightWidth) || 0;
|
|
9175
|
-
const scrollbarWidth = container.offsetWidth - borderLeft - borderRight - container.clientWidth;
|
|
9176
|
-
const hasVisibleScrollbar = scrollbarWidth > 0;
|
|
10277
|
+
const hasVisibleScrollbar = getScrollbarWidth(container) > 0;
|
|
9177
10278
|
const originalStyles = {
|
|
9178
10279
|
container: {
|
|
9179
10280
|
overflow: container.style.overflow,
|
|
@@ -10052,7 +11153,7 @@ const Sidebar = ({ childContainerClassName, children, breakpoint = "lg", classNa
|
|
|
10052
11153
|
});
|
|
10053
11154
|
}) }), overflowItemCount > 0 ? (jsxRuntime.jsx(MoreMenu, { iconButtonProps: {
|
|
10054
11155
|
variant: "ghost-neutral",
|
|
10055
|
-
}, ...moreMenuProps, className: moreMenuProps?.className, "data-testid": `${dataTestId}-more-menu`, children: close => (jsxRuntime.jsx(
|
|
11156
|
+
}, ...moreMenuProps, className: moreMenuProps?.className, "data-testid": `${dataTestId}-more-menu`, children: close => (jsxRuntime.jsx(MenuContent, { ...menuListProps, "data-testid": dataTestId, children: react.Children.map(children, child => {
|
|
10056
11157
|
return itemOverflowMap[child.props.id] === true
|
|
10057
11158
|
? react.cloneElement(child, {
|
|
10058
11159
|
onClick: e => {
|
|
@@ -13499,9 +14600,10 @@ exports.List = List;
|
|
|
13499
14600
|
exports.ListItem = ListItem;
|
|
13500
14601
|
exports.MAX_HASH_LENGTH = MAX_HASH_LENGTH;
|
|
13501
14602
|
exports.MAX_URL_LENGTH = MAX_URL_LENGTH;
|
|
14603
|
+
exports.MenuContent = MenuContent;
|
|
13502
14604
|
exports.MenuDivider = MenuDivider;
|
|
13503
14605
|
exports.MenuItem = MenuItem;
|
|
13504
|
-
exports.
|
|
14606
|
+
exports.MenuTree = MenuTree;
|
|
13505
14607
|
exports.MoreMenu = MoreMenu;
|
|
13506
14608
|
exports.Notice = Notice;
|
|
13507
14609
|
exports.PackageNameStoryComponent = PackageNameStoryComponent;
|
|
@@ -13625,8 +14727,10 @@ exports.useListItemHeight = useListItemHeight;
|
|
|
13625
14727
|
exports.useLocalStorage = useLocalStorage;
|
|
13626
14728
|
exports.useLocalStorageReducer = useLocalStorageReducer;
|
|
13627
14729
|
exports.useMeasure = useMeasure;
|
|
14730
|
+
exports.useMenuTree = useMenuTree;
|
|
13628
14731
|
exports.useMergeRefs = useMergeRefs;
|
|
13629
14732
|
exports.useModifierKey = useModifierKey;
|
|
14733
|
+
exports.useOptionalPopoverContext = useOptionalPopoverContext;
|
|
13630
14734
|
exports.useOverflowBorder = useOverflowBorder;
|
|
13631
14735
|
exports.useOverflowItems = useOverflowItems;
|
|
13632
14736
|
exports.usePersistedState = usePersistedState;
|