@aurora-ds/components 1.1.6 → 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: {
@@ -1565,6 +1581,18 @@ Box.displayName = 'Box';
1565
1581
  const Stack = ({ flexDirection = 'row', display = 'flex', gap = 'sm', ...rest }) => (jsx(Box, { display: display, flexDirection: flexDirection, gap: gap, ...rest }));
1566
1582
  Stack.displayName = 'Stack';
1567
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
+
1568
1596
  const TEXTFIELD_WRAPPER_VARIANTS = createVariants((theme) => {
1569
1597
  const c = theme.colors;
1570
1598
  return {
@@ -1687,18 +1715,11 @@ const ICON_BUTTON_SIZE_MAP = {
1687
1715
  md: 'sm',
1688
1716
  lg: 'md',
1689
1717
  };
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
1718
  /**
1698
1719
  * Business logic for the TextField component: id resolution, ref merging,
1699
1720
  * password visibility toggling and size/status derived tokens.
1700
1721
  */
1701
- const useTextField = ({ id, ref, type, size, status, endAction, }) => {
1722
+ const useTextField = ({ id, ref, type, size, endAction, }) => {
1702
1723
  const generatedId = useId();
1703
1724
  const fieldId = id ?? generatedId;
1704
1725
  const helperId = `${fieldId}-helper`;
@@ -1720,7 +1741,6 @@ const useTextField = ({ id, ref, type, size, status, endAction, }) => {
1720
1741
  resolvedType,
1721
1742
  iconSize: ICON_SIZE_MAP$1[size],
1722
1743
  iconButtonSize: ICON_BUTTON_SIZE_MAP[size],
1723
- helperColor: HELPER_COLOR_MAP$1[status],
1724
1744
  hasEndSection: endAction !== undefined || isPassword,
1725
1745
  focusInput,
1726
1746
  };
@@ -1738,8 +1758,8 @@ const useTextField = ({ id, ref, type, size, status, endAction, }) => {
1738
1758
  * @example <TextField label="Search" startIcon={SearchIcon} endAction={<ClearButton />} />
1739
1759
  */
1740
1760
  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 }))] }));
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 }))] }));
1743
1763
  };
1744
1764
  TextField.displayName = 'TextField';
1745
1765
 
@@ -2175,21 +2195,15 @@ const SelectTrigger = ({ ref, size = 'md', status = 'default', open, hasValue, s
2175
2195
  };
2176
2196
  SelectTrigger.displayName = 'SelectTrigger';
2177
2197
 
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
2198
  /**
2186
2199
  * Business logic for the Select component: id resolution, ref merging,
2187
2200
  * controlled/uncontrolled value handling, open state, option grouping and
2188
2201
  * focus restoration to the trigger when the menu closes.
2189
2202
  */
2190
- const useSelect = ({ id, ref, value, defaultValue, onChange, options, status, disabled, }) => {
2203
+ const useSelect = ({ id, ref, value, defaultValue, onChange, options, disabled, }) => {
2191
2204
  const generatedId = useId();
2192
2205
  const fieldId = id ?? generatedId;
2206
+ const helperId = `${fieldId}-helper`;
2193
2207
  const menuId = `${fieldId}-menu`;
2194
2208
  const triggerRef = useRef(null);
2195
2209
  const mergedRef = useMergedRefs(ref, triggerRef);
@@ -2234,6 +2248,7 @@ const useSelect = ({ id, ref, value, defaultValue, onChange, options, status, di
2234
2248
  const close = useCallback(() => setOpen(false), []);
2235
2249
  return {
2236
2250
  fieldId,
2251
+ helperId,
2237
2252
  menuId,
2238
2253
  triggerRef,
2239
2254
  mergedRef,
@@ -2244,19 +2259,187 @@ const useSelect = ({ id, ref, value, defaultValue, onChange, options, status, di
2244
2259
  selectedOption,
2245
2260
  groupedOptions,
2246
2261
  handleSelect,
2247
- helperColor: HELPER_COLOR_MAP[status],
2248
2262
  };
2249
2263
  };
2250
2264
 
2251
2265
  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) => {
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) => {
2254
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)));
2255
2269
  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 }))] }));
2270
+ }) }), helperText !== undefined && (jsx(FormHelperText, { id: helperId, status: status, children: helperText }))] }));
2257
2271
  };
2258
2272
  Select.displayName = 'Select';
2259
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
+
2260
2443
  const CARD_VARIANTS = createVariants((theme) => ({
2261
2444
  base: {
2262
2445
  boxSizing: 'border-box',
@@ -2340,6 +2523,361 @@ const Grid = ({ display = 'grid', columns, rows, autoFlow, autoColumns, autoRows
2340
2523
  };
2341
2524
  Grid.displayName = 'Grid';
2342
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
+
2343
2881
  const AlertContext = createContext({
2344
2882
  variant: 'default',
2345
2883
  accentColor: 'defaultActive',
@@ -2411,31 +2949,8 @@ const Alert = AlertBase;
2411
2949
  Alert.Title = AlertTitle;
2412
2950
  Alert.Body = AlertBody;
2413
2951
 
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
2952
  const TRANSITION = `${DEFAULT_TRANSITION_DURATION_MS}ms ease`;
2428
2953
  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
2954
  panel: {
2440
2955
  position: 'fixed',
2441
2956
  inset: 0,
@@ -2493,84 +3008,6 @@ const DialogContext = createContext({
2493
3008
  CloseIconComponent: null,
2494
3009
  });
2495
3010
 
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
3011
  const FOCUSABLE_SELECTOR = [
2575
3012
  'a[href]',
2576
3013
  'button:not([disabled])',
@@ -2706,7 +3143,7 @@ const DialogBase = ({ open, onClose, children, closeOnBackdropClick = false, ful
2706
3143
  if (!isVisible) {
2707
3144
  return null;
2708
3145
  }
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);
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);
2710
3147
  };
2711
3148
  DialogBase.displayName = 'Dialog';
2712
3149
  const Dialog = DialogBase;
@@ -2917,13 +3354,19 @@ const themeShadows = {
2917
3354
 
2918
3355
  /**
2919
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.
2920
3362
  */
2921
3363
  const themeSpacing = {
2922
3364
  none: '0',
2923
3365
  '2xs': '0.125rem', // 2px
2924
3366
  xs: '0.25rem', // 4px
2925
- 'xs+': '0.375rem', // 6px
3367
+ xsPlus: '0.375rem', // 6px
2926
3368
  sm: '0.5rem', // 8px
3369
+ smPlus: '0.75rem', // 12px
2927
3370
  md: '1rem', // 16px
2928
3371
  lg: '1.5rem', // 24px
2929
3372
  xl: '2rem', // 32px
@@ -2937,9 +3380,9 @@ const themeSpacing = {
2937
3380
  * Default transition tokens
2938
3381
  */
2939
3382
  const themeTransition = {
2940
- fast: '150ms ease-out',
2941
- normal: '250ms ease-out',
2942
- slow: '350ms ease-out',
3383
+ fast: '150ms ease-in-out',
3384
+ normal: '250ms ease-in-out',
3385
+ slow: '350ms ease-in-out',
2943
3386
  };
2944
3387
 
2945
3388
  /**
@@ -3094,5 +3537,5 @@ const darkTheme = createTheme({
3094
3537
  breakpoints: themeBreakpoints,
3095
3538
  });
3096
3539
 
3097
- 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 };
3098
3541
  //# sourceMappingURL=index.js.map