@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/cjs/index.js +88 -76
- package/dist/cjs/index.js.map +1 -1
- package/dist/esm/index.js +88 -76
- package/dist/esm/index.js.map +1 -1
- package/dist/index.d.ts +5 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
3703
|
-
const
|
|
3704
|
-
|
|
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(
|
|
3725
|
+
return Array.from(panel.querySelectorAll(ITEM_SELECTOR))
|
|
3726
|
+
.filter((el) => el.closest('[data-menu-panel]') === panel);
|
|
3708
3727
|
}, []);
|
|
3709
|
-
|
|
3710
|
-
|
|
3711
|
-
|
|
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
|
-
|
|
3747
|
+
setFocusedId(undefined);
|
|
3715
3748
|
return;
|
|
3716
3749
|
}
|
|
3717
3750
|
const raf = requestAnimationFrame(() => {
|
|
3718
3751
|
panelRef.current?.focus();
|
|
3719
|
-
const
|
|
3720
|
-
|
|
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,
|
|
3725
|
-
// Keep
|
|
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
|
-
|
|
3734
|
-
|
|
3735
|
-
|
|
3736
|
-
|
|
3737
|
-
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
3797
|
-
|
|
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
|
-
|
|
3804
|
-
if (focusedIndex >= 0) {
|
|
3805
|
-
options[focusedIndex]?.click();
|
|
3806
|
-
}
|
|
3812
|
+
getFocusedItem()?.click();
|
|
3807
3813
|
},
|
|
3808
3814
|
' ': (e) => {
|
|
3809
3815
|
e.preventDefault();
|
|
3810
|
-
|
|
3811
|
-
if (focusedIndex >= 0) {
|
|
3812
|
-
options[focusedIndex]?.click();
|
|
3813
|
-
}
|
|
3816
|
+
getFocusedItem()?.click();
|
|
3814
3817
|
},
|
|
3815
3818
|
}, { enabled: open && !childOpen });
|
|
3816
|
-
return { panelRef, style,
|
|
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,
|
|
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":
|
|
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
|
|
|
@@ -4154,14 +4158,22 @@ const MENU_ITEM_STYLES = theme.createStyles((theme) => {
|
|
|
4154
4158
|
};
|
|
4155
4159
|
}, { id: 'menu-item' });
|
|
4156
4160
|
|
|
4157
|
-
const MenuItem = ({ ref, label, icon, iconColor, role = 'menuitem', checked, selected, focused, disabled, size = 'default', closeOnClick, submenu, submenuTrigger = 'click', submenuPlacement = 'right', onClick, ...rest }) => {
|
|
4161
|
+
const MenuItem = ({ ref, id, label, icon, iconColor, role = 'menuitem', checked, selected, focused, disabled, size = 'default', closeOnClick, submenu, submenuTrigger = 'click', submenuPlacement = 'right', onClick, ...rest }) => {
|
|
4158
4162
|
const hasSubmenu = submenu !== undefined;
|
|
4159
4163
|
const isCheckable = role === 'menuitemcheckbox' || role === 'menuitemradio';
|
|
4160
4164
|
const isOption = role === 'option';
|
|
4161
4165
|
const isHighlighted = isCheckable ? Boolean(checked) : Boolean(selected);
|
|
4166
|
+
// Stable id used for roving focus (data-focused) and the panel's
|
|
4167
|
+
// aria-activedescendant. Consumers may override it via the `id` prop.
|
|
4168
|
+
const generatedId = React.useId();
|
|
4169
|
+
const itemId = id ?? generatedId;
|
|
4170
|
+
// Keyboard focus is driven by the owning panel via context (no DOM mutation).
|
|
4171
|
+
// `focused` remains a manual override for consumers that drive it themselves.
|
|
4172
|
+
const { focusedId } = React.useContext(MenuNavigationContext);
|
|
4173
|
+
const isFocused = focused || focusedId === itemId;
|
|
4162
4174
|
const { liRef, mergedRef, submenuOpen, handleClick, scheduleOpen, scheduleClose, clearTimers, closeSubmenu, } = useMenuItem({ ref, role, hasSubmenu, disabled, onClick, closeOnClick, submenuTrigger });
|
|
4163
4175
|
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":
|
|
4176
|
+
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, 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
4177
|
};
|
|
4166
4178
|
MenuItem.displayName = 'MenuItem';
|
|
4167
4179
|
|