@aurora-ds/components 1.1.6 → 1.2.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
@@ -1,5 +1,5 @@
1
1
  import { jsx, jsxs, Fragment as Fragment$1 } from 'react/jsx-runtime';
2
- import { keyframes, createVariants, createStyles, useTheme, cx, createTheme } from '@aurora-ds/theme';
2
+ import { keyframes, createStyles, useTheme, cx, createVariants, createTheme } from '@aurora-ds/theme';
3
3
  import * as React from 'react';
4
4
  import { createElement, Fragment, useRef, useState, useCallback, useEffect, useId, isValidElement, cloneElement, useLayoutEffect, useMemo, createContext, useContext } from 'react';
5
5
  import { createPortal } from 'react-dom';
@@ -161,24 +161,32 @@ const skeletonShimmerAnimation = keyframes({
161
161
  '100%': { backgroundPosition: '0% 50%' },
162
162
  });
163
163
 
164
- const APPEARANCES = ['contained', 'outlined', 'text'];
165
- const buildActionButtonVariantBase = (theme) => ({
166
- position: 'relative',
167
- display: 'inline-flex',
168
- alignItems: 'center',
169
- justifyContent: 'center',
170
- boxSizing: 'border-box',
171
- border: '1px solid transparent',
172
- borderRadius: theme.radius.md,
173
- fontFamily: 'inherit',
174
- userSelect: 'none',
175
- cursor: 'pointer',
176
- outline: 'none',
177
- transition: `background-color ${theme.transition.fast}, border-color ${theme.transition.fast}, color ${theme.transition.fast}, box-shadow ${theme.transition.fast}`,
178
- ':focus-visible': { boxShadow: theme.shadows.focus },
179
- ':disabled': { cursor: 'not-allowed', opacity: theme.opacity.high, boxShadow: 'none' },
180
- });
181
- const buildActionButtonCompoundVariants = (theme) => {
164
+ /** Default duration in milliseconds for mount/unmount transition animations. */
165
+ const DEFAULT_TRANSITION_DURATION_MS = 250;
166
+ const DEFAULT_BUTTON_HEIGHT = 40;
167
+ const DEFAULT_DRAWER_ITEM_SIZE = 40;
168
+ /** Drawer widths (px) — single source of truth for both the Drawer and its items. */
169
+ const EXPANDED_DRAWER_WIDTH = 240;
170
+ const COLLAPSED_DRAWER_WIDTH = 58;
171
+ /**
172
+ * Horizontal padding (px) applied by `Drawer.Body` on EACH side of its items
173
+ * (Box `px="sm"` → theme.spacing.sm = 0.5rem = 8px).
174
+ */
175
+ const DRAWER_BODY_HORIZONTAL_PADDING = 8;
176
+ /**
177
+ * DrawerItem widths (px), always derived from the drawer width minus the body
178
+ * horizontal padding. Using explicit widths (instead of `width: 100%`) lets the
179
+ * item animate its own width in sync with the drawer for a smooth transition.
180
+ */
181
+ const EXPANDED_DRAWER_ITEM_WIDTH = EXPANDED_DRAWER_WIDTH - DRAWER_BODY_HORIZONTAL_PADDING * 2; // 264
182
+ const COLLAPSED_DRAWER_ITEM_WIDTH = COLLAPSED_DRAWER_WIDTH - DRAWER_BODY_HORIZONTAL_PADDING * 2; // 44
183
+
184
+ /**
185
+ * Returns the complete root styles for an action button (base + color/variant),
186
+ * all in one object so CSS transitions work correctly.
187
+ * Size-specific styles (height, padding, fontSize) are added by the caller.
188
+ */
189
+ const buildActionButtonRootStyle = (theme, variant, color) => {
182
190
  const c = theme.colors;
183
191
  const intents = {
184
192
  primary: {
@@ -217,57 +225,60 @@ const buildActionButtonCompoundVariants = (theme) => {
217
225
  fg: c.errorMain, fgHover: c.errorHover, border: c.errorMain,
218
226
  },
219
227
  };
220
- const appearanceStyles = (intent, appearance) => {
221
- if (appearance === 'contained') {
222
- return {
223
- backgroundColor: intent.main,
224
- borderColor: intent.main,
225
- color: intent.on,
226
- boxShadow: theme.shadows.xs,
227
- ':hover:not(:disabled)': { backgroundColor: intent.hover, borderColor: intent.hover, boxShadow: theme.shadows.sm },
228
- ':active:not(:disabled)': { backgroundColor: intent.active, borderColor: intent.active, boxShadow: theme.shadows.none },
229
- };
228
+ const intent = intents[color];
229
+ const colorVariantStyles = variant === 'contained'
230
+ ? {
231
+ backgroundColor: intent.main,
232
+ borderColor: intent.main,
233
+ color: intent.on,
234
+ boxShadow: theme.shadows.xs,
235
+ ':hover:not(:disabled)': { backgroundColor: intent.hover, borderColor: intent.hover, boxShadow: theme.shadows.sm },
236
+ ':active:not(:disabled)': { backgroundColor: intent.active, borderColor: intent.active, boxShadow: theme.shadows.none },
230
237
  }
231
- if (appearance === 'outlined') {
232
- return {
238
+ : variant === 'outlined'
239
+ ? {
233
240
  backgroundColor: 'transparent',
234
241
  borderColor: intent.border,
235
242
  color: intent.fg,
236
243
  ':hover:not(:disabled)': { backgroundColor: intent.subtleHover, color: intent.fgHover },
237
244
  ':active:not(:disabled)': { backgroundColor: intent.subtleActive, borderColor: intent.active, color: intent.active },
245
+ }
246
+ : {
247
+ backgroundColor: 'transparent',
248
+ borderColor: 'transparent',
249
+ color: intent.fg,
250
+ ':hover:not(:disabled)': { backgroundColor: intent.subtleHover, color: intent.fgHover },
251
+ ':active:not(:disabled)': { backgroundColor: intent.subtleActive, color: intent.active },
238
252
  };
239
- }
240
- return {
241
- backgroundColor: 'transparent',
242
- borderColor: 'transparent',
243
- color: intent.fg,
244
- ':hover:not(:disabled)': { backgroundColor: intent.subtleHover, color: intent.fgHover },
245
- ':active:not(:disabled)': { backgroundColor: intent.subtleActive, color: intent.active },
246
- };
253
+ return {
254
+ position: 'relative',
255
+ display: 'inline-flex',
256
+ alignItems: 'center',
257
+ justifyContent: 'center',
258
+ boxSizing: 'border-box',
259
+ border: '1px solid transparent',
260
+ borderRadius: theme.radius.md,
261
+ fontFamily: 'inherit',
262
+ userSelect: 'none',
263
+ height: DEFAULT_BUTTON_HEIGHT,
264
+ cursor: 'pointer',
265
+ outline: 'none',
266
+ transition: `background-color ${theme.transition.normal}, border-color ${theme.transition.normal}, color ${theme.transition.normal}, box-shadow ${theme.transition.normal}`,
267
+ ...colorVariantStyles,
268
+ ':focus-visible': { boxShadow: theme.shadows.focus },
269
+ ':disabled': { cursor: 'not-allowed', opacity: theme.opacity.high, boxShadow: 'none' },
247
270
  };
248
- return Object.keys(intents).flatMap((color) => APPEARANCES.map((appearance) => ({
249
- color,
250
- variant: appearance,
251
- styles: appearanceStyles(intents[color], appearance),
252
- })));
253
271
  };
254
272
 
255
- const BUTTON_VARIANTS = createVariants((theme) => ({
256
- base: buildActionButtonVariantBase(theme),
257
- variants: {
258
- size: {
259
- sm: { height: '2rem', padding: `0 ${theme.spacing.sm}`, fontSize: theme.fontSize.xs },
260
- md: { height: '2.5rem', padding: `0 ${theme.spacing.md}`, fontSize: theme.fontSize.sm },
261
- lg: { height: '3rem', padding: `0 ${theme.spacing.lg}`, fontSize: theme.fontSize.md },
262
- },
263
- // Appearance/color styling is provided entirely by compoundVariants.
264
- variant: { contained: {}, outlined: {}, text: {} },
265
- color: { primary: {}, secondary: {}, neutral: {}, info: {}, success: {}, warning: {}, error: {} },
266
- },
267
- defaultVariants: { size: 'md', variant: 'contained', color: 'primary' },
268
- compoundVariants: buildActionButtonCompoundVariants(theme),
269
- }), { id: 'button' });
270
- const BUTTON_STYLES = createStyles({
273
+ const BUTTON_STYLES = createStyles((theme) => ({
274
+ root: ({ variant, color, size }) => ({
275
+ ...buildActionButtonRootStyle(theme, variant, color),
276
+ ...(size === 'sm'
277
+ ? { height: '2rem', padding: `0 ${theme.spacing.sm}`, fontSize: theme.fontSize.xs }
278
+ : size === 'lg'
279
+ ? { height: '3rem', padding: `0 ${theme.spacing.lg}`, fontSize: theme.fontSize.md }
280
+ : { height: '2.5rem', padding: `0 ${theme.spacing.md}`, fontSize: theme.fontSize.sm }),
281
+ }),
271
282
  /** Inner wrapper holding icons + label, centered by the button. */
272
283
  content: {
273
284
  display: 'inline-flex',
@@ -290,7 +301,14 @@ const BUTTON_STYLES = createStyles({
290
301
  animation: `${spinAnimation} 0.75s linear infinite`,
291
302
  '@media (prefers-reduced-motion: reduce)': { animation: 'none' },
292
303
  },
293
- }, { id: 'button-extra' });
304
+ }), { id: 'button' });
305
+ // Pre-generate CSS for all variant/color/size combinations at module load.
306
+ // This ensures the CSS is already in the stylesheet before the first user interaction,
307
+ // preventing the "first click is instant" issue caused by lazy CSS injection.
308
+ const BUTTON_VARIANT_VALUES = ['contained', 'outlined', 'text'];
309
+ const BUTTON_COLOR_VALUES = ['primary', 'secondary', 'neutral', 'info', 'success', 'warning', 'error'];
310
+ const BUTTON_SIZE_VALUES = ['sm', 'md', 'lg'];
311
+ BUTTON_VARIANT_VALUES.forEach(variant => BUTTON_COLOR_VALUES.forEach(color => BUTTON_SIZE_VALUES.forEach(size => BUTTON_STYLES.root({ variant, color, size }))));
294
312
 
295
313
  const ICON_STYLES = createStyles((theme) => ({
296
314
  root: ({ size, strokeColor, fill, backgroundColor, padding, borderRadius }) => ({
@@ -556,35 +574,29 @@ const ICON_SIZE$2 = {
556
574
  * @example <Button label='Delete' variant='outlined' color='error' startIcon={IconRegistry.CloseIcon} />
557
575
  * @example <Button label='Submitting…' color='success' isLoading width='100%' />
558
576
  */
559
- const Button = ({ ref, variant = 'contained', color = 'primary', size = 'md', width, flexGrow, flexShrink, isLoading = false, startIcon: StartIcon, endIcon: EndIcon, label, className, type = 'button', disabled, style, ...rest }) => {
577
+ const Button = ({ ref, variant = 'contained', color = 'primary', size = 'md', width, flexGrow, flexShrink, isLoading = false, startIcon: StartIcon, endIcon: EndIcon, label, children, className, type = 'button', disabled, style, ...rest }) => {
560
578
  const isDisabled = disabled || isLoading;
561
579
  const iconSize = ICON_SIZE$2[size];
562
- const rootClassName = BUTTON_VARIANTS({ variant, color, size }, className);
580
+ const rootClassName = cx(BUTTON_STYLES.root({ variant, color, size }), className);
563
581
  const mergedStyle = {
564
582
  ...style,
565
583
  ...(width !== undefined ? { width } : {}),
566
584
  ...(flexGrow !== undefined ? { flexGrow } : {}),
567
585
  ...(flexShrink !== undefined ? { flexShrink } : {}),
568
586
  };
569
- return (jsxs("button", { ref: ref, type: type, className: rootClassName, disabled: isDisabled, "aria-busy": isLoading || undefined, style: mergedStyle, ...rest, children: [isLoading && (jsx("span", { className: BUTTON_STYLES.spinnerWrap, children: jsx(Icon, { icon: SpinnerIcon, size: iconSize, className: BUTTON_STYLES.spinnerIcon }) })), jsxs("span", { className: cx(BUTTON_STYLES.content, isLoading && BUTTON_STYLES.contentHidden), children: [StartIcon && (jsx(Icon, { icon: StartIcon, size: iconSize })), label && (jsx(Text, { variant: 'span', fontSize: LABEL_FONT_SIZE$1[size], fontWeight: 'medium', lineHeight: 'none', children: label })), EndIcon && (jsx(Icon, { icon: EndIcon, size: iconSize }))] })] }));
587
+ return (jsxs("button", { ref: ref, type: type, className: rootClassName, disabled: isDisabled, "aria-busy": isLoading || undefined, style: mergedStyle, ...rest, children: [isLoading && (jsx("span", { className: BUTTON_STYLES.spinnerWrap, children: jsx(Icon, { icon: SpinnerIcon, size: iconSize, className: BUTTON_STYLES.spinnerIcon }) })), jsxs("span", { className: cx(BUTTON_STYLES.content, isLoading && BUTTON_STYLES.contentHidden), children: [StartIcon && (jsx(Icon, { icon: StartIcon, size: iconSize })), (label !== undefined || children !== undefined) && (jsx(Text, { variant: 'span', fontSize: LABEL_FONT_SIZE$1[size], fontWeight: 'medium', lineHeight: 'none', children: label ?? children })), EndIcon && (jsx(Icon, { icon: EndIcon, size: iconSize }))] })] }));
570
588
  };
571
589
  Button.displayName = 'Button';
572
590
 
573
- const ICON_BUTTON_VARIANTS = createVariants((theme) => ({
574
- base: buildActionButtonVariantBase(theme),
575
- variants: {
576
- size: {
577
- sm: { width: '2rem', height: '2rem', padding: '0' },
578
- md: { width: '2.5rem', height: '2.5rem', padding: '0' },
579
- lg: { width: '3rem', height: '3rem', padding: '0' },
580
- },
581
- variant: { contained: {}, outlined: {}, text: {} },
582
- color: { primary: {}, secondary: {}, neutral: {}, info: {}, success: {}, warning: {}, error: {} },
583
- },
584
- defaultVariants: { size: 'md', variant: 'contained', color: 'primary' },
585
- compoundVariants: buildActionButtonCompoundVariants(theme),
586
- }), { id: 'icon-button' });
587
- const ICON_BUTTON_STYLES = createStyles({
591
+ const ICON_BUTTON_STYLES = createStyles((theme) => ({
592
+ root: ({ variant, color, size }) => ({
593
+ ...buildActionButtonRootStyle(theme, variant, color),
594
+ ...(size === 'sm'
595
+ ? { width: '2rem', height: '2rem', padding: '0' }
596
+ : size === 'lg'
597
+ ? { width: '3rem', height: '3rem', padding: '0' }
598
+ : { width: '2.5rem', height: '2.5rem', padding: '0' }),
599
+ }),
588
600
  /** Spinning animation applied to the SpinnerIcon. */
589
601
  spinnerIcon: {
590
602
  animation: `${spinAnimation} 0.75s linear infinite`,
@@ -600,7 +612,11 @@ const ICON_BUTTON_STYLES = createStyles({
600
612
  alignItems: 'center',
601
613
  justifyContent: 'center',
602
614
  },
603
- }, { id: 'icon-button-extra' });
615
+ }), { id: 'icon-button' });
616
+ const ICON_BUTTON_VARIANT_VALUES = ['contained', 'outlined', 'text'];
617
+ const ICON_BUTTON_COLOR_VALUES = ['primary', 'secondary', 'neutral', 'info', 'success', 'warning', 'error'];
618
+ const ICON_BUTTON_SIZE_VALUES = ['sm', 'md', 'lg'];
619
+ ICON_BUTTON_VARIANT_VALUES.forEach(variant => ICON_BUTTON_COLOR_VALUES.forEach(color => ICON_BUTTON_SIZE_VALUES.forEach(size => ICON_BUTTON_STYLES.root({ variant, color, size }))));
604
620
 
605
621
  /** Maps the icon-button size to an Icon size token. */
606
622
  const ICON_SIZE$1 = {
@@ -619,7 +635,7 @@ const ICON_SIZE$1 = {
619
635
  const IconButton = ({ ref, icon: IconComponent, ariaLabel, variant = 'contained', color = 'primary', size = 'md', isLoading = false, className, type = 'button', disabled, ...rest }) => {
620
636
  const isDisabled = disabled || isLoading;
621
637
  const iconSize = ICON_SIZE$1[size];
622
- const rootClassName = ICON_BUTTON_VARIANTS({ variant, color, size }, className);
638
+ const rootClassName = cx(ICON_BUTTON_STYLES.root({ variant, color, size }), className);
623
639
  return (jsxs("button", { ref: ref, type: type, className: rootClassName, disabled: isDisabled, "aria-label": ariaLabel, "aria-busy": isLoading || undefined, ...rest, children: [isLoading && (jsx("span", { className: ICON_BUTTON_STYLES.spinnerWrap, children: jsx(Icon, { icon: SpinnerIcon, size: iconSize, className: ICON_BUTTON_STYLES.spinnerIcon }) })), jsx(Icon, { icon: IconComponent, size: iconSize, className: cx(isLoading && ICON_BUTTON_STYLES.iconHidden) })] }));
624
640
  };
625
641
  IconButton.displayName = 'IconButton';
@@ -667,12 +683,20 @@ const LINK_STYLES = createStyles((theme) => ({
667
683
  /**
668
684
  * Theme-aware anchor element with optional icons and underline control.
669
685
  *
686
+ * Supports SPA navigation (e.g. React Router) via `onClick` without `href`.
687
+ * In that case the component stays accessible: it gets `role="link"`,
688
+ * `tabIndex={0}` and keyboard Enter support automatically.
689
+ *
670
690
  * @example <Link href='/about'>About</Link>
671
691
  * @example <Link href='https://example.com' external>External site</Link>
672
692
  * @example <Link href='/profile' underline='always' startIcon={UserIcon}>Profile</Link>
673
693
  * @example <Link href='/terms' underline='none'>Terms</Link>
694
+ * @example <Link onClick={() => navigate('/about')}>About (SPA)</Link>
674
695
  */
675
- const Link = ({ ref, underline = 'hover', external = false, disabled = false, startIcon: StartIcon, endIcon: EndIcon, children, className, onClick, onKeyDown, ...rest }) => {
696
+ const Link = ({ ref, underline = 'hover', external = false, disabled = false, startIcon: StartIcon, endIcon: EndIcon, children, className, href, onClick, onKeyDown, ...rest }) => {
697
+ // An <a> without href has no implicit ARIA role and is not focusable.
698
+ // When used for SPA navigation (onClick only), we restore both behaviours.
699
+ const hasHref = !!href;
676
700
  const handleClick = (e) => {
677
701
  if (disabled) {
678
702
  e.preventDefault();
@@ -680,14 +704,22 @@ const Link = ({ ref, underline = 'hover', external = false, disabled = false, st
680
704
  }
681
705
  onClick?.(e);
682
706
  };
683
- // Prevents Enter navigation when disabled; satisfies jsx-a11y/click-events-have-key-events.
684
707
  const handleKeyDown = (e) => {
685
708
  if (disabled && e.key === 'Enter') {
686
709
  e.preventDefault();
687
710
  }
711
+ // Without href, the browser does not fire a click on Enter natively.
712
+ if (!hasHref && !disabled && e.key === 'Enter') {
713
+ e.currentTarget.click();
714
+ }
688
715
  onKeyDown?.(e);
689
716
  };
690
- return (jsxs("a", { ref: ref, className: cx(LINK_STYLES.root({ underline }), className), "aria-disabled": disabled || undefined, tabIndex: disabled ? -1 : 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' }) }))] }));
717
+ return (jsxs("a", { ref: ref, href: href, className: cx(LINK_STYLES.root({ underline }), className), "aria-disabled": disabled || undefined,
718
+ // Without href: must be explicitly put in the tab order.
719
+ // With href: the browser handles focusability natively (no tabIndex needed).
720
+ tabIndex: disabled ? -1 : (!hasHref ? 0 : undefined),
721
+ // Without href: <a> has no implicit ARIA role — add role="link" explicitly.
722
+ 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' }) }))] }));
691
723
  };
692
724
  Link.displayName = 'Link';
693
725
 
@@ -835,7 +867,7 @@ const BADGE_VARIANTS = createVariants((theme) => ({
835
867
  size: {
836
868
  sm: {
837
869
  height: '1.25rem',
838
- padding: `0.125rem ${theme.spacing['xs+']}`,
870
+ padding: `0.125rem ${theme.spacing.xsPlus}`,
839
871
  fontSize: theme.fontSize['2xs'],
840
872
  },
841
873
  md: {
@@ -1565,6 +1597,18 @@ Box.displayName = 'Box';
1565
1597
  const Stack = ({ flexDirection = 'row', display = 'flex', gap = 'sm', ...rest }) => (jsx(Box, { display: display, flexDirection: flexDirection, gap: gap, ...rest }));
1566
1598
  Stack.displayName = 'Stack';
1567
1599
 
1600
+ const HELPER_COLOR_MAP = {
1601
+ default: 'textSecondary',
1602
+ error: 'errorHover',
1603
+ success: 'successHover',
1604
+ warning: 'warningHover',
1605
+ };
1606
+ const FormHelperText = ({ id, status = 'default', ariaLive, className, children, }) => {
1607
+ const resolvedAriaLive = ariaLive ?? (status === 'error' ? 'assertive' : 'polite');
1608
+ return (jsx(Text, { id: id, variant: 'span', fontSize: 'xs', color: HELPER_COLOR_MAP[status], "aria-live": resolvedAriaLive, className: className, children: children }));
1609
+ };
1610
+ FormHelperText.displayName = 'FormHelperText';
1611
+
1568
1612
  const TEXTFIELD_WRAPPER_VARIANTS = createVariants((theme) => {
1569
1613
  const c = theme.colors;
1570
1614
  return {
@@ -1687,18 +1731,11 @@ const ICON_BUTTON_SIZE_MAP = {
1687
1731
  md: 'sm',
1688
1732
  lg: 'md',
1689
1733
  };
1690
- /** Maps status to a theme color token used for the helper text. */
1691
- const HELPER_COLOR_MAP$1 = {
1692
- default: 'textSecondary',
1693
- error: 'errorHover',
1694
- success: 'successHover',
1695
- warning: 'warningHover',
1696
- };
1697
1734
  /**
1698
1735
  * Business logic for the TextField component: id resolution, ref merging,
1699
1736
  * password visibility toggling and size/status derived tokens.
1700
1737
  */
1701
- const useTextField = ({ id, ref, type, size, status, endAction, }) => {
1738
+ const useTextField = ({ id, ref, type, size, endAction, }) => {
1702
1739
  const generatedId = useId();
1703
1740
  const fieldId = id ?? generatedId;
1704
1741
  const helperId = `${fieldId}-helper`;
@@ -1720,7 +1757,6 @@ const useTextField = ({ id, ref, type, size, status, endAction, }) => {
1720
1757
  resolvedType,
1721
1758
  iconSize: ICON_SIZE_MAP$1[size],
1722
1759
  iconButtonSize: ICON_BUTTON_SIZE_MAP[size],
1723
- helperColor: HELPER_COLOR_MAP$1[status],
1724
1760
  hasEndSection: endAction !== undefined || isPassword,
1725
1761
  focusInput,
1726
1762
  };
@@ -1738,8 +1774,8 @@ const useTextField = ({ id, ref, type, size, status, endAction, }) => {
1738
1774
  * @example <TextField label="Search" startIcon={SearchIcon} endAction={<ClearButton />} />
1739
1775
  */
1740
1776
  const TextField = ({ ref, label, helperText, size = 'md', status = 'default', startIcon: StartIcon, endAction, type, id, disabled, required, ...rest }) => {
1741
- const { fieldId, helperId, mergedRef, isPassword, showPassword, togglePassword, resolvedType, iconSize, iconButtonSize, helperColor, hasEndSection, focusInput, } = useTextField({ id, ref, type, size, status, endAction });
1742
- 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, 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, 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', tabIndex: -1, onClick: togglePassword }))] }))] }), helperText !== undefined && (jsx(Text, { id: helperId, variant: 'span', fontSize: 'xs', color: helperColor, children: helperText }))] }));
1777
+ const { fieldId, helperId, mergedRef, isPassword, showPassword, togglePassword, resolvedType, iconSize, iconButtonSize, hasEndSection, focusInput, } = useTextField({ id, ref, type, size, endAction });
1778
+ 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 }))] }));
1743
1779
  };
1744
1780
  TextField.displayName = 'TextField';
1745
1781
 
@@ -1874,7 +1910,9 @@ const useMenuPosition = ({ anchorEl, open, menuRef, minWidth, gap = 4, }) => {
1874
1910
  */
1875
1911
  const useMenu = ({ open, onClose, anchorEl, minWidth }) => {
1876
1912
  const panelRef = useRef(null);
1913
+ const baseId = useId();
1877
1914
  const [focusedIndex, setFocusedIndex] = useState(-1);
1915
+ const [activeDescendant, setActiveDescendant] = useState(undefined);
1878
1916
  const { style } = useMenuPosition({ anchorEl, open, menuRef: panelRef, minWidth });
1879
1917
  /** Returns all non-disabled option elements inside the panel. */
1880
1918
  const getOptions = useCallback(() => {
@@ -1897,22 +1935,31 @@ const useMenu = ({ open, onClose, anchorEl, minWidth }) => {
1897
1935
  });
1898
1936
  return () => cancelAnimationFrame(raf);
1899
1937
  }, [open, getOptions]);
1900
- // Keep data-focused in sync with focusedIndex
1938
+ // Keep data-focused (visual highlight) and aria-activedescendant (screen reader
1939
+ // announcement) in sync with focusedIndex. Each option is assigned a stable id
1940
+ // so the listbox can reference the active one via aria-activedescendant.
1901
1941
  useEffect(() => {
1902
1942
  if (!open) {
1943
+ setActiveDescendant(undefined);
1903
1944
  return;
1904
1945
  }
1905
1946
  const options = getOptions();
1947
+ let activeId;
1906
1948
  options.forEach((el, idx) => {
1949
+ if (!el.id) {
1950
+ el.id = `${baseId}-option-${idx}`;
1951
+ }
1907
1952
  if (idx === focusedIndex) {
1908
1953
  el.setAttribute('data-focused', 'true');
1909
1954
  el.scrollIntoView({ block: 'nearest' });
1955
+ activeId = el.id;
1910
1956
  }
1911
1957
  else {
1912
1958
  el.removeAttribute('data-focused');
1913
1959
  }
1914
1960
  });
1915
- }, [focusedIndex, open, getOptions]);
1961
+ setActiveDescendant(activeId);
1962
+ }, [focusedIndex, open, getOptions, baseId]);
1916
1963
  useKeyPress({
1917
1964
  Escape: onClose,
1918
1965
  ArrowDown: (e) => {
@@ -1950,7 +1997,7 @@ const useMenu = ({ open, onClose, anchorEl, minWidth }) => {
1950
1997
  }
1951
1998
  },
1952
1999
  }, { enabled: open });
1953
- return { panelRef, style };
2000
+ return { panelRef, style, activeDescendant };
1954
2001
  };
1955
2002
 
1956
2003
  const MENU_GROUP_STYLES = createStyles((theme) => {
@@ -1991,7 +2038,8 @@ const MENU_GROUP_STYLES = createStyles((theme) => {
1991
2038
  }, { id: 'menu-group' });
1992
2039
 
1993
2040
  const MenuGroup = ({ label, divider, children, }) => {
1994
- return (jsxs("div", { className: MENU_GROUP_STYLES.root, children: [divider && (jsx("div", { className: MENU_GROUP_STYLES.divider, role: 'separator', "aria-hidden": true })), label !== undefined && (jsx("span", { className: MENU_GROUP_STYLES.label, children: label })), jsx("ul", { className: MENU_GROUP_STYLES.list, role: 'group', "aria-label": label, children: children })] }));
2041
+ const labelId = useId();
2042
+ return (jsxs("div", { className: MENU_GROUP_STYLES.root, children: [divider && (jsx("div", { className: MENU_GROUP_STYLES.divider, role: 'separator', "aria-hidden": true })), label !== undefined && (jsx("span", { id: labelId, className: MENU_GROUP_STYLES.label, "aria-hidden": true, children: label })), jsx("ul", { className: MENU_GROUP_STYLES.list, role: 'group', "aria-labelledby": label !== undefined ? labelId : undefined, children: children })] }));
1995
2043
  };
1996
2044
  MenuGroup.displayName = 'MenuGroup';
1997
2045
 
@@ -2042,12 +2090,12 @@ const MenuItem = ({ ref, label, icon, selected, focused, disabled, onClick, ...r
2042
2090
  };
2043
2091
  MenuItem.displayName = 'MenuItem';
2044
2092
 
2045
- const MenuBase = ({ open, onClose, anchorEl, minWidth, maxHeight = '20rem', id, children, }) => {
2046
- const { panelRef, style } = useMenu({ open, onClose, anchorEl, minWidth });
2093
+ const MenuBase = ({ open, onClose, anchorEl, minWidth, maxHeight = '20rem', id, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, children, }) => {
2094
+ const { panelRef, style, activeDescendant } = useMenu({ open, onClose, anchorEl, minWidth });
2047
2095
  if (!open) {
2048
2096
  return null;
2049
2097
  }
2050
- return createPortal(jsxs(Fragment$1, { children: [jsx("div", { className: MENU_STYLES.backdrop, onClick: onClose, "aria-hidden": true }), jsx("div", { ref: panelRef, id: id, role: 'listbox', tabIndex: -1, className: MENU_STYLES.panel, style: { ...style, maxHeight, outline: 'none' }, children: children })] }), document.body);
2098
+ return createPortal(jsxs(Fragment$1, { children: [jsx("div", { className: MENU_STYLES.backdrop, onClick: onClose, "aria-hidden": true }), jsx("div", { ref: panelRef, id: id, role: 'listbox', tabIndex: -1, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-activedescendant": activeDescendant, className: MENU_STYLES.panel, style: { ...style, maxHeight, outline: 'none' }, children: children })] }), document.body);
2051
2099
  };
2052
2100
  MenuBase.displayName = 'Menu';
2053
2101
  const Menu = MenuBase;
@@ -2169,28 +2217,23 @@ const ICON_SIZE_MAP = {
2169
2217
  md: 'md',
2170
2218
  lg: 'lg',
2171
2219
  };
2172
- const SelectTrigger = ({ ref, size = 'md', status = 'default', open, hasValue, startIcon, disabled, children, ...rest }) => {
2220
+ const SelectTrigger = ({ ref, size = 'md', status = 'default', open, hasValue, startIcon, disabled, children, 'aria-expanded': ariaExpanded, 'aria-controls': ariaControls, ...rest }) => {
2173
2221
  const iconSize = ICON_SIZE_MAP[size];
2174
- return (jsxs("button", { type: 'button', ref: ref, className: SELECT_TRIGGER_VARIANTS({ size, status }), "data-open": open || undefined, "data-disabled": disabled || undefined, disabled: disabled, ...rest, children: [startIcon !== undefined && (jsx(Icon, { icon: startIcon, size: iconSize, strokeColor: 'textSecondary' })), jsx("span", { className: hasValue ? SELECT_TRIGGER_STYLES.value : SELECT_TRIGGER_STYLES.placeholder, children: children }), jsx(Icon, { icon: ChevronDownIcon, size: iconSize, className: cx(SELECT_TRIGGER_STYLES.chevron, open ? SELECT_TRIGGER_STYLES.chevronOpen : undefined), strokeColor: 'textSecondary' })] }));
2222
+ return (jsxs("button", { type: 'button', role: 'combobox', ref: ref, className: SELECT_TRIGGER_VARIANTS({ size, status }), "data-open": open || undefined, "data-disabled": disabled || undefined, disabled: disabled, "aria-expanded": ariaExpanded, "aria-controls": ariaControls, ...rest, children: [startIcon !== undefined && (jsx(Icon, { icon: startIcon, size: iconSize, strokeColor: 'textSecondary' })), jsx("span", { className: hasValue ? SELECT_TRIGGER_STYLES.value : SELECT_TRIGGER_STYLES.placeholder, children: children }), jsx(Icon, { icon: ChevronDownIcon, size: iconSize, className: cx(SELECT_TRIGGER_STYLES.chevron, open ? SELECT_TRIGGER_STYLES.chevronOpen : undefined), strokeColor: 'textSecondary' })] }));
2175
2223
  };
2176
2224
  SelectTrigger.displayName = 'SelectTrigger';
2177
2225
 
2178
- /** Maps status to a theme color token used for the helper text. */
2179
- const HELPER_COLOR_MAP = {
2180
- default: 'textSecondary',
2181
- error: 'errorHover',
2182
- success: 'successHover',
2183
- warning: 'warningHover',
2184
- };
2185
2226
  /**
2186
2227
  * Business logic for the Select component: id resolution, ref merging,
2187
2228
  * controlled/uncontrolled value handling, open state, option grouping and
2188
2229
  * focus restoration to the trigger when the menu closes.
2189
2230
  */
2190
- const useSelect = ({ id, ref, value, defaultValue, onChange, options, status, disabled, }) => {
2231
+ const useSelect = ({ id, ref, value, defaultValue, onChange, options, disabled, }) => {
2191
2232
  const generatedId = useId();
2192
2233
  const fieldId = id ?? generatedId;
2234
+ const helperId = `${fieldId}-helper`;
2193
2235
  const menuId = `${fieldId}-menu`;
2236
+ const labelId = `${fieldId}-label`;
2194
2237
  const triggerRef = useRef(null);
2195
2238
  const mergedRef = useMergedRefs(ref, triggerRef);
2196
2239
  const [open, setOpen] = useState(false);
@@ -2234,6 +2277,8 @@ const useSelect = ({ id, ref, value, defaultValue, onChange, options, status, di
2234
2277
  const close = useCallback(() => setOpen(false), []);
2235
2278
  return {
2236
2279
  fieldId,
2280
+ labelId,
2281
+ helperId,
2237
2282
  menuId,
2238
2283
  triggerRef,
2239
2284
  mergedRef,
@@ -2244,19 +2289,187 @@ const useSelect = ({ id, ref, value, defaultValue, onChange, options, status, di
2244
2289
  selectedOption,
2245
2290
  groupedOptions,
2246
2291
  handleSelect,
2247
- helperColor: HELPER_COLOR_MAP[status],
2248
2292
  };
2249
2293
  };
2250
2294
 
2251
2295
  const Select = ({ ref, value, defaultValue, onChange, options, label, helperText, placeholder, size = 'md', status = 'default', disabled, required, width, id, }) => {
2252
- const { fieldId, menuId, triggerRef, mergedRef, open, toggle, close, currentValue, selectedOption, groupedOptions, handleSelect, helperColor, } = useSelect({ id, ref, value, defaultValue, onChange, options, status, disabled });
2253
- 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, 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, disabled: disabled, required: required, "aria-haspopup": 'listbox', "aria-expanded": open, "aria-controls": menuId, "aria-invalid": status === 'error' || undefined, onClick: toggle, children: selectedOption !== undefined ? selectedOption.label : placeholder }), jsx(Menu, { open: open, onClose: close, anchorEl: triggerRef.current, id: menuId, children: Array.from(groupedOptions.entries()).map(([groupKey, groupOpts], groupIndex) => {
2296
+ const { fieldId, labelId, helperId, menuId, triggerRef, mergedRef, open, toggle, close, currentValue, selectedOption, groupedOptions, handleSelect, } = useSelect({ id, ref, value, defaultValue, onChange, options, disabled });
2297
+ 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, 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, "aria-labelledby": label !== undefined ? labelId : undefined, "aria-label": label === undefined ? placeholder : undefined, children: Array.from(groupedOptions.entries()).map(([groupKey, groupOpts], groupIndex) => {
2254
2298
  const items = groupOpts.map((opt) => (jsx(Menu.Item, { label: opt.label, icon: opt.icon, selected: opt.value === currentValue, disabled: opt.disabled, onClick: () => handleSelect(opt.value) }, opt.value)));
2255
2299
  return groupKey !== undefined ? (jsx(Menu.Group, { label: groupKey, divider: groupIndex > 0, children: items }, groupKey)) : (jsx(Menu.Group, { divider: groupIndex > 0, children: items }, '__ungrouped'));
2256
- }) }), helperText !== undefined && (jsx(Text, { variant: 'span', fontSize: 'xs', color: helperColor, children: helperText }))] }));
2300
+ }) }), helperText !== undefined && (jsx(FormHelperText, { id: helperId, status: status, children: helperText }))] }));
2257
2301
  };
2258
2302
  Select.displayName = 'Select';
2259
2303
 
2304
+ const CHECKBOX_ROOT_VARIANTS = createVariants((theme) => ({
2305
+ base: {
2306
+ display: 'inline-flex',
2307
+ alignItems: 'center',
2308
+ gap: theme.spacing.sm,
2309
+ cursor: 'pointer',
2310
+ userSelect: 'none',
2311
+ },
2312
+ variants: {
2313
+ disabled: {
2314
+ true: {
2315
+ cursor: 'not-allowed',
2316
+ opacity: theme.opacity.high,
2317
+ },
2318
+ false: {},
2319
+ },
2320
+ },
2321
+ defaultVariants: {
2322
+ disabled: 'false',
2323
+ },
2324
+ }), { id: 'checkbox-root' });
2325
+ const CHECKBOX_INPUT_VARIANTS = createVariants((theme) => {
2326
+ const c = theme.colors;
2327
+ return {
2328
+ base: {
2329
+ appearance: 'none',
2330
+ position: 'relative',
2331
+ margin: 0,
2332
+ borderWidth: '1px',
2333
+ borderStyle: 'solid',
2334
+ borderColor: c.borderStrong,
2335
+ borderRadius: theme.radius.sm,
2336
+ backgroundColor: c.surfacePaper,
2337
+ transition: `border-color ${theme.transition.fast}, background-color ${theme.transition.fast}`,
2338
+ cursor: 'pointer',
2339
+ '&:focus-visible': {
2340
+ outline: '2px solid transparent',
2341
+ boxShadow: `0 0 0 2px ${c.primarySubtleActive}`,
2342
+ },
2343
+ '&::after': {
2344
+ content: '""',
2345
+ position: 'absolute',
2346
+ left: '50%',
2347
+ top: '50%',
2348
+ transform: 'translate(-50%, -56%) rotate(45deg)',
2349
+ width: '0.25rem',
2350
+ height: '0.5rem',
2351
+ borderRight: `2px solid ${c.textInverse}`,
2352
+ borderBottom: `2px solid ${c.textInverse}`,
2353
+ opacity: 0,
2354
+ },
2355
+ '&:checked::after': {
2356
+ opacity: 1,
2357
+ },
2358
+ '&:indeterminate::after': {
2359
+ width: '0.5rem',
2360
+ height: '0',
2361
+ borderRight: '0',
2362
+ borderBottom: `2px solid ${c.textInverse}`,
2363
+ transform: 'translate(-50%, -50%)',
2364
+ opacity: 1,
2365
+ },
2366
+ },
2367
+ variants: {
2368
+ size: {
2369
+ sm: {
2370
+ width: '1rem',
2371
+ height: '1rem',
2372
+ },
2373
+ md: {
2374
+ width: '1.125rem',
2375
+ height: '1.125rem',
2376
+ },
2377
+ lg: {
2378
+ width: '1.25rem',
2379
+ height: '1.25rem',
2380
+ },
2381
+ },
2382
+ status: {
2383
+ default: {
2384
+ '&:checked, &:indeterminate': {
2385
+ borderColor: c.primaryMain,
2386
+ backgroundColor: c.primaryMain,
2387
+ },
2388
+ },
2389
+ error: {
2390
+ borderColor: c.errorMain,
2391
+ '&:checked, &:indeterminate': {
2392
+ borderColor: c.errorMain,
2393
+ backgroundColor: c.errorMain,
2394
+ },
2395
+ },
2396
+ success: {
2397
+ borderColor: c.successMain,
2398
+ '&:checked, &:indeterminate': {
2399
+ borderColor: c.successMain,
2400
+ backgroundColor: c.successMain,
2401
+ },
2402
+ },
2403
+ warning: {
2404
+ borderColor: c.warningMain,
2405
+ '&:checked, &:indeterminate': {
2406
+ borderColor: c.warningMain,
2407
+ backgroundColor: c.warningMain,
2408
+ },
2409
+ },
2410
+ },
2411
+ disabled: {
2412
+ true: {
2413
+ cursor: 'not-allowed',
2414
+ backgroundColor: c.disabledMain,
2415
+ borderColor: c.disabledMain,
2416
+ '&:checked, &:indeterminate': {
2417
+ backgroundColor: c.disabledText,
2418
+ borderColor: c.disabledText,
2419
+ },
2420
+ },
2421
+ false: {},
2422
+ },
2423
+ },
2424
+ defaultVariants: {
2425
+ size: 'md',
2426
+ status: 'default',
2427
+ disabled: 'false',
2428
+ },
2429
+ };
2430
+ }, { id: 'checkbox-input' });
2431
+ const CHECKBOX_STYLES = createStyles((theme) => ({
2432
+ wrapper: {
2433
+ display: 'inline-flex',
2434
+ flexDirection: 'column',
2435
+ gap: theme.spacing.xs,
2436
+ },
2437
+ helper: {
2438
+ marginLeft: `calc(1.125rem + ${theme.spacing.sm})`,
2439
+ },
2440
+ }), { id: 'checkbox-extra' });
2441
+
2442
+ /** Handles id generation, ref merging and native indeterminate state sync. */
2443
+ const useCheckbox = ({ id, ref, indeterminate = false }) => {
2444
+ const generatedId = useId();
2445
+ const checkboxId = id ?? generatedId;
2446
+ const helperId = `${checkboxId}-helper`;
2447
+ const inputRef = useRef(null);
2448
+ const mergedRef = useMergedRefs(ref, inputRef);
2449
+ useEffect(() => {
2450
+ if (inputRef.current) {
2451
+ inputRef.current.indeterminate = indeterminate;
2452
+ }
2453
+ }, [indeterminate]);
2454
+ return {
2455
+ checkboxId,
2456
+ helperId,
2457
+ mergedRef,
2458
+ inputRef,
2459
+ };
2460
+ };
2461
+
2462
+ const Checkbox = ({ ref, label, helperText, size = 'md', status = 'default', indeterminate = false, error, id, disabled, required, ...rest }) => {
2463
+ const resolvedStatus = error ? 'error' : status;
2464
+ const { checkboxId, helperId, mergedRef } = useCheckbox({ id, ref, indeterminate });
2465
+ return (jsxs("div", { className: CHECKBOX_STYLES.wrapper, children: [jsxs("label", { htmlFor: checkboxId, className: CHECKBOX_ROOT_VARIANTS({ disabled: disabled ? 'true' : 'false' }), children: [jsx("input", { ref: mergedRef, id: checkboxId, type: 'checkbox', disabled: disabled, required: required, "aria-required": required || undefined, "aria-invalid": resolvedStatus === 'error' || undefined, "aria-describedby": helperText !== undefined ? helperId : undefined, "aria-errormessage": resolvedStatus === 'error' && helperText !== undefined ? helperId : undefined, className: CHECKBOX_INPUT_VARIANTS({
2466
+ size,
2467
+ status: resolvedStatus,
2468
+ disabled: disabled ? 'true' : 'false',
2469
+ }), ...rest }), label !== undefined && (jsx(Text, { variant: 'span', fontSize: size === 'lg' ? 'md' : 'sm', color: disabled ? 'textDisabled' : 'textSecondary', children: label }))] }), helperText !== undefined && (jsx(FormHelperText, { id: helperId, status: resolvedStatus, className: CHECKBOX_STYLES.helper, children: helperText }))] }));
2470
+ };
2471
+ Checkbox.displayName = 'Checkbox';
2472
+
2260
2473
  const CARD_VARIANTS = createVariants((theme) => ({
2261
2474
  base: {
2262
2475
  boxSizing: 'border-box',
@@ -2340,6 +2553,361 @@ const Grid = ({ display = 'grid', columns, rows, autoFlow, autoColumns, autoRows
2340
2553
  };
2341
2554
  Grid.displayName = 'Grid';
2342
2555
 
2556
+ const DrawerContext = createContext({
2557
+ isExpanded: true,
2558
+ });
2559
+ const useDrawerContext = () => useContext(DrawerContext);
2560
+
2561
+ const TRANSITION$2 = `${DEFAULT_TRANSITION_DURATION_MS}ms ease`;
2562
+ const DRAWER_STYLES = createStyles((theme) => ({
2563
+ root: ({ isExpanded }) => ({
2564
+ display: 'flex',
2565
+ flexDirection: 'column',
2566
+ width: isExpanded ? EXPANDED_DRAWER_WIDTH : COLLAPSED_DRAWER_WIDTH,
2567
+ transition: `width ${theme.transition.slow}`,
2568
+ overflow: 'hidden',
2569
+ backgroundColor: theme.colors.surfacePaper,
2570
+ borderRight: `1px solid ${theme.colors.borderMain}`,
2571
+ boxSizing: 'border-box',
2572
+ flexShrink: 0,
2573
+ }),
2574
+ /** Temporary variant: slides in from the left as a fixed portal overlay. */
2575
+ temporaryPanel: {
2576
+ position: 'fixed',
2577
+ top: 0,
2578
+ left: 0,
2579
+ bottom: 0,
2580
+ width: EXPANDED_DRAWER_WIDTH,
2581
+ zIndex: theme.zIndex.modal,
2582
+ display: 'flex',
2583
+ flexDirection: 'column',
2584
+ backgroundColor: theme.colors.surfacePaper,
2585
+ borderRight: `1px solid ${theme.colors.borderMain}`,
2586
+ boxSizing: 'border-box',
2587
+ overflowY: 'auto',
2588
+ overflowX: 'hidden',
2589
+ willChange: 'transform',
2590
+ transform: 'translateX(-100%)',
2591
+ transition: `transform ${TRANSITION$2}`,
2592
+ boxShadow: theme.shadows.xl,
2593
+ },
2594
+ temporaryPanelVisible: {
2595
+ transform: 'translateX(0)',
2596
+ },
2597
+ }));
2598
+
2599
+ /**
2600
+ * Responsive breakpoints (min-width, mobile-first).
2601
+ * Keep in sync with themeBreakpoints.
2602
+ */
2603
+ const BREAKPOINTS = {
2604
+ sm: 640};
2605
+ /** Max-width media query strings (max = breakpoint - 1px). */
2606
+ const MEDIA_MAX = {
2607
+ sm: `max-width: ${BREAKPOINTS.sm - 1}px`};
2608
+
2609
+ const MOBILE_MQ = `(max-width: ${BREAKPOINTS.sm - 1}px)`;
2610
+ /**
2611
+ * Resolves the effective drawer variant based on the explicit `variant` prop
2612
+ * and the current viewport width.
2613
+ *
2614
+ * - If `variant` is explicitly provided (`'permanent'` or `'temporary'`), returns it as-is.
2615
+ * - Otherwise, auto-detects: `'temporary'` on mobile (< sm breakpoint), `'permanent'` on desktop.
2616
+ *
2617
+ * Reacts to viewport changes (window resize) so switching between mobile and desktop
2618
+ * automatically updates the variant without requiring a page reload.
2619
+ */
2620
+ const useDrawerVariant = (variant) => {
2621
+ const [isMobile, setIsMobile] = useState(() => {
2622
+ if (typeof window === 'undefined') {
2623
+ return false;
2624
+ }
2625
+ return window.matchMedia(MOBILE_MQ).matches;
2626
+ });
2627
+ useEffect(() => {
2628
+ if (typeof window === 'undefined') {
2629
+ return;
2630
+ }
2631
+ const mq = window.matchMedia(MOBILE_MQ);
2632
+ const handler = (e) => setIsMobile(e.matches);
2633
+ mq.addEventListener('change', handler);
2634
+ return () => mq.removeEventListener('change', handler);
2635
+ }, []);
2636
+ if (variant !== undefined) {
2637
+ return variant;
2638
+ }
2639
+ return isMobile ? 'temporary' : 'permanent';
2640
+ };
2641
+
2642
+ const TRANSITION$1 = `${DEFAULT_TRANSITION_DURATION_MS}ms ease`;
2643
+ const BACKDROP_STYLES = createStyles((theme) => ({
2644
+ root: {
2645
+ position: 'fixed',
2646
+ inset: 0,
2647
+ zIndex: theme.zIndex.modal - 1,
2648
+ backgroundColor: 'rgba(0, 0, 0, 0)',
2649
+ transition: `background-color ${TRANSITION$1}`,
2650
+ },
2651
+ visible: {
2652
+ backgroundColor: 'rgba(0, 0, 0, 0.5)',
2653
+ },
2654
+ }), { id: 'backdrop' });
2655
+
2656
+ /**
2657
+ * Semi-transparent full-screen overlay used to visually block page content
2658
+ * while a modal element (dialog, temporary drawer…) is open.
2659
+ *
2660
+ * The `visible` prop drives the opacity transition: `false` = transparent,
2661
+ * `true` = `rgba(0,0,0,0.5)`. Mount/unmount is managed by the parent.
2662
+ *
2663
+ * @example
2664
+ * <Backdrop visible={isFadingIn} onClick={onClose} />
2665
+ */
2666
+ const Backdrop = ({ visible, onClick }) => (jsx("div", { className: cx(BACKDROP_STYLES.root, visible && BACKDROP_STYLES.visible), onClick: onClick, "aria-hidden": true }));
2667
+ Backdrop.displayName = 'Backdrop';
2668
+
2669
+ /**
2670
+ * Manages mount/unmount transitions with a two-phase approach (visible → fading-in).
2671
+ *
2672
+ * Opening sequence:
2673
+ * 1. `useLayoutEffect` sets `isVisible=true` synchronously before the browser paints
2674
+ * → element is in the DOM at its initial hidden state on the very first frame.
2675
+ * 2. Double `requestAnimationFrame` in `useEffect` waits for two rendered frames
2676
+ * before setting `isFadingIn=true`, guaranteeing the CSS transition always starts
2677
+ * from the painted hidden state (prevents flickering).
2678
+ *
2679
+ * Closing sequence:
2680
+ * `isFadingIn=false` → CSS transition plays → after `duration` ms → `isVisible=false`.
2681
+ *
2682
+ * @param isOpen - Whether the element should be shown.
2683
+ * @param duration - Transition duration in milliseconds (defaults to `DEFAULT_TRANSITION_DURATION_MS`).
2684
+ */
2685
+ const useTransitionRender = (isOpen, duration = DEFAULT_TRANSITION_DURATION_MS) => {
2686
+ const [isVisible, setIsVisible] = useState(isOpen);
2687
+ const [isFadingIn, setIsFadingIn] = useState(isOpen);
2688
+ // Mount synchronously before paint so the element is in the DOM at opacity:0
2689
+ // on the very first frame — no extra render cycle between null and the initial state.
2690
+ useLayoutEffect(() => {
2691
+ if (isOpen) {
2692
+ setIsVisible(true);
2693
+ }
2694
+ }, [isOpen]);
2695
+ useEffect(() => {
2696
+ if (isOpen) {
2697
+ // Double RAF: frame 1 → element rendered; frame 2 → element painted at hidden
2698
+ // state → THEN trigger the CSS transition.
2699
+ let raf2;
2700
+ const raf1 = requestAnimationFrame(() => {
2701
+ raf2 = requestAnimationFrame(() => setIsFadingIn(true));
2702
+ });
2703
+ return () => {
2704
+ cancelAnimationFrame(raf1);
2705
+ cancelAnimationFrame(raf2);
2706
+ };
2707
+ }
2708
+ else {
2709
+ setIsFadingIn(false);
2710
+ const timeout = setTimeout(() => setIsVisible(false), duration);
2711
+ return () => clearTimeout(timeout);
2712
+ }
2713
+ }, [isOpen, duration]);
2714
+ return { isVisible, isFadingIn };
2715
+ };
2716
+
2717
+ /**
2718
+ * Locks scrolling on `document.body` while `active` is true.
2719
+ *
2720
+ * Preserves the current scroll position and keeps the scrollbar gutter
2721
+ * (`overflow-y: scroll`) to avoid horizontal layout shift when the scrollbar
2722
+ * disappears. The original styles and scroll position are restored on cleanup.
2723
+ *
2724
+ * @example useBodyScrollLock(isDialogOpen)
2725
+ */
2726
+ const useBodyScrollLock = (active) => {
2727
+ useEffect(() => {
2728
+ if (!active) {
2729
+ return;
2730
+ }
2731
+ const scrollY = window.scrollY;
2732
+ const body = document.body;
2733
+ body.style.position = 'fixed';
2734
+ body.style.top = `-${scrollY}px`;
2735
+ body.style.overflowY = 'scroll';
2736
+ body.style.width = '100%';
2737
+ return () => {
2738
+ body.style.position = '';
2739
+ body.style.top = '';
2740
+ body.style.overflowY = '';
2741
+ body.style.width = '';
2742
+ window.scrollTo(0, scrollY);
2743
+ };
2744
+ }, [active]);
2745
+ };
2746
+
2747
+ const DrawerHeader = ({ children, role, ariaLabel, ariaLabelledBy, ariaDescribedBy, ...rest }) => {
2748
+ return (jsx(Box, { px: 'sm', py: 'xs', role: role, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, ...rest, children: children }));
2749
+ };
2750
+
2751
+ const DrawerBody = ({ children, role, ariaLabel, ariaLabelledBy, ariaDescribedBy, ...rest }) => {
2752
+ return (jsx(Stack, { px: 'sm', py: 'xs', height: '100%', flexDirection: 'column', gap: 'none', role: role, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, overflow: 'auto', ...rest, children: children }));
2753
+ };
2754
+
2755
+ const DrawerFooter = ({ children, role, ariaLabel, ariaLabelledBy, ariaDescribedBy, ...rest }) => {
2756
+ return (jsx(Box, { px: 'sm', py: 'xs', role: role, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, ...rest, children: children }));
2757
+ };
2758
+
2759
+ const DRAWER_ITEM_STYLES = createStyles((theme) => ({
2760
+ root: ({ selected, isExpanded }) => ({
2761
+ position: 'relative',
2762
+ display: 'flex',
2763
+ alignItems: 'center',
2764
+ width: isExpanded ? EXPANDED_DRAWER_ITEM_WIDTH : COLLAPSED_DRAWER_ITEM_WIDTH,
2765
+ gap: theme.spacing.sm,
2766
+ padding: `0 ${theme.spacing.smPlus}`,
2767
+ border: '1px solid transparent',
2768
+ borderRadius: theme.radius.md,
2769
+ fontFamily: 'inherit',
2770
+ fontSize: theme.fontSize.sm,
2771
+ userSelect: 'none',
2772
+ cursor: 'pointer',
2773
+ outline: 'none',
2774
+ textDecoration: 'none',
2775
+ boxSizing: 'border-box',
2776
+ transition: `width ${theme.transition.slow}, background-color ${theme.transition.fast}, color ${theme.transition.fast}`,
2777
+ whiteSpace: 'nowrap',
2778
+ overflow: 'hidden',
2779
+ height: DEFAULT_DRAWER_ITEM_SIZE,
2780
+ ...(selected
2781
+ ? {
2782
+ backgroundColor: theme.colors.primarySubtle,
2783
+ color: theme.colors.primaryMain,
2784
+ ':hover': { backgroundColor: theme.colors.primarySubtleHover, color: theme.colors.primaryHover },
2785
+ ':active': { backgroundColor: theme.colors.primarySubtleActive, color: theme.colors.primaryActive },
2786
+ }
2787
+ : {
2788
+ backgroundColor: 'transparent',
2789
+ color: theme.colors.defaultMain,
2790
+ ':hover:not(:disabled)': { backgroundColor: theme.colors.defaultSubtleHover, color: theme.colors.defaultHover },
2791
+ ':active:not(:disabled)': { backgroundColor: theme.colors.defaultSubtleActive, color: theme.colors.defaultActive },
2792
+ }),
2793
+ ':focus-visible': { boxShadow: theme.shadows.focus },
2794
+ ':disabled': { cursor: 'not-allowed', opacity: theme.opacity.high },
2795
+ }),
2796
+ iconWrap: {
2797
+ display: 'flex',
2798
+ alignItems: 'center',
2799
+ justifyContent: 'center',
2800
+ flexShrink: 0,
2801
+ },
2802
+ labelWrapper: ({ isFadingIn }) => ({
2803
+ display: 'flex',
2804
+ alignItems: 'center',
2805
+ flexDirection: 'row',
2806
+ gap: theme.spacing.sm,
2807
+ width: '100%',
2808
+ justifyContent: 'space-between',
2809
+ flex: 1,
2810
+ overflow: 'hidden',
2811
+ minWidth: 0,
2812
+ opacity: isFadingIn ? 1 : 0,
2813
+ transition: `opacity ${DEFAULT_TRANSITION_DURATION_MS}ms ease`,
2814
+ }),
2815
+ label: {
2816
+ flex: 1,
2817
+ overflow: 'hidden',
2818
+ textOverflow: 'ellipsis',
2819
+ fontWeight: theme.fontWeight.medium,
2820
+ },
2821
+ endContent: {
2822
+ display: 'flex',
2823
+ alignItems: 'center',
2824
+ flexShrink: 0,
2825
+ marginLeft: 'auto',
2826
+ },
2827
+ }));
2828
+
2829
+ /**
2830
+ * Navigation/action item for use inside Drawer.Body or Drawer.Footer.
2831
+ *
2832
+ * - In **expanded** mode: shows icon + label (fade-in) + optional end content.
2833
+ * - In **collapsed** mode: shows only the icon; the label fades out before unmounting
2834
+ * and appears as a right-side tooltip on hover.
2835
+ * - Renders as `<a>` when `href` is provided, otherwise as `<button>`.
2836
+ * - The `selected` prop applies a primary-color highlight.
2837
+ */
2838
+ const DrawerItem = ({ startIcon, label, selected = false, endContent, href, onClick, disabled = false, ariaLabel, ariaLabelledBy, ariaDescribedBy, ariaControls, ariaExpanded, ariaHasPopup, ariaCurrent, }) => {
2839
+ const { isExpanded } = useDrawerContext();
2840
+ const { isVisible: isLabelVisible, isFadingIn: isLabelFadingIn } = useTransitionRender(isExpanded);
2841
+ const rootClassName = DRAWER_ITEM_STYLES.root({ selected, isExpanded });
2842
+ const computedAriaLabel = ariaLabel ?? (!isExpanded ? label : undefined);
2843
+ const computedAriaCurrent = ariaCurrent ?? (selected ? 'page' : undefined);
2844
+ const handleClick = (e) => {
2845
+ if (disabled) {
2846
+ e.preventDefault();
2847
+ return;
2848
+ }
2849
+ onClick?.();
2850
+ };
2851
+ const innerContent = (jsxs(Stack, { width: '100%', justifyContent: 'start', children: [jsx("span", { className: DRAWER_ITEM_STYLES.iconWrap, children: jsx(Icon, { icon: startIcon, size: 'md' }) }), isLabelVisible && (jsxs("span", { className: DRAWER_ITEM_STYLES.labelWrapper({ isFadingIn: isLabelFadingIn }), children: [jsx(Text, { variant: 'span', fontSize: 'sm', fontWeight: 'medium', className: DRAWER_ITEM_STYLES.label, textAlign: 'start', children: label }), endContent && (jsx("span", { className: DRAWER_ITEM_STYLES.endContent, children: endContent }))] }))] }));
2852
+ const item = href ? (jsx("a", { href: href, className: rootClassName, "aria-disabled": disabled || undefined, "aria-current": computedAriaCurrent, "aria-label": computedAriaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, "aria-controls": ariaControls, "aria-expanded": ariaExpanded, "aria-haspopup": ariaHasPopup, tabIndex: disabled ? -1 : undefined, onClick: handleClick, children: innerContent })) : (jsx("button", { type: 'button', className: rootClassName, disabled: disabled, "aria-current": computedAriaCurrent, "aria-label": computedAriaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, "aria-controls": ariaControls, "aria-expanded": ariaExpanded, "aria-haspopup": ariaHasPopup, onClick: handleClick, children: innerContent }));
2853
+ return (jsx(Tooltip, { label: label, placement: 'right', inline: true, withArrow: true, disabled: isExpanded || disabled, children: item }));
2854
+ };
2855
+ DrawerItem.displayName = 'DrawerItem';
2856
+
2857
+ /**
2858
+ * Internal component: renders the temporary drawer as a fixed portal overlay
2859
+ * that slides in from the left with a backdrop, animated via `useTransitionRender`.
2860
+ */
2861
+ const DrawerTemporaryPanel = ({ isExpanded, onClose, children, role, ariaLabel, ariaLabelledBy, ariaDescribedBy, }) => {
2862
+ const { isVisible, isFadingIn } = useTransitionRender(isExpanded);
2863
+ useBodyScrollLock(isExpanded);
2864
+ useKeyPress({ Escape: onClose }, { enabled: isExpanded });
2865
+ if (!isVisible) {
2866
+ return null;
2867
+ }
2868
+ 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);
2869
+ };
2870
+ DrawerTemporaryPanel.displayName = 'DrawerTemporaryPanel';
2871
+ // ─── Main Drawer ─────────────────────────────────────────────────────────────
2872
+ /**
2873
+ * Collapsible side navigation drawer with controlled expanded/collapsed state.
2874
+ *
2875
+ * **Variants**
2876
+ * - `'permanent'` (default on desktop): inline drawer that pushes page content.
2877
+ * Toggles between `expanded` and `collapsed` states with animated width.
2878
+ * - `'temporary'` (default on mobile): portal overlay with a backdrop that slides
2879
+ * in from the left. `isExpanded` controls open/closed; `onClose` is called on
2880
+ * backdrop click or Escape.
2881
+ * - Omit `variant` to auto-detect based on viewport width.
2882
+ *
2883
+ * @example Permanent
2884
+ * <Drawer isExpanded={open} onExpandedChange={setOpen} height="100dvh">
2885
+ * <Drawer.Header>…</Drawer.Header>
2886
+ * <Drawer.Body>
2887
+ * <Drawer.Item startIcon={HomeIcon} label="Home" selected />
2888
+ * </Drawer.Body>
2889
+ * </Drawer>
2890
+ *
2891
+ * @example Temporary
2892
+ * <Drawer variant="temporary" isExpanded={open} onClose={() => setOpen(false)}>
2893
+ * …
2894
+ * </Drawer>
2895
+ */
2896
+ const DrawerBase = ({ height = '100dvh', isExpanded, onExpandedChange, onClose, variant, children, role = 'navigation', ariaLabel = 'Navigation', ariaLabelledBy, ariaDescribedBy, }) => {
2897
+ const resolvedVariant = useDrawerVariant(variant);
2898
+ const handleClose = onClose ?? (() => onExpandedChange?.(false));
2899
+ if (resolvedVariant === 'temporary') {
2900
+ return (jsx(DrawerTemporaryPanel, { isExpanded: isExpanded, onClose: handleClose, height: height, role: role, ariaLabel: ariaLabel, ariaLabelledBy: ariaLabelledBy, ariaDescribedBy: ariaDescribedBy, children: children }));
2901
+ }
2902
+ return (jsx(DrawerContext.Provider, { value: { isExpanded }, children: jsx("nav", { className: DRAWER_STYLES.root({ isExpanded }), style: { height }, role: role, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, children: children }) }));
2903
+ };
2904
+ DrawerBase.displayName = 'Drawer';
2905
+ const Drawer = DrawerBase;
2906
+ Drawer.Header = DrawerHeader;
2907
+ Drawer.Body = DrawerBody;
2908
+ Drawer.Footer = DrawerFooter;
2909
+ Drawer.Item = DrawerItem;
2910
+
2343
2911
  const AlertContext = createContext({
2344
2912
  variant: 'default',
2345
2913
  accentColor: 'defaultActive',
@@ -2392,6 +2960,15 @@ const VARIANT_ARIA_LIVE = {
2392
2960
  warning: 'polite',
2393
2961
  info: 'polite',
2394
2962
  };
2963
+ /** role="alert" implies aria-live="assertive" — reserve it for errors.
2964
+ * Other variants use role="status" (implies aria-live="polite"). */
2965
+ const VARIANT_ROLE = {
2966
+ default: 'status',
2967
+ success: 'status',
2968
+ error: 'alert',
2969
+ warning: 'status',
2970
+ info: 'status',
2971
+ };
2395
2972
  /**
2396
2973
  * Inline alert banner with 5 visual variants: default, success, error, warning, info.
2397
2974
  * Use `Alert.Title` (icon + heading) and `Alert.Body` (message) as children.
@@ -2404,38 +2981,15 @@ const VARIANT_ARIA_LIVE = {
2404
2981
  */
2405
2982
  const AlertBase = ({ variant = 'default', children, width = '100%', outline = false, shadow = 'none', }) => {
2406
2983
  const { backgroundColor, borderColor, accentColor } = VARIANT_TOKENS[variant];
2407
- return (jsx(AlertContext.Provider, { value: { variant, accentColor }, children: jsx(Stack, { role: 'alert', "aria-live": VARIANT_ARIA_LIVE[variant], flexDirection: 'column', gap: 'xs', padding: 'md', borderRadius: 'lg', backgroundColor: backgroundColor, borderColor: outline ? borderColor : undefined, borderWidth: outline ? '1px' : undefined, borderStyle: outline ? 'solid' : undefined, boxShadow: shadow, width: width, children: children }) }));
2984
+ return (jsx(AlertContext.Provider, { value: { variant, accentColor }, children: jsx(Stack, { role: VARIANT_ROLE[variant], "aria-live": VARIANT_ARIA_LIVE[variant], flexDirection: 'column', gap: 'xs', padding: 'md', borderRadius: 'lg', backgroundColor: backgroundColor, borderColor: outline ? borderColor : undefined, borderWidth: outline ? '1px' : undefined, borderStyle: outline ? 'solid' : undefined, boxShadow: shadow, width: width, children: children }) }));
2408
2985
  };
2409
2986
  AlertBase.displayName = 'Alert';
2410
2987
  const Alert = AlertBase;
2411
2988
  Alert.Title = AlertTitle;
2412
2989
  Alert.Body = AlertBody;
2413
2990
 
2414
- /**
2415
- * Responsive breakpoints (min-width, mobile-first).
2416
- * Keep in sync with themeBreakpoints.
2417
- */
2418
- const BREAKPOINTS = {
2419
- sm: 640};
2420
- /** Max-width media query strings (max = breakpoint - 1px). */
2421
- const MEDIA_MAX = {
2422
- sm: `max-width: ${BREAKPOINTS.sm - 1}px`};
2423
-
2424
- /** Default duration in milliseconds for mount/unmount transition animations. */
2425
- const DEFAULT_TRANSITION_DURATION_MS = 250;
2426
-
2427
2991
  const TRANSITION = `${DEFAULT_TRANSITION_DURATION_MS}ms ease`;
2428
2992
  const DIALOG_STYLES = createStyles((theme) => ({
2429
- backdrop: {
2430
- position: 'fixed',
2431
- inset: 0,
2432
- zIndex: theme.zIndex.modal - 1,
2433
- backgroundColor: 'rgba(0, 0, 0, 0)',
2434
- transition: `background-color ${TRANSITION}`,
2435
- },
2436
- backdropVisible: {
2437
- backgroundColor: 'rgba(0, 0, 0, 0.5)',
2438
- },
2439
2993
  panel: {
2440
2994
  position: 'fixed',
2441
2995
  inset: 0,
@@ -2493,84 +3047,6 @@ const DialogContext = createContext({
2493
3047
  CloseIconComponent: null,
2494
3048
  });
2495
3049
 
2496
- /**
2497
- * Manages mount/unmount transitions with a two-phase approach (visible → fading-in).
2498
- *
2499
- * Opening sequence:
2500
- * 1. `useLayoutEffect` sets `isVisible=true` synchronously before the browser paints
2501
- * → element is in the DOM at its initial hidden state on the very first frame.
2502
- * 2. Double `requestAnimationFrame` in `useEffect` waits for two rendered frames
2503
- * before setting `isFadingIn=true`, guaranteeing the CSS transition always starts
2504
- * from the painted hidden state (prevents flickering).
2505
- *
2506
- * Closing sequence:
2507
- * `isFadingIn=false` → CSS transition plays → after `duration` ms → `isVisible=false`.
2508
- *
2509
- * @param isOpen - Whether the element should be shown.
2510
- * @param duration - Transition duration in milliseconds (defaults to `DEFAULT_TRANSITION_DURATION_MS`).
2511
- */
2512
- const useTransitionRender = (isOpen, duration = DEFAULT_TRANSITION_DURATION_MS) => {
2513
- const [isVisible, setIsVisible] = useState(isOpen);
2514
- const [isFadingIn, setIsFadingIn] = useState(isOpen);
2515
- // Mount synchronously before paint so the element is in the DOM at opacity:0
2516
- // on the very first frame — no extra render cycle between null and the initial state.
2517
- useLayoutEffect(() => {
2518
- if (isOpen) {
2519
- setIsVisible(true);
2520
- }
2521
- }, [isOpen]);
2522
- useEffect(() => {
2523
- if (isOpen) {
2524
- // Double RAF: frame 1 → element rendered; frame 2 → element painted at hidden
2525
- // state → THEN trigger the CSS transition.
2526
- let raf2;
2527
- const raf1 = requestAnimationFrame(() => {
2528
- raf2 = requestAnimationFrame(() => setIsFadingIn(true));
2529
- });
2530
- return () => {
2531
- cancelAnimationFrame(raf1);
2532
- cancelAnimationFrame(raf2);
2533
- };
2534
- }
2535
- else {
2536
- setIsFadingIn(false);
2537
- const timeout = setTimeout(() => setIsVisible(false), duration);
2538
- return () => clearTimeout(timeout);
2539
- }
2540
- }, [isOpen, duration]);
2541
- return { isVisible, isFadingIn };
2542
- };
2543
-
2544
- /**
2545
- * Locks scrolling on `document.body` while `active` is true.
2546
- *
2547
- * Preserves the current scroll position and keeps the scrollbar gutter
2548
- * (`overflow-y: scroll`) to avoid horizontal layout shift when the scrollbar
2549
- * disappears. The original styles and scroll position are restored on cleanup.
2550
- *
2551
- * @example useBodyScrollLock(isDialogOpen)
2552
- */
2553
- const useBodyScrollLock = (active) => {
2554
- useEffect(() => {
2555
- if (!active) {
2556
- return;
2557
- }
2558
- const scrollY = window.scrollY;
2559
- const body = document.body;
2560
- body.style.position = 'fixed';
2561
- body.style.top = `-${scrollY}px`;
2562
- body.style.overflowY = 'scroll';
2563
- body.style.width = '100%';
2564
- return () => {
2565
- body.style.position = '';
2566
- body.style.top = '';
2567
- body.style.overflowY = '';
2568
- body.style.width = '';
2569
- window.scrollTo(0, scrollY);
2570
- };
2571
- }, [active]);
2572
- };
2573
-
2574
3050
  const FOCUSABLE_SELECTOR = [
2575
3051
  'a[href]',
2576
3052
  'button:not([disabled])',
@@ -2672,7 +3148,7 @@ const useDialog = ({ open, onClose, closeOnBackdropClick, maxWidth, maxHeight, m
2672
3148
  */
2673
3149
  const DialogHeader = ({ title, onClose }) => {
2674
3150
  const { titleId, CloseIconComponent } = useContext(DialogContext);
2675
- return (jsxs(Stack, { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 'md', paddingTop: 'md', paddingBottom: 'md', paddingLeft: 'lg', paddingRight: 'md', flexShrink: 0, children: [jsx(Text, { id: titleId, variant: 'span', fontSize: 'md', fontWeight: 'semibold', color: 'textPrimary', children: title }), jsx(IconButton, { icon: CloseIconComponent, ariaLabel: 'Close dialog', variant: 'text', color: 'neutral', size: 'sm', type: 'button', onClick: onClose })] }));
3151
+ return (jsxs(Stack, { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 'md', paddingTop: 'md', paddingBottom: 'md', paddingLeft: 'lg', paddingRight: 'md', flexShrink: 0, children: [jsx(Text, { id: titleId, variant: 'span', as: 'h2', fontSize: 'md', fontWeight: 'semibold', color: 'textPrimary', children: title }), jsx(IconButton, { icon: CloseIconComponent, ariaLabel: 'Close dialog', variant: 'text', color: 'neutral', size: 'sm', type: 'button', onClick: onClose })] }));
2676
3152
  };
2677
3153
  DialogHeader.displayName = 'Dialog.Header';
2678
3154
 
@@ -2706,7 +3182,7 @@ const DialogBase = ({ open, onClose, children, closeOnBackdropClick = false, ful
2706
3182
  if (!isVisible) {
2707
3183
  return null;
2708
3184
  }
2709
- return createPortal(jsxs(Fragment$1, { children: [jsx("div", { className: cx(DIALOG_STYLES.backdrop, isFadingIn && DIALOG_STYLES.backdropVisible), onClick: handleBackdropClick, "aria-hidden": true }), 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);
3185
+ 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);
2710
3186
  };
2711
3187
  DialogBase.displayName = 'Dialog';
2712
3188
  const Dialog = DialogBase;
@@ -2917,13 +3393,19 @@ const themeShadows = {
2917
3393
 
2918
3394
  /**
2919
3395
  * Default spacing tokens
3396
+ *
3397
+ * ⚠️ Token keys MUST stay CSS-custom-property safe (letters, digits, hyphens only).
3398
+ * They are turned into CSS variables (e.g. `smPlus` → `--theme-spacing-sm-plus`) by
3399
+ * the theme proxy, so characters like `+` would produce invalid variable names and
3400
+ * silently break any style that uses them.
2920
3401
  */
2921
3402
  const themeSpacing = {
2922
3403
  none: '0',
2923
3404
  '2xs': '0.125rem', // 2px
2924
3405
  xs: '0.25rem', // 4px
2925
- 'xs+': '0.375rem', // 6px
3406
+ xsPlus: '0.375rem', // 6px
2926
3407
  sm: '0.5rem', // 8px
3408
+ smPlus: '0.75rem', // 12px
2927
3409
  md: '1rem', // 16px
2928
3410
  lg: '1.5rem', // 24px
2929
3411
  xl: '2rem', // 32px
@@ -2937,9 +3419,9 @@ const themeSpacing = {
2937
3419
  * Default transition tokens
2938
3420
  */
2939
3421
  const themeTransition = {
2940
- fast: '150ms ease-out',
2941
- normal: '250ms ease-out',
2942
- slow: '350ms ease-out',
3422
+ fast: '150ms ease-in-out',
3423
+ normal: '250ms ease-in-out',
3424
+ slow: '350ms ease-in-out',
2943
3425
  };
2944
3426
 
2945
3427
  /**
@@ -3094,5 +3576,5 @@ const darkTheme = createTheme({
3094
3576
  breakpoints: themeBreakpoints,
3095
3577
  });
3096
3578
 
3097
- export { Alert, Badge, Box, Button, Card, Dialog, Form, Grid, Icon, IconButton, InfoBubble, Link, Menu, Select, Skeleton, Stack, Switch, Text, TextField, Tooltip, darkTheme, lightTheme };
3579
+ export { Alert, Backdrop, Badge, Box, Button, Card, Checkbox, Dialog, Drawer, Form, Grid, Icon, IconButton, InfoBubble, Link, Menu, Select, Skeleton, Stack, Switch, Text, TextField, Tooltip, darkTheme, lightTheme, useDrawerContext };
3098
3580
  //# sourceMappingURL=index.js.map