@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 +99 -78
- package/dist/cjs/index.js.map +1 -1
- package/dist/esm/index.js +99 -78
- package/dist/esm/index.js.map +1 -1
- package/dist/index.d.ts +6 -2
- package/package.json +1 -1
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
|
-
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
3683
|
-
const
|
|
3684
|
-
|
|
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(
|
|
3705
|
+
return Array.from(panel.querySelectorAll(ITEM_SELECTOR))
|
|
3706
|
+
.filter((el) => el.closest('[data-menu-panel]') === panel);
|
|
3688
3707
|
}, []);
|
|
3689
|
-
|
|
3690
|
-
|
|
3691
|
-
|
|
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
|
-
|
|
3727
|
+
setFocusedId(undefined);
|
|
3695
3728
|
return;
|
|
3696
3729
|
}
|
|
3697
3730
|
const raf = requestAnimationFrame(() => {
|
|
3698
3731
|
panelRef.current?.focus();
|
|
3699
|
-
const
|
|
3700
|
-
|
|
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,
|
|
3705
|
-
// Keep
|
|
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
|
-
|
|
3714
|
-
|
|
3715
|
-
|
|
3716
|
-
|
|
3717
|
-
|
|
3718
|
-
|
|
3719
|
-
|
|
3720
|
-
|
|
3721
|
-
|
|
3722
|
-
|
|
3723
|
-
|
|
3724
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
3777
|
-
|
|
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
|
-
|
|
3784
|
-
if (focusedIndex >= 0) {
|
|
3785
|
-
options[focusedIndex]?.click();
|
|
3786
|
-
}
|
|
3792
|
+
getFocusedItem()?.click();
|
|
3787
3793
|
},
|
|
3788
3794
|
' ': (e) => {
|
|
3789
3795
|
e.preventDefault();
|
|
3790
|
-
|
|
3791
|
-
if (focusedIndex >= 0) {
|
|
3792
|
-
options[focusedIndex]?.click();
|
|
3793
|
-
}
|
|
3796
|
+
getFocusedItem()?.click();
|
|
3794
3797
|
},
|
|
3795
3798
|
}, { enabled: open && !childOpen });
|
|
3796
|
-
return { panelRef, style,
|
|
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,
|
|
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":
|
|
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
|
|
|
@@ -4070,17 +4074,18 @@ const MENU_ITEM_STYLES = createStyles((theme) => {
|
|
|
4070
4074
|
display: 'flex',
|
|
4071
4075
|
alignItems: 'center',
|
|
4072
4076
|
gap: theme.spacing.sm,
|
|
4073
|
-
paddingLeft: theme.spacing.md
|
|
4077
|
+
paddingLeft: `calc(${theme.spacing.md} - 2px)`,
|
|
4074
4078
|
paddingRight: theme.spacing.md,
|
|
4075
4079
|
...(size === 'default'
|
|
4076
4080
|
? { height: DEFAULT_DRAWER_ITEM_SIZE }
|
|
4077
4081
|
: { paddingTop: theme.spacing.xs, paddingBottom: theme.spacing.xs }),
|
|
4082
|
+
borderLeft: '2px solid transparent',
|
|
4078
4083
|
cursor: 'pointer',
|
|
4079
4084
|
userSelect: 'none',
|
|
4080
4085
|
color: c.textPrimary,
|
|
4081
4086
|
fontSize: theme.fontSize.sm,
|
|
4082
4087
|
listStyle: 'none',
|
|
4083
|
-
transition: `background-color ${theme.transition.fast}`,
|
|
4088
|
+
transition: `background-color ${theme.transition.fast}, border-color ${theme.transition.fast}`,
|
|
4084
4089
|
'&[data-selected]': {
|
|
4085
4090
|
backgroundColor: c.primarySubtle,
|
|
4086
4091
|
color: c.primaryMain,
|
|
@@ -4092,6 +4097,14 @@ const MENU_ITEM_STYLES = createStyles((theme) => {
|
|
|
4092
4097
|
'&[data-focused][data-selected]': {
|
|
4093
4098
|
backgroundColor: c.primarySubtleHover,
|
|
4094
4099
|
},
|
|
4100
|
+
'&[data-submenu-open]': {
|
|
4101
|
+
backgroundColor: c.primarySubtle,
|
|
4102
|
+
color: c.primaryMain,
|
|
4103
|
+
borderLeftColor: c.primaryMain,
|
|
4104
|
+
},
|
|
4105
|
+
'&[data-submenu-open]:hover:not([data-disabled])': {
|
|
4106
|
+
backgroundColor: c.primarySubtleHover,
|
|
4107
|
+
},
|
|
4095
4108
|
'&[data-disabled]': {
|
|
4096
4109
|
cursor: 'not-allowed',
|
|
4097
4110
|
opacity: theme.opacity.high,
|
|
@@ -4134,14 +4147,22 @@ const MENU_ITEM_STYLES = createStyles((theme) => {
|
|
|
4134
4147
|
};
|
|
4135
4148
|
}, { id: 'menu-item' });
|
|
4136
4149
|
|
|
4137
|
-
const MenuItem = ({ ref, label, icon, iconColor, role = 'menuitem', checked, selected, focused, disabled, size = 'default', closeOnClick, submenu, submenuTrigger = 'click', submenuPlacement = 'right', onClick, ...rest }) => {
|
|
4150
|
+
const MenuItem = ({ ref, id, label, icon, iconColor, role = 'menuitem', checked, selected, focused, disabled, size = 'default', closeOnClick, submenu, submenuTrigger = 'click', submenuPlacement = 'right', onClick, ...rest }) => {
|
|
4138
4151
|
const hasSubmenu = submenu !== undefined;
|
|
4139
4152
|
const isCheckable = role === 'menuitemcheckbox' || role === 'menuitemradio';
|
|
4140
4153
|
const isOption = role === 'option';
|
|
4141
4154
|
const isHighlighted = isCheckable ? Boolean(checked) : Boolean(selected);
|
|
4155
|
+
// Stable id used for roving focus (data-focused) and the panel's
|
|
4156
|
+
// aria-activedescendant. Consumers may override it via the `id` prop.
|
|
4157
|
+
const generatedId = useId();
|
|
4158
|
+
const itemId = id ?? generatedId;
|
|
4159
|
+
// Keyboard focus is driven by the owning panel via context (no DOM mutation).
|
|
4160
|
+
// `focused` remains a manual override for consumers that drive it themselves.
|
|
4161
|
+
const { focusedId } = useContext(MenuNavigationContext);
|
|
4162
|
+
const isFocused = focused || focusedId === itemId;
|
|
4142
4163
|
const { liRef, mergedRef, submenuOpen, handleClick, scheduleOpen, scheduleClose, clearTimers, closeSubmenu, } = useMenuItem({ ref, role, hasSubmenu, disabled, onClick, closeOnClick, submenuTrigger });
|
|
4143
4164
|
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":
|
|
4165
|
+
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, "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 && (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
4166
|
};
|
|
4146
4167
|
MenuItem.displayName = 'MenuItem';
|
|
4147
4168
|
|