@aurora-ds/components 1.2.0 → 1.4.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
@@ -661,36 +661,42 @@ const IconButton = ({ ref, icon: IconComponent, ariaLabel, variant = 'contained'
661
661
  IconButton.displayName = 'IconButton';
662
662
 
663
663
  const LINK_STYLES = theme.createStyles((theme) => ({
664
- root: ({ underline = 'hover' }) => ({
665
- display: 'inline-flex',
666
- alignItems: 'center',
667
- gap: '0.25em',
668
- color: theme.colors.linkMain,
669
- fontFamily: 'inherit',
670
- fontSize: 'inherit',
671
- lineHeight: 'inherit',
672
- fontWeight: 'inherit',
673
- textDecoration: underline === 'always' ? 'underline' : 'none',
674
- cursor: 'pointer',
675
- borderRadius: theme.radius.xs,
676
- transition: `color ${theme.transition.fast}`,
677
- ':hover:not([aria-disabled="true"])': {
678
- color: theme.colors.linkHover,
679
- textDecoration: underline !== 'none' ? 'underline' : 'none',
680
- },
681
- ':active:not([aria-disabled="true"])': {
682
- color: theme.colors.linkActive,
683
- },
684
- ':focus-visible': {
685
- outline: `2px solid ${theme.colors.linkMain}`,
686
- outlineOffset: '2px',
687
- },
688
- '&[aria-disabled="true"]': {
689
- color: theme.colors.linkDisabled,
690
- cursor: 'not-allowed',
691
- textDecoration: 'none',
692
- },
693
- }),
664
+ root: ({ underline = 'hover', color = 'default' }) => {
665
+ const mainColor = color === 'secondary' ? theme.colors.textSecondary : theme.colors.linkMain;
666
+ const hoverColor = color === 'secondary' ? theme.colors.textTertiary : theme.colors.linkHover;
667
+ const activeColor = color === 'secondary' ? theme.colors.textPrimary : theme.colors.linkActive;
668
+ const disabledColor = color === 'secondary' ? theme.colors.textDisabled : theme.colors.linkDisabled;
669
+ return {
670
+ display: 'inline-flex',
671
+ alignItems: 'center',
672
+ gap: '0.25em',
673
+ color: mainColor,
674
+ fontFamily: 'inherit',
675
+ fontSize: 'inherit',
676
+ lineHeight: 'inherit',
677
+ fontWeight: 'inherit',
678
+ textDecoration: underline === 'always' ? 'underline' : 'none',
679
+ cursor: 'pointer',
680
+ borderRadius: theme.radius.xs,
681
+ transition: `color ${theme.transition.fast}`,
682
+ ':hover:not([aria-disabled="true"])': {
683
+ color: hoverColor,
684
+ textDecoration: underline !== 'none' ? 'underline' : 'none',
685
+ },
686
+ ':active:not([aria-disabled="true"])': {
687
+ color: activeColor,
688
+ },
689
+ ':focus-visible': {
690
+ outline: `2px solid ${mainColor}`,
691
+ outlineOffset: '2px',
692
+ },
693
+ '&[aria-disabled="true"]': {
694
+ color: disabledColor,
695
+ cursor: 'not-allowed',
696
+ textDecoration: 'none',
697
+ },
698
+ };
699
+ },
694
700
  icon: {
695
701
  display: 'inline-flex',
696
702
  alignItems: 'center',
@@ -713,7 +719,7 @@ const LINK_STYLES = theme.createStyles((theme) => ({
713
719
  * @example <Link href='/terms' underline='none'>Terms</Link>
714
720
  * @example <Link onClick={() => navigate('/about')}>About (SPA)</Link>
715
721
  */
716
- const Link = ({ ref, underline = 'hover', external = false, disabled = false, startIcon: StartIcon, endIcon: EndIcon, children, className, href, onClick, onKeyDown, ...rest }) => {
722
+ const Link = ({ ref, underline = 'hover', color = 'default', external = false, disabled = false, startIcon: StartIcon, endIcon: EndIcon, children, className, href, onClick, onKeyDown, ...rest }) => {
717
723
  // An <a> without href has no implicit ARIA role and is not focusable.
718
724
  // When used for SPA navigation (onClick only), we restore both behaviours.
719
725
  const hasHref = !!href;
@@ -734,7 +740,7 @@ const Link = ({ ref, underline = 'hover', external = false, disabled = false, st
734
740
  }
735
741
  onKeyDown?.(e);
736
742
  };
737
- return (jsxRuntime.jsxs("a", { ref: ref, href: href, className: theme.cx(LINK_STYLES.root({ underline }), className), "aria-disabled": disabled || undefined,
743
+ return (jsxRuntime.jsxs("a", { ref: ref, href: href, className: theme.cx(LINK_STYLES.root({ underline, color }), className), "aria-disabled": disabled || undefined,
738
744
  // Without href: must be explicitly put in the tab order.
739
745
  // With href: the browser handles focusability natively (no tabIndex needed).
740
746
  tabIndex: disabled ? -1 : (!hasHref ? 0 : undefined),
@@ -2010,7 +2016,15 @@ const useMenu = ({ open, onClose, anchorEl, minWidth }) => {
2010
2016
  setFocusedIndex(count - 1);
2011
2017
  }
2012
2018
  },
2013
- Enter: () => {
2019
+ Enter: (e) => {
2020
+ e.preventDefault();
2021
+ const options = getOptions();
2022
+ if (focusedIndex >= 0) {
2023
+ options[focusedIndex]?.click();
2024
+ }
2025
+ },
2026
+ ' ': (e) => {
2027
+ e.preventDefault();
2014
2028
  const options = getOptions();
2015
2029
  if (focusedIndex >= 0) {
2016
2030
  options[focusedIndex]?.click();
@@ -2573,6 +2587,78 @@ const Grid = ({ display = 'grid', columns, rows, autoFlow, autoColumns, autoRows
2573
2587
  };
2574
2588
  Grid.displayName = 'Grid';
2575
2589
 
2590
+ const BREADCRUMB_STYLES = theme.createStyles((_theme) => ({
2591
+ nav: {
2592
+ display: 'block',
2593
+ },
2594
+ list: {
2595
+ display: 'flex',
2596
+ flexWrap: 'wrap',
2597
+ alignItems: 'center',
2598
+ listStyle: 'none',
2599
+ margin: 0,
2600
+ padding: 0,
2601
+ },
2602
+ }), { id: 'breadcrumb' });
2603
+
2604
+ const BreadcrumbContext = React.createContext({
2605
+ separator: '/',
2606
+ });
2607
+ const useBreadcrumbContext = () => React.useContext(BreadcrumbContext);
2608
+
2609
+ const BREADCRUMB_ITEM_STYLES = theme.createStyles((theme) => ({
2610
+ item: {
2611
+ display: 'inline-flex',
2612
+ alignItems: 'center',
2613
+ fontSize: theme.fontSize.sm,
2614
+ lineHeight: theme.lineHeight.normal,
2615
+ },
2616
+ separator: {
2617
+ display: 'inline-flex',
2618
+ alignItems: 'center',
2619
+ marginLeft: theme.spacing.sm,
2620
+ marginRight: theme.spacing.sm,
2621
+ color: theme.colors.textTertiary,
2622
+ userSelect: 'none',
2623
+ },
2624
+ }), { id: 'breadcrumb-item' });
2625
+
2626
+ const BreadcrumbItem = ({ label, href, onClick, current = false, isFirst = false, }) => {
2627
+ const { separator } = useBreadcrumbContext();
2628
+ return (jsxRuntime.jsxs("li", { className: BREADCRUMB_ITEM_STYLES.item, "aria-current": current ? 'page' : undefined, children: [!isFirst && (jsxRuntime.jsx("span", { "aria-hidden": true, className: BREADCRUMB_ITEM_STYLES.separator, children: separator })), current ? (jsxRuntime.jsx(Text, { variant: 'span', fontWeight: 'semibold', color: 'textPrimary', children: label })) : (jsxRuntime.jsx(Link, { href: href, onClick: onClick, color: 'secondary', underline: 'hover', children: label }))] }));
2629
+ };
2630
+ BreadcrumbItem.displayName = 'Breadcrumb.Item';
2631
+
2632
+ /**
2633
+ * WAI-ARIA compliant breadcrumb navigation using a compound component API.
2634
+ *
2635
+ * ```tsx
2636
+ * <Breadcrumb separator='/'>
2637
+ * <Breadcrumb.Item label='Home' href='/' />
2638
+ * <Breadcrumb.Item label='Products' href='/products' />
2639
+ * <Breadcrumb.Item label='Laptop Pro X' current />
2640
+ * </Breadcrumb>
2641
+ * ```
2642
+ *
2643
+ * The last item is typically marked as `current` — it renders as bold text (non-clickable)
2644
+ * and exposes `aria-current="page"` for assistive technologies.
2645
+ */
2646
+ const BreadcrumbBase = ({ children, separator = '/', ariaLabel = 'Breadcrumb', }) => {
2647
+ const contextValue = React.useMemo(() => ({ separator }), [separator]);
2648
+ const items = React.Children.map(children, (child, index) => {
2649
+ if (!React.isValidElement(child)) {
2650
+ return child;
2651
+ }
2652
+ return React.cloneElement(child, {
2653
+ isFirst: index === 0,
2654
+ });
2655
+ });
2656
+ return (jsxRuntime.jsx(BreadcrumbContext.Provider, { value: contextValue, children: jsxRuntime.jsx("nav", { "aria-label": ariaLabel, className: BREADCRUMB_STYLES.nav, children: jsxRuntime.jsx("ol", { className: BREADCRUMB_STYLES.list, children: items }) }) }));
2657
+ };
2658
+ BreadcrumbBase.displayName = 'Breadcrumb';
2659
+ const Breadcrumb = BreadcrumbBase;
2660
+ Breadcrumb.Item = BreadcrumbItem;
2661
+
2576
2662
  const DrawerContext = React.createContext({
2577
2663
  isExpanded: true,
2578
2664
  });
@@ -2928,6 +3014,234 @@ Drawer.Body = DrawerBody;
2928
3014
  Drawer.Footer = DrawerFooter;
2929
3015
  Drawer.Item = DrawerItem;
2930
3016
 
3017
+ const TABS_LIST_STYLES = theme.createStyles(() => ({
3018
+ root: {
3019
+ display: 'flex',
3020
+ flexDirection: 'row',
3021
+ alignItems: 'center',
3022
+ overflowX: 'auto',
3023
+ scrollbarWidth: 'none',
3024
+ width: 'fit-content',
3025
+ '::-webkit-scrollbar': { display: 'none' },
3026
+ },
3027
+ }));
3028
+
3029
+ const TabsContext = React.createContext(null);
3030
+
3031
+ /**
3032
+ * Internal hook — consumes the Tabs context and throws if used outside a `<Tabs>` provider.
3033
+ * @internal
3034
+ */
3035
+ const useTabsContext = () => {
3036
+ const ctx = React.useContext(TabsContext);
3037
+ if (!ctx) {
3038
+ throw new Error('This component must be used inside a <Tabs> provider.');
3039
+ }
3040
+ return ctx;
3041
+ };
3042
+
3043
+ const TabsList = ({ children, ariaLabel, ariaLabelledBy }) => {
3044
+ const { value, setValue, baseId, getTabValues } = useTabsContext();
3045
+ /**
3046
+ * Returns true if the tab at the given index is aria-disabled.
3047
+ * We check the DOM attribute because disabled state lives in TabItem,
3048
+ * not in the shared context, and we want to avoid adding it to the context.
3049
+ */
3050
+ const isTabDisabled = (tabValue) => {
3051
+ const el = document.getElementById(`${baseId}-tab-${tabValue}`);
3052
+ return el?.getAttribute('aria-disabled') === 'true';
3053
+ };
3054
+ /**
3055
+ * Moves focus to the nearest non-disabled tab starting from `startIndex`
3056
+ * and stepping in the given `direction` (+1 = right, -1 = left).
3057
+ * Stops after a full loop if all tabs are disabled.
3058
+ */
3059
+ const focusNextEnabled = (startIndex, direction) => {
3060
+ const values = getTabValues();
3061
+ for (let i = 1; i <= values.length; i++) {
3062
+ const index = (startIndex + direction * i + values.length) % values.length;
3063
+ const nextValue = values[index];
3064
+ if (!isTabDisabled(nextValue)) {
3065
+ document.getElementById(`${baseId}-tab-${nextValue}`)?.focus();
3066
+ setValue(nextValue);
3067
+ return;
3068
+ }
3069
+ }
3070
+ };
3071
+ const handleKeyDown = (event) => {
3072
+ const values = getTabValues();
3073
+ const currentIndex = values.indexOf(value);
3074
+ if (currentIndex === -1) {
3075
+ return;
3076
+ }
3077
+ switch (event.key) {
3078
+ case 'ArrowRight':
3079
+ event.preventDefault();
3080
+ focusNextEnabled(currentIndex, 1);
3081
+ break;
3082
+ case 'ArrowLeft':
3083
+ event.preventDefault();
3084
+ focusNextEnabled(currentIndex, -1);
3085
+ break;
3086
+ case 'Home': {
3087
+ event.preventDefault();
3088
+ // Focus the first non-disabled tab
3089
+ const firstIdx = values.findIndex((v) => !isTabDisabled(v));
3090
+ if (firstIdx !== -1) {
3091
+ document.getElementById(`${baseId}-tab-${values[firstIdx]}`)?.focus();
3092
+ setValue(values[firstIdx]);
3093
+ }
3094
+ break;
3095
+ }
3096
+ case 'End': {
3097
+ event.preventDefault();
3098
+ // Focus the last non-disabled tab
3099
+ const lastIdx = [...values].reverse().findIndex((v) => !isTabDisabled(v));
3100
+ if (lastIdx !== -1) {
3101
+ const realIndex = values.length - 1 - lastIdx;
3102
+ document.getElementById(`${baseId}-tab-${values[realIndex]}`)?.focus();
3103
+ setValue(values[realIndex]);
3104
+ }
3105
+ break;
3106
+ }
3107
+ }
3108
+ };
3109
+ return (jsxRuntime.jsx("div", { role: 'tablist', "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, className: TABS_LIST_STYLES.root, onKeyDown: handleKeyDown, children: children }));
3110
+ };
3111
+ TabsList.displayName = 'Tabs.List';
3112
+
3113
+ const TABS_PANEL_STYLES = theme.createStyles(() => ({
3114
+ root: {
3115
+ width: '100%',
3116
+ },
3117
+ }));
3118
+
3119
+ const TabsPanel = ({ value, children, keepMounted = false }) => {
3120
+ const { value: activeValue, baseId } = useTabsContext();
3121
+ const isActive = activeValue === value;
3122
+ if (!isActive && !keepMounted) {
3123
+ return null;
3124
+ }
3125
+ return (jsxRuntime.jsx("div", { role: 'tabpanel', id: `${baseId}-panel-${value}`, "aria-labelledby": `${baseId}-tab-${value}`,
3126
+ // WAI-ARIA APG: tabIndex={0} allows keyboard scrolling when the panel has no focusable child.
3127
+ // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
3128
+ tabIndex: 0, hidden: !isActive, className: TABS_PANEL_STYLES.root, children: children }));
3129
+ };
3130
+ TabsPanel.displayName = 'Tabs.Panel';
3131
+
3132
+ const TAB_ITEM_STYLES = theme.createStyles((theme) => ({
3133
+ root: ({ isActive, disabled }) => ({
3134
+ display: 'inline-flex',
3135
+ alignItems: 'center',
3136
+ justifyContent: 'center',
3137
+ boxSizing: 'border-box',
3138
+ paddingTop: theme.spacing.xs,
3139
+ paddingBottom: theme.spacing.xs,
3140
+ paddingLeft: theme.spacing.md,
3141
+ paddingRight: theme.spacing.md,
3142
+ border: 'none',
3143
+ borderBottom: `2px solid ${isActive ? theme.colors.primaryMain : 'transparent'}`,
3144
+ background: 'transparent',
3145
+ cursor: disabled ? 'not-allowed' : 'pointer',
3146
+ fontFamily: 'inherit',
3147
+ whiteSpace: 'nowrap',
3148
+ transition: `border-color ${theme.transition.fast}`,
3149
+ ...(disabled ? {} : {
3150
+ ':hover': {
3151
+ borderBottomColor: isActive ? theme.colors.primaryMain : theme.colors.textTertiary,
3152
+ },
3153
+ }),
3154
+ ':focus-visible': {
3155
+ outline: `2px solid ${theme.colors.primaryMain}`,
3156
+ outlineOffset: '2px',
3157
+ },
3158
+ }),
3159
+ }));
3160
+
3161
+ /**
3162
+ * Handles tab registration, active state derivation and interaction props
3163
+ * for a single `Tabs.Tab` (TabItem) button.
3164
+ *
3165
+ * Registers the tab value into the shared ordered registry on mount so that
3166
+ * `TabsList` can perform correct ArrowLeft/Right/Home/End keyboard navigation.
3167
+ */
3168
+ const useTabItem = ({ value, disabled }) => {
3169
+ const { value: activeValue, setValue, baseId, registerTab } = useTabsContext();
3170
+ // Maintains the ordered tab registry used by TabsList for keyboard navigation.
3171
+ React.useEffect(() => registerTab(value), [registerTab, value]);
3172
+ const isActive = activeValue === value;
3173
+ return {
3174
+ isActive,
3175
+ buttonProps: {
3176
+ id: `${baseId}-tab-${value}`,
3177
+ 'aria-selected': isActive,
3178
+ 'aria-controls': `${baseId}-panel-${value}`,
3179
+ 'aria-disabled': disabled || undefined,
3180
+ tabIndex: isActive ? 0 : -1,
3181
+ onClick: () => {
3182
+ if (!disabled) {
3183
+ setValue(value);
3184
+ }
3185
+ },
3186
+ },
3187
+ };
3188
+ };
3189
+
3190
+ const TabItem = ({ value, label, disabled = false }) => {
3191
+ const { isActive, buttonProps } = useTabItem({ value, disabled });
3192
+ return (jsxRuntime.jsx("button", { type: 'button', role: 'tab', className: TAB_ITEM_STYLES.root({ isActive, disabled }), ...buttonProps, children: jsxRuntime.jsx(Text, { variant: 'span', fontSize: 'sm', fontWeight: isActive ? 'semibold' : 'medium', color: disabled ? 'textDisabled' : isActive ? 'textPrimary' : 'textSecondary', children: label }) }));
3193
+ };
3194
+ TabItem.displayName = 'Tabs.Tab';
3195
+
3196
+ /**
3197
+ * WAI-ARIA compliant tab interface using a compound component API.
3198
+ *
3199
+ * ```tsx
3200
+ * <Tabs defaultValue={'active'} onChange={handleChange}>
3201
+ * <Tabs.List ariaLabel={'Subscriptions'}>
3202
+ * <Tabs.Tab value={'active'} label={'Active'} />
3203
+ * <Tabs.Tab value={'cancelled'} label={'Cancelled'} />
3204
+ * </Tabs.List>
3205
+ * <Tabs.Panel value={'active'}>...</Tabs.Panel>
3206
+ * <Tabs.Panel value={'cancelled'}>...</Tabs.Panel>
3207
+ * </Tabs>
3208
+ * ```
3209
+ *
3210
+ * Supports controlled (`value` + `onChange`) and uncontrolled (`defaultValue`) modes.
3211
+ * Keyboard navigation: `ArrowLeft/Right`, `Home`, `End`.
3212
+ * Only the active panel is mounted — use `keepMounted` on `Tabs.Panel` to preserve state across switches.
3213
+ */
3214
+ const TabsBase = ({ children, value: controlledValue, defaultValue, onChange, id, }) => {
3215
+ const reactId = React.useId();
3216
+ const baseId = id ?? `tabs-${reactId}`;
3217
+ const [internalValue, setInternalValue] = React.useState(defaultValue ?? '');
3218
+ const isControlled = controlledValue !== undefined;
3219
+ const value = isControlled ? controlledValue : internalValue;
3220
+ const tabsRef = React.useRef([]);
3221
+ const registerTab = React.useCallback((tabValue) => {
3222
+ if (!tabsRef.current.includes(tabValue)) {
3223
+ tabsRef.current.push(tabValue);
3224
+ }
3225
+ return () => {
3226
+ tabsRef.current = tabsRef.current.filter((v) => v !== tabValue);
3227
+ };
3228
+ }, []);
3229
+ const getTabValues = React.useCallback(() => tabsRef.current, []);
3230
+ const setValue = React.useCallback((next) => {
3231
+ if (!isControlled) {
3232
+ setInternalValue(next);
3233
+ }
3234
+ onChange?.(next);
3235
+ }, [isControlled, onChange]);
3236
+ const contextValue = React.useMemo(() => ({ value, setValue, baseId, registerTab, getTabValues }), [value, setValue, baseId, registerTab, getTabValues]);
3237
+ return (jsxRuntime.jsx(TabsContext.Provider, { value: contextValue, children: children }));
3238
+ };
3239
+ TabsBase.displayName = 'Tabs';
3240
+ const Tabs = TabsBase;
3241
+ Tabs.List = TabsList;
3242
+ Tabs.Tab = TabItem;
3243
+ Tabs.Panel = TabsPanel;
3244
+
2931
3245
  const AlertContext = React.createContext({
2932
3246
  variant: 'default',
2933
3247
  accentColor: 'defaultActive',
@@ -3600,6 +3914,7 @@ exports.Alert = Alert;
3600
3914
  exports.Backdrop = Backdrop;
3601
3915
  exports.Badge = Badge;
3602
3916
  exports.Box = Box;
3917
+ exports.Breadcrumb = Breadcrumb;
3603
3918
  exports.Button = Button;
3604
3919
  exports.Card = Card;
3605
3920
  exports.Checkbox = Checkbox;
@@ -3616,6 +3931,7 @@ exports.Select = Select;
3616
3931
  exports.Skeleton = Skeleton;
3617
3932
  exports.Stack = Stack;
3618
3933
  exports.Switch = Switch;
3934
+ exports.Tabs = Tabs;
3619
3935
  exports.Text = Text;
3620
3936
  exports.TextField = TextField;
3621
3937
  exports.Tooltip = Tooltip;