@aurora-ds/components 1.7.21 → 1.8.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/dist/esm/index.js CHANGED
@@ -3652,7 +3652,13 @@ const useMenuPosition = ({ anchorEl, open, menuRef, minWidth, gap = 4, placement
3652
3652
  window.removeEventListener('resize', computePosition);
3653
3653
  };
3654
3654
  }, [open, computePosition]);
3655
- return { style: { ...style, visibility: isPositioned ? 'visible' : 'hidden' } };
3655
+ // Hide the panel until it is correctly positioned to avoid the position-jump
3656
+ // flicker. `opacity` (rather than `visibility`/`display`) is used so the panel
3657
+ // stays in the accessibility tree and remains interactive — only its paint is
3658
+ // deferred for a single frame.
3659
+ return {
3660
+ style: isPositioned ? style : { ...style, opacity: 0 },
3661
+ };
3656
3662
  };
3657
3663
 
3658
3664
  /** Custom DOM event fired on a submenu trigger item to request it opens. */
@@ -3663,70 +3669,88 @@ const OPEN_SUBMENU_EVENT = 'menu:open-submenu';
3663
3669
  * item can close the whole menu.
3664
3670
  */
3665
3671
  const MenuContext = createContext({});
3672
+ /**
3673
+ * Scoped per `MenuPanel`: shares the keyboard-focused item id with that panel's
3674
+ * items only. Nested submenus provide their own value, so each panel manages its
3675
+ * own roving focus independently.
3676
+ */
3677
+ const MenuNavigationContext = createContext({});
3666
3678
 
3679
+ /** CSS selector matching the focusable items of a menu/listbox panel. */
3680
+ const ITEM_SELECTOR = '[role^="menuitem"]:not([data-disabled]), [role="option"]:not([data-disabled])';
3667
3681
  /**
3668
3682
  * Business logic for a menu panel: positioning, roving keyboard navigation
3669
- * (Arrow/Home/End/Enter/Space), Escape-to-close, opening submenus (ArrowRight),
3670
- * pausing keyboard while a descendant submenu is open, and keeping the
3671
- * `data-focused` attribute in sync with the active item. Uses the WAI-ARIA
3683
+ * (Arrow/Home/End/Enter/Space), Escape-to-close, opening submenus (ArrowRight)
3684
+ * and pausing keyboard while a descendant submenu is open. Uses the WAI-ARIA
3672
3685
  * `menu` / `menuitem` pattern.
3686
+ *
3687
+ * Focus is tracked as a stable item **id** in React state (never by mutating the
3688
+ * DOM). The id is shared with items through `MenuNavigationContext` so each item
3689
+ * renders its own `data-focused`, and surfaced as the panel's
3690
+ * `aria-activedescendant`. Item order is read from the DOM (read-only) so the
3691
+ * logic stays correct across groups and dynamic content.
3673
3692
  */
