@itwin/itwinui-react 3.9.0 → 3.10.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.
Files changed (63) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/cjs/core/Breadcrumbs/Breadcrumbs.js +2 -3
  3. package/cjs/core/Buttons/Button.js +1 -1
  4. package/cjs/core/Buttons/IconButton.js +1 -1
  5. package/cjs/core/Buttons/IdeasButton.js +6 -2
  6. package/cjs/core/ComboBox/ComboBox.js +1 -1
  7. package/cjs/core/DropdownMenu/DropdownMenu.js +36 -13
  8. package/cjs/core/Input/Input.js +1 -1
  9. package/cjs/core/LabeledSelect/LabeledSelect.d.ts +26 -4
  10. package/cjs/core/Menu/Menu.js +9 -0
  11. package/cjs/core/Menu/MenuItem.d.ts +12 -0
  12. package/cjs/core/Menu/MenuItem.js +105 -66
  13. package/cjs/core/NotificationMarker/NotificationMarker.d.ts +7 -6
  14. package/cjs/core/Popover/Popover.d.ts +32 -9
  15. package/cjs/core/Popover/Popover.js +65 -17
  16. package/cjs/core/Select/Select.js +2 -3
  17. package/cjs/core/SideNavigation/SideNavigation.js +1 -1
  18. package/cjs/core/Table/TablePaginator.js +1 -3
  19. package/cjs/core/Table/columns/selectionColumn.js +10 -1
  20. package/cjs/core/Table/hooks/useSubRowSelection.js +1 -1
  21. package/cjs/core/ThemeProvider/ThemeProvider.js +53 -17
  22. package/cjs/core/TimePicker/TimePicker.js +12 -12
  23. package/cjs/core/ToggleSwitch/ToggleSwitch.d.ts +4 -0
  24. package/cjs/core/ToggleSwitch/ToggleSwitch.js +2 -2
  25. package/cjs/core/Tooltip/Tooltip.js +19 -7
  26. package/cjs/utils/components/Portal.d.ts +6 -2
  27. package/cjs/utils/components/Portal.js +11 -14
  28. package/cjs/utils/providers/ScopeProvider.d.ts +26 -0
  29. package/cjs/utils/providers/ScopeProvider.js +77 -0
  30. package/cjs/utils/providers/index.d.ts +1 -0
  31. package/cjs/utils/providers/index.js +1 -0
  32. package/esm/core/Breadcrumbs/Breadcrumbs.js +2 -3
  33. package/esm/core/Buttons/Button.js +1 -1
  34. package/esm/core/Buttons/IconButton.js +1 -1
  35. package/esm/core/Buttons/IdeasButton.js +3 -2
  36. package/esm/core/ComboBox/ComboBox.js +1 -1
  37. package/esm/core/DropdownMenu/DropdownMenu.js +36 -13
  38. package/esm/core/Input/Input.js +1 -1
  39. package/esm/core/LabeledSelect/LabeledSelect.d.ts +26 -4
  40. package/esm/core/Menu/Menu.js +9 -0
  41. package/esm/core/Menu/MenuItem.d.ts +12 -0
  42. package/esm/core/Menu/MenuItem.js +105 -66
  43. package/esm/core/NotificationMarker/NotificationMarker.d.ts +7 -6
  44. package/esm/core/Popover/Popover.d.ts +32 -9
  45. package/esm/core/Popover/Popover.js +68 -20
  46. package/esm/core/Select/Select.js +2 -3
  47. package/esm/core/SideNavigation/SideNavigation.js +1 -1
  48. package/esm/core/Table/TablePaginator.js +2 -4
  49. package/esm/core/Table/columns/selectionColumn.js +10 -1
  50. package/esm/core/Table/hooks/useSubRowSelection.js +1 -1
  51. package/esm/core/ThemeProvider/ThemeProvider.js +54 -18
  52. package/esm/core/TimePicker/TimePicker.js +12 -12
  53. package/esm/core/ToggleSwitch/ToggleSwitch.d.ts +4 -0
  54. package/esm/core/ToggleSwitch/ToggleSwitch.js +2 -2
  55. package/esm/core/Tooltip/Tooltip.js +19 -7
  56. package/esm/utils/components/Portal.d.ts +6 -2
  57. package/esm/utils/components/Portal.js +9 -8
  58. package/esm/utils/providers/ScopeProvider.d.ts +26 -0
  59. package/esm/utils/providers/ScopeProvider.js +48 -0
  60. package/esm/utils/providers/index.d.ts +1 -0
  61. package/esm/utils/providers/index.js +1 -0
  62. package/package.json +2 -1
  63. package/styles.css +1 -1
@@ -16,7 +16,7 @@ import { Box, ButtonBase } from '../../utils/index.js';
16
16
  */
