@aurora-ds/components 1.5.0 → 1.7.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
@@ -359,8 +359,8 @@ const buildActionButtonRootStyle = (theme, variant, color, focusOptions) => {
359
359
  backgroundColor: intent.main,
360
360
  borderColor: intent.main,
361
361
  color: intent.on,
362
- ':hover:not(:disabled)': { backgroundColor: intent.hover, borderColor: intent.hover },
363
- ':active:not(:disabled)': { backgroundColor: intent.active, borderColor: intent.active },
362
+ ':hover:not(:disabled)': { backgroundColor: intent.hover },
363
+ ':active:not(:disabled)': { backgroundColor: intent.active },
364
364
  }
365
365
  : variant === 'outlined'
366
366
  ? {
@@ -368,7 +368,7 @@ const buildActionButtonRootStyle = (theme, variant, color, focusOptions) => {
368
368
  borderColor: intent.border,
369
369
  color: intent.fg,
370
370
  ':hover:not(:disabled)': { backgroundColor: intent.subtleHover, color: intent.fgHover },
371
- ':active:not(:disabled)': { backgroundColor: intent.subtleActive, borderColor: intent.active, color: intent.active },
371
+ ':active:not(:disabled)': { backgroundColor: intent.subtleActive, color: intent.active },
372
372
  }
373
373
  : {
374
374
  backgroundColor: 'transparent',
@@ -390,7 +390,7 @@ const buildActionButtonRootStyle = (theme, variant, color, focusOptions) => {
390
390
  height: DEFAULT_BUTTON_HEIGHT,
391
391
  cursor: 'pointer',
392
392
  outline: 'none',
393
- transition: `background-color ${theme.transition.normal}, border-color ${theme.transition.normal}, color ${theme.transition.normal}`,
393
+ transition: `background-color ${theme.transition.normal}, color ${theme.transition.normal}`,
394
394
  ...colorVariantStyles,
395
395
  ':focus-visible': getFocusRingStyles(theme, focusOptions),
396
396
  ':disabled': { cursor: 'not-allowed', opacity: theme.opacity.high },
@@ -952,13 +952,13 @@ const LINK_STYLES = createStyles((theme) => ({
952
952
  * In that case the component stays accessible: it gets `role="link"`,
953
953
  * `tabIndex={0}` and keyboard Enter support automatically.
954
954
  *
955
- * @example <Link href='/about'>About</Link>
956
- * @example <Link href='https://example.com' external>External site</Link>
957
- * @example <Link href='/profile' underline='always' startIcon={UserIcon}>Profile</Link>
958
- * @example <Link href='/terms' underline='none'>Terms</Link>
959
- * @example <Link onClick={() => navigate('/about')}>About (SPA)</Link>
955
+ * @example <Link href='/about' label='About' />
956
+ * @example <Link href='https://example.com' label='External site' external />
957
+ * @example <Link href='/profile' underline='always' startIcon={UserIcon} label='Profile' />
958
+ * @example <Link href='/terms' underline='none' label='Terms' />
959
+ * @example <Link onClick={() => navigate('/about')} label='About (SPA)' />
960
960
  */
961
- const Link = ({ ref, underline = 'hover', color = 'default', external = false, disabled = false, startIcon: StartIcon, endIcon: EndIcon, children, className, href, onClick, onKeyDown, ...rest }) => {
961
+ const Link = ({ ref, label, fontSize = 'md', underline = 'hover', color = 'default', external = false, disabled = false, startIcon: StartIcon, endIcon: EndIcon, className, href, onClick, onKeyDown, ...rest }) => {
962
962
  // An <a> without href has no implicit ARIA role and is not focusable.
963
963
  // When used for SPA navigation (onClick only), we restore both behaviours.
964
964
  const hasHref = !!href;
@@ -984,7 +984,7 @@ const Link = ({ ref, underline = 'hover', color = 'default', external = false, d
984
984
  // With href: the browser handles focusability natively (no tabIndex needed).
985
985
  tabIndex: disabled ? -1 : (!hasHref ? 0 : undefined),
986
986
  // Without href: <a> has no implicit ARIA role — add role="link" explicitly.
987
- role: !hasHref ? 'link' : undefined, target: external ? '_blank' : undefined, rel: external ? 'noopener noreferrer' : undefined, onClick: handleClick, onKeyDown: handleKeyDown, ...rest, children: [StartIcon && (jsx("span", { className: LINK_STYLES.icon, "aria-hidden": true, children: jsx(StartIcon, { width: '1em', height: '1em' }) })), children, EndIcon && (jsx("span", { className: LINK_STYLES.icon, "aria-hidden": true, children: jsx(EndIcon, { width: '1em', height: '1em' }) }))] }));
987
+ role: !hasHref ? 'link' : undefined, target: external ? '_blank' : undefined, rel: external ? 'noopener noreferrer' : undefined, onClick: handleClick, onKeyDown: handleKeyDown, ...rest, children: [StartIcon && (jsx("span", { className: LINK_STYLES.icon, "aria-hidden": true, children: jsx(StartIcon, { width: '1em', height: '1em' }) })), jsx(Text, { as: 'span', fontSize: fontSize, children: label }), EndIcon && (jsx("span", { className: LINK_STYLES.icon, "aria-hidden": true, children: jsx(EndIcon, { width: '1em', height: '1em' }) }))] }));
988
988
  };
989
989
  Link.displayName = 'Link';
990
990
 
@@ -3278,17 +3278,17 @@ const TEXTFIELD_WRAPPER_VARIANTS = createVariants((theme) => {
3278
3278
  return {
3279
3279
  base: {
3280
3280
  display: 'flex',
3281
- alignItems: 'center',
3282
- gap: theme.spacing.sm,
3281
+ // `stretch` lets the inner input fill the full height of the box so
3282
+ // clicking anywhere (including the vertical padding area) hits it.
3283
+ alignItems: 'stretch',
3283
3284
  boxSizing: 'border-box',
3284
3285
  borderWidth: '1px',
3285
3286
  borderStyle: 'solid',
3286
3287
  borderRadius: theme.radius.md,
3287
3288
  backgroundColor: c.surfacePaper,
3289
+ // No focus ring on text inputs: focus is conveyed by the status
3290
+ // border colour change (see the `status` variants below).
3288
3291
  transition: `border-color ${theme.transition.fast}`,
3289
- // Full, always-visible focus ring when the inner input is focused
3290
- // (single source of truth) — complements the status border colour.
3291
- ':focus-within': getFocusRingStyles(theme),
3292
3292
  '&[data-disabled]': {
3293
3293
  opacity: theme.opacity.high,
3294
3294
  backgroundColor: c.disabledMain,
@@ -3297,10 +3297,12 @@ const TEXTFIELD_WRAPPER_VARIANTS = createVariants((theme) => {
3297
3297
  },
3298
3298
  variants: {
3299
3299
  size: {
3300
+ // Horizontal padding lives on the input itself (see TEXTFIELD_INPUT_VARIANTS),
3301
+ // not here, so the whole bordered area is part of the clickable input.
3300
3302
  // Font-size is set on the wrapper so the native input inherits it via `fontSize: 'inherit'`.
3301
- sm: { height: '2rem', paddingLeft: theme.spacing.sm, paddingRight: theme.spacing.xs, fontSize: theme.fontSize.xs },
3302
- md: { height: '2.5rem', paddingLeft: theme.spacing.sm, paddingRight: theme.spacing.xs, fontSize: theme.fontSize.sm },
3303
- lg: { height: '3rem', paddingLeft: theme.spacing.md, paddingRight: theme.spacing.sm, fontSize: theme.fontSize.md },
3303
+ sm: { height: '2rem', fontSize: theme.fontSize.xs },
3304
+ md: { height: '2.5rem', fontSize: theme.fontSize.sm },
3305
+ lg: { height: '3rem', fontSize: theme.fontSize.md },
3304
3306
  },
3305
3307
  status: {
3306
3308
  default: {
@@ -3325,11 +3327,17 @@ const TEXTFIELD_WRAPPER_VARIANTS = createVariants((theme) => {
3325
3327
  defaultVariants: { size: 'md', status: 'default' },
3326
3328
  };
3327
3329
  }, { id: 'textfield-wrapper' });
3328
- const TEXTFIELD_STYLES = createStyles((theme) => ({
3329
- /** Visually hidden native input fills the remaining space inside the wrapper. */
3330
- input: {
3330
+ /**
3331
+ * The native input carries the horizontal padding and fills 100% of the
3332
+ * wrapper height, so the entire bordered box is part of the clickable input
3333
+ * (no dead zone between the text and the border).
3334
+ */
3335
+ const TEXTFIELD_INPUT_VARIANTS = createVariants((theme) => ({
3336
+ base: {
3331
3337
  flex: 1,
3332
3338
  minWidth: 0,
3339
+ height: '100%',
3340
+ boxSizing: 'border-box',
3333
3341
  border: 'none',
3334
3342
  outline: 'none',
3335
3343
  background: 'transparent',
@@ -3337,15 +3345,25 @@ const TEXTFIELD_STYLES = createStyles((theme) => ({
3337
3345
  fontFamily: 'inherit',
3338
3346
  fontSize: 'inherit',
3339
3347
  lineHeight: 'normal',
3340
- padding: '0',
3341
3348
  '&::placeholder': { color: theme.colors.textTertiary },
3342
3349
  '&:disabled': { cursor: 'not-allowed', color: theme.colors.textDisabled },
3343
3350
  },
3351
+ variants: {
3352
+ size: {
3353
+ sm: { paddingLeft: theme.spacing.sm, paddingRight: theme.spacing.sm },
3354
+ md: { paddingLeft: theme.spacing.sm, paddingRight: theme.spacing.sm },
3355
+ lg: { paddingLeft: theme.spacing.md, paddingRight: theme.spacing.md },
3356
+ },
3357
+ },
3358
+ defaultVariants: { size: 'md' },
3359
+ }), { id: 'textfield-input' });
3360
+ const TEXTFIELD_STYLES = createStyles((theme) => ({
3344
3361
  /** Wrapper for the start icon — aligned with the input baseline. Clickable to focus the input. */
3345
3362
  startIconWrap: {
3346
3363
  display: 'flex',
3347
3364
  alignItems: 'center',
3348
3365
  flexShrink: 0,
3366
+ paddingLeft: theme.spacing.sm,
3349
3367
  cursor: 'pointer',
3350
3368
  },
3351
3369
  /** Wrapper for end actions (custom content + optional password toggle). */
@@ -3353,6 +3371,7 @@ const TEXTFIELD_STYLES = createStyles((theme) => ({
3353
3371
  display: 'flex',
3354
3372
  alignItems: 'center',
3355
3373
  flexShrink: 0,
3374
+ paddingRight: theme.spacing.xs,
3356
3375
  gap: theme.spacing['2xs'],
3357
3376
  },
3358
3377
  }), { id: 'textfield-extra' });
@@ -3413,7 +3432,11 @@ const useTextField = ({ id, ref, type, size, endAction, }) => {
3413
3432
  */
3414
3433
  const TextField = ({ ref, label, helperText, size = 'md', status = 'default', startIcon: StartIcon, endAction, type, id, disabled, required, ...rest }) => {
3415
3434
  const { fieldId, helperId, mergedRef, isPassword, showPassword, togglePassword, resolvedType, iconSize, iconButtonSize, hasEndSection, focusInput, } = useTextField({ id, ref, type, size, endAction });
3416
- return (jsxs(Stack, { flexDirection: 'column', gap: 'xs', children: [label !== undefined && (jsxs(Text, { variant: 'label', fontSize: 'sm', fontWeight: 'medium', color: 'textSecondary', htmlFor: fieldId, children: [label, required && (jsx(Text, { variant: 'span', color: 'errorMain', "aria-hidden": true, children: ' *' }))] })), jsxs("div", { className: TEXTFIELD_WRAPPER_VARIANTS({ size, status }), "data-disabled": disabled || undefined, children: [StartIcon && (jsx("span", { className: TEXTFIELD_STYLES.startIconWrap, onClick: focusInput, "aria-hidden": true, children: jsx(Icon, { icon: StartIcon, size: iconSize, strokeColor: 'textSecondary' }) })), jsx("input", { ref: mergedRef, id: fieldId, type: resolvedType, disabled: disabled, required: required, "aria-required": required || undefined, "aria-invalid": status === 'error' || undefined, "aria-describedby": helperText !== undefined ? helperId : undefined, "aria-errormessage": status === 'error' && helperText !== undefined ? helperId : undefined, className: TEXTFIELD_STYLES.input, ...rest }), hasEndSection && (jsxs("span", { className: TEXTFIELD_STYLES.endActionWrap, children: [endAction, isPassword && (jsx(IconButton, { icon: showPassword ? EyeSlashIcon : EyeIcon, ariaLabel: showPassword ? 'Hide password' : 'Show password', variant: 'text', color: 'neutral', size: iconButtonSize, type: 'button', onClick: togglePassword }))] }))] }), helperText !== undefined && (jsx(FormHelperText, { id: helperId, status: status, children: helperText }))] }));
3435
+ // Associate the label with the input via `aria-labelledby` (and NOT `htmlFor`)
3436
+ // so the label stays accessible without natively focusing the input on click —
3437
+ // only clicking inside the bordered box should focus the field.
3438
+ const labelId = `${fieldId}-label`;
3439
+ return (jsxs(Stack, { flexDirection: 'column', gap: 'xs', children: [label !== undefined && (jsxs(Text, { variant: 'label', fontSize: 'sm', fontWeight: 'medium', color: 'textSecondary', id: labelId, children: [label, required && (jsx(Text, { variant: 'span', color: 'errorMain', "aria-hidden": true, children: ' *' }))] })), jsxs("div", { className: TEXTFIELD_WRAPPER_VARIANTS({ size, status }), "data-disabled": disabled || undefined, children: [StartIcon && (jsx("span", { className: TEXTFIELD_STYLES.startIconWrap, onClick: focusInput, "aria-hidden": true, children: jsx(Icon, { icon: StartIcon, size: iconSize, strokeColor: 'textSecondary' }) })), jsx("input", { ref: mergedRef, id: fieldId, type: resolvedType, disabled: disabled, required: required, "aria-required": required || undefined, "aria-invalid": status === 'error' || undefined, "aria-labelledby": label !== undefined ? labelId : undefined, "aria-describedby": helperText !== undefined ? helperId : undefined, "aria-errormessage": status === 'error' && helperText !== undefined ? helperId : undefined, className: TEXTFIELD_INPUT_VARIANTS({ size }), ...rest }), hasEndSection && (jsxs("span", { className: TEXTFIELD_STYLES.endActionWrap, children: [endAction, isPassword && (jsx(IconButton, { icon: showPassword ? EyeSlashIcon : EyeIcon, ariaLabel: showPassword ? 'Hide password' : 'Show password', variant: 'text', color: 'neutral', size: iconButtonSize, type: 'button', onClick: togglePassword }))] }))] }), helperText !== undefined && (jsx(FormHelperText, { id: helperId, status: status, children: helperText }))] }));
3417
3440
  };
3418
3441
  TextField.displayName = 'TextField';
3419
3442
 
@@ -4006,8 +4029,8 @@ const SELECT_TRIGGER_VARIANTS = createVariants((theme) => {
4006
4029
  transition: `border-color ${theme.transition.fast}`,
4007
4030
  outline: 'none',
4008
4031
  fontFamily: 'inherit',
4009
- // Full, always-visible focus ring (single source of truth).
4010
- ':focus-visible': getFocusRingStyles(theme),
4032
+ // No focus ring on the trigger: focus is conveyed by the border
4033
+ // colour change (see the `status` variants below).
4011
4034
  '&[data-open]': {
4012
4035
  borderColor: c.primaryMain,
4013
4036
  },
@@ -4044,6 +4067,9 @@ const SELECT_TRIGGER_VARIANTS = createVariants((theme) => {
4044
4067
  ':hover:not([data-disabled]):not([data-open])': {
4045
4068
  borderColor: c.borderStrong,
4046
4069
  },
4070
+ '&:focus-visible:not([data-open])': {
4071
+ borderColor: c.primaryMain,
4072
+ },
4047
4073
  },
4048
4074
  error: {
4049
4075
  borderColor: c.errorMain,
@@ -5626,7 +5652,13 @@ const DRAWER_STYLES = createStyles((theme) => ({
5626
5652
  * Keep in sync with themeBreakpoints.
5627
5653
  */
5628
5654
  const BREAKPOINTS = {
5629
- sm: 640};
5655
+ xs: 480,
5656
+ sm: 640,
5657
+ md: 768,
5658
+ lg: 1024,
5659
+ xl: 1280,
5660
+ '2xl': 1536,
5661
+ };
5630
5662
  /** Max-width media query strings (max = breakpoint - 1px). */
5631
5663
  const MEDIA_MAX = {
5632
5664
  sm: `max-width: ${BREAKPOINTS.sm - 1}px`};
@@ -6525,6 +6557,13 @@ const AlertContext = createContext({
6525
6557
  });
6526
6558
  const useAlertContext = () => useContext(AlertContext);
6527
6559
 
6560
+ const VARIANT_DISMISS_COLOR = {
6561
+ default: 'neutral',
6562
+ success: 'success',
6563
+ error: 'error',
6564
+ warning: 'warning',
6565
+ info: 'info',
6566
+ };
6528
6567
  const VARIANT_ICONS = {
6529
6568
  success: AlertSuccessIcon,
6530
6569
  error: AlertErrorIcon,
@@ -6537,7 +6576,7 @@ const isSvgComponent = (value) => typeof value === 'function';
6537
6576
  * Alert title row: renders the variant icon alongside the title text.
6538
6577
  * Must be used inside an `<Alert>` component.
6539
6578
  */
6540
- const AlertTitle = ({ children, icon }) => {
6579
+ const AlertTitle = ({ children, icon, onDismiss }) => {
6541
6580
  const { variant, accentColor } = useAlertContext();
6542
6581
  // Resolve which icon to render:
6543
6582
  // - custom icon prop always takes precedence over the built-in variant icon
@@ -6547,7 +6586,7 @@ const AlertTitle = ({ children, icon }) => {
6547
6586
  const ResolvedIcon = icon && isSvgComponent(icon) ? icon : (icon === undefined ? (builtInIcon ?? null) : null);
6548
6587
  const customNode = icon && !isSvgComponent(icon) ? icon : null;
6549
6588
  const hasIcon = ResolvedIcon !== null || customNode !== null;
6550
- return (jsxs(Stack, { flexDirection: 'row', alignItems: 'center', gap: 'sm', children: [hasIcon && (jsx(Stack, { flexShrink: 0, alignItems: 'center', color: accentColor, width: '1.25rem', height: '1.25rem', "aria-hidden": true, children: ResolvedIcon ? (jsx(ResolvedIcon, { width: 20, height: 20 })) : (customNode) })), jsx(Text, { fontWeight: 'semibold', fontSize: 'sm', color: accentColor, children: children })] }));
6589
+ return (jsxs(Stack, { flexDirection: 'row', alignItems: 'center', gap: 'sm', justifyContent: onDismiss ? 'space-between' : undefined, children: [jsxs(Stack, { flexDirection: 'row', alignItems: 'center', gap: 'sm', children: [hasIcon && (jsx(Stack, { flexShrink: 0, alignItems: 'center', color: accentColor, width: '1.25rem', height: '1.25rem', "aria-hidden": true, children: ResolvedIcon ? (jsx(ResolvedIcon, { width: 20, height: 20 })) : (customNode) })), jsx(Text, { fontWeight: 'semibold', fontSize: 'sm', color: accentColor, children: children })] }), onDismiss && (jsx(IconButton, { icon: CloseIcon, ariaLabel: 'Dismiss', variant: 'text', color: VARIANT_DISMISS_COLOR[variant], size: 'sm', onClick: onDismiss }))] }));
6551
6590
  };
6552
6591
  AlertTitle.displayName = 'Alert.Title';
6553
6592
 
@@ -6557,6 +6596,22 @@ AlertTitle.displayName = 'Alert.Title';
6557
6596
  const AlertBody = ({ children }) => (jsx(Text, { as: 'p', fontSize: 'sm', color: 'textSecondary', children: children }));
6558
6597
  AlertBody.displayName = 'Alert.Body';
6559
6598
 
6599
+ /**
6600
+ * Alert actions row: renders action elements (buttons, links…) at the bottom of an `<Alert>`.
6601
+ * Must be used inside an `<Alert>` component.
6602
+ *
6603
+ * @example
6604
+ * <Alert variant="error">
6605
+ * <Alert.Title>Something went wrong</Alert.Title>
6606
+ * <Alert.Body>Please try again.</Alert.Body>
6607
+ * <Alert.Actions>
6608
+ * <Button size="sm" variant="outlined" color="error">Retry</Button>
6609
+ * </Alert.Actions>
6610
+ * </Alert>
6611
+ */
6612
+ const AlertActions = ({ children }) => (jsx(Stack, { flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', justifyContent: 'end', gap: 'sm', paddingTop: 'xs', children: children }));
6613
+ AlertActions.displayName = 'Alert.Actions';
6614
+
6560
6615
  const VARIANT_TOKENS = {
6561
6616
  default: { backgroundColor: 'surfacePaper', borderColor: 'defaultMain', accentColor: 'defaultActive' },
6562
6617
  success: { backgroundColor: 'successSubtle', borderColor: 'successMain', accentColor: 'successActive' },
@@ -6598,6 +6653,7 @@ AlertBase.displayName = 'Alert';
6598
6653
  const Alert = AlertBase;
6599
6654
  Alert.Title = AlertTitle;
6600
6655
  Alert.Body = AlertBody;
6656
+ Alert.Actions = AlertActions;
6601
6657
 
6602
6658
  const TRANSITION = `${DEFAULT_TRANSITION_DURATION_MS}ms ease`;
6603
6659
  const DIALOG_STYLES = createStyles((theme) => ({
@@ -6644,12 +6700,18 @@ const DIALOG_STYLES = createStyles((theme) => ({
6644
6700
  transform: 'translateY(0)',
6645
6701
  },
6646
6702
  },
6647
- // Full-height variant on mobile (applied via `fullscreen` prop)
6703
+ // Full-viewport variant (applied via `fullscreen` prop — all screen sizes)
6648
6704
  panelFullscreen: {
6649
- [`@media (${MEDIA_MAX.sm})`]: {
6650
- height: '100dvh',
6651
- maxHeight: 'none',
6652
- },
6705
+ top: 0,
6706
+ left: 0,
6707
+ right: 0,
6708
+ bottom: 0,
6709
+ margin: 0,
6710
+ width: '100%',
6711
+ height: '100dvh',
6712
+ maxWidth: 'none',
6713
+ maxHeight: 'none',
6714
+ borderRadius: 0,
6653
6715
  },
6654
6716
  }), { id: 'dialog' });
6655
6717
 
@@ -6746,6 +6808,114 @@ const Dialog = DialogBase;
6746
6808
  Dialog.Header = DialogHeader;
6747
6809
  Dialog.Body = DialogBody;
6748
6810
 
6811
+ /**
6812
+ * Listens to a CSS media query string and returns whether it currently matches.
6813
+ *
6814
+ * @param query - A valid CSS media query string, e.g. `'(max-width: 639px)'`.
6815
+ * @returns `true` when the media query matches, `false` otherwise.
6816
+ *
6817
+ * @example
6818
+ * const isMobile = useMediaQuery('(max-width: 639px)')
6819
+ */
6820
+ const useMediaQuery = (query) => {
6821
+ const [matches, setMatches] = useState(() => {
6822
+ if (typeof window === 'undefined') {
6823
+ return false;
6824
+ }
6825
+ return window.matchMedia(query).matches;
6826
+ });
6827
+ useEffect(() => {
6828
+ if (typeof window === 'undefined') {
6829
+ return;
6830
+ }
6831
+ const mediaQueryList = window.matchMedia(query);
6832
+ setMatches(mediaQueryList.matches);
6833
+ const listener = (event) => setMatches(event.matches);
6834
+ mediaQueryList.addEventListener('change', listener);
6835
+ return () => mediaQueryList.removeEventListener('change', listener);
6836
+ }, [query]);
6837
+ return matches;
6838
+ };
6839
+ /**
6840
+ * Returns `true` when the viewport width is **below** the given breakpoint (mobile-first max).
6841
+ *
6842
+ * @param breakpoint - One of the Aurora breakpoints: `xs` | `sm` | `md` | `lg` | `xl` | `2xl`.
6843
+ *
6844
+ * @example
6845
+ * const isMobile = useBreakpointMax('sm') // true when width < 640px
6846
+ */
6847
+ const useBreakpointMax = (breakpoint) => useMediaQuery(`(max-width: ${BREAKPOINTS[breakpoint] - 1}px)`);
6848
+ /**
6849
+ * Returns `true` when the viewport width is **at or above** the given breakpoint.
6850
+ *
6851
+ * @param breakpoint - One of the Aurora breakpoints: `xs` | `sm` | `md` | `lg` | `xl` | `2xl`.
6852
+ *
6853
+ * @example
6854
+ * const isDesktop = useBreakpointMin('md') // true when width >= 768px
6855
+ */
6856
+ const useBreakpointMin = (breakpoint) => useMediaQuery(`(min-width: ${BREAKPOINTS[breakpoint]}px)`);
6857
+
6858
+ /**
6859
+ * Manages keyboard navigation for a listbox-style menu.
6860
+ *
6861
+ * Binds ArrowDown, ArrowUp, Home, End, and Enter using `useKeyPress`.
6862
+ * Automatically skips disabled items and optionally wraps around (loop).
6863
+ * Resets focus when `enabled` toggles.
6864
+ */
6865
+ const useListKeyNav = ({ itemCount, enabled, onSelect, isDisabled, loop = true, initialIndex = 0, }) => {
6866
+ const [focusedIndex, setFocusedIndex] = useState(-1);
6867
+ const initialIndexRef = useRef(initialIndex);
6868
+ useEffect(() => {
6869
+ initialIndexRef.current = initialIndex;
6870
+ }, [initialIndex]);
6871
+ useEffect(() => {
6872
+ setFocusedIndex(enabled ? initialIndexRef.current : -1);
6873
+ }, [enabled]);
6874
+ const getNextIndex = useCallback((current, direction) => {
6875
+ if (itemCount === 0) {
6876
+ return -1;
6877
+ }
6878
+ let next = current + direction;
6879
+ for (let i = 0; i < itemCount; i++) {
6880
+ if (next < 0) {
6881
+ next = loop ? itemCount - 1 : 0;
6882
+ }
6883
+ if (next >= itemCount) {
6884
+ next = loop ? 0 : itemCount - 1;
6885
+ }
6886
+ if (!isDisabled?.(next)) {
6887
+ return next;
6888
+ }
6889
+ next += direction;
6890
+ }
6891
+ return current;
6892
+ }, [itemCount, loop, isDisabled]);
6893
+ useKeyPress({
6894
+ ArrowDown: (e) => {
6895
+ e.preventDefault();
6896
+ setFocusedIndex((prev) => getNextIndex(prev, 1));
6897
+ },
6898
+ ArrowUp: (e) => {
6899
+ e.preventDefault();
6900
+ setFocusedIndex((prev) => getNextIndex(prev, -1));
6901
+ },
6902
+ Home: (e) => {
6903
+ e.preventDefault();
6904
+ setFocusedIndex(getNextIndex(-1, 1));
6905
+ },
6906
+ End: (e) => {
6907
+ e.preventDefault();
6908
+ setFocusedIndex(getNextIndex(itemCount, -1));
6909
+ },
6910
+ Enter: () => {
6911
+ if (focusedIndex >= 0) {
6912
+ onSelect(focusedIndex);
6913
+ }
6914
+ },
6915
+ }, { enabled });
6916
+ return { focusedIndex, setFocusedIndex };
6917
+ };
6918
+
6749
6919
  const lightPalette = {
6750
6920
  // Surface
6751
6921
  surfaceBackground: '#f8fafc',
@@ -7154,5 +7324,5 @@ const darkTheme = createTheme({
7154
7324
  breakpoints: themeBreakpoints,
7155
7325
  });
7156
7326
 
7157
- export { Accordion, Alert, Article, Aside, Avatar, AvatarGroup, Backdrop, Badge, Box, Breadcrumb, Button, Card, Checkbox, DatePicker, DefaultErrorFallback, Dialog, Drawer, ErrorBoundary, Fab, Footer, Form, Grid, Header, Icon, IconButton, Image, InfoBubble, Link, LoaderScreen, Main, Menu, Nav, Pagination, RadioButton, RadioGroup, Section, Select, Separator, Skeleton, Stack, SvgImage, Switch, Table, Tabs, Text, TextField, ToggleButton, ToggleGroup, ToggleIconButton, Tooltip, darkTheme, lightTheme, useDrawerContext };
7327
+ export { Accordion, Alert, Article, Aside, Avatar, AvatarGroup, Backdrop, Badge, Box, Breadcrumb, Button, Card, Checkbox, DatePicker, DefaultErrorFallback, Dialog, Drawer, ErrorBoundary, Fab, Footer, Form, Grid, Header, Icon, IconButton, Image, InfoBubble, Link, LoaderScreen, Main, Menu, Nav, Pagination, RadioButton, RadioGroup, Section, Select, Separator, Skeleton, Stack, SvgImage, Switch, Table, Tabs, Text, TextField, ToggleButton, ToggleGroup, ToggleIconButton, Tooltip, darkTheme, lightTheme, useBodyScrollLock, useBreakpointMax, useBreakpointMin, useControllableState, useDrawerContext, useFocusTrap, useKeyPress, useListKeyNav, useMediaQuery, useMenuPosition, useMergedRefs, useTooltipPosition, useTransitionRender };
7158
7328
  //# sourceMappingURL=index.js.map