@aurora-ds/components 1.7.16 → 1.7.18

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
@@ -3645,7 +3645,9 @@ const useMenuPanel = ({ open, onClose, anchorEl, minWidth, placement = 'bottom',
3645
3645
  }
3646
3646
  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);
3647
3647
  }, []);
3648
- // On open: focus panel, initialise focusedIndex to checked/selected item (or 0)
3648
+ // On open: focus panel, pre-select a checked/selected item if any.
3649
+ // When nothing is pre-selected, leave focusedIndex at -1 so no item appears
3650
+ // highlighted on mouse-open — the first ArrowDown/Up will start navigation.
3649
3651
  useEffect(() => {
3650
3652
  if (!open) {
3651
3653
  setFocusedIndex(-1);
@@ -3655,7 +3657,7 @@ const useMenuPanel = ({ open, onClose, anchorEl, minWidth, placement = 'bottom',
3655
3657
  panelRef.current?.focus();
3656
3658
  const options = getOptions();
3657
3659
  const selectedIdx = options.findIndex((el) => el.getAttribute('aria-checked') === 'true' || el.getAttribute('data-selected') !== null);
3658
- setFocusedIndex(selectedIdx >= 0 ? selectedIdx : 0);
3660
+ setFocusedIndex(selectedIdx >= 0 ? selectedIdx : -1);
3659
3661
  });
3660
3662
  return () => cancelAnimationFrame(raf);
3661
3663
  }, [open, getOptions]);
@@ -3700,7 +3702,11 @@ const useMenuPanel = ({ open, onClose, anchorEl, minWidth, placement = 'bottom',
3700
3702
  e.preventDefault();
3701
3703
  setFocusedIndex((prev) => {
3702
3704
  const count = getOptions().length;
3703
- return count === 0 ? prev : (prev - 1 + count) % count;
3705
+ if (count === 0) {
3706
+ return prev;
3707
+ }
3708
+ // prev === -1 means no item focused yet → jump to last item
3709
+ return prev <= 0 ? count - 1 : prev - 1;
3704
3710
  });
3705
3711
  },
3706
3712
  ArrowRight: (e) => {
@@ -3749,33 +3755,90 @@ const useMenuPanel = ({ open, onClose, anchorEl, minWidth, placement = 'bottom',
3749
3755
  return { panelRef, style, activeDescendant, setChildOpen };
3750
3756
  };
3751
3757
 
3758
+ /** Returns the visible scrollbar width (0 with overlay/inset scrollbars like macOS). */
3759
+ const getScrollbarWidth = () => window.innerWidth - document.documentElement.clientWidth;
3752
3760
  /**
3753
- * Locks scrolling on `document.body` while `active` is true.
3761
+ * Returns every element that is currently scrollable, skipping entire subtrees
3762
+ * rooted at `[data-scroll-lock-ignore]` (overlays: menus, dialogs, drawers…).
3763
+ *
3764
+ * Uses a TreeWalker so ignored subtrees are never visited at all.
3765
+ * `getComputedStyle` is called only for elements whose scroll dimensions already
3766
+ * confirm overflow — avoiding expensive style recalculation for every node.
3767
+ *
3768
+ * `<html>` is checked separately; its computed `overflow` is often `'visible'`
3769
+ * even when the full page scrolls.
3770
+ */
3771
+ const getScrollableElements = () => {
3772
+ const scrollable = [];
3773
+ // Document-level scroll lives on <html>.
3774
+ if (document.documentElement.scrollHeight > window.innerHeight) {
3775
+ scrollable.push(document.documentElement);
3776
+ }
3777
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT, {
3778
+ acceptNode(node) {
3779
+ const el = node;
3780
+ // Reject entire overlay subtrees in one step (menus, dialogs, drawers…).
3781
+ if (el.hasAttribute('data-scroll-lock-ignore')) {
3782
+ return NodeFilter.FILTER_REJECT;
3783
+ }
3784
+ // Cheap dimension pre-check — skips getComputedStyle for most nodes.
3785
+ const mayScrollY = el.scrollHeight > el.clientHeight;
3786
+ const mayScrollX = el.scrollWidth > el.clientWidth;
3787
+ if (!mayScrollY && !mayScrollX) {
3788
+ return NodeFilter.FILTER_SKIP;
3789
+ }
3790
+ // Confirm that overflow is actually set to scroll/auto.
3791
+ const { overflowY, overflowX } = window.getComputedStyle(el);
3792
+ const scrollsY = mayScrollY && (overflowY === 'scroll' || overflowY === 'auto');
3793
+ const scrollsX = mayScrollX && (overflowX === 'scroll' || overflowX === 'auto');
3794
+ return scrollsY || scrollsX ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
3795
+ },
3796
+ });
3797
+ let node;
3798
+ while ((node = walker.nextNode())) {
3799
+ scrollable.push(node);
3800
+ }
3801
+ return scrollable;
3802
+ };
3803
+ /**
3804
+ * Locks **all** currently scrollable elements while `active` is true.
3805
+ *
3806
+ * Instead of assuming the scroll lives on `document.body`, this hook discovers
3807
+ * every element with real overflow and freezes it with `overflow: hidden`.
3808
+ * Elements marked `data-scroll-lock-ignore` — and their entire subtrees — are
3809
+ * skipped so overlays (menus, dialogs, drawers…) remain scrollable internally.
3754
3810
  *
3755
- * Preserves the current scroll position and keeps the scrollbar gutter
3756
- * (`overflow-y: scroll`) to avoid horizontal layout shift when the scrollbar
3757
- * disappears. The original styles and scroll position are restored on cleanup.
3811
+ * For `<html>`, `paddingRight` is compensated when a native scrollbar is present
3812
+ * to prevent horizontal layout shift.
3758
3813
  *
3759
- * @example useBodyScrollLock(isDialogOpen)
3814
+ * All original styles are restored on cleanup.
3815
+ *
3816
+ * @example useBodyScrollLock(isMenuOpen)
3760
3817
  */
3761
3818
  const useBodyScrollLock = (active) => {
3762
3819
  useEffect(() => {
3763
3820
  if (!active) {
3764
3821
  return;
3765
3822
  }
3766
- const scrollY = window.scrollY;
3767
- const body = document.body;
3768
- body.style.position = 'fixed';
3769
- body.style.top = `-${scrollY}px`;
3770
- body.style.overflowY = 'scroll';
3771
- body.style.width = '100%';
3772
- return () => {
3773
- body.style.position = '';
3774
- body.style.top = '';
3775
- body.style.overflowY = '';
3776
- body.style.width = '';
3777
- window.scrollTo(0, scrollY);
3778
- };
3823
+ const scrollbarWidth = getScrollbarWidth();
3824
+ const elements = getScrollableElements();
3825
+ const restorers = elements.map((el) => {
3826
+ const savedOverflow = el.style.overflow;
3827
+ const savedPaddingRight = el.style.paddingRight;
3828
+ el.style.overflow = 'hidden';
3829
+ // Preserve the scrollbar gutter on the document root.
3830
+ if (el === document.documentElement && scrollbarWidth > 0) {
3831
+ const currentPadding = parseFloat(window.getComputedStyle(el).paddingRight) || 0;
3832
+ el.style.paddingRight = `${currentPadding + scrollbarWidth}px`;
3833
+ }
3834
+ return () => {
3835
+ el.style.overflow = savedOverflow;
3836
+ if (el === document.documentElement && scrollbarWidth > 0) {
3837
+ el.style.paddingRight = savedPaddingRight;
3838
+ }
3839
+ };
3840
+ });
3841
+ return () => restorers.forEach((restore) => restore());
3779
3842
  }, [active]);
3780
3843
  };
3781
3844
 
@@ -3808,7 +3871,7 @@ const MenuPanel = ({ open, onClose, anchorEl, minWidth, maxHeight = '20rem', pla
3808
3871
  if (!open) {
3809
3872
  return null;
3810
3873
  }
3811
- 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, "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);
3874
+ 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);
3812
3875
  };
3813
3876
  MenuPanel.displayName = 'MenuPanel';
3814
3877
 
@@ -3955,14 +4018,15 @@ const useMenuItem = ({ ref, role, hasSubmenu, disabled, onClick, closeOnClick, }
3955
4018
  const MENU_ITEM_STYLES = createStyles((theme) => {
3956
4019
  const c = theme.colors;
3957
4020
  return {
3958
- root: {
4021
+ root: ({ size }) => ({
3959
4022
  display: 'flex',
3960
4023
  alignItems: 'center',
3961
4024
  gap: theme.spacing.sm,
3962
- paddingTop: theme.spacing.xs,
3963
- paddingBottom: theme.spacing.xs,
3964
4025
  paddingLeft: theme.spacing.md,
3965
4026
  paddingRight: theme.spacing.md,
4027
+ ...(size === 'default'
4028
+ ? { height: DEFAULT_DRAWER_ITEM_SIZE }
4029
+ : { paddingTop: theme.spacing.xs, paddingBottom: theme.spacing.xs }),
3966
4030
  cursor: 'pointer',
3967
4031
  userSelect: 'none',
3968
4032
  color: c.textPrimary,
@@ -3990,7 +4054,7 @@ const MENU_ITEM_STYLES = createStyles((theme) => {
3990
4054
  ':active:not([data-disabled])': {
3991
4055
  backgroundColor: c.defaultSubtleActive,
3992
4056
  },
3993
- },
4057
+ }),
3994
4058
  /** Fixed-width leading slot keeping labels aligned for checkbox/radio items. */
3995
4059
  indicator: {
3996
4060
  display: 'inline-flex',
@@ -4022,13 +4086,13 @@ const MENU_ITEM_STYLES = createStyles((theme) => {
4022
4086
  };
4023
4087
  }, { id: 'menu-item' });
4024
4088
 
4025
- const MenuItem = ({ ref, label, icon, iconColor, role = 'menuitem', checked, selected, focused, disabled, closeOnClick, submenu, submenuPlacement = 'right', onClick, ...rest }) => {
4089
+ const MenuItem = ({ ref, label, icon, iconColor, role = 'menuitem', checked, selected, focused, disabled, size = 'default', closeOnClick, submenu, submenuPlacement = 'right', onClick, ...rest }) => {
4026
4090
  const hasSubmenu = submenu !== undefined;
4027
4091
  const isCheckable = role === 'menuitemcheckbox' || role === 'menuitemradio';
4028
4092
  const isOption = role === 'option';
4029
4093
  const isHighlighted = isCheckable ? Boolean(checked) : Boolean(selected);
4030
4094
  const { liRef, mergedRef, submenuOpen, handleClick, scheduleOpen, scheduleClose, clearTimers, closeSubmenu, } = useMenuItem({ ref, role, hasSubmenu, disabled, onClick, closeOnClick });
4031
- 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, onClick: handleClick, onMouseEnter: hasSubmenu && !disabled ? scheduleOpen : undefined, onMouseLeave: hasSubmenu ? scheduleClose : undefined, ...rest, children: [isCheckable && (jsxs("span", { className: MENU_ITEM_STYLES.indicator, "aria-hidden": true, children: [checked && role === 'menuitemcheckbox' && (jsx(Icon, { icon: CheckIcon, size: 'sm', 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: clearTimers, onMouseLeave: scheduleClose, children: submenu }))] }));
4095
+ 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 ? scheduleOpen : undefined, onMouseLeave: hasSubmenu ? scheduleClose : undefined, ...rest, children: [isCheckable && (jsxs("span", { className: MENU_ITEM_STYLES.indicator, "aria-hidden": true, children: [checked && role === 'menuitemcheckbox' && (jsx(Icon, { icon: CheckIcon, size: 'sm', 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: clearTimers, onMouseLeave: scheduleClose, children: submenu }))] }));
4032
4096
  };
4033
4097
  MenuItem.displayName = 'MenuItem';
4034
4098
 
@@ -4223,7 +4287,7 @@ const useSelect = ({ id, ref, value, defaultValue, onChange, options, disabled,
4223
4287
  const Select = ({ ref, value, defaultValue, onChange, options, label, helperText, placeholder, size = 'md', status = 'default', disabled, required, width, id, }) => {
4224
4288
  const { fieldId, labelId, helperId, menuId, triggerRef, mergedRef, open, toggle, close, currentValue, selectedOption, groupedOptions, handleSelect, } = useSelect({ id, ref, value, defaultValue, onChange, options, disabled });
4225
4289
  return (jsxs(Stack, { flexDirection: 'column', gap: 'xs', style: { width: width ?? '100%' }, children: [label !== undefined && (jsxs(Text, { variant: 'label', fontSize: 'sm', fontWeight: 'medium', color: 'textSecondary', htmlFor: fieldId, id: labelId, children: [label, required && (jsx(Text, { variant: 'span', color: 'errorMain', "aria-hidden": true, children: ' *' }))] })), jsx(SelectTrigger, { ref: mergedRef, id: fieldId, size: size, status: status, open: open, hasValue: selectedOption !== undefined, startIcon: selectedOption?.icon, startIconColor: selectedOption?.iconColor, disabled: disabled, "aria-haspopup": 'listbox', "aria-expanded": open, "aria-controls": menuId, "aria-labelledby": label !== undefined ? `${labelId} ${fieldId}` : undefined, "aria-required": required || undefined, "aria-invalid": status === 'error' || undefined, "aria-errormessage": status === 'error' && helperText !== undefined ? helperId : undefined, "aria-describedby": helperText !== undefined ? helperId : undefined, onClick: toggle, children: selectedOption !== undefined ? selectedOption.label : placeholder }), jsx(Menu, { open: open, onClose: close, anchorEl: triggerRef.current, id: menuId, role: 'listbox', "aria-labelledby": label !== undefined ? labelId : undefined, "aria-label": label === undefined ? placeholder : undefined, children: Array.from(groupedOptions.entries()).map(([groupKey, groupOpts], groupIndex) => {
4226
- const items = groupOpts.map((opt) => (jsx(Menu.Item, { role: 'option', label: opt.label, icon: opt.icon, iconColor: opt.iconColor, selected: opt.value === currentValue, disabled: opt.disabled, onClick: () => handleSelect(opt.value) }, opt.value)));
4290
+ const items = groupOpts.map((opt) => (jsx(Menu.Item, { role: 'option', size: 'compact', label: opt.label, icon: opt.icon, iconColor: opt.iconColor, selected: opt.value === currentValue, disabled: opt.disabled, onClick: () => handleSelect(opt.value) }, opt.value)));
4227
4291
  return groupKey !== undefined ? (jsx(Menu.Group, { label: groupKey, divider: groupIndex > 0, children: items }, groupKey)) : (jsx(Menu.Group, { divider: groupIndex > 0, children: items }, '__ungrouped'));
4228
4292
  }) }), helperText !== undefined && (jsx(FormHelperText, { id: helperId, status: status, children: helperText }))] }));
4229
4293
  };
@@ -5941,7 +6005,7 @@ const DrawerTemporaryPanel = ({ isExpanded, onClose, children, role, ariaLabel,
5941
6005
  if (!isVisible) {
5942
6006
  return null;
5943
6007
  }
5944
- return createPortal(jsxs(Fragment$1, { children: [jsx(Backdrop, { visible: isFadingIn, onClick: onClose }), jsx(DrawerContext.Provider, { value: { isExpanded: true }, children: jsx("nav", { className: cx(DRAWER_STYLES.temporaryPanel, isFadingIn && DRAWER_STYLES.temporaryPanelVisible), role: role, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, children: children }) })] }), document.body);
6008
+ return createPortal(jsxs(Fragment$1, { children: [jsx(Backdrop, { visible: isFadingIn, onClick: onClose }), jsx(DrawerContext.Provider, { value: { isExpanded: true }, children: jsx("nav", { className: cx(DRAWER_STYLES.temporaryPanel, isFadingIn && DRAWER_STYLES.temporaryPanelVisible), role: role, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, "data-scroll-lock-ignore": true, children: children }) })] }), document.body);
5945
6009
  };
5946
6010
  DrawerTemporaryPanel.displayName = 'DrawerTemporaryPanel';
5947
6011
  // ─── Main Drawer ─────────────────────────────────────────────────────────────
@@ -6850,7 +6914,7 @@ const DialogBase = ({ open, onClose, children, closeOnBackdropClick = false, ful
6850
6914
  if (!isVisible) {
6851
6915
  return null;
6852
6916
  }
6853
- return createPortal(jsxs(Fragment$1, { children: [jsx(Backdrop, { visible: isFadingIn, onClick: handleBackdropClick }), jsx("div", { ref: panelRef, role: 'dialog', "aria-modal": true, "aria-labelledby": labelledBy, "aria-label": ariaLabel, tabIndex: -1, className: cx(DIALOG_STYLES.panel, isFadingIn && DIALOG_STYLES.panelVisible, fullscreen && DIALOG_STYLES.panelFullscreen), style: cssVars, children: jsx(DialogContext.Provider, { value: { titleId, CloseIconComponent: CloseIcon }, children: children }) })] }), document.body);
6917
+ return createPortal(jsxs(Fragment$1, { children: [jsx(Backdrop, { visible: isFadingIn, onClick: handleBackdropClick }), jsx("div", { ref: panelRef, role: 'dialog', "aria-modal": true, "aria-labelledby": labelledBy, "aria-label": ariaLabel, tabIndex: -1, "data-scroll-lock-ignore": true, className: cx(DIALOG_STYLES.panel, isFadingIn && DIALOG_STYLES.panelVisible, fullscreen && DIALOG_STYLES.panelFullscreen), style: cssVars, children: jsx(DialogContext.Provider, { value: { titleId, CloseIconComponent: CloseIcon }, children: children }) })] }), document.body);
6854
6918
  };
6855
6919
  DialogBase.displayName = 'Dialog';
6856
6920
  const Dialog = DialogBase;