@aurora-ds/components 1.1.5 → 1.1.7

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 }) => ({
@@ -559,7 +577,7 @@ const ICON_SIZE$2 = {
559
577
  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 }) => {
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 } : {}),
@@ -570,21 +588,15 @@ const Button = ({ ref, variant = 'contained', color = 'primary', size = 'md', wi
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';
@@ -835,7 +851,7 @@ const BADGE_VARIANTS = createVariants((theme) => ({
835
851
  size: {
836
852
  sm: {
837
853
  height: '1.25rem',
838
- padding: `0.125rem ${theme.spacing['xs+']}`,
854
+ padding: `0.125rem ${theme.spacing.xsPlus}`,
839
855
  fontSize: theme.fontSize['2xs'],
840
856
  },
841
857
  md: {
@@ -1236,6 +1252,12 @@ const SKELETON_VARIANTS = createVariants((theme) => ({
1236
1252
  const Skeleton = ({ ref, variant = 'rectangular', animation = 'shimmer', width, height, className, style, ...rest }) => (jsx("span", { ref: ref, className: SKELETON_VARIANTS({ variant, animation: animation === false ? 'none' : animation }, className), style: { width, height, ...style }, "aria-hidden": true, ...rest }));
1237
1253
  Skeleton.displayName = 'Skeleton';
1238
1254
 
1255
+ const FORM_STYLES = createStyles(() => ({
1256
+ root: {
1257
+ display: 'contents',
1258
+ },
1259
+ }));
1260
+
1239
1261
  /**
1240
1262
  * Thin wrapper around `<form>`. Prevents the default browser submit and
1241
1263
  * delegates to the `onSubmit` callback.
@@ -1251,7 +1273,7 @@ const Form = ({ children, onSubmit, 'aria-label': ariaLabel, 'aria-labelledby':
1251
1273
  event.preventDefault();
1252
1274
  onSubmit(event);
1253
1275
  };
1254
- return (jsx("form", { onSubmit: handleSubmit, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, noValidate: true, children: children }));
1276
+ return (jsx("form", { onSubmit: handleSubmit, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, className: FORM_STYLES.root, noValidate: true, children: children }));
1255
1277
  };
1256
1278
  Form.displayName = 'Form';
1257
1279
 
@@ -1559,6 +1581,18 @@ Box.displayName = 'Box';
1559
1581
  const Stack = ({ flexDirection = 'row', display = 'flex', gap = 'sm', ...rest }) => (jsx(Box, { display: display, flexDirection: flexDirection, gap: gap, ...rest }));
1560
1582
  Stack.displayName = 'Stack';
1561
1583
 
1584
+ const HELPER_COLOR_MAP = {
1585
+ default: 'textSecondary',
1586
+ error: 'errorHover',
1587
+ success: 'successHover',
1588
+ warning: 'warningHover',
1589
+ };
1590
+ const FormHelperText = ({ id, status = 'default', ariaLive, className, children, }) => {
1591
+ const resolvedAriaLive = ariaLive ?? (status === 'error' ? 'assertive' : 'polite');
1592
+ return (jsx(Text, { id: id, variant: 'span', fontSize: 'xs', color: HELPER_COLOR_MAP[status], "aria-live": resolvedAriaLive, className: className, children: children }));
1593
+ };
1594
+ FormHelperText.displayName = 'FormHelperText';
1595
+
1562
1596
  const TEXTFIELD_WRAPPER_VARIANTS = createVariants((theme) => {
1563
1597
  const c = theme.colors;
1564
1598
  return {
@@ -1681,18 +1715,11 @@ const ICON_BUTTON_SIZE_MAP = {
1681
1715
  md: 'sm',
1682
1716
  lg: 'md',
1683
1717
  };
1684
- /** Maps status to a theme color token used for the helper text. */
1685
- const HELPER_COLOR_MAP$1 = {
1686
- default: 'textSecondary',
1687
- error: 'errorHover',
1688
- success: 'successHover',
1689
- warning: 'warningHover',
1690
- };
1691
1718
  /**
1692
1719
  * Business logic for the TextField component: id resolution, ref merging,
1693
1720
  * password visibility toggling and size/status derived tokens.
1694
1721
  */
1695
- const useTextField = ({ id, ref, type, size, status, endAction, }) => {
1722
+ const useTextField = ({ id, ref, type, size, endAction, }) => {
1696
1723
  const generatedId = useId();
1697
1724
  const fieldId = id ?? generatedId;
1698
1725
  const helperId = `${fieldId}-helper`;
@@ -1714,7 +1741,6 @@ const useTextField = ({ id, ref, type, size, status, endAction, }) => {
1714
1741
  resolvedType,
1715
1742
  iconSize: ICON_SIZE_MAP$1[size],
1716
1743
  iconButtonSize: ICON_BUTTON_SIZE_MAP[size],
1717
- helperColor: HELPER_COLOR_MAP$1[status],
1718
1744
  hasEndSection: endAction !== undefined || isPassword,
1719
1745
  focusInput,
1720
1746
  };
@@ -1732,8 +1758,8 @@ const useTextField = ({ id, ref, type, size, status, endAction, }) => {
1732
1758
  * @example <TextField label="Search" startIcon={SearchIcon} endAction={<ClearButton />} />
1733
1759
  */
1734
1760
  const TextField = ({ ref, label, helperText, size = 'md', status = 'default', startIcon: StartIcon, endAction, type, id, disabled, required, ...rest }) => {
1735
- const { fieldId, helperId, mergedRef, isPassword, showPassword, togglePassword, resolvedType, iconSize, iconButtonSize, helperColor, hasEndSection, focusInput, } = useTextField({ id, ref, type, size, status, endAction });
1736
- 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 }))] }));
1761
+ const { fieldId, helperId, mergedRef, isPassword, showPassword, togglePassword, resolvedType, iconSize, iconButtonSize, hasEndSection, focusInput, } = useTextField({ id, ref, type, size, endAction });
1762
+ 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(FormHelperText, { id: helperId, status: status, children: helperText }))] }));
1737
1763
  };
1738
1764
  TextField.displayName = 'TextField';
1739
1765
 
@@ -2169,21 +2195,15 @@ const SelectTrigger = ({ ref, size = 'md', status = 'default', open, hasValue, s
2169
2195
  };
2170
2196
  SelectTrigger.displayName = 'SelectTrigger';
2171
2197
 
2172
- /** Maps status to a theme color token used for the helper text. */
2173
- const HELPER_COLOR_MAP = {
2174
- default: 'textSecondary',
2175
- error: 'errorHover',
2176
- success: 'successHover',
2177
- warning: 'warningHover',
2178
- };
2179
2198
  /**
2180
2199
  * Business logic for the Select component: id resolution, ref merging,
2181
2200
  * controlled/uncontrolled value handling, open state, option grouping and
2182
2201
  * focus restoration to the trigger when the menu closes.
2183
2202
  */
2184
- const useSelect = ({ id, ref, value, defaultValue, onChange, options, status, disabled, }) => {
2203
+ const useSelect = ({ id, ref, value, defaultValue, onChange, options, disabled, }) => {
2185
2204
  const generatedId = useId();
2186
2205
  const fieldId = id ?? generatedId;
2206
+ const helperId = `${fieldId}-helper`;
2187
2207
  const menuId = `${fieldId}-menu`;
2188
2208
  const triggerRef = useRef(null);
2189
2209
  const mergedRef = useMergedRefs(ref, triggerRef);
@@ -2228,6 +2248,7 @@ const useSelect = ({ id, ref, value, defaultValue, onChange, options, status, di
2228
2248
  const close = useCallback(() => setOpen(false), []);
2229
2249
  return {
2230
2250
  fieldId,
2251
+ helperId,
2231
2252
  menuId,
2232
2253
  triggerRef,
2233
2254
  mergedRef,
@@ -2238,19 +2259,187 @@ const useSelect = ({ id, ref, value, defaultValue, onChange, options, status, di
2238
2259
  selectedOption,
2239
2260
  groupedOptions,
2240
2261
  handleSelect,
2241
- helperColor: HELPER_COLOR_MAP[status],
2242
2262
  };
2243
2263
  };
2244
2264
 
2245
2265
  const Select = ({ ref, value, defaultValue, onChange, options, label, helperText, placeholder, size = 'md', status = 'default', disabled, required, width, id, }) => {
2246
- const { fieldId, menuId, triggerRef, mergedRef, open, toggle, close, currentValue, selectedOption, groupedOptions, handleSelect, helperColor, } = useSelect({ id, ref, value, defaultValue, onChange, options, status, disabled });
2247
- 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) => {
2266
+ const { fieldId, helperId, menuId, triggerRef, mergedRef, open, toggle, close, currentValue, selectedOption, groupedOptions, handleSelect, } = useSelect({ id, ref, value, defaultValue, onChange, options, disabled });
2267
+ 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, "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, children: Array.from(groupedOptions.entries()).map(([groupKey, groupOpts], groupIndex) => {
2248
2268
  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)));
2249
2269
  return groupKey !== undefined ? (jsx(Menu.Group, { label: groupKey, divider: groupIndex > 0, children: items }, groupKey)) : (jsx(Menu.Group, { divider: groupIndex > 0, children: items }, '__ungrouped'));
2250
- }) }), helperText !== undefined && (jsx(Text, { variant: 'span', fontSize: 'xs', color: helperColor, children: helperText }))] }));
2270
+ }) }), helperText !== undefined && (jsx(FormHelperText, { id: helperId, status: status, children: helperText }))] }));
2251
2271
  };
