@aurora-ds/components 1.7.21 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cjs/index.js CHANGED
@@ -3672,7 +3672,13 @@ const useMenuPosition = ({ anchorEl, open, menuRef, minWidth, gap = 4, placement
3672
3672
  window.removeEventListener('resize', computePosition);
3673
3673
  };
3674
3674
  }, [open, computePosition]);
3675
- return { style: { ...style, visibility: isPositioned ? 'visible' : 'hidden' } };
3675
+ // Hide the panel until it is correctly positioned to avoid the position-jump
3676
+ // flicker. `opacity` (rather than `visibility`/`display`) is used so the panel
3677
+ // stays in the accessibility tree and remains interactive — only its paint is
3678
+ // deferred for a single frame.
3679
+ return {
3680
+ style: isPositioned ? style : { ...style, opacity: 0 },
3681
+ };
3676
3682
  };
3677
3683
 
3678
3684
  /** Custom DOM event fired on a submenu trigger item to request it opens. */
@@ -3683,70 +3689,88 @@ const OPEN_SUBMENU_EVENT = 'menu:open-submenu';
3683
3689
  * item can close the whole menu.
3684
3690
  */
3685
3691
  const MenuContext = React.createContext({});
3692
+ /**
3693
+ * Scoped per `MenuPanel`: shares the keyboard-focused item id with that panel's
3694
+ * items only. Nested submenus provide their own value, so each panel manages its
3695
+ * own roving focus independently.
3696
+ */
3697
+ const MenuNavigationContext = React.createContext({});
3686
3698
 
3699
+ /** CSS selector matching the focusable items of a menu/listbox panel. */
3700
+ const ITEM_SELECTOR = '[role^="menuitem"]:not([data-disabled]), [role="option"]:not([data-disabled])';
3687
3701
  /**
3688
3702
  * Business logic for a menu panel: positioning, roving keyboard navigation
3689
- * (Arrow/Home/End/Enter/Space), Escape-to-close, opening submenus (ArrowRight),
3690
- * pausing keyboard while a descendant submenu is open, and keeping the
3691
- * `data-focused` attribute in sync with the active item. Uses the WAI-ARIA
3703
+ * (Arrow/Home/End/Enter/Space), Escape-to-close, opening submenus (ArrowRight)
3704
+ * and pausing keyboard while a descendant submenu is open. Uses the WAI-ARIA
3692
3705
  * `menu` / `menuitem` pattern.
3706
+ *
3707
+ * Focus is tracked as a stable item **id** in React state (never by mutating the
3708
+ * DOM). The id is shared with items through `MenuNavigationContext` so each item
3709
+ * renders its own `data-focused`, and surfaced as the panel's
3710
+ * `aria-activedescendant`. Item order is read from the DOM (read-only) so the
3711
+ * logic stays correct across groups and dynamic content.
3693
3712
  */