3674
3693
  const useMenuPanel = ({ open, onClose, anchorEl, minWidth, placement = 'bottom', onArrowLeft, }) => {
3675
3694
  const panelRef = useRef(null);
3676
- const baseId = useId();
3677
- const [focusedIndex, setFocusedIndex] = useState(-1);
3678
- const [activeDescendant, setActiveDescendant] = useState(undefined);
3695
+ const [focusedId, setFocusedId] = useState(undefined);
3679
3696
  // True while a descendant submenu is open → pause this panel's keyboard nav.
3680
3697
  const [childOpen, setChildOpen] = useState(false);
3681
3698
  const { style } = useMenuPosition({ anchorEl, open, menuRef: panelRef, minWidth, placement });
3682
- /** Returns all non-disabled item elements that belong to THIS panel (not a nested submenu). */
3683
- const getOptions = useCallback(() => {
3684
- if (!panelRef.current) {
3699
+ /** Read this panel's focusable items in DOM order (excluding nested submenus). */
3700
+ const getItems = useCallback(() => {
3701
+ const panel = panelRef.current;
3702
+ if (!panel) {
3685
3703
  return [];
3686
3704
  }
3687
- 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);
3705
+ return Array.from(panel.querySelectorAll(ITEM_SELECTOR))
3706
+ .filter((el) => el.closest('[data-menu-panel]') === panel);
3688
3707
  }, []);
3689
- // On open: focus panel, pre-select a checked/selected item if any.
3690
- // When nothing is pre-selected, leave focusedIndex at -1 so no item appears
3691
- // highlighted on mouse-open the first ArrowDown/Up will start navigation.
3708
+ /** Returns the currently focused item element (if still present). */
3709
+ const getFocusedItem = useCallback(() => getItems().find((el) => el.id === focusedId), [getItems, focusedId]);
3710
+ /** Move the roving focus by `step`, wrapping around. Starts from the edge when nothing is focused. */
3711
+ const moveFocus = useCallback((step) => {
3712
+ const items = getItems();
3713
+ if (items.length === 0) {
3714
+ return;
3715
+ }
3716
+ const current = items.findIndex((el) => el.id === focusedId);
3717
+ const next = current === -1
3718
+ ? (step === 1 ? 0 : items.length - 1)
3719
+ : (current + step + items.length) % items.length;
3720
+ setFocusedId(items[next]?.id);
3721
+ }, [getItems, focusedId]);
3722
+ // On open: focus the panel and pre-select a checked/selected item if any.
3723
+ // When nothing is pre-selected, leave focusedId undefined so no item appears
3724
+ // highlighted on mouse-open — the first ArrowDown/Up starts navigation.
3692
3725
  useEffect(() => {
3693
3726
  if (!open) {
3694
- setFocusedIndex(-1);
3727
+ setFocusedId(undefined);
3695
3728
  return;
3696
3729
  }
3697
3730
  const raf = requestAnimationFrame(() => {
3698
3731
  panelRef.current?.focus();
3699
- const options = getOptions();
3700
- const selectedIdx = options.findIndex((el) => el.getAttribute('aria-checked') === 'true' || el.getAttribute('data-selected') !== null);
3701
- setFocusedIndex(selectedIdx >= 0 ? selectedIdx : -1);
3732
+ const preselected = getItems().find((el) => el.getAttribute('aria-checked') === 'true' || el.hasAttribute('data-selected'));
3733
+ setFocusedId(preselected?.id);
3702
3734
  });
3703
3735
  return () => cancelAnimationFrame(raf);
3704
- }, [open, getOptions]);
3705
- // Keep data-focused (visual highlight) and aria-activedescendant (screen reader
3706
- // announcement) in sync with focusedIndex. Each item is assigned a stable id
3707
- // so the menu can reference the active one via aria-activedescendant.
3736
+ }, [open, getItems]);
3737
+ // Keep the focused item scrolled into view (read-only DOM access).
3708
3738
  useEffect(() => {
3709
- if (!open) {
3710
- setActiveDescendant(undefined);
3739
+ if (!open || !focusedId) {
3711
3740
  return;
3712
3741
  }
3713
- const options = getOptions();
3714
- let activeId;
3715
- options.forEach((el, idx) => {
3716
- if (!el.id) {
3717
- el.id = `${baseId}-option-${idx}`;
3718
- }
3719
- if (idx === focusedIndex) {
3720
- el.setAttribute('data-focused', 'true');
3721
- el.scrollIntoView({ block: 'nearest' });
3722
- activeId = el.id;
3723
- }
3724
- else {
3725
- el.removeAttribute('data-focused');
3726
- }
3727
- });
3728
- setActiveDescendant(activeId);
3729
- }, [focusedIndex, open, getOptions, baseId]);
3742
+ getFocusedItem()?.scrollIntoView({ block: 'nearest' });
3743
+ }, [open, focusedId, getFocusedItem]);
3744
+ // Switching to the mouse clears the keyboard highlight (hover takes over).
3745
+ useEffect(() => {
3746
+ const panel = panelRef.current;
3747
+ if (!panel || !open) {
3748
+ return;
3749
+ }
3750
+ const clearFocus = () => setFocusedId(undefined);
3751
+ panel.addEventListener('mousemove', clearFocus);
3752
+ return () => panel.removeEventListener('mousemove', clearFocus);
3753
+ }, [open]);
3730
3754
  useKeyPress({
3731
3755
  Escape: (e) => {
3732
3756
  e.preventDefault();
@@ -3734,25 +3758,15 @@ const useMenuPanel = ({ open, onClose, anchorEl, minWidth, placement = 'bottom',
3734
3758
  },
3735
3759
  ArrowDown: (e) => {
3736
3760
  e.preventDefault();
3737
- setFocusedIndex((prev) => {
3738
- const count = getOptions().length;
3739
- return count === 0 ? prev : (prev + 1) % count;
3740
- });
3761
+ moveFocus(1);
3741
3762
  },
3742
3763
  ArrowUp: (e) => {
3743
3764
  e.preventDefault();
3744
- setFocusedIndex((prev) => {
3745
- const count = getOptions().length;
3746
- if (count === 0) {
3747
- return prev;
3748
- }
3749
- // prev === -1 means no item focused yet → jump to last item
3750
- return prev <= 0 ? count - 1 : prev - 1;
3751
- });
3765
+ moveFocus(-1);
3752
3766
  },
3753
3767
  ArrowRight: (e) => {
3754
- // Open the submenu of the focused item (if any)
3755
- const focused = getOptions()[focusedIndex];
3768
+ // Open the submenu of the focused item (if any).
3769
+ const focused = getFocusedItem();
3756
3770
  if (focused?.getAttribute('aria-haspopup') === 'menu') {
3757
3771
  e.preventDefault();
3758
3772
  focused.dispatchEvent(new CustomEvent(OPEN_SUBMENU_EVENT));
@@ -3766,34 +3780,23 @@ const useMenuPanel = ({ open, onClose, anchorEl, minWidth, placement = 'bottom',
3766
3780
  },
3767
3781
  Home: (e) => {
3768
3782
  e.preventDefault();
3769
- const count = getOptions().length;
3770
- if (count > 0) {
3771
- setFocusedIndex(0);
3772
- }
3783
+ setFocusedId(getItems()[0]?.id);
3773
3784
  },
3774
3785
  End: (e) => {
3775
3786
  e.preventDefault();
3776
- const count = getOptions().length;
3777
- if (count > 0) {
3778
- setFocusedIndex(count - 1);
3779
- }
3787
+ const items = getItems();
3788
+ setFocusedId(items[items.length - 1]?.id);
3780
3789
  },
3781
3790
  Enter: (e) => {
3782
3791
  e.preventDefault();
3783
- const options = getOptions();
3784
- if (focusedIndex >= 0) {
3785
- options[focusedIndex]?.click();
3786
- }
3792
+ getFocusedItem()?.click();
3787
3793
  },
3788
3794
  ' ': (e) => {
3789
3795
  e.preventDefault();
3790
- const options = getOptions();
3791
- if (focusedIndex >= 0) {
3792
- options[focusedIndex]?.click();
3793
- }
3796
+ getFocusedItem()?.click();
3794
3797
  },
3795
3798
  }, { enabled: open && !childOpen });
3796
- return { panelRef, style, activeDescendant, setChildOpen };
3799
+ return { panelRef, style, focusedId, setChildOpen };
3797
3800
  };
3798
3801
 
3799
3802
  /** Returns the visible scrollbar width (0 with overlay/inset scrollbars like macOS). */
@@ -3900,7 +3903,7 @@ const MenuPanel = ({ open, onClose, anchorEl, minWidth, maxHeight = '20rem', pla
3900
3903
  const closeMenu = isSubmenu ? parentContext.closeMenu ?? onClose : onClose;
3901
3904
  // Only the root menu locks the page scroll (submenus inherit the locked state).
3902
3905
  useBodyScrollLock(open && !isSubmenu);
3903
- const { panelRef, style, activeDescendant, setChildOpen } = useMenuPanel({
3906
+ const { panelRef, style, focusedId, setChildOpen } = useMenuPanel({
3904
3907
  open,
3905
3908
  onClose,
3906
3909
  anchorEl,
@@ -3909,10 +3912,11 @@ const MenuPanel = ({ open, onClose, anchorEl, minWidth, maxHeight = '20rem', pla
3909
3912
  onArrowLeft,
3910
3913
  });
3911
3914
  const contextValue = useMemo(() => ({ setChildOpen, closeMenu }), [setChildOpen, closeMenu]);
3915
+ const navigationValue = useMemo(() => ({ focusedId }), [focusedId]);
3912
3916
  if (!open) {
3913
3917
  return null;
3914
3918
  }
3915
- return createPortal(jsxs(MenuContext.Provider, { value: contextValue, children: [!isSubmenu && (jsx("div", { className: MENU_PANEL_STYLES.backdrop, onClick: onClose, "aria-hidden": true })), 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);
3919
+ return createPortal(jsxs(MenuContext.Provider, { value: contextValue, children: [!isSubmenu && (jsx("div", { className: MENU_PANEL_STYLES.backdrop, onClick: onClose, "aria-hidden": true })), 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: jsx(MenuNavigationContext.Provider, { value: navigationValue, children: children }) })] }), document.body);
3916
3920
  };
3917
3921
  MenuPanel.displayName = 'MenuPanel';
3918
3922
 
@@ -4134,14 +4138,22 @@ const MENU_ITEM_STYLES = createStyles((theme) => {
4134
4138
  };
4135
4139
  }, { id: 'menu-item' });
4136
4140
 
4137
- const MenuItem = ({ ref, label, icon, iconColor, role = 'menuitem', checked, selected, focused, disabled, size = 'default', closeOnClick, submenu, submenuTrigger = 'click', submenuPlacement = 'right', onClick, ...rest }) => {
4141
+ const MenuItem = ({ ref, id, label, icon, iconColor, role = 'menuitem', checked, selected, focused, disabled, size = 'default', closeOnClick, submenu, submenuTrigger = 'click', submenuPlacement = 'right', onClick, ...rest }) => {
4138
4142
  const hasSubmenu = submenu !== undefined;
4139
4143
  const isCheckable = role === 'menuitemcheckbox' || role === 'menuitemradio';
4140
4144
  const isOption = role === 'option';
4141
4145
  const isHighlighted = isCheckable ? Boolean(checked) : Boolean(selected);
4146
+ // Stable id used for roving focus (data-focused) and the panel's
4147
+ // aria-activedescendant. Consumers may override it via the `id` prop.
4148
+ const generatedId = useId();
4149
+ const itemId = id ?? generatedId;
4150
+ // Keyboard focus is driven by the owning panel via context (no DOM mutation).
4151
+ // `focused` remains a manual override for consumers that drive it themselves.
4152
+ const { focusedId } = useContext(MenuNavigationContext);
4153
+ const isFocused = focused || focusedId === itemId;
4142
4154
  const { liRef, mergedRef, submenuOpen, handleClick, scheduleOpen, scheduleClose, clearTimers, closeSubmenu, } = useMenuItem({ ref, role, hasSubmenu, disabled, onClick, closeOnClick, submenuTrigger });
4143
4155
  const isHoverTrigger = submenuTrigger === 'hover';
4144
- return (jsxs(Fragment$1, { children: [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 && (jsxs("span", { className: MENU_ITEM_STYLES.indicator, "aria-hidden": true, children: [checked && role === 'menuitemcheckbox' && (jsx(Icon, { icon: CheckIcon, size: size === 'default' ? 'sm' : 'md', strokeColor: 'primaryMain' })), checked && role === 'menuitemradio' && (jsx("span", { className: MENU_ITEM_STYLES.radioDot }))] })), icon !== undefined && (jsx(Icon, { icon: icon, size: 'sm', strokeColor: iconColor ?? (isHighlighted ? 'primaryMain' : 'textSecondary') })), jsx(Text, { variant: 'span', fontSize: 'sm', className: MENU_ITEM_STYLES.label, children: label }), hasSubmenu && (jsx("span", { className: MENU_ITEM_STYLES.submenuChevron, "aria-hidden": true, children: jsx(Icon, { icon: ChevronRightIcon, size: 'sm', strokeColor: 'textTertiary' }) }))] }), hasSubmenu && (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 }))] }));
4156
+ return (jsxs(Fragment$1, { children: [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, className: MENU_ITEM_STYLES.root({ size }), onClick: handleClick, onMouseEnter: hasSubmenu && !disabled && isHoverTrigger ? scheduleOpen : undefined, onMouseLeave: hasSubmenu && isHoverTrigger ? scheduleClose : undefined, ...rest, children: [isCheckable && (jsxs("span", { className: MENU_ITEM_STYLES.indicator, "aria-hidden": true, children: [checked && role === 'menuitemcheckbox' && (jsx(Icon, { icon: CheckIcon, size: size === 'default' ? 'sm' : 'md', strokeColor: 'primaryMain' })), checked && role === 'menuitemradio' && (jsx("span", { className: MENU_ITEM_STYLES.radioDot }))] })), icon !== undefined && (jsx(Icon, { icon: icon, size: 'sm', strokeColor: iconColor ?? (isHighlighted ? 'primaryMain' : 'textSecondary') })), jsx(Text, { variant: 'span', fontSize: 'sm', className: MENU_ITEM_STYLES.label, children: label }), hasSubmenu && (jsx("span", { className: MENU_ITEM_STYLES.submenuChevron, "aria-hidden": true, children: jsx(Icon, { icon: ChevronRightIcon, size: 'sm', strokeColor: 'textTertiary' }) }))] }), hasSubmenu && (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 }))] }));
4145
4157
  };
4146
4158
  MenuItem.displayName = 'MenuItem';
4147
4159