2252
2272
  Select.displayName = 'Select';
2253
2273
 
2274
+ const CHECKBOX_ROOT_VARIANTS = createVariants((theme) => ({
2275
+ base: {
2276
+ display: 'inline-flex',
2277
+ alignItems: 'center',
2278
+ gap: theme.spacing.sm,
2279
+ cursor: 'pointer',
2280
+ userSelect: 'none',
2281
+ },
2282
+ variants: {
2283
+ disabled: {
2284
+ true: {
2285
+ cursor: 'not-allowed',
2286
+ opacity: theme.opacity.high,
2287
+ },
2288
+ false: {},
2289
+ },
2290
+ },
2291
+ defaultVariants: {
2292
+ disabled: 'false',
2293
+ },
2294
+ }), { id: 'checkbox-root' });
2295
+ const CHECKBOX_INPUT_VARIANTS = createVariants((theme) => {
2296
+ const c = theme.colors;
2297
+ return {
2298
+ base: {
2299
+ appearance: 'none',
2300
+ position: 'relative',
2301
+ margin: 0,
2302
+ borderWidth: '1px',
2303
+ borderStyle: 'solid',
2304
+ borderColor: c.borderStrong,
2305
+ borderRadius: theme.radius.sm,
2306
+ backgroundColor: c.surfacePaper,
2307
+ transition: `border-color ${theme.transition.fast}, background-color ${theme.transition.fast}`,
2308
+ cursor: 'pointer',
2309
+ '&:focus-visible': {
2310
+ outline: '2px solid transparent',
2311
+ boxShadow: `0 0 0 2px ${c.primarySubtleActive}`,
2312
+ },
2313
+ '&::after': {
2314
+ content: '""',
2315
+ position: 'absolute',
2316
+ left: '50%',
2317
+ top: '50%',
2318
+ transform: 'translate(-50%, -56%) rotate(45deg)',
2319
+ width: '0.25rem',
2320
+ height: '0.5rem',
2321
+ borderRight: `2px solid ${c.textInverse}`,
2322
+ borderBottom: `2px solid ${c.textInverse}`,
2323
+ opacity: 0,
2324
+ },
2325
+ '&:checked::after': {
2326
+ opacity: 1,
2327
+ },
2328
+ '&:indeterminate::after': {
2329
+ width: '0.5rem',
2330
+ height: '0',
2331
+ borderRight: '0',
2332
+ borderBottom: `2px solid ${c.textInverse}`,
2333
+ transform: 'translate(-50%, -50%)',
2334
+ opacity: 1,
2335
+ },
2336
+ },
2337
+ variants: {
2338
+ size: {
2339
+ sm: {
2340
+ width: '1rem',
2341
+ height: '1rem',
2342
+ },
2343
+ md: {
2344
+ width: '1.125rem',
2345
+ height: '1.125rem',
2346
+ },
2347
+ lg: {
2348
+ width: '1.25rem',
2349
+ height: '1.25rem',
2350
+ },
2351
+ },
2352
+ status: {
2353
+ default: {
2354
+ '&:checked, &:indeterminate': {
2355
+ borderColor: c.primaryMain,
2356
+ backgroundColor: c.primaryMain,
2357
+ },
2358
+ },
2359
+ error: {
2360
+ borderColor: c.errorMain,
2361
+ '&:checked, &:indeterminate': {
2362
+ borderColor: c.errorMain,
2363
+ backgroundColor: c.errorMain,
2364
+ },
2365
+ },
2366
+ success: {
2367
+ borderColor: c.successMain,
2368
+ '&:checked, &:indeterminate': {
2369
+ borderColor: c.successMain,
2370
+ backgroundColor: c.successMain,
2371
+ },
2372
+ },
2373
+ warning: {
2374
+ borderColor: c.warningMain,
2375
+ '&:checked, &:indeterminate': {
2376
+ borderColor: c.warningMain,
2377
+ backgroundColor: c.warningMain,
2378
+ },
2379
+ },
2380
+ },
2381
+ disabled: {
2382
+ true: {
2383
+ cursor: 'not-allowed',
2384
+ backgroundColor: c.disabledMain,
2385
+ borderColor: c.disabledMain,
2386
+ '&:checked, &:indeterminate': {
2387
+ backgroundColor: c.disabledText,
2388
+ borderColor: c.disabledText,
2389
+ },
2390
+ },
2391
+ false: {},
2392
+ },
2393
+ },
2394
+ defaultVariants: {
2395
+ size: 'md',
2396
+ status: 'default',
2397
+ disabled: 'false',
2398
+ },
2399
+ };
2400
+ }, { id: 'checkbox-input' });
2401
+ const CHECKBOX_STYLES = createStyles((theme) => ({
2402
+ wrapper: {
2403
+ display: 'inline-flex',
2404
+ flexDirection: 'column',
2405
+ gap: theme.spacing.xs,
2406
+ },
2407
+ helper: {
2408
+ marginLeft: `calc(1.125rem + ${theme.spacing.sm})`,
2409
+ },
2410
+ }), { id: 'checkbox-extra' });
2411
+
2412
+ /** Handles id generation, ref merging and native indeterminate state sync. */
2413
+ const useCheckbox = ({ id, ref, indeterminate = false }) => {
2414
+ const generatedId = useId();
2415
+ const checkboxId = id ?? generatedId;
2416
+ const helperId = `${checkboxId}-helper`;
2417
+ const inputRef = useRef(null);
2418
+ const mergedRef = useMergedRefs(ref, inputRef);
2419
+ useEffect(() => {
2420
+ if (inputRef.current) {
2421
+ inputRef.current.indeterminate = indeterminate;
2422
+ }
2423
+ }, [indeterminate]);
2424
+ return {
2425
+ checkboxId,
2426
+ helperId,
2427
+ mergedRef,
2428
+ inputRef,
2429
+ };
2430
+ };
2431
+
2432
+ const Checkbox = ({ ref, label, helperText, size = 'md', status = 'default', indeterminate = false, error, id, disabled, required, ...rest }) => {
2433
+ const resolvedStatus = error ? 'error' : status;
2434
+ const { checkboxId, helperId, mergedRef } = useCheckbox({ id, ref, indeterminate });
2435
+ 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, className: CHECKBOX_INPUT_VARIANTS({
2436
+ size,
2437
+ status: resolvedStatus,
2438
+ disabled: disabled ? 'true' : 'false',
2439
+ }), ...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 }))] }));
2440
+ };
2441
+ Checkbox.displayName = 'Checkbox';
2442
+
2254
2443
  const CARD_VARIANTS = createVariants((theme) => ({
2255
2444
  base: {
2256
2445
  boxSizing: 'border-box',
@@ -2334,6 +2523,361 @@ const Grid = ({ display = 'grid', columns, rows, autoFlow, autoColumns, autoRows
2334
2523
  };
2335
2524
  Grid.displayName = 'Grid';
2336
2525
 
2526
+ const DrawerContext = createContext({
2527
+ isExpanded: true,
2528
+ });
2529
+ const useDrawerContext = () => useContext(DrawerContext);
2530
+
2531
+ const TRANSITION$2 = `${DEFAULT_TRANSITION_DURATION_MS}ms ease`;
2532
+ const DRAWER_STYLES = createStyles((theme) => ({
2533
+ root: ({ isExpanded }) => ({
2534
+ display: 'flex',
2535
+ flexDirection: 'column',
2536
+ width: isExpanded ? EXPANDED_DRAWER_WIDTH : COLLAPSED_DRAWER_WIDTH,
2537
+ transition: `width ${theme.transition.slow}`,
2538
+ overflow: 'hidden',
2539
+ backgroundColor: theme.colors.surfacePaper,
2540
+ borderRight: `1px solid ${theme.colors.borderMain}`,
2541
+ boxSizing: 'border-box',
2542
+ flexShrink: 0,
2543
+ }),
2544
+ /** Temporary variant: slides in from the left as a fixed portal overlay. */
2545
+ temporaryPanel: {
2546
+ position: 'fixed',
2547
+ top: 0,
2548
+ left: 0,
2549
+ bottom: 0,
2550
+ width: EXPANDED_DRAWER_WIDTH,
2551
+ zIndex: theme.zIndex.modal,
2552
+ display: 'flex',
2553
+ flexDirection: 'column',
2554
+ backgroundColor: theme.colors.surfacePaper,
2555
+ borderRight: `1px solid ${theme.colors.borderMain}`,
2556
+ boxSizing: 'border-box',
2557
+ overflowY: 'auto',
2558
+ overflowX: 'hidden',
2559
+ willChange: 'transform',
2560
+ transform: 'translateX(-100%)',
2561
+ transition: `transform ${TRANSITION$2}`,
2562
+ boxShadow: theme.shadows.xl,
2563
+ },
2564
+ temporaryPanelVisible: {
2565
+ transform: 'translateX(0)',
2566
+ },
2567
+ }));
2568
+
2569
+ /**
2570
+ * Responsive breakpoints (min-width, mobile-first).
2571
+ * Keep in sync with themeBreakpoints.
2572
+ */
2573
+ const BREAKPOINTS = {
2574
+ sm: 640};
2575
+ /** Max-width media query strings (max = breakpoint - 1px). */
2576
+ const MEDIA_MAX = {
2577
+ sm: `max-width: ${BREAKPOINTS.sm - 1}px`};
2578
+
2579
+ const MOBILE_MQ = `(max-width: ${BREAKPOINTS.sm - 1}px)`;
2580
+ /**
2581
+ * Resolves the effective drawer variant based on the explicit `variant` prop
2582
+ * and the current viewport width.
2583
+ *
2584
+ * - If `variant` is explicitly provided (`'permanent'` or `'temporary'`), returns it as-is.
2585
+ * - Otherwise, auto-detects: `'temporary'` on mobile (< sm breakpoint), `'permanent'` on desktop.
2586
+ *
2587
+ * Reacts to viewport changes (window resize) so switching between mobile and desktop
2588
+ * automatically updates the variant without requiring a page reload.
2589
+ */
2590
+ const useDrawerVariant = (variant) => {
2591
+ const [isMobile, setIsMobile] = useState(() => {
2592
+ if (typeof window === 'undefined') {
2593
+ return false;
2594
+ }
2595
+ return window.matchMedia(MOBILE_MQ).matches;
2596
+ });
2597
+ useEffect(() => {
2598
+ if (typeof window === 'undefined') {
2599
+ return;
2600
+ }
2601
+ const mq = window.matchMedia(MOBILE_MQ);
2602
+ const handler = (e) => setIsMobile(e.matches);
2603
+ mq.addEventListener('change', handler);
2604
+ return () => mq.removeEventListener('change', handler);
2605
+ }, []);
2606
+ if (variant !== undefined) {
2607
+ return variant;
2608
+ }
2609
+ return isMobile ? 'temporary' : 'permanent';
2610
+ };
2611
+
2612
+ const TRANSITION$1 = `${DEFAULT_TRANSITION_DURATION_MS}ms ease`;
2613
+ const BACKDROP_STYLES = createStyles((theme) => ({
2614
+ root: {
2615
+ position: 'fixed',
2616
+ inset: 0,
2617
+ zIndex: theme.zIndex.modal - 1,
2618
+ backgroundColor: 'rgba(0, 0, 0, 0)',
2619
+ transition: `background-color ${TRANSITION$1}`,
2620
+ },
2621
+ visible: {
2622
+ backgroundColor: 'rgba(0, 0, 0, 0.5)',
2623
+ },
2624
+ }), { id: 'backdrop' });
2625
+
2626
+ /**
2627
+ * Semi-transparent full-screen overlay used to visually block page content
2628
+ * while a modal element (dialog, temporary drawer…) is open.
2629
+ *
2630
+ * The `visible` prop drives the opacity transition: `false` = transparent,
2631
+ * `true` = `rgba(0,0,0,0.5)`. Mount/unmount is managed by the parent.
2632
+ *
2633
+ * @example
2634
+ * <Backdrop visible={isFadingIn} onClick={onClose} />
2635
+ */
2636
+ const Backdrop = ({ visible, onClick }) => (jsx("div", { className: cx(BACKDROP_STYLES.root, visible && BACKDROP_STYLES.visible), onClick: onClick, "aria-hidden": true }));
2637
+ Backdrop.displayName = 'Backdrop';
2638
+
2639
+ /**
2640
+ * Manages mount/unmount transitions with a two-phase approach (visible → fading-in).
2641
+ *
2642
+ * Opening sequence:
2643
+ * 1. `useLayoutEffect` sets `isVisible=true` synchronously before the browser paints
2644
+ * → element is in the DOM at its initial hidden state on the very first frame.
2645
+ * 2. Double `requestAnimationFrame` in `useEffect` waits for two rendered frames
2646
+ * before setting `isFadingIn=true`, guaranteeing the CSS transition always starts
2647
+ * from the painted hidden state (prevents flickering).
2648
+ *
2649
+ * Closing sequence:
2650
+ * `isFadingIn=false` → CSS transition plays → after `duration` ms → `isVisible=false`.
2651
+ *
2652
+ * @param isOpen - Whether the element should be shown.
2653
+ * @param duration - Transition duration in milliseconds (defaults to `DEFAULT_TRANSITION_DURATION_MS`).
2654
+ */
2655
+ const useTransitionRender = (isOpen, duration = DEFAULT_TRANSITION_DURATION_MS) => {
2656
+ const [isVisible, setIsVisible] = useState(isOpen);
2657
+ const [isFadingIn, setIsFadingIn] = useState(isOpen);
2658
+ // Mount synchronously before paint so the element is in the DOM at opacity:0
2659
+ // on the very first frame — no extra render cycle between null and the initial state.
2660
+ useLayoutEffect(() => {
2661
+ if (isOpen) {
2662
+ setIsVisible(true);
2663
+ }
2664
+ }, [isOpen]);
2665
+ useEffect(() => {
2666
+ if (isOpen) {
2667
+ // Double RAF: frame 1 → element rendered; frame 2 → element painted at hidden
2668
+ // state → THEN trigger the CSS transition.
2669
+ let raf2;
2670
+ const raf1 = requestAnimationFrame(() => {
2671
+ raf2 = requestAnimationFrame(() => setIsFadingIn(true));
2672
+ });
2673
+ return () => {
2674
+ cancelAnimationFrame(raf1);
2675
+ cancelAnimationFrame(raf2);
2676
+ };
2677
+ }
2678
+ else {
2679
+ setIsFadingIn(false);
2680
+ const timeout = setTimeout(() => setIsVisible(false), duration);
2681
+ return () => clearTimeout(timeout);
2682
+ }
2683
+ }, [isOpen, duration]);
2684
+ return { isVisible, isFadingIn };
2685
+ };
2686
+
2687
+ /**
2688
+ * Locks scrolling on `document.body` while `active` is true.
2689
+ *
2690
+ * Preserves the current scroll position and keeps the scrollbar gutter
2691
+ * (`overflow-y: scroll`) to avoid horizontal layout shift when the scrollbar
2692
+ * disappears. The original styles and scroll position are restored on cleanup.
2693
+ *
2694
+ * @example useBodyScrollLock(isDialogOpen)
2695
+ */
2696
+ const useBodyScrollLock = (active) => {
2697
+ useEffect(() => {
2698
+ if (!active) {
2699
+ return;
2700
+ }
2701
+ const scrollY = window.scrollY;
2702
+ const body = document.body;
2703
+ body.style.position = 'fixed';
2704
+ body.style.top = `-${scrollY}px`;
2705
+ body.style.overflowY = 'scroll';
2706
+ body.style.width = '100%';
2707
+ return () => {
2708
+ body.style.position = '';
2709
+ body.style.top = '';
2710
+ body.style.overflowY = '';
2711
+ body.style.width = '';
2712
+ window.scrollTo(0, scrollY);
2713
+ };
2714
+ }, [active]);
2715
+ };
2716
+
2717
+ const DrawerHeader = ({ children, role, ariaLabel, ariaLabelledBy, ariaDescribedBy, ...rest }) => {
2718
+ return (jsx(Box, { px: 'sm', py: 'xs', role: role, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, ...rest, children: children }));
2719
+ };
2720
+
2721
+ const DrawerBody = ({ children, role, ariaLabel, ariaLabelledBy, ariaDescribedBy, ...rest }) => {
2722
+ 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 }));
2723
+ };
2724
+
2725
+ const DrawerFooter = ({ children, role, ariaLabel, ariaLabelledBy, ariaDescribedBy, ...rest }) => {
2726
+ return (jsx(Box, { px: 'sm', py: 'xs', role: role, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, ...rest, children: children }));
2727
+ };
2728
+
2729
+ const DRAWER_ITEM_STYLES = createStyles((theme) => ({
2730
+ root: ({ selected, isExpanded }) => ({
2731
+ position: 'relative',
2732
+ display: 'flex',
2733
+ alignItems: 'center',
2734
+ width: isExpanded ? EXPANDED_DRAWER_ITEM_WIDTH : COLLAPSED_DRAWER_ITEM_WIDTH,
2735
+ gap: theme.spacing.sm,
2736
+ padding: `0 ${theme.spacing.smPlus}`,
2737
+ border: '1px solid transparent',
2738
+ borderRadius: theme.radius.md,
2739
+ fontFamily: 'inherit',
2740
+ fontSize: theme.fontSize.sm,
2741
+ userSelect: 'none',
2742
+ cursor: 'pointer',
2743
+ outline: 'none',
2744
+ textDecoration: 'none',
2745
+ boxSizing: 'border-box',
2746
+ transition: `width ${theme.transition.slow}, background-color ${theme.transition.fast}, color ${theme.transition.fast}`,
2747
+ whiteSpace: 'nowrap',
2748
+ overflow: 'hidden',
2749
+ height: DEFAULT_DRAWER_ITEM_SIZE,
2750
+ ...(selected
2751
+ ? {
2752
+ backgroundColor: theme.colors.primarySubtle,
2753
+ color: theme.colors.primaryMain,
2754
+ ':hover': { backgroundColor: theme.colors.primarySubtleHover, color: theme.colors.primaryHover },
2755
+ ':active': { backgroundColor: theme.colors.primarySubtleActive, color: theme.colors.primaryActive },
2756
+ }
2757
+ : {
2758
+ backgroundColor: 'transparent',
2759
+ color: theme.colors.defaultMain,
2760
+ ':hover:not(:disabled)': { backgroundColor: theme.colors.defaultSubtleHover, color: theme.colors.defaultHover },
2761
+ ':active:not(:disabled)': { backgroundColor: theme.colors.defaultSubtleActive, color: theme.colors.defaultActive },
2762
+ }),
2763
+ ':focus-visible': { boxShadow: theme.shadows.focus },
2764
+ ':disabled': { cursor: 'not-allowed', opacity: theme.opacity.high },
2765
+ }),
2766
+ iconWrap: {
2767
+ display: 'flex',
2768
+ alignItems: 'center',
2769
+ justifyContent: 'center',
2770
+ flexShrink: 0,
2771
+ },
2772
+ labelWrapper: ({ isFadingIn }) => ({
2773
+ display: 'flex',
2774
+ alignItems: 'center',
2775
+ flexDirection: 'row',
2776
+ gap: theme.spacing.sm,
2777
+ width: '100%',
2778
+ justifyContent: 'space-between',
2779
+ flex: 1,
2780
+ overflow: 'hidden',
2781
+ minWidth: 0,
2782
+ opacity: isFadingIn ? 1 : 0,
2783
+ transition: `opacity ${DEFAULT_TRANSITION_DURATION_MS}ms ease`,
2784
+ }),
2785
+ label: {
2786
+ flex: 1,
2787
+ overflow: 'hidden',
2788
+ textOverflow: 'ellipsis',
2789
+ fontWeight: theme.fontWeight.medium,
2790
+ },
2791
+ endContent: {
2792
+ display: 'flex',
2793
+ alignItems: 'center',
2794
+ flexShrink: 0,
2795
+ marginLeft: 'auto',
2796
+ },
2797
+ }));
2798
+
2799
+ /**
2800
+ * Navigation/action item for use inside Drawer.Body or Drawer.Footer.
2801
+ *
2802
+ * - In **expanded** mode: shows icon + label (fade-in) + optional end content.
2803
+ * - In **collapsed** mode: shows only the icon; the label fades out before unmounting
2804
+ * and appears as a right-side tooltip on hover.
2805
+ * - Renders as `<a>` when `href` is provided, otherwise as `<button>`.
2806
+ * - The `selected` prop applies a primary-color highlight.
2807
+ */
2808
+ const DrawerItem = ({ startIcon, label, selected = false, endContent, href, onClick, disabled = false, ariaLabel, ariaLabelledBy, ariaDescribedBy, ariaControls, ariaExpanded, ariaHasPopup, ariaCurrent, }) => {
2809
+ const { isExpanded } = useDrawerContext();
2810
+ const { isVisible: isLabelVisible, isFadingIn: isLabelFadingIn } = useTransitionRender(isExpanded);
2811
+ const rootClassName = DRAWER_ITEM_STYLES.root({ selected, isExpanded });
2812
+ const computedAriaLabel = ariaLabel ?? (!isExpanded ? label : undefined);
2813
+ const computedAriaCurrent = ariaCurrent ?? (selected ? 'page' : undefined);
2814
+ const handleClick = (e) => {
2815
+ if (disabled) {
2816
+ e.preventDefault();
2817
+ return;
2818
+ }
2819
+ onClick?.();
2820
+ };
2821
+ 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 }))] }))] }));
2822
+ 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 }));
2823
+ return (jsx(Tooltip, { label: label, placement: 'right', inline: true, withArrow: true, disabled: isExpanded || disabled, children: item }));
2824
+ };
2825
+ DrawerItem.displayName = 'DrawerItem';
2826
+
2827
+ /**
2828
+ * Internal component: renders the temporary drawer as a fixed portal overlay
2829
+ * that slides in from the left with a backdrop, animated via `useTransitionRender`.
2830
+ */
2831
+ const DrawerTemporaryPanel = ({ isExpanded, onClose, children, role, ariaLabel, ariaLabelledBy, ariaDescribedBy, }) => {
2832
+ const { isVisible, isFadingIn } = useTransitionRender(isExpanded);
2833
+ useBodyScrollLock(isExpanded);
2834
+ useKeyPress({ Escape: onClose }, { enabled: isExpanded });
2835
+ if (!isVisible) {
2836
+ return null;
2837
+ }
2838
+ 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);
2839
+ };
2840
+ DrawerTemporaryPanel.displayName = 'DrawerTemporaryPanel';
2841
+ // ─── Main Drawer ─────────────────────────────────────────────────────────────
2842
+ /**
2843
+ * Collapsible side navigation drawer with controlled expanded/collapsed state.
2844
+ *
2845
+ * **Variants**
2846
+ * - `'permanent'` (default on desktop): inline drawer that pushes page content.
2847
+ * Toggles between `expanded` and `collapsed` states with animated width.
2848
+ * - `'temporary'` (default on mobile): portal overlay with a backdrop that slides
2849
+ * in from the left. `isExpanded` controls open/closed; `onClose` is called on
2850
+ * backdrop click or Escape.
2851
+ * - Omit `variant` to auto-detect based on viewport width.
2852
+ *
2853
+ * @example Permanent
2854
+ * <Drawer isExpanded={open} onExpandedChange={setOpen} height="100dvh">
2855
+ * <Drawer.Header>…</Drawer.Header>
2856
+ * <Drawer.Body>
2857
+ * <Drawer.Item startIcon={HomeIcon} label="Home" selected />
2858
+ * </Drawer.Body>
2859
+ * </Drawer>
2860
+ *
2861
+ * @example Temporary
2862
+ * <Drawer variant="temporary" isExpanded={open} onClose={() => setOpen(false)}>
2863
+ * …
2864
+ * </Drawer>
2865
+ */
2866
+ const DrawerBase = ({ height = '100dvh', isExpanded, onExpandedChange, onClose, variant, children, role = 'navigation', ariaLabel = 'Navigation', ariaLabelledBy, ariaDescribedBy, }) => {
2867
+ const resolvedVariant = useDrawerVariant(variant);
2868
+ const handleClose = onClose ?? (() => onExpandedChange?.(false));
2869
+ if (resolvedVariant === 'temporary') {
2870
+ return (jsx(DrawerTemporaryPanel, { isExpanded: isExpanded, onClose: handleClose, height: height, role: role, ariaLabel: ariaLabel, ariaLabelledBy: ariaLabelledBy, ariaDescribedBy: ariaDescribedBy, children: children }));
2871
+ }
2872
+ 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 }) }));
2873
+ };
2874
+ DrawerBase.displayName = 'Drawer';
2875
+ const Drawer = DrawerBase;
2876
+ Drawer.Header = DrawerHeader;
2877
+ Drawer.Body = DrawerBody;
2878
+ Drawer.Footer = DrawerFooter;
2879
+ Drawer.Item = DrawerItem;
2880
+
2337
2881
  const AlertContext = createContext({
2338
2882
  variant: 'default',
2339
2883
  accentColor: 'defaultActive',
@@ -2405,31 +2949,8 @@ const Alert = AlertBase;
2405
2949
  Alert.Title = AlertTitle;
2406
2950
  Alert.Body = AlertBody;
2407
2951
 
2408
- /**
2409
- * Responsive breakpoints (min-width, mobile-first).
2410
- * Keep in sync with themeBreakpoints.
2411
- */
2412
- const BREAKPOINTS = {
2413
- sm: 640};
2414
- /** Max-width media query strings (max = breakpoint - 1px). */
2415
- const MEDIA_MAX = {
2416
- sm: `max-width: ${BREAKPOINTS.sm - 1}px`};
2417
-
2418
- /** Default duration in milliseconds for mount/unmount transition animations. */
2419
- const DEFAULT_TRANSITION_DURATION_MS = 250;
2420
-
2421
2952
  const TRANSITION = `${DEFAULT_TRANSITION_DURATION_MS}ms ease`;
2422
2953
  const DIALOG_STYLES = createStyles((theme) => ({
2423
- backdrop: {
2424
- position: 'fixed',
2425
- inset: 0,
2426
- zIndex: theme.zIndex.modal - 1,
2427
- backgroundColor: 'rgba(0, 0, 0, 0)',
2428
- transition: `background-color ${TRANSITION}`,
2429
- },
2430
- backdropVisible: {
2431
- backgroundColor: 'rgba(0, 0, 0, 0.5)',
2432
- },
2433
2954
  panel: {
2434
2955
  position: 'fixed',
2435
2956
  inset: 0,
@@ -2487,84 +3008,6 @@ const DialogContext = createContext({
2487
3008
  CloseIconComponent: null,
2488
3009
  });
2489
3010
 
2490
- /**
2491
- * Manages mount/unmount transitions with a two-phase approach (visible → fading-in).
2492
- *
2493
- * Opening sequence:
2494
- * 1. `useLayoutEffect` sets `isVisible=true` synchronously before the browser paints
2495
- * → element is in the DOM at its initial hidden state on the very first frame.
2496
- * 2. Double `requestAnimationFrame` in `useEffect` waits for two rendered frames
2497
- * before setting `isFadingIn=true`, guaranteeing the CSS transition always starts
2498
- * from the painted hidden state (prevents flickering).
2499
- *
2500
- * Closing sequence:
2501
- * `isFadingIn=false` → CSS transition plays → after `duration` ms → `isVisible=false`.
2502
- *
2503
- * @param isOpen - Whether the element should be shown.
2504
- * @param duration - Transition duration in milliseconds (defaults to `DEFAULT_TRANSITION_DURATION_MS`).
2505
- */
2506
- const useTransitionRender = (isOpen, duration = DEFAULT_TRANSITION_DURATION_MS) => {
2507
- const [isVisible, setIsVisible] = useState(isOpen);
2508
- const [isFadingIn, setIsFadingIn] = useState(isOpen);
2509
- // Mount synchronously before paint so the element is in the DOM at opacity:0
2510
- // on the very first frame — no extra render cycle between null and the initial state.
2511
- useLayoutEffect(() => {
2512
- if (isOpen) {
2513
- setIsVisible(true);
2514
- }
2515
- }, [isOpen]);
2516
- useEffect(() => {
2517
- if (isOpen) {
2518
- // Double RAF: frame 1 → element rendered; frame 2 → element painted at hidden
2519
- // state → THEN trigger the CSS transition.
2520
- let raf2;
2521
- const raf1 = requestAnimationFrame(() => {
2522
- raf2 = requestAnimationFrame(() => setIsFadingIn(true));
2523
- });
2524
- return () => {
2525
- cancelAnimationFrame(raf1);
2526
- cancelAnimationFrame(raf2);
2527
- };
2528
- }
2529
- else {
2530
- setIsFadingIn(false);
2531
- const timeout = setTimeout(() => setIsVisible(false), duration);
2532
- return () => clearTimeout(timeout);
2533
- }
2534
- }, [isOpen, duration]);
2535
- return { isVisible, isFadingIn };
2536
- };
2537
-
2538
- /**
2539
- * Locks scrolling on `document.body` while `active` is true.
2540
- *
2541
- * Preserves the current scroll position and keeps the scrollbar gutter
2542
- * (`overflow-y: scroll`) to avoid horizontal layout shift when the scrollbar
2543
- * disappears. The original styles and scroll position are restored on cleanup.
2544
- *
2545
- * @example useBodyScrollLock(isDialogOpen)
2546
- */
2547
- const useBodyScrollLock = (active) => {
2548
- useEffect(() => {
2549
- if (!active) {
2550
- return;
2551
- }
2552
- const scrollY = window.scrollY;
2553
- const body = document.body;
2554
- body.style.position = 'fixed';
2555
- body.style.top = `-${scrollY}px`;
2556
- body.style.overflowY = 'scroll';
2557
- body.style.width = '100%';
2558
- return () => {
2559
- body.style.position = '';
2560
- body.style.top = '';
2561
- body.style.overflowY = '';
2562
- body.style.width = '';
2563
- window.scrollTo(0, scrollY);
2564
- };
2565
- }, [active]);
2566
- };
2567
-
2568
3011
  const FOCUSABLE_SELECTOR = [
2569
3012
  'a[href]',
2570
3013
  'button:not([disabled])',
@@ -2700,7 +3143,7 @@ const DialogBase = ({ open, onClose, children, closeOnBackdropClick = false, ful
2700
3143
  if (!isVisible) {
2701
3144
  return null;
2702
3145
  }
2703
- 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);
3146
+ 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);
2704
3147
  };
2705
3148
  DialogBase.displayName = 'Dialog';
2706
3149
  const Dialog = DialogBase;
@@ -2911,13 +3354,19 @@ const themeShadows = {
2911
3354
 
2912
3355
  /**
2913
3356
  * Default spacing tokens
3357
+ *
3358
+ * ⚠️ Token keys MUST stay CSS-custom-property safe (letters, digits, hyphens only).
3359
+ * They are turned into CSS variables (e.g. `smPlus` → `--theme-spacing-sm-plus`) by
3360
+ * the theme proxy, so characters like `+` would produce invalid variable names and
3361
+ * silently break any style that uses them.
2914
3362
  */
2915
3363
  const themeSpacing = {
2916
3364
  none: '0',
2917
3365
  '2xs': '0.125rem', // 2px
2918
3366
  xs: '0.25rem', // 4px
2919
- 'xs+': '0.375rem', // 6px
3367
+ xsPlus: '0.375rem', // 6px
2920
3368
  sm: '0.5rem', // 8px
3369
+ smPlus: '0.75rem', // 12px
2921
3370
  md: '1rem', // 16px
2922
3371
  lg: '1.5rem', // 24px
2923
3372
  xl: '2rem', // 32px
@@ -2931,9 +3380,9 @@ const themeSpacing = {
2931
3380
  * Default transition tokens
2932
3381
  */
2933
3382
  const themeTransition = {
2934
- fast: '150ms ease-out',
2935
- normal: '250ms ease-out',
2936
- slow: '350ms ease-out',
3383
+ fast: '150ms ease-in-out',
3384
+ normal: '250ms ease-in-out',
3385
+ slow: '350ms ease-in-out',
2937
3386
  };
2938
3387
 
2939
3388
  /**
@@ -3088,5 +3537,5 @@ const darkTheme = createTheme({
3088
3537
  breakpoints: themeBreakpoints,
3089
3538
  });
3090
3539
 
3091
- export { Alert, Badge, Box, Button, Card, Dialog, Form, Grid, Icon, IconButton, InfoBubble, Link, Menu, Select, Skeleton, Stack, Switch, Text, TextField, Tooltip, darkTheme, lightTheme };
3540
+ 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 };
3092
3541
  //# sourceMappingURL=index.js.map