3694
3713
  const useMenuPanel = ({ open, onClose, anchorEl, minWidth, placement = 'bottom', onArrowLeft, }) => {
3695
3714
  const panelRef = React.useRef(null);
3696
- const baseId = React.useId();
3697
- const [focusedIndex, setFocusedIndex] = React.useState(-1);
3698
- const [activeDescendant, setActiveDescendant] = React.useState(undefined);
3715
+ const [focusedId, setFocusedId] = React.useState(undefined);
3699
3716
  // True while a descendant submenu is open → pause this panel's keyboard nav.
3700
3717
  const [childOpen, setChildOpen] = React.useState(false);
3701
3718
  const { style } = useMenuPosition({ anchorEl, open, menuRef: panelRef, minWidth, placement });
3702
- /** Returns all non-disabled item elements that belong to THIS panel (not a nested submenu). */
3703
- const getOptions = React.useCallback(() => {
3704
- if (!panelRef.current) {
3719
+ /** Read this panel's focusable items in DOM order (excluding nested submenus). */
3720
+ const getItems = React.useCallback(() => {
3721
+ const panel = panelRef.current;
3722
+ if (!panel) {
3705
3723
  return [];
3706
3724
  }
3707
- return Array.from(panelRef.current.querySelectorAll('[role^="menuitem"]:not([data-disabled]), [role="option"]:not([data-disabled])')).filter((el) => el.closest('[data-menu-panel]') === panelRef.current);
3725
+ return Array.from(panel.querySelectorAll(ITEM_SELECTOR))
3726
+ .filter((el) => el.closest('[data-menu-panel]') === panel);
3708
3727
  }, []);
3709
- // On open: focus panel, pre-select a checked/selected item if any.
3710
- // When nothing is pre-selected, leave focusedIndex at -1 so no item appears
3711
- // highlighted on mouse-open the first ArrowDown/Up will start navigation.
3728
+ /** Returns the currently focused item element (if still present). */
3729
+ const getFocusedItem = React.useCallback(() => getItems().find((el) => el.id === focusedId), [getItems, focusedId]);
3730
+ /** Move the roving focus by `step`, wrapping around. Starts from the edge when nothing is focused. */
3731
+ const moveFocus = React.useCallback((step) => {
3732
+ const items = getItems();
3733
+ if (items.length === 0) {
3734
+ return;
3735
+ }
3736
+ const current = items.findIndex((el) => el.id === focusedId);
3737
+ const next = current === -1
3738
+ ? (step === 1 ? 0 : items.length - 1)
3739
+ : (current + step + items.length) % items.length;
3740
+ setFocusedId(items[next]?.id);
3741
+ }, [getItems, focusedId]);
3742
+ // On open: focus the panel and pre-select a checked/selected item if any.
3743
+ // When nothing is pre-selected, leave focusedId undefined so no item appears
3744
+ // highlighted on mouse-open — the first ArrowDown/Up starts navigation.
3712
3745
  React.useEffect(() => {
3713
3746
  if (!open) {
3714
- setFocusedIndex(-1);
3747
+ setFocusedId(undefined);
3715
3748
  return;
3716
3749
  }
3717
3750
  const raf = requestAnimationFrame(() => {
3718
3751
  panelRef.current?.focus();
3719
- const options = getOptions();
3720
- const selectedIdx = options.findIndex((el) => el.getAttribute('aria-checked') === 'true' || el.getAttribute('data-selected') !== null);
3721
- setFocusedIndex(selectedIdx >= 0 ? selectedIdx : -1);
3752
+ const preselected = getItems().find((el) => el.getAttribute('aria-checked') === 'true' || el.hasAttribute('data-selected'));
3753
+ setFocusedId(preselected?.id);
3722
3754
  });
3723
3755
  return () => cancelAnimationFrame(raf);
3724
- }, [open, getOptions]);
3725
- // Keep data-focused (visual highlight) and aria-activedescendant (screen reader
3726
- // announcement) in sync with focusedIndex. Each item is assigned a stable id
3727
- // so the menu can reference the active one via aria-activedescendant.
3756
+ }, [open, getItems]);
3757
+ // Keep the focused item scrolled into view (read-only DOM access).
3728
3758
  React.useEffect(() => {
3729
- if (!open) {
3730
- setActiveDescendant(undefined);
3759
+ if (!open || !focusedId) {
3731
3760
  return;
3732
3761
  }
3733
- const options = getOptions();
3734
- let activeId;
3735
- options.forEach((el, idx) => {
3736
- if (!el.id) {
3737
- el.id = `${baseId}-option-${idx}`;
3738
- }
3739
- if (idx === focusedIndex) {
3740
- el.setAttribute('data-focused', 'true');
3741
- el.scrollIntoView({ block: 'nearest' });
3742
- activeId = el.id;
3743
- }
3744
- else {
3745
- el.removeAttribute('data-focused');
3746
- }
3747
- });
3748
- setActiveDescendant(activeId);
3749
- }, [focusedIndex, open, getOptions, baseId]);
3762
+ getFocusedItem()?.scrollIntoView({ block: 'nearest' });
3763
+ }, [open, focusedId, getFocusedItem]);
3764
+ // Switching to the mouse clears the keyboard highlight (hover takes over).
3765
+ React.useEffect(() => {
3766
+ const panel = panelRef.current;
3767
+ if (!panel || !open) {
3768
+ return;
3769
+ }
3770
+ const clearFocus = () => setFocusedId(undefined);
3771
+ panel.addEventListener('mousemove', clearFocus);
3772
+ return () => panel.removeEventListener('mousemove', clearFocus);
3773
+ }, [open]);
3750
3774
  useKeyPress({
3751
3775
  Escape: (e) => {
3752
3776
  e.preventDefault();
@@ -3754,25 +3778,15 @@ const useMenuPanel = ({ open, onClose, anchorEl, minWidth, placement = 'bottom',
3754
3778
  },
3755
3779
  ArrowDown: (e) => {
3756
3780
  e.preventDefault();
3757
- setFocusedIndex((prev) => {
3758
- const count = getOptions().length;
3759
- return count === 0 ? prev : (prev + 1) % count;
3760
- });
3781
+ moveFocus(1);
3761
3782
  },
3762
3783
  ArrowUp: (e) => {
3763
3784
  e.preventDefault();
3764
- setFocusedIndex((prev) => {
3765
- const count = getOptions().length;
3766
- if (count === 0) {
3767
- return prev;
3768
- }
3769
- // prev === -1 means no item focused yet → jump to last item
3770
- return prev <= 0 ? count - 1 : prev - 1;
3771
- });
3785
+ moveFocus(-1);
3772
3786
  },
3773
3787
  ArrowRight: (e) => {
3774
- // Open the submenu of the focused item (if any)
3775
- const focused = getOptions()[focusedIndex];
3788
+ // Open the submenu of the focused item (if any).
3789
+ const focused = getFocusedItem();
3776
3790
  if (focused?.getAttribute('aria-haspopup') === 'menu') {
3777
3791
  e.preventDefault();
3778
3792
  focused.dispatchEvent(new CustomEvent(OPEN_SUBMENU_EVENT));
@@ -3786,34 +3800,23 @@ const useMenuPanel = ({ open, onClose, anchorEl, minWidth, placement = 'bottom',
3786
3800
  },
3787
3801
  Home: (e) => {
3788
3802
  e.preventDefault();
3789
- const count = getOptions().length;
3790
- if (count > 0) {
3791
- setFocusedIndex(0);
3792
- }
3803
+ setFocusedId(getItems()[0]?.id);
3793
3804
  },
3794
3805
  End: (e) => {
3795
3806
  e.preventDefault();
3796
- const count = getOptions().length;
3797
- if (count > 0) {
3798
- setFocusedIndex(count - 1);
3799
- }
3807
+ const items = getItems();
3808
+ setFocusedId(items[items.length - 1]?.id);
3800
3809
  },
3801
3810
  Enter: (e) => {
3802
3811
  e.preventDefault();
3803
- const options = getOptions();
3804
- if (focusedIndex >= 0) {
3805
- options[focusedIndex]?.click();
3806
- }
3812
+ getFocusedItem()?.click();
3807
3813
  },
3808
3814
  ' ': (e) => {
3809
3815
  e.preventDefault();
3810
- const options = getOptions();
3811
- if (focusedIndex >= 0) {
3812
- options[focusedIndex]?.click();
3813
- }
3816
+ getFocusedItem()?.click();
3814
3817
  },
3815
3818
  }, { enabled: open && !childOpen });
3816
- return { panelRef, style, activeDescendant, setChildOpen };
3819
+ return { panelRef, style, focusedId, setChildOpen };
3817
3820
  };
3818
3821
 
3819
3822
  /** Returns the visible scrollbar width (0 with overlay/inset scrollbars like macOS). */
@@ -3920,7 +3923,7 @@ const MenuPanel = ({ open, onClose, anchorEl, minWidth, maxHeight = '20rem', pla
3920
3923
  const closeMenu = isSubmenu ? parentContext.closeMenu ?? onClose : onClose;
3921
3924
  // Only the root menu locks the page scroll (submenus inherit the locked state).
3922
3925
  useBodyScrollLock(open && !isSubmenu);
3923
- const { panelRef, style, activeDescendant, setChildOpen } = useMenuPanel({
3926
+ const { panelRef, style, focusedId, setChildOpen } = useMenuPanel({
3924
3927
  open,
3925
3928
  onClose,
3926
3929
  anchorEl,
@@ -3929,10 +3932,11 @@ const MenuPanel = ({ open, onClose, anchorEl, minWidth, maxHeight = '20rem', pla
3929
3932
  onArrowLeft,
3930
3933
  });
3931
3934
  const contextValue = React.useMemo(() => ({ setChildOpen, closeMenu }), [setChildOpen, closeMenu]);
3935
+ const navigationValue = React.useMemo(() => ({ focusedId }), [focusedId]);
3932
3936
  if (!open) {
3933
3937
  return null;
3934
3938
  }
3935
- return reactDom.createPortal(jsxRuntime.jsxs(MenuContext.Provider, { value: contextValue, children: [!isSubmenu && (jsxRuntime.jsx("div", { className: MENU_PANEL_STYLES.backdrop, onClick: onClose, "aria-hidden": true })), jsxRuntime.jsx("div", { ref: panelRef, id: id, role: role, tabIndex: -1, "data-menu-panel": true, "data-scroll-lock-ignore": true, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-activedescendant": activeDescendant, className: MENU_PANEL_STYLES.panel, style: { ...style, maxHeight, outline: 'none' }, onMouseEnter: onMouseEnter, onMouseLeave: onMouseLeave, children: children })] }), document.body);
3939
+ return reactDom.createPortal(jsxRuntime.jsxs(MenuContext.Provider, { value: contextValue, children: [!isSubmenu && (jsxRuntime.jsx("div", { className: MENU_PANEL_STYLES.backdrop, onClick: onClose, "aria-hidden": true })), jsxRuntime.jsx("div", { ref: panelRef, id: id, role: role, tabIndex: -1, "data-menu-panel": true, "data-scroll-lock-ignore": true, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-activedescendant": focusedId, className: MENU_PANEL_STYLES.panel, style: { ...style, maxHeight, outline: 'none' }, onMouseEnter: onMouseEnter, onMouseLeave: onMouseLeave, children: jsxRuntime.jsx(MenuNavigationContext.Provider, { value: navigationValue, children: children }) })] }), document.body);
3936
3940
  };
3937
3941
  MenuPanel.displayName = 'MenuPanel';
3938
3942
 
@@ -4090,17 +4094,18 @@ const MENU_ITEM_STYLES = theme.createStyles((theme) => {
4090
4094
  display: 'flex',
4091
4095
  alignItems: 'center',
4092
4096
  gap: theme.spacing.sm,
4093
- paddingLeft: theme.spacing.md,
4097
+ paddingLeft: `calc(${theme.spacing.md} - 2px)`,
4094
4098
  paddingRight: theme.spacing.md,
4095
4099
  ...(size === 'default'
4096
4100
  ? { height: DEFAULT_DRAWER_ITEM_SIZE }
4097
4101
  : { paddingTop: theme.spacing.xs, paddingBottom: theme.spacing.xs }),
4102
+ borderLeft: '2px solid transparent',
4098
4103
  cursor: 'pointer',
4099
4104
  userSelect: 'none',
4100
4105
  color: c.textPrimary,
4101
4106
  fontSize: theme.fontSize.sm,
4102
4107
  listStyle: 'none',
4103
- transition: `background-color ${theme.transition.fast}`,
4108
+ transition: `background-color ${theme.transition.fast}, border-color ${theme.transition.fast}`,
4104
4109
  '&[data-selected]': {
4105
4110
  backgroundColor: c.primarySubtle,
4106
4111
  color: c.primaryMain,
@@ -4112,6 +4117,14 @@ const MENU_ITEM_STYLES = theme.createStyles((theme) => {
4112
4117
  '&[data-focused][data-selected]': {
4113
4118
  backgroundColor: c.primarySubtleHover,
4114
4119
  },
4120
+ '&[data-submenu-open]': {
4121
+ backgroundColor: c.primarySubtle,
4122
+ color: c.primaryMain,
4123
+ borderLeftColor: c.primaryMain,
4124
+ },
4125
+ '&[data-submenu-open]:hover:not([data-disabled])': {
4126
+ backgroundColor: c.primarySubtleHover,
4127
+ },
4115
4128
  '&[data-disabled]': {
4116
4129
  cursor: 'not-allowed',
4117
4130
  opacity: theme.opacity.high,
@@ -4154,14 +4167,22 @@ const MENU_ITEM_STYLES = theme.createStyles((theme) => {
4154
4167
  };
4155
4168
  }, { id: 'menu-item' });
4156
4169
 
4157
- const MenuItem = ({ ref, label, icon, iconColor, role = 'menuitem', checked, selected, focused, disabled, size = 'default', closeOnClick, submenu, submenuTrigger = 'click', submenuPlacement = 'right', onClick, ...rest }) => {
4170
+ const MenuItem = ({ ref, id, label, icon, iconColor, role = 'menuitem', checked, selected, focused, disabled, size = 'default', closeOnClick, submenu, submenuTrigger = 'click', submenuPlacement = 'right', onClick, ...rest }) => {
4158
4171
  const hasSubmenu = submenu !== undefined;
4159
4172
  const isCheckable = role === 'menuitemcheckbox' || role === 'menuitemradio';
4160
4173
  const isOption = role === 'option';
4161
4174
  const isHighlighted = isCheckable ? Boolean(checked) : Boolean(selected);
4175
+ // Stable id used for roving focus (data-focused) and the panel's
4176
+ // aria-activedescendant. Consumers may override it via the `id` prop.
4177
+ const generatedId = React.useId();
4178
+ const itemId = id ?? generatedId;
4179
+ // Keyboard focus is driven by the owning panel via context (no DOM mutation).
4180
+ // `focused` remains a manual override for consumers that drive it themselves.
4181
+ const { focusedId } = React.useContext(MenuNavigationContext);
4182
+ const isFocused = focused || focusedId === itemId;
4162
4183
  const { liRef, mergedRef, submenuOpen, handleClick, scheduleOpen, scheduleClose, clearTimers, closeSubmenu, } = useMenuItem({ ref, role, hasSubmenu, disabled, onClick, closeOnClick, submenuTrigger });
4163
4184
  const isHoverTrigger = submenuTrigger === 'hover';
4164
- return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsxs("li", { ref: mergedRef, role: role, "aria-checked": isCheckable ? Boolean(checked) : undefined, "aria-selected": isOption ? Boolean(selected) : undefined, "aria-disabled": disabled, "aria-haspopup": hasSubmenu ? 'menu' : undefined, "aria-expanded": hasSubmenu ? submenuOpen : undefined, "data-selected": isHighlighted || undefined, "data-focused": focused || undefined, "data-disabled": disabled || undefined, className: MENU_ITEM_STYLES.root({ size }), onClick: handleClick, onMouseEnter: hasSubmenu && !disabled && isHoverTrigger ? scheduleOpen : undefined, onMouseLeave: hasSubmenu && isHoverTrigger ? scheduleClose : undefined, ...rest, children: [isCheckable && (jsxRuntime.jsxs("span", { className: MENU_ITEM_STYLES.indicator, "aria-hidden": true, children: [checked && role === 'menuitemcheckbox' && (jsxRuntime.jsx(Icon, { icon: CheckIcon, size: size === 'default' ? 'sm' : 'md', strokeColor: 'primaryMain' })), checked && role === 'menuitemradio' && (jsxRuntime.jsx("span", { className: MENU_ITEM_STYLES.radioDot }))] })), icon !== undefined && (jsxRuntime.jsx(Icon, { icon: icon, size: 'sm', strokeColor: iconColor ?? (isHighlighted ? 'primaryMain' : 'textSecondary') })), jsxRuntime.jsx(Text, { variant: 'span', fontSize: 'sm', className: MENU_ITEM_STYLES.label, children: label }), hasSubmenu && (jsxRuntime.jsx("span", { className: MENU_ITEM_STYLES.submenuChevron, "aria-hidden": true, children: jsxRuntime.jsx(Icon, { icon: ChevronRightIcon, size: 'sm', strokeColor: 'textTertiary' }) }))] }), hasSubmenu && (jsxRuntime.jsx(MenuPanel, { open: submenuOpen, onClose: () => closeSubmenu(true), onArrowLeft: () => closeSubmenu(true), anchorEl: liRef.current, placement: submenuPlacement, isSubmenu: true, "aria-label": label, onMouseEnter: isHoverTrigger ? clearTimers : undefined, onMouseLeave: isHoverTrigger ? scheduleClose : undefined, children: submenu }))] }));
4185
+ return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsxs("li", { ref: mergedRef, id: itemId, role: role, "aria-checked": isCheckable ? Boolean(checked) : undefined, "aria-selected": isOption ? Boolean(selected) : undefined, "aria-disabled": disabled, "aria-haspopup": hasSubmenu ? 'menu' : undefined, "aria-expanded": hasSubmenu ? submenuOpen : undefined, "data-selected": isHighlighted || undefined, "data-focused": isFocused || undefined, "data-disabled": disabled || undefined, "data-submenu-open": hasSubmenu && submenuOpen || undefined, className: MENU_ITEM_STYLES.root({ size }), onClick: handleClick, onMouseEnter: hasSubmenu && !disabled && isHoverTrigger ? scheduleOpen : undefined, onMouseLeave: hasSubmenu && isHoverTrigger ? scheduleClose : undefined, ...rest, children: [isCheckable && (jsxRuntime.jsxs("span", { className: MENU_ITEM_STYLES.indicator, "aria-hidden": true, children: [checked && role === 'menuitemcheckbox' && (jsxRuntime.jsx(Icon, { icon: CheckIcon, size: size === 'default' ? 'sm' : 'md', strokeColor: 'primaryMain' })), checked && role === 'menuitemradio' && (jsxRuntime.jsx("span", { className: MENU_ITEM_STYLES.radioDot }))] })), icon !== undefined && (jsxRuntime.jsx(Icon, { icon: icon, size: 'sm', strokeColor: iconColor ?? (isHighlighted ? 'primaryMain' : 'textSecondary') })), jsxRuntime.jsx(Text, { variant: 'span', fontSize: 'sm', className: MENU_ITEM_STYLES.label, children: label }), hasSubmenu && (jsxRuntime.jsx("span", { className: MENU_ITEM_STYLES.submenuChevron, "aria-hidden": true, children: jsxRuntime.jsx(Icon, { icon: ChevronRightIcon, size: 'sm', strokeColor: 'textTertiary' }) }))] }), hasSubmenu && (jsxRuntime.jsx(MenuPanel, { open: submenuOpen, onClose: () => closeSubmenu(true), onArrowLeft: () => closeSubmenu(true), anchorEl: liRef.current, placement: submenuPlacement, isSubmenu: true, "aria-label": label, onMouseEnter: isHoverTrigger ? clearTimers : undefined, onMouseLeave: isHoverTrigger ? scheduleClose : undefined, children: submenu }))] }));
4165
4186
  };
4166
4187
  MenuItem.displayName = 'MenuItem';
4167
4188