17
17
  export const Button = React.forwardRef((props, ref) => {
18
18
  const { children, className, size, styleType = 'default', startIcon, endIcon, labelProps, startIconProps, endIconProps, stretched, ...rest } = props;
19
- return (React.createElement(ButtonBase, { ref: ref, className: cx('iui-button', className), "data-iui-variant": styleType !== 'default' ? styleType : undefined, "data-iui-size": size, ...rest, style: {
19
+ return (React.createElement(ButtonBase, { ref: ref, className: cx('iui-button', 'iui-field', className), "data-iui-variant": styleType !== 'default' ? styleType : undefined, "data-iui-size": size, ...rest, style: {
20
20
  '--_iui-width': stretched ? '100%' : undefined,
21
21
  ...props.style,
22
22
  } },
@@ -18,7 +18,7 @@ import { ButtonGroupContext } from '../ButtonGroup/ButtonGroup.js';
18
18
  export const IconButton = React.forwardRef((props, ref) => {
19
19
  const { isActive, children, styleType = 'default', size, className, label, iconProps, labelProps, ...rest } = props;
20
20
  const buttonGroupOrientation = React.useContext(ButtonGroupContext);
21
- const button = (React.createElement(ButtonBase, { ref: ref, className: cx('iui-button', className), "data-iui-variant": styleType !== 'default' ? styleType : undefined, "data-iui-size": size, "data-iui-active": isActive, "aria-pressed": isActive, ...rest },
21
+ const button = (React.createElement(ButtonBase, { ref: ref, className: cx('iui-button', 'iui-field', className), "data-iui-variant": styleType !== 'default' ? styleType : undefined, "data-iui-size": size, "data-iui-active": isActive, "aria-pressed": isActive, ...rest },
22
22
  React.createElement(Box, { as: 'span', "aria-hidden": true, ...iconProps, className: cx('iui-button-icon', iconProps?.className) }, children),
23
23
  label ? React.createElement(VisuallyHidden, null, label) : null));
24
24
  return label ? (React.createElement(Tooltip, { placement: buttonGroupOrientation === 'vertical' ? 'right' : 'top', ...labelProps, content: label, ariaStrategy: 'none' }, button)) : (button);
@@ -2,6 +2,7 @@
2
2
  * Copyright (c) Bentley Systems, Incorporated. All rights reserved.
3
3
  * See LICENSE.md in the project root for license terms and full copyright notice.
4
4
  *--------------------------------------------------------------------------------------------*/
5
+ import cx from 'classnames';
5
6
  import * as React from 'react';
6
7
  import { SvgSmileyHappy } from '../../utils/index.js';
7
8
  import { Button } from './Button.js';
@@ -11,6 +12,6 @@ import { Button } from './Button.js';
11
12
  * <IdeasButton />
12
13
  */
13
14
  export const IdeasButton = React.forwardRef((props, ref) => {
14
- const { feedbackLabel = 'Feedback', onClick, ...rest } = props;
15
- return (React.createElement(Button, { ref: ref, "data-iui-variant": 'idea', onClick: onClick, startIcon: React.createElement(SvgSmileyHappy, { "aria-hidden": true }), ...rest }, feedbackLabel));
15
+ const { feedbackLabel = 'Feedback', className, onClick, ...rest } = props;
16
+ return (React.createElement(Button, { ref: ref, className: cx('iui-button-idea', className), "data-iui-variant": 'high-visibility', onClick: onClick, startIcon: React.createElement(SvgSmileyHappy, { "aria-hidden": true }), ...rest }, feedbackLabel));
16
17
  });
@@ -282,7 +282,7 @@ export const ComboBox = React.forwardRef((props, forwardedRef) => {
282
282
  onVisibleChange: (open) => (open ? show() : hide()),
283
283
  matchWidth: true,
284
284
  closeOnOutsideClick: true,
285
- trigger: { focus: true },
285
+ interactions: { click: false, focus: true },
286
286
  });
287
287
  return (React.createElement(ComboBoxRefsContext.Provider, { value: { inputRef, menuRef, optionsExtraInfoRef } },
288
288
  React.createElement(ComboBoxActionContext.Provider, { value: dispatch },
@@ -6,6 +6,8 @@ import * as React from 'react';
6
6
  import { useMergedRefs, Portal, cloneElementWithRef, useControlledState, mergeRefs, mergeEventHandlers, } from '../../utils/index.js';
7
7
  import { Menu } from '../Menu/Menu.js';
8
8
  import { usePopover } from '../Popover/Popover.js';
9
+ import { FloatingNode, FloatingTree, useFloatingNodeId, } from '@floating-ui/react';
10
+ import { MenuItemContext } from '../Menu/MenuItem.js';
9
11
  /**
10
12
  * Dropdown menu component.
11
13
  * Built on top of the {@link Popover} component.
@@ -26,6 +28,11 @@ import { usePopover } from '../Popover/Popover.js';
26
28
  * </DropdownMenu>
27
29
  */
28
30
  export const DropdownMenu = React.forwardRef((props, forwardedRef) => {
31
+ return (React.createElement(FloatingTree, null,
32
+ React.createElement(DropdownMenuContent, { ref: forwardedRef, ...props })));
33
+ });
34
+ // ----------------------------------------------------------------------------
35
+ const DropdownMenuContent = React.forwardRef((props, forwardedRef) => {
29
36
  const { menuItems, children, role = 'menu', visible: visibleProp, placement = 'bottom-start', matchWidth = false, onVisibleChange, portal = true, ...rest } = props;
30
37
  const [visible, setVisible] = useControlledState(false, visibleProp, onVisibleChange);
31
38
  const triggerRef = React.useRef(null);
@@ -39,11 +46,23 @@ export const DropdownMenu = React.forwardRef((props, forwardedRef) => {
39
46
  }
40
47
  return menuItems;
41
48
  }, [menuItems, close]);
49
+ const [currentFocusedNodeIndex, setCurrentFocusedNodeIndex] = React.useState(null);
50
+ const focusableNodes = React.useRef([]);
51
+ const nodeId = useFloatingNodeId();
42
52
  const popover = usePopover({
53
+ nodeId,
43
54
  visible,
44
55
  onVisibleChange: (open) => (open ? setVisible(true) : close()),
45
56
  placement,
46
57
  matchWidth,
58
+ interactions: {
59
+ listNavigation: {
60
+ activeIndex: currentFocusedNodeIndex,
61
+ onNavigate: setCurrentFocusedNodeIndex,
62
+ listRef: focusableNodes,
63
+ focusItemOnOpen: true,
64
+ },
65
+ },
47
66
  });
48
67
  const popoverRef = useMergedRefs(forwardedRef, popover.refs.setFloating);
49
68
  return (React.createElement(React.Fragment, null,
@@ -52,17 +71,21 @@ export const DropdownMenu = React.forwardRef((props, forwardedRef) => {
52
71
  'aria-expanded': popover.open,
53
72
  ref: mergeRefs(triggerRef, popover.refs.setReference),
54
73
  })),
55
- popover.open && (React.createElement(Portal, { portal: portal },
56
- React.createElement(Menu, { ...popover.getFloatingProps({
57
- role,
58
- ...rest,
59
- onKeyDown: mergeEventHandlers(props.onKeyDown, (e) => {
60
- if (e.defaultPrevented) {
61
- return;
62
- }
63
- if (e.key === 'Tab') {
64
- close();
65
- }
66
- }),
67
- }), ref: popoverRef }, menuContent)))));
74
+ React.createElement(FloatingNode, { id: nodeId }, popover.open && (React.createElement(Portal, { portal: portal },
75
+ React.createElement(MenuItemContext.Provider, { value: {
76
+ setCurrentFocusedNodeIndex,
77
+ focusableNodes,
78
+ } },
79
+ React.createElement(Menu, { setFocus: false, ...popover.getFloatingProps({
80
+ role,
81
+ ...rest,
82
+ onKeyDown: mergeEventHandlers(props.onKeyDown, (e) => {
83
+ if (e.defaultPrevented) {
84
+ return;
85
+ }
86
+ if (e.key === 'Tab') {
87
+ close();
88
+ }
89
+ }),
90
+ }), ref: popoverRef }, menuContent)))))));
68
91
  });
@@ -15,5 +15,5 @@ export const Input = React.forwardRef((props, ref) => {
15
15
  const { size, htmlSize, status, className, ...rest } = props;
16
16
  const inputRef = React.useRef(null);
17
17
  const refs = useMergedRefs(inputRef, ref);
18
- return (React.createElement(Box, { as: 'input', className: cx('iui-input', className), "data-iui-size": size, "data-iui-status": status, size: htmlSize, ref: refs, ...rest }));
18
+ return (React.createElement(Box, { as: 'input', className: cx('iui-input', 'iui-field', className), "data-iui-size": size, "data-iui-status": status, size: htmlSize, ref: refs, ...rest }));
19
19
  });
@@ -298,6 +298,11 @@ export declare const LabeledSelect: <T>(props: ({
298
298
  menuStyle?: React.CSSProperties | undefined;
299
299
  popoverProps?: Pick<{
300
300
  placement?: import("@floating-ui/utils").Placement | undefined;
301
+ /**
302
+ * @deprecated Pass a `<StatusMessage startIcon={svgIcon} />` to the `message` prop instead.
303
+ *
304
+ * Custom svg icon. Will override status icon if specified.
305
+ */
301
306
  visible?: boolean | undefined;
302
307
  onVisibleChange?: ((visible: boolean) => void) | undefined;
303
308
  closeOnOutsideClick?: boolean | undefined;
@@ -317,10 +322,16 @@ export declare const LabeledSelect: <T>(props: ({
317
322
  animationFrame?: boolean | undefined;
318
323
  layoutShift?: boolean | undefined;
319
324
  } | undefined;
320
- trigger?: Partial<Record<"click" | "hover" | "focus", boolean>> | undefined;
325
+ interactions?: {
326
+ click?: boolean | import("@floating-ui/react").UseClickProps | undefined;
327
+ dismiss?: boolean | import("@floating-ui/react").UseDismissProps | undefined;
328
+ hover?: boolean | import("@floating-ui/react").UseHoverProps<import("@floating-ui/react").ReferenceType> | undefined;
329
+ focus?: boolean | import("@floating-ui/react").UseFocusProps | undefined;
330
+ listNavigation?: import("@floating-ui/react").UseListNavigationProps | undefined;
331
+ } | undefined;
321
332
  role?: "dialog" | "menu" | "listbox" | undefined;
322
333
  matchWidth?: boolean | undefined;
323
- }, "placement" | "visible" | "onVisibleChange" | "closeOnOutsideClick" | "matchWidth"> | undefined;
334
+ } & Omit<import("@floating-ui/react").UseFloatingOptions<import("@floating-ui/react").ReferenceType>, "placement" | "middleware">, "placement" | "visible" | "onVisibleChange" | "closeOnOutsideClick" | "matchWidth"> | undefined;
324
335
  triggerProps?: (Omit<React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "ref"> & {
325
336
  ref?: ((instance: HTMLDivElement | null) => void) | React.RefObject<HTMLDivElement> | null | undefined;
326
337
  }) | undefined;
@@ -428,6 +439,11 @@ export declare const LabeledSelect: <T>(props: ({
428
439
  menuStyle?: React.CSSProperties | undefined;
429
440
  popoverProps?: Pick<{
430
441
  placement?: import("@floating-ui/utils").Placement | undefined;
442
+ /**
443
+ * @deprecated Pass a `<StatusMessage startIcon={svgIcon} />` to the `message` prop instead.
444
+ *
445
+ * Custom svg icon. Will override status icon if specified.
446
+ */
431
447
  visible?: boolean | undefined;
432
448
  onVisibleChange?: ((visible: boolean) => void) | undefined;
433
449
  closeOnOutsideClick?: boolean | undefined;
@@ -447,10 +463,16 @@ export declare const LabeledSelect: <T>(props: ({
447
463
  animationFrame?: boolean | undefined;
448
464
  layoutShift?: boolean | undefined;
449
465
  } | undefined;
450
- trigger?: Partial<Record<"click" | "hover" | "focus", boolean>> | undefined;
466
+ interactions?: {
467
+ click?: boolean | import("@floating-ui/react").UseClickProps | undefined;
468
+ dismiss?: boolean | import("@floating-ui/react").UseDismissProps | undefined;
469
+ hover?: boolean | import("@floating-ui/react").UseHoverProps<import("@floating-ui/react").ReferenceType> | undefined;
470
+ focus?: boolean | import("@floating-ui/react").UseFocusProps | undefined;
471
+ listNavigation?: import("@floating-ui/react").UseListNavigationProps | undefined;
472
+ } | undefined;
451
473
  role?: "dialog" | "menu" | "listbox" | undefined;
452
474
  matchWidth?: boolean | undefined;
453
- }, "placement" | "visible" | "onVisibleChange" | "closeOnOutsideClick" | "matchWidth"> | undefined;
475
+ } & Omit<import("@floating-ui/react").UseFloatingOptions<import("@floating-ui/react").ReferenceType>, "placement" | "middleware">, "placement" | "visible" | "onVisibleChange" | "closeOnOutsideClick" | "matchWidth"> | undefined;
454
476
  triggerProps?: (Omit<React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "ref"> & {
455
477
  ref?: ((instance: HTMLDivElement | null) => void) | React.RefObject<HTMLDivElement> | null | undefined;
456
478
  }) | undefined;
@@ -5,6 +5,7 @@
5
5
  import * as React from 'react';
6
6
  import cx from 'classnames';
7
7
  import { useMergedRefs, getFocusableElements, Box, mergeEventHandlers, } from '../../utils/index.js';
8
+ import { MenuItemContext } from './MenuItem.js';
8
9
  /**
9
10
  * Basic menu component. Can be used for select or dropdown components.
10
11
  */
@@ -13,11 +14,19 @@ export const Menu = React.forwardRef((props, ref) => {
13
14
  const [focusedIndex, setFocusedIndex] = React.useState();
14
15
  const menuRef = React.useRef(null);
15
16
  const refs = useMergedRefs(menuRef, ref);
17
+ const menuItemContext = React.useContext(MenuItemContext);
16
18
  const getFocusableNodes = React.useCallback(() => {
17
19
  const focusableItems = getFocusableElements(menuRef.current);
18
20
  // Filter out focusable elements that are inside each menu item, e.g. checkbox, anchor
19
21
  return focusableItems.filter((i) => !focusableItems.some((p) => p.contains(i.parentElement)));
20
22
  }, []);
23
+ React.useEffect(() => {
24
+ const focusableNodes = getFocusableNodes();
25
+ if (menuItemContext != null &&
26
+ menuItemContext.focusableNodes.current !== focusableNodes) {
27
+ menuItemContext.focusableNodes.current = focusableNodes;
28
+ }
29
+ }, [getFocusableNodes, menuItemContext]);
21
30
  React.useEffect(() => {
22
31
  const items = getFocusableNodes();
23
32
  if (focusedIndex != null) {
@@ -1,6 +1,14 @@
1
1
  import * as React from 'react';
2
2
  import type { PolymorphicForwardRefComponent } from '../../utils/index.js';
3
3
  import type { ListItemOwnProps } from '../List/ListItem.js';
4
+ /**
5
+ * Should be wrapped around the `Menu` containing the `MenuItem`s.
6
+ */
7
+ export declare const MenuItemContext: React.Context<{
8
+ setCurrentFocusedNodeIndex: React.Dispatch<React.SetStateAction<number | null>>;
9
+ focusableNodes: React.MutableRefObject<(HTMLElement | null)[]>;
10
+ setHasFocusedNodeInSubmenu?: React.Dispatch<React.SetStateAction<boolean>> | undefined;
11
+ }>;
4
12
  export type MenuItemProps = {
5
13
  /**
6
14
  * Item is selected.
@@ -65,3 +73,7 @@ export type MenuItemProps = {
65
73
  * Basic menu item component. Should be used inside `Menu` component for each item.
66
74
  */
67
75
  export declare const MenuItem: PolymorphicForwardRefComponent<"div", MenuItemProps>;
76
+ export type TreeEvent = {
77
+ nodeId: string;
78
+ parentId: string | null;
79
+ };
@@ -3,37 +3,76 @@
3
3
  * See LICENSE.md in the project root for license terms and full copyright notice.
4
4
  *--------------------------------------------------------------------------------------------*/
5
5
  import * as React from 'react';
6
- import { SvgCaretRightSmall, Portal, useMergedRefs, useId, } from '../../utils/index.js';
6
+ import { SvgCaretRightSmall, Portal, useMergedRefs, useId, useSyncExternalStore, createWarningLogger, } from '../../utils/index.js';
7
7
  import { Menu } from './Menu.js';
8
8
  import { ListItem } from '../List/ListItem.js';
9
- import { flushSync } from 'react-dom';
10
9
  import { usePopover } from '../Popover/Popover.js';
10
+ import { FloatingNode, useFloatingNodeId, useFloatingParentNodeId, useFloatingTree, } from '@floating-ui/react';
11
+ const logWarningInDev = createWarningLogger();
11
12
  /**
12
- * Context used to provide menu item ref to sub-menu items.
13
+ * Should be wrapped around the `Menu` containing the `MenuItem`s.
13
14
  */
14
- const MenuItemContext = React.createContext({ ref: undefined, setIsNestedSubmenuVisible: () => { } });
15
+ export const MenuItemContext = React.createContext({
16
+ setCurrentFocusedNodeIndex: () => { },
17
+ focusableNodes: { current: [] },
18
+ setHasFocusedNodeInSubmenu: undefined,
19
+ });
15
20
  /**
16
21
  * Basic menu item component. Should be used inside `Menu` component for each item.
17
22
  */
18
23
  export const MenuItem = React.forwardRef((props, forwardedRef) => {
19
- const { children, isSelected, disabled, value, onClick, sublabel, size = !!sublabel ? 'large' : 'default', icon, startIcon = icon, badge, endIcon = badge, role = 'menuitem', subMenuItems = [], ...rest } = props;
24
+ const { children, isSelected, disabled, value, onClick: onClickProp, sublabel, size = !!sublabel ? 'large' : 'default', icon, startIcon = icon, badge, endIcon = badge, role = 'menuitem', subMenuItems = [], ...rest } = props;
25
+ if (onClickProp != null && subMenuItems.length > 0) {
26
+ logWarningInDev('Passing a non-empty submenuItems array and onClick to MenuItem at the same time is not supported. This is because when a non empty submenuItems array is passed, clicking the MenuItem toggles the submenu visibility.');
27
+ }
28
+ const parent = React.useContext(MenuItemContext);
20
29
  const menuItemRef = React.useRef(null);
21
- const [focusOnSubmenu, setFocusOnSubmenu] = React.useState(false);
22
30
  const submenuId = useId();
23
31
  const [isSubmenuVisible, setIsSubmenuVisible] = React.useState(false);
24
- const [isNestedSubmenuVisible, setIsNestedSubmenuVisible] = React.useState(false);
25
- const parent = React.useContext(MenuItemContext);
26
- const onVisibleChange = (open) => {
27
- setIsSubmenuVisible(open);
28
- // we don't want parent to close when mouse goes into a nested submenu,
29
- // so we need to let the parent know whether the submenu is still open.
30
- parent.setIsNestedSubmenuVisible(open);
31
- };
32
+ const [currentFocusedNodeIndex, setCurrentFocusedNodeIndex] = React.useState(null);
33
+ const [hasFocusedNodeInSubmenu, setHasFocusedNodeInSubmenu] = React.useState(false);
34
+ const nodeId = useFloatingNodeId();
35
+ const tree = useFloatingTree();
36
+ const parentId = useFloatingParentNodeId();
37
+ const focusableNodeIndexInParentTree = parent.focusableNodes.current.findIndex((el) => el === menuItemRef.current);
38
+ useSyncExternalStore(React.useCallback(() => {
39
+ const closeUnrelatedMenus = (event) => {
40
+ if (
41
+ // When a node "X" is focused, close "X"'s siblings' submenus
42
+ // i.e. only one submenu in each menu can be open at a time
43
+ (parentId === event.parentId && nodeId !== event.nodeId) ||
44
+ // Consider a node "X" with its submenu "Y".
45
+ // Focusing "X" should close all submenus of "Y".
46
+ parentId === event.nodeId) {
47
+ setIsSubmenuVisible(false);
48
+ setHasFocusedNodeInSubmenu(false);
49
+ }
50
+ };
51
+ tree?.events.on('onNodeFocused', closeUnrelatedMenus);
52
+ return () => {
53
+ tree?.events.off('onNodeFocused', closeUnrelatedMenus);
54
+ };
55
+ }, [nodeId, parentId, tree?.events]), () => undefined, () => undefined);
56
+ const focusableNodes = React.useRef([]);
32
57
  const popover = usePopover({
33
- visible: isSubmenuVisible || isNestedSubmenuVisible,
34
- onVisibleChange,
58
+ nodeId,
59
+ visible: isSubmenuVisible,
60
+ onVisibleChange: setIsSubmenuVisible,
35
61
  placement: 'right-start',
36
- trigger: { hover: true, focus: true },
62
+ interactions: !disabled
63
+ ? {
64
+ click: false,
65
+ hover: {
66
+ enabled: !hasFocusedNodeInSubmenu, // If focus is still inside submenu, don't close the submenu upon hovering out.
67
+ },
68
+ listNavigation: {
69
+ listRef: focusableNodes,
70
+ activeIndex: currentFocusedNodeIndex,
71
+ nested: subMenuItems.length > 0,
72
+ onNavigate: setCurrentFocusedNodeIndex,
73
+ },
74
+ }
75
+ : {},
37
76
  });
38
77
  const onKeyDown = (event) => {
39
78
  if (event.altKey) {
@@ -43,61 +82,61 @@ export const MenuItem = React.forwardRef((props, forwardedRef) => {
43
82
  case 'Enter':
44
83
  case ' ':
45
84
  case 'Spacebar': {
46
- !disabled && onClick?.(value);
47
- event.preventDefault();
48
- break;
49
- }
50
- case 'ArrowRight': {
51
- if (subMenuItems.length > 0) {
52
- setIsSubmenuVisible(true);
53
- // flush and reset state so we are ready to focus again next time
54
- flushSync(() => setFocusOnSubmenu(true));
55
- setFocusOnSubmenu(false);
56
- event.preventDefault();
57
- event.stopPropagation();
58
- }
59
- break;
60
- }
61
- case 'ArrowLeft': {
62
- if (parent.ref) {
63
- parent.ref.current?.focus();
64
- parent.setIsNestedSubmenuVisible(false);
65
- }
66
- event.stopPropagation();
85
+ onClick();
67
86
  event.preventDefault();
68
87
  break;
69
88
  }
70
- case 'Escape': {
71
- // focus might get lost if submenu closes so move it back to parent
72
- parent.ref?.current?.focus();
73
- break;
74
- }
75
89
  default:
76
90
  break;
77
91
  }
78
92
  };
79
- const handlers = {
80
- onClick: () => !disabled && onClick?.(value),
81
- onKeyDown,
93
+ const onMouseEnter = (e) => {
94
+ // Focus the item when hovered.
95
+ if (e.target === e.currentTarget) {
96
+ menuItemRef.current?.focus();
97
+ // Since we manually focus the MenuItem on hover, we need to manually update the active index for
98
+ // Floating UI's keyboard navigation to work correctly.
99
+ if (parent != null && focusableNodeIndexInParentTree != null) {
100
+ parent.setCurrentFocusedNodeIndex(focusableNodeIndexInParentTree);
101
+ }
102
+ }
103
+ };
104
+ const onFocus = () => {
105
+ parent.setHasFocusedNodeInSubmenu?.(true);
106
+ tree?.events.emit('onNodeFocused', {
107
+ nodeId,
108
+ parentId,
109
+ });
82
110
  };
83
- return (React.createElement(ListItem, { as: 'div', actionable: true, size: size, active: isSelected, disabled: disabled, ref: useMergedRefs(menuItemRef, forwardedRef, subMenuItems.length > 0 ? popover.refs.setReference : null), role: role, tabIndex: disabled || role === 'presentation' ? undefined : -1, "aria-selected": isSelected, "aria-haspopup": subMenuItems.length > 0 ? 'true' : undefined, "aria-controls": subMenuItems.length > 0 ? submenuId : undefined, "aria-expanded": subMenuItems.length > 0 ? popover.open : undefined, "aria-disabled": disabled, ...(subMenuItems.length === 0
84
- ? { ...handlers, ...rest }
85
- : popover.getReferenceProps({ ...handlers, ...rest })) },
86
- startIcon && (React.createElement(ListItem.Icon, { as: 'span', "aria-hidden": true }, startIcon)),
87
- React.createElement(ListItem.Content, null,
88
- React.createElement("div", null, children),
89
- sublabel && React.createElement(ListItem.Description, null, sublabel)),
90
- !endIcon && subMenuItems.length > 0 && (React.createElement(ListItem.Icon, { as: 'span', "aria-hidden": true },
91
- React.createElement(SvgCaretRightSmall, null))),
92
- endIcon && (React.createElement(ListItem.Icon, { as: 'span', "aria-hidden": true }, endIcon)),
93
- subMenuItems.length > 0 && popover.open && (React.createElement(Portal, null,
94
- React.createElement(MenuItemContext.Provider, { value: { ref: menuItemRef, setIsNestedSubmenuVisible } },
95
- React.createElement(Menu, { setFocus: focusOnSubmenu, ref: popover.refs.setFloating, ...popover.getFloatingProps({
96
- id: submenuId,
97
- onPointerMove: () => {
98
- // pointer might move into a nested submenu and set isSubmenuVisible to false,
99
- // so we need to flip it back to true when pointer re-enters this submenu.
100
- setIsSubmenuVisible(true);
101
- },
102
- }) }, subMenuItems))))));
111
+ const onClick = () => {
112
+ onClickProp?.(value);
113
+ setIsSubmenuVisible((prev) => !prev);
114
+ };
115
+ const handlers = !disabled
116
+ ? {
117
+ onClick,
118
+ onKeyDown,
119
+ onMouseEnter,
120
+ onFocus,
121
+ }
122
+ : {};
123
+ return (React.createElement(React.Fragment, null,
124
+ React.createElement(ListItem, { as: 'div', actionable: true, size: size, active: isSelected, disabled: disabled, ref: useMergedRefs(menuItemRef, forwardedRef, subMenuItems.length > 0 ? popover.refs.setReference : null), role: role, tabIndex: disabled || role === 'presentation' ? undefined : -1, "aria-selected": isSelected, "aria-haspopup": subMenuItems.length > 0 ? 'true' : undefined, "aria-controls": subMenuItems.length > 0 ? submenuId : undefined, "aria-expanded": subMenuItems.length > 0 ? popover.open : undefined, "aria-disabled": disabled, ...(subMenuItems.length === 0
125
+ ? { ...handlers, ...rest }
126
+ : popover.getReferenceProps({ ...handlers, ...rest })) },
127
+ startIcon && (React.createElement(ListItem.Icon, { as: 'span', "aria-hidden": true }, startIcon)),
128
+ React.createElement(ListItem.Content, null,
129
+ React.createElement("div", null, children),
130
+ sublabel && React.createElement(ListItem.Description, null, sublabel)),
131
+ !endIcon && subMenuItems.length > 0 && (React.createElement(ListItem.Icon, { as: 'span', "aria-hidden": true },
132
+ React.createElement(SvgCaretRightSmall, null))),
133
+ endIcon && (React.createElement(ListItem.Icon, { as: 'span', "aria-hidden": true }, endIcon))),
134
+ subMenuItems.length > 0 && !disabled && popover.open && (React.createElement(FloatingNode, { id: nodeId },
135
+ React.createElement(Portal, null,
136
+ React.createElement(MenuItemContext.Provider, { value: {
137
+ setCurrentFocusedNodeIndex,
138
+ focusableNodes,
139
+ setHasFocusedNodeInSubmenu,
140
+ } },
141
+ React.createElement(Menu, { setFocus: false, ref: popover.refs.setFloating, ...popover.getFloatingProps({ id: submenuId }) }, subMenuItems)))))));
103
142
  });
@@ -24,14 +24,15 @@ type NotificationMarkerProps = {
24
24
  */
25
25
  pulsing?: boolean;
26
26
  /**
27
- * Set this programmatically to false when you just want to render the passed children without the notification
28
- * @default true
27
+ * Instead of conditionally rendering the `NotificationMarker`, the `enabled` prop can be used.
28
+ *
29
+ * When `enabled` is set to `false`, the DOM element will still be present, but the notification marker will not be displayed visually.
30
+ *
29
31
  * @example
30
- * let [newMessagesCount, ...] = useState(0);
31
- * ...
32
- * <NotificationMarker enabled={newMessagesCount > 0}>
33
- * <SvgNotification />
32
+ * <NotificationMarker enabled={notifications.length > 0}>
33
+ *
34
34
  * </NotificationMarker>
35
+ * @default true
35
36
  */
36
37
  enabled?: boolean;
37
38
  };
@@ -1,5 +1,5 @@
1
1
  import * as React from 'react';
2
- import type { Placement } from '@floating-ui/react';
2
+ import type { Placement, UseListNavigationProps, ReferenceType, UseFloatingOptions, UseHoverProps, UseClickProps, UseFocusProps, UseDismissProps } from '@floating-ui/react';
3
3
  import type { PolymorphicForwardRefComponent } from '../../utils/index.js';
4
4
  import type { PortalProps } from '../../utils/components/Portal.js';
5
5
  type PopoverOptions = {
@@ -59,16 +59,39 @@ type PopoverInternalProps = {
59
59
  layoutShift?: boolean;
60
60
  };
61
61
  /**
62
- * By default, the popover will only open on click.
63
- * `hover` and `focus` can be manually specified as triggers.
62
+ * By default, only the click and dismiss interactions/triggers are enabled.
63
+ * Explicitly pass `false` to disable the defaults.
64
+ *
65
+ * Pass a boolean to enable/disable any of the supported interactions.
66
+ * Alternatively, pass an object to override the default props that the Popover sets for an interaction/trigger.
67
+ *
68
+ * When additional parameters are _required_ for an interaction/trigger, an object must be passed to enable it.
69
+ * Booleans will not be allowed in this case.
70
+ *
71
+ * @example
72
+ * <Popover
73
+ * interactions={{
74
+ * click: false,
75
+ * focus: true,
76
+ * hover: { move: false },
77
+ * listNavigation: { … },
78
+ * }}
79
+ * // …
80
+ * >…</Popover>
64
81
  */
65
- trigger?: Partial<Record<'hover' | 'click' | 'focus', boolean>>;
82
+ interactions?: {
83
+ click?: boolean | UseClickProps;
84
+ dismiss?: boolean | UseDismissProps;
85
+ hover?: boolean | UseHoverProps<ReferenceType>;
86
+ focus?: boolean | UseFocusProps;
87
+ listNavigation?: UseListNavigationProps;
88
+ };
66
89
  role?: 'dialog' | 'menu' | 'listbox';
67
90
  /**
68
91
  * Whether the popover should match the width of the trigger.
69
92
  */
70
93
  matchWidth?: boolean;
71
- };
94
+ } & Omit<UseFloatingOptions, 'middleware' | 'placement'>;
72
95
  export declare const usePopover: (options: PopoverOptions & PopoverInternalProps) => {
73
96
  placement: Placement;
74
97
  strategy: import("@floating-ui/utils").Strategy;
@@ -83,11 +106,11 @@ export declare const usePopover: (options: PopoverOptions & PopoverInternalProps
83
106
  floating: React.MutableRefObject<HTMLElement | null>;
84
107
  setReference: (node: import("@floating-ui/react-dom").ReferenceType | null) => void;
85
108
  setFloating: (node: HTMLElement | null) => void;
86
- } & import("@floating-ui/react").ExtendedRefs<import("@floating-ui/react").ReferenceType>;
109
+ } & import("@floating-ui/react").ExtendedRefs<ReferenceType>;
87
110
  elements: {
88
111
  reference: import("@floating-ui/react-dom").ReferenceType | null;
89
112
  floating: HTMLElement | null;
90
- } & import("@floating-ui/react").ExtendedElements<import("@floating-ui/react").ReferenceType>;
113
+ } & import("@floating-ui/react").ExtendedElements<ReferenceType>;
91
114
  context: {
92
115
  x: number;
93
116
  y: number;
@@ -103,8 +126,8 @@ export declare const usePopover: (options: PopoverOptions & PopoverInternalProps
103
126
  dataRef: React.MutableRefObject<import("@floating-ui/react").ContextData>;
104
127
  nodeId: string | undefined;
105
128
  floatingId: string;
106
- refs: import("@floating-ui/react").ExtendedRefs<import("@floating-ui/react").ReferenceType>;
107
- elements: import("@floating-ui/react").ExtendedElements<import("@floating-ui/react").ReferenceType>;
129
+ refs: import("@floating-ui/react").ExtendedRefs<ReferenceType>;
130
+ elements: import("@floating-ui/react").ExtendedElements<ReferenceType>;
108
131
  };
109
132
  getFloatingProps: (userProps?: React.HTMLProps<HTMLElement>) => Record<string, unknown>;
110
133
  getReferenceProps: (userProps?: React.HTMLProps<Element> | undefined) => Record<string, unknown>;