@aurora-ds/components 0.24.9 → 0.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.md +35 -7
  2. package/dist/cjs/components/actions/button/Button.d.ts +8 -0
  3. package/dist/cjs/components/actions/button/Button.props.d.ts +10 -0
  4. package/dist/cjs/components/actions/button/utils/getButtonSizeStyles.utils.d.ts +12 -0
  5. package/dist/cjs/components/actions/button/utils/getButtonVariantStyles.utils.d.ts +7 -0
  6. package/dist/cjs/components/actions/icon-button/IconButton.d.ts +13 -0
  7. package/dist/cjs/components/actions/icon-button/IconButton.props.d.ts +11 -1
  8. package/dist/cjs/components/actions/icon-button/utils/getIconButtonSizeStyles.utils.d.ts +9 -0
  9. package/dist/cjs/components/data-display/avatar/Avatar.d.ts +16 -1
  10. package/dist/cjs/components/data-display/avatar/Avatar.props.d.ts +18 -4
  11. package/dist/cjs/components/data-display/avatar/utils/getAvatarSizes.utils.d.ts +11 -0
  12. package/dist/cjs/components/data-display/chip/utils/getChipColorStyles.utils.d.ts +12 -0
  13. package/dist/cjs/components/data-display/chip/utils/getChipContentSize.utils.d.ts +6 -0
  14. package/dist/cjs/components/data-display/chip/utils/getChipSizeStyles.utils.d.ts +10 -0
  15. package/dist/cjs/components/data-display/status/utils/getStatusColorStyles.utils.d.ts +7 -0
  16. package/dist/cjs/components/data-display/status/utils/getStatusContentSize.utils.d.ts +8 -0
  17. package/dist/cjs/components/data-display/status/utils/getStatusSizeStyles.utils.d.ts +12 -0
  18. package/dist/cjs/components/forms/date-picker/calendar/calendar-grid/CalendarGrid.props.d.ts +1 -1
  19. package/dist/cjs/components/forms/date-picker/utils/datePicker.utils.d.ts +13 -0
  20. package/dist/cjs/components/forms/input/Input.props.d.ts +9 -1
  21. package/dist/cjs/components/forms/select/Select.d.ts +18 -0
  22. package/dist/cjs/components/forms/select/Select.props.d.ts +14 -0
  23. package/dist/cjs/components/forms/textarea/TextArea.props.d.ts +9 -1
  24. package/dist/cjs/components/foundation/text/Text.props.d.ts +2 -0
  25. package/dist/cjs/components/foundation/text/utils/getTextVariantStyles.utils.d.ts +7 -0
  26. package/dist/cjs/components/foundation/text/utils/getTruncateTextStyles.utils.d.ts +21 -0
  27. package/dist/cjs/components/foundation/text/utils/parseTextWithBold.utils.d.ts +7 -0
  28. package/dist/cjs/components/navigation/breadcrumb/utils/buildBreadcrumbChildren.utils.d.ts +5 -0
  29. package/dist/cjs/components/navigation/breadcrumb/utils/flattenChildren.utils.d.ts +5 -0
  30. package/dist/cjs/components/navigation/breadcrumb/utils/insertSeparators.utils.d.ts +5 -0
  31. package/dist/cjs/components/navigation/breadcrumb/utils/isSeparator.utils.d.ts +5 -0
  32. package/dist/cjs/components/overlay/alert/Alert.d.ts +5 -0
  33. package/dist/cjs/components/overlay/alert/utils/getAlertIcon.utils.d.ts +8 -0
  34. package/dist/cjs/components/overlay/alert/utils/getAlertPositionStyles.utils.d.ts +8 -0
  35. package/dist/cjs/components/overlay/alert/utils/getAlertVariantColors.utils.d.ts +14 -0
  36. package/dist/cjs/components/overlay/modal/Modal.d.ts +24 -0
  37. package/dist/cjs/index.js +234 -28
  38. package/dist/cjs/index.js.map +1 -1
  39. package/dist/esm/components/actions/button/Button.d.ts +8 -0
  40. package/dist/esm/components/actions/button/Button.props.d.ts +10 -0
  41. package/dist/esm/components/actions/button/utils/getButtonSizeStyles.utils.d.ts +12 -0
  42. package/dist/esm/components/actions/button/utils/getButtonVariantStyles.utils.d.ts +7 -0
  43. package/dist/esm/components/actions/icon-button/IconButton.d.ts +13 -0
  44. package/dist/esm/components/actions/icon-button/IconButton.props.d.ts +11 -1
  45. package/dist/esm/components/actions/icon-button/utils/getIconButtonSizeStyles.utils.d.ts +9 -0
  46. package/dist/esm/components/data-display/avatar/Avatar.d.ts +16 -1
  47. package/dist/esm/components/data-display/avatar/Avatar.props.d.ts +18 -4
  48. package/dist/esm/components/data-display/avatar/utils/getAvatarSizes.utils.d.ts +11 -0
  49. package/dist/esm/components/data-display/chip/utils/getChipColorStyles.utils.d.ts +12 -0
  50. package/dist/esm/components/data-display/chip/utils/getChipContentSize.utils.d.ts +6 -0
  51. package/dist/esm/components/data-display/chip/utils/getChipSizeStyles.utils.d.ts +10 -0
  52. package/dist/esm/components/data-display/status/utils/getStatusColorStyles.utils.d.ts +7 -0
  53. package/dist/esm/components/data-display/status/utils/getStatusContentSize.utils.d.ts +8 -0
  54. package/dist/esm/components/data-display/status/utils/getStatusSizeStyles.utils.d.ts +12 -0
  55. package/dist/esm/components/forms/date-picker/calendar/calendar-grid/CalendarGrid.props.d.ts +1 -1
  56. package/dist/esm/components/forms/date-picker/utils/datePicker.utils.d.ts +13 -0
  57. package/dist/esm/components/forms/input/Input.props.d.ts +9 -1
  58. package/dist/esm/components/forms/select/Select.d.ts +18 -0
  59. package/dist/esm/components/forms/select/Select.props.d.ts +14 -0
  60. package/dist/esm/components/forms/textarea/TextArea.props.d.ts +9 -1
  61. package/dist/esm/components/foundation/text/Text.props.d.ts +2 -0
  62. package/dist/esm/components/foundation/text/utils/getTextVariantStyles.utils.d.ts +7 -0
  63. package/dist/esm/components/foundation/text/utils/getTruncateTextStyles.utils.d.ts +21 -0
  64. package/dist/esm/components/foundation/text/utils/parseTextWithBold.utils.d.ts +7 -0
  65. package/dist/esm/components/navigation/breadcrumb/utils/buildBreadcrumbChildren.utils.d.ts +5 -0
  66. package/dist/esm/components/navigation/breadcrumb/utils/flattenChildren.utils.d.ts +5 -0
  67. package/dist/esm/components/navigation/breadcrumb/utils/insertSeparators.utils.d.ts +5 -0
  68. package/dist/esm/components/navigation/breadcrumb/utils/isSeparator.utils.d.ts +5 -0
  69. package/dist/esm/components/overlay/alert/Alert.d.ts +5 -0
  70. package/dist/esm/components/overlay/alert/utils/getAlertIcon.utils.d.ts +8 -0
  71. package/dist/esm/components/overlay/alert/utils/getAlertPositionStyles.utils.d.ts +8 -0
  72. package/dist/esm/components/overlay/alert/utils/getAlertVariantColors.utils.d.ts +14 -0
  73. package/dist/esm/components/overlay/modal/Modal.d.ts +24 -0
  74. package/dist/esm/index.js +235 -29
  75. package/dist/esm/index.js.map +1 -1
  76. package/dist/index.d.ts +153 -8
  77. package/package.json +2 -2
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 React, { Children, isValidElement, cloneElement, createElement, Fragment, useMemo, memo, useCallback, forwardRef, useState, useRef, useEffect, useLayoutEffect, createContext, useContext } from 'react';
2
+ import React, { Children, isValidElement, cloneElement, createElement, Fragment, useMemo, memo, useCallback, forwardRef, useId, useState, useRef, useEffect, useLayoutEffect, createContext, useContext } from 'react';
3
3
  import { createStyles, useTheme, keyframes, colors } from '@aurora-ds/theme';
4
4
  import { createPortal } from 'react-dom';
5
5
 
@@ -226,7 +226,7 @@ const parseTextWithBold = (children) => {
226
226
  * - Preserve whitespace with `preserveWhitespace` prop
227
227
  * - Bold text with **double asterisks** syntax
228
228
  */
229
- const Text = ({ children, variant = 'span', color, fontSize, fontFamily, maxLines, underline, preserveWhitespace, ariaLabel, ariaLabelledBy, ariaDescribedBy, role, tabIndex, }) => {
229
+ const Text = ({ children, variant = 'span', color, fontSize, fontFamily, maxLines, underline, preserveWhitespace, ariaLabel, ariaLabelledBy, ariaDescribedBy, role, tabIndex, id, }) => {
230
230
  const theme = useTheme();
231
231
  const variantStyles = useMemo(() => getTextVariantStyles(theme), [theme]);
232
232
  const tag = variantStyles[variant].tag;
@@ -234,6 +234,7 @@ const Text = ({ children, variant = 'span', color, fontSize, fontFamily, maxLine
234
234
  // Force inline truncate styles when needed (fix for multi-line clamp not applied in some envs)
235
235
  const truncateStyles = maxLines ? getTruncateTextStyles(maxLines) : undefined;
236
236
  return createElement(tag, {
237
+ id,
237
238
  className: TEXT_STYLES.root({ variant, color, fontSize, fontFamily, maxLines, underline, preserveWhitespace }),
238
239
  style: truncateStyles,
239
240
  'aria-label': ariaLabel,
@@ -530,20 +531,37 @@ const AVATAR_STYLES = createStyles((theme) => {
530
531
  /**
531
532
  * Avatar component
532
533
  *
533
- * Displays a user's avatar with optional image or fallback text.
534
+ * Displays a user's avatar with an optional image or fallback initials.
535
+ *
536
+ * **Accessibility:**
537
+ * - When `onClick` is provided, the avatar becomes a focusable button
538
+ * with keyboard support (Enter/Space activates the click)
539
+ * - Provide `label` or `ariaLabel` for screen readers
540
+ * - For decorative avatars, both can be omitted
541
+ *
542
+ * @example
543
+ * ```tsx
544
+ * // Static avatar with image
545
+ * <Avatar image="/user.jpg" label="Jane Doe" />
546
+ *
547
+ * // Interactive avatar (clickable)
548
+ * <Avatar label="JD" onClick={() => openProfile()} ariaLabel="Open Jane's profile" />
549
+ * ```
534
550
  */
535
- const Avatar = ({ image, label, onClick, size = 'medium', color, borderColor, backgroundColor, }) => {
551
+ const Avatar = ({ image, label, onClick, size = 'medium', color, borderColor, backgroundColor, ariaLabel, tabIndex, }) => {
536
552
  // hooks
537
553
  const theme = useTheme();
538
554
  // variables
539
555
  const AVATAR_SIZES = getAvatarSizes(theme);
540
556
  const hasImage = !!image;
541
557
  const clickable = !!onClick;
542
- return (jsx("div", { className: AVATAR_STYLES.root({ hasImage, clickable, size, color, borderColor, backgroundColor }), onClick: (event) => {
543
- if (onClick) {
544
- onClick(event);
545
- }
546
- }, children: hasImage ? (jsx("img", { src: image, alt: label || 'Avatar', className: AVATAR_STYLES.image })) : (jsx(Text, { variant: 'label', fontSize: AVATAR_SIZES[size].fontSize, children: label || '?' })) }));
558
+ const handleKeyDown = (event) => {
559
+ if (onClick && (event.key === 'Enter' || event.key === ' ')) {
560
+ event.preventDefault();
561
+ onClick(event);
562
+ }
563
+ };
564
+ return (jsx("div", { className: AVATAR_STYLES.root({ hasImage, clickable, size, color, borderColor, backgroundColor }), onClick: onClick, onKeyDown: clickable ? handleKeyDown : undefined, role: clickable ? 'button' : undefined, tabIndex: clickable ? (tabIndex ?? 0) : tabIndex, "aria-label": ariaLabel || label, children: hasImage ? (jsx("img", { src: image, alt: label || ariaLabel || '', className: AVATAR_STYLES.image })) : (jsx(Text, { variant: 'label', fontSize: AVATAR_SIZES[size].fontSize, children: label || '?' })) }));
547
565
  };
548
566
  Avatar.displayName = 'Avatar';
549
567
 
@@ -785,13 +803,21 @@ const BUTTON_STYLES = createStyles((theme) => {
785
803
  * - `contained`: Solid background button (default)
786
804
  * - `outlined`: Border only button
787
805
  * - `text`: Text only button without background
788
- */
789
- const Button = ({ label, startIcon, endIcon, variant = 'contained', active = false, onClick, disabled, type = 'button', textColor: customTextColor, backgroundColor, hoverBackgroundColor, activeBackgroundColor, size = 'medium', ariaLabel, ariaLabelledBy, ariaDescribedBy, role, tabIndex, }) => {
806
+ * - `destructive`: Danger/delete action button
807
+ *
808
+ * **Accessibility:**
809
+ * - Use `ariaLabel` when the button has no visible text or the label is unclear
810
+ * - Use `ariaPressed` for toggle buttons to indicate their state
811
+ * - Use `ariaBusy` when the button triggers an async action
812
+ * - Use `ariaExpanded` when the button controls a collapsible section
813
+ * - Use `ariaControls` to reference the controlled element
814
+ */
815
+ const Button = ({ label, startIcon, endIcon, variant = 'contained', active = false, onClick, disabled, type = 'button', textColor: customTextColor, backgroundColor, hoverBackgroundColor, activeBackgroundColor, size = 'medium', ariaLabel, ariaLabelledBy, ariaDescribedBy, role, tabIndex, ariaPressed, ariaBusy, ariaExpanded, ariaHasPopup, ariaControls, }) => {
790
816
  const theme = useTheme();
791
817
  const variantStyles = getButtonVariantStyles(theme);
792
818
  const sizeStyles = getButtonSizeStyles();
793
819
  const textColor = disabled ? 'disabledText' : (customTextColor ?? variantStyles[variant].textColor);
794
- return (jsxs("button", { onClick: onClick, disabled: disabled, type: type, className: BUTTON_STYLES.root({ variant, active, textColor: customTextColor, backgroundColor, hoverBackgroundColor, activeBackgroundColor, size }), "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, role: role, tabIndex: tabIndex, children: [startIcon && (jsx(Icon, { color: textColor, children: startIcon })), jsx(Text, { variant: 'label', color: textColor, fontSize: sizeStyles[size].fontSize, children: label }), endIcon && (jsx(Icon, { color: textColor, children: endIcon }))] }));
820
+ return (jsxs("button", { onClick: onClick, disabled: disabled, type: type, className: BUTTON_STYLES.root({ variant, active, textColor: customTextColor, backgroundColor, hoverBackgroundColor, activeBackgroundColor, size }), "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, "aria-pressed": ariaPressed, "aria-busy": ariaBusy, "aria-expanded": ariaExpanded, "aria-haspopup": ariaHasPopup, "aria-controls": ariaControls, role: role, tabIndex: tabIndex, children: [startIcon && (jsx(Icon, { color: textColor, children: startIcon })), jsx(Text, { variant: 'label', color: textColor, fontSize: sizeStyles[size].fontSize, children: label }), endIcon && (jsx(Icon, { color: textColor, children: endIcon }))] }));
795
821
  };
796
822
  Button.displayName = 'Button';
797
823
 
@@ -859,12 +885,25 @@ const ICON_BUTTON_STYLES = createStyles((theme) => {
859
885
  };
860
886
  });
861
887
 
862
- const IconButton = ({ icon, variant = 'contained', active = false, type = 'button', onClick, disabled, textColor: customTextColor, backgroundColor, hoverBackgroundColor, activeBackgroundColor, size = 'medium', ariaLabel, ariaLabelledBy, ariaDescribedBy, role, tabIndex, }) => {
888
+ /**
889
+ * IconButton component
890
+ *
891
+ * A button that displays only an icon without text.
892
+ *
893
+ * **⚠️ Accessibility:** Always provide an `ariaLabel` for icon-only buttons
894
+ * so screen readers can describe the action.
895
+ *
896
+ * @example
897
+ * ```tsx
898
+ * <IconButton icon={<CloseIcon />} ariaLabel="Close dialog" onClick={onClose} />
899
+ * ```
900
+ */
901
+ const IconButton = ({ icon, variant = 'contained', active = false, type = 'button', onClick, disabled, textColor: customTextColor, backgroundColor, hoverBackgroundColor, activeBackgroundColor, size = 'medium', ariaLabel, ariaLabelledBy, ariaDescribedBy, role, tabIndex, ariaPressed, ariaBusy, ariaExpanded, ariaHasPopup, ariaControls, }) => {
863
902
  const theme = useTheme();
864
903
  const variantStyles = getButtonVariantStyles(theme);
865
904
  const textColor = disabled ? 'disabledText' : (customTextColor ?? variantStyles[variant].textColor);
866
905
  const iconSize = getIconButtonSizeStyles()[size].iconSize;
867
- return (jsx("button", { onClick: onClick, disabled: disabled, type: type, className: ICON_BUTTON_STYLES.root({ variant, active, size, textColor: customTextColor, backgroundColor, hoverBackgroundColor, activeBackgroundColor }), "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, role: role, tabIndex: tabIndex, children: jsx(Icon, { color: textColor, size: iconSize, children: icon }) }));
906
+ return (jsx("button", { onClick: onClick, disabled: disabled, type: type, className: ICON_BUTTON_STYLES.root({ variant, active, size, textColor: customTextColor, backgroundColor, hoverBackgroundColor, activeBackgroundColor }), "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, "aria-pressed": ariaPressed, "aria-busy": ariaBusy, "aria-expanded": ariaExpanded, "aria-haspopup": ariaHasPopup, "aria-controls": ariaControls, role: role, tabIndex: tabIndex, children: jsx(Icon, { color: textColor, size: iconSize, children: icon }) }));
868
907
  };
869
908
  IconButton.displayName = 'IconButton';
870
909
 
@@ -1215,15 +1254,38 @@ const UploadIcon = () => (jsxs("svg", { xmlns: 'http://www.w3.org/2000/svg', wid
1215
1254
 
1216
1255
  /**
1217
1256
  * Input component
1257
+ *
1258
+ * A text input field with optional label, icons, and error state.
1259
+ *
1260
+ * **Accessibility:**
1261
+ * - `aria-label` defaults to the `label` prop for screen readers
1262
+ * - When `error` is provided, `aria-invalid="true"` and `aria-errormessage`
1263
+ * are automatically set to link the input to its error message
1264
+ * - `mandatory` adds `aria-required="true"` to the input element
1265
+ *
1266
+ * @example
1267
+ * ```tsx
1268
+ * <Input
1269
+ * value={email}
1270
+ * onChange={setEmail}
1271
+ * label="Email address"
1272
+ * mandatory
1273
+ * error={emailError}
1274
+ * type="email"
1275
+ * />
1276
+ * ```
1218
1277
  */
1219
- const Input = forwardRef(({ value, onChange, onFocus, onBlur, onClick, label, mandatory = false, placeholder, disabled = false, type = 'text', ariaLabel, startIcon, endIcon, width, }, ref) => {
1278
+ const Input = forwardRef(({ value, onChange, onFocus, onBlur, onClick, label, mandatory = false, placeholder, disabled = false, type = 'text', ariaLabel, startIcon, endIcon, width, error, id, }, ref) => {
1279
+ const generatedId = useId();
1280
+ const inputId = id ?? generatedId;
1281
+ const errorId = error ? `${inputId}-error` : undefined;
1220
1282
  const [showPassword, setShowPassword] = useState(false);
1221
1283
  const handleChange = (event) => {
1222
1284
  onChange(event.target.value);
1223
1285
  };
1224
1286
  const inputType = type === 'password' ? (showPassword ? 'text' : 'password') : type;
1225
1287
  const hasPasswordToggle = type === 'password';
1226
- return (jsxs(Stack, { direction: 'column', gap: 'xs', align: 'stretch', width: width ?? '100%', children: [label && (jsxs(Stack, { direction: 'row', gap: 'xs', align: 'center', children: [jsx(Text, { variant: 'label', fontSize: 'sm', children: label }), mandatory && (jsx(Text, { variant: 'label', fontSize: 'sm', color: 'error', children: "*" }))] })), jsxs("div", { className: `${INPUT_STYLES.container({ width })} ${disabled ? 'disabled' : ''}`, children: [jsx("input", { ref: ref, type: inputType, value: value, onChange: handleChange, onFocus: onFocus, onBlur: onBlur, onClick: onClick, placeholder: placeholder, disabled: disabled, className: INPUT_STYLES.root({ disabled, hasStartIcon: !!startIcon, hasEndIcon: !!endIcon, hasPasswordToggle }), "aria-label": ariaLabel || label }), startIcon && (jsx("div", { className: INPUT_STYLES.startIcon, children: jsx(Icon, { color: 'textTertiary', children: startIcon }) })), endIcon && (jsx("div", { className: hasPasswordToggle ? INPUT_STYLES.endIconShifted : INPUT_STYLES.endIcon, children: jsx(Icon, { color: 'textTertiary', children: endIcon }) })), hasPasswordToggle && (jsx("div", { className: INPUT_STYLES.passwordToggle, children: jsx(IconButton, { icon: showPassword ? jsx(EyeOffIcon, {}) : jsx(EyeIcon, {}), onClick: () => setShowPassword(!showPassword), disabled: disabled, ariaLabel: ariaLabel || label, variant: 'text', size: 'small', textColor: 'textSecondary' }) }))] })] }));
1288
+ return (jsxs(Stack, { direction: 'column', gap: 'xs', align: 'stretch', width: width ?? '100%', children: [label && (jsxs(Stack, { direction: 'row', gap: 'xs', align: 'center', children: [jsx(Text, { variant: 'label', fontSize: 'sm', id: `${inputId}-label`, children: label }), mandatory && (jsx(Text, { variant: 'label', fontSize: 'sm', color: 'error', ariaLabel: 'required', children: "*" }))] })), jsxs("div", { className: `${INPUT_STYLES.container({ width })} ${disabled ? 'disabled' : ''}`, children: [jsx("input", { id: inputId, ref: ref, type: inputType, value: value, onChange: handleChange, onFocus: onFocus, onBlur: onBlur, onClick: onClick, placeholder: placeholder, disabled: disabled, className: INPUT_STYLES.root({ disabled, hasStartIcon: !!startIcon, hasEndIcon: !!endIcon, hasPasswordToggle, hasError: !!error }), "aria-label": ariaLabel || label, "aria-required": mandatory || undefined, "aria-invalid": error ? true : undefined, "aria-errormessage": errorId }), startIcon && (jsx("div", { className: INPUT_STYLES.startIcon, children: jsx(Icon, { color: 'textTertiary', children: startIcon }) })), endIcon && (jsx("div", { className: hasPasswordToggle ? INPUT_STYLES.endIconShifted : INPUT_STYLES.endIcon, children: jsx(Icon, { color: 'textTertiary', children: endIcon }) })), hasPasswordToggle && (jsx("div", { className: INPUT_STYLES.passwordToggle, children: jsx(IconButton, { icon: showPassword ? jsx(EyeOffIcon, {}) : jsx(EyeIcon, {}), onClick: () => setShowPassword(!showPassword), disabled: disabled, ariaLabel: showPassword ? 'Hide password' : 'Show password', variant: 'text', size: 'small', textColor: 'textSecondary' }) }))] }), error && (jsx(Text, { variant: 'span', fontSize: 'sm', color: 'error', id: errorId, role: 'alert', children: error }))] }));
1227
1289
  });
1228
1290
  Input.displayName = 'Input';
1229
1291
  var Input_default = memo(Input);
@@ -1275,23 +1337,40 @@ const TEXTAREA_STYLES = createStyles((theme) => ({
1275
1337
 
1276
1338
  /**
1277
1339
  * TextArea component with auto-expanding height based on content
1340
+ *
1341
+ * **Accessibility:**
1342
+ * - `aria-label` defaults to the `label` prop for screen readers
1343
+ * - When `error` is provided, `aria-invalid="true"` and `aria-errormessage`
1344
+ * are automatically set to link the textarea to its error message
1345
+ * - `mandatory` adds `aria-required="true"` to the textarea element
1346
+ *
1347
+ * @example
1348
+ * ```tsx
1349
+ * <TextArea
1350
+ * value={message}
1351
+ * onChange={setMessage}
1352
+ * label="Message"
1353
+ * mandatory
1354
+ * error={messageError}
1355
+ * minRows={4}
1356
+ * />
1357
+ * ```
1278
1358
  */
1279
- const TextArea = forwardRef(({ value, onChange, onFocus, onBlur, label, mandatory = false, placeholder, disabled = false, ariaLabel, width, minRows = 3, maxRows, }, ref) => {
1359
+ const TextArea = forwardRef(({ value, onChange, onFocus, onBlur, label, mandatory = false, placeholder, disabled = false, ariaLabel, width, minRows = 3, maxRows, error, id, }, ref) => {
1360
+ const generatedId = useId();
1361
+ const textareaId = id ?? generatedId;
1362
+ const errorId = error ? `${textareaId}-error` : undefined;
1280
1363
  const internalRef = useRef(null);
1281
1364
  const textareaRef = ref || internalRef;
1282
1365
  const adjustHeight = useCallback(() => {
1283
1366
  const textarea = textareaRef.current;
1284
1367
  if (textarea) {
1285
- // Reset height to calculate the correct scrollHeight
1286
1368
  textarea.style.height = 'auto';
1287
- // Set minHeight based on minRows
1288
1369
  const lineHeight = parseInt(getComputedStyle(textarea).lineHeight) || 20;
1289
1370
  const minHeight = lineHeight * minRows;
1290
1371
  const maxHeight = maxRows ? lineHeight * maxRows : Infinity;
1291
- // Set the height to the greater of minHeight or scrollHeight, but capped at maxHeight
1292
1372
  const newHeight = Math.min(Math.max(minHeight, textarea.scrollHeight), maxHeight);
1293
1373
  textarea.style.height = `${newHeight}px`;
1294
- // Enable scrolling if content exceeds maxHeight
1295
1374
  textarea.style.overflowY = textarea.scrollHeight > maxHeight ? 'auto' : 'hidden';
1296
1375
  }
1297
1376
  }, [minRows, maxRows, textareaRef]);
@@ -1301,7 +1380,7 @@ const TextArea = forwardRef(({ value, onChange, onFocus, onBlur, label, mandator
1301
1380
  const handleChange = (event) => {
1302
1381
  onChange(event.target.value);
1303
1382
  };
1304
- return (jsxs(Stack, { direction: 'column', gap: 'xs', align: 'stretch', width: width ?? '100%', children: [label && (jsxs(Stack, { direction: 'row', gap: 'xs', align: 'center', children: [jsx(Text, { variant: 'label', fontSize: 'sm', children: label }), mandatory && (jsx(Text, { variant: 'label', fontSize: 'sm', color: 'error', children: "*" }))] })), jsx("div", { className: `${TEXTAREA_STYLES.container({ width })} ${disabled ? 'disabled' : ''}`, children: jsx("textarea", { ref: textareaRef, value: value, onChange: handleChange, onFocus: onFocus, onBlur: onBlur, placeholder: placeholder, disabled: disabled, className: TEXTAREA_STYLES.root({ disabled }), "aria-label": ariaLabel || label, rows: minRows }) })] }));
1383
+ return (jsxs(Stack, { direction: 'column', gap: 'xs', align: 'stretch', width: width ?? '100%', children: [label && (jsxs(Stack, { direction: 'row', gap: 'xs', align: 'center', children: [jsx(Text, { variant: 'label', fontSize: 'sm', id: `${textareaId}-label`, children: label }), mandatory && (jsx(Text, { variant: 'label', fontSize: 'sm', color: 'error', ariaLabel: 'required', children: "*" }))] })), jsx("div", { className: `${TEXTAREA_STYLES.container({ width })} ${disabled ? 'disabled' : ''}`, children: jsx("textarea", { id: textareaId, ref: textareaRef, value: value, onChange: handleChange, onFocus: onFocus, onBlur: onBlur, placeholder: placeholder, disabled: disabled, className: TEXTAREA_STYLES.root({ disabled, hasError: !!error }), "aria-label": ariaLabel || label, "aria-required": mandatory || undefined, "aria-invalid": error ? true : undefined, "aria-errormessage": errorId, rows: minRows }) }), error && (jsx(Text, { variant: 'span', fontSize: 'sm', color: 'error', id: errorId, role: 'alert', children: error }))] }));
1305
1384
  });
1306
1385
  TextArea.displayName = 'TextArea';
1307
1386
  var TextArea_default = memo(TextArea);
@@ -1624,10 +1703,31 @@ MenuItem.displayName = 'MenuItem';
1624
1703
 
1625
1704
  /**
1626
1705
  * Select component that uses Menu for dropdown
1706
+ *
1707
+ * **Accessibility:**
1708
+ * - Uses `role="combobox"` with `aria-expanded` and `aria-haspopup="listbox"`
1709
+ * - Keyboard support: Enter/Space opens, Escape closes
1710
+ * - When `error` is provided, `aria-invalid="true"` and `aria-errormessage` are set
1711
+ * - `mandatory` adds `aria-required="true"`
1712
+ *
1713
+ * @example
1714
+ * ```tsx
1715
+ * <Select
1716
+ * options={[{ value: 'fr', label: 'France' }]}
1717
+ * value={country}
1718
+ * onChange={setCountry}
1719
+ * label="Country"
1720
+ * mandatory
1721
+ * error={countryError}
1722
+ * />
1723
+ * ```
1627
1724
  */
1628
- const Select = ({ options, value, onChange, label, mandatory = false, placeholder = 'Select an option', disabled = false, width }) => {
1725
+ const Select = ({ options, value, onChange, label, mandatory = false, placeholder = 'Select an option', disabled = false, width, error, id, ariaLabel, }) => {
1629
1726
  const [isOpen, setIsOpen] = useState(false);
1630
1727
  const triggerRef = useRef(null);
1728
+ const generatedId = useId();
1729
+ const selectId = id ?? generatedId;
1730
+ const errorId = error ? `${selectId}-error` : undefined;
1631
1731
  const selectedOption = options.find(option => option.value === value);
1632
1732
  const menuWidth = triggerRef.current?.offsetWidth ?? width;
1633
1733
  const handleTriggerClick = () => {
@@ -1635,6 +1735,18 @@ const Select = ({ options, value, onChange, label, mandatory = false, placeholde
1635
1735
  setIsOpen(!isOpen);
1636
1736
  }
1637
1737
  };
1738
+ const handleTriggerKeyDown = (event) => {
1739
+ if (disabled) {
1740
+ return;
1741
+ }
1742
+ if (event.key === 'Enter' || event.key === ' ') {
1743
+ event.preventDefault();
1744
+ setIsOpen(prev => !prev);
1745
+ }
1746
+ if (event.key === 'Escape' && isOpen) {
1747
+ setIsOpen(false);
1748
+ }
1749
+ };
1638
1750
  const handleClose = () => {
1639
1751
  setIsOpen(false);
1640
1752
  };
@@ -1642,7 +1754,7 @@ const Select = ({ options, value, onChange, label, mandatory = false, placeholde
1642
1754
  onChange(selectedValue);
1643
1755
  setIsOpen(false);
1644
1756
  };
1645
- return (jsxs(Stack, { direction: 'column', gap: 'xs', align: 'stretch', width: width ?? '100%', children: [label && (jsxs(Stack, { direction: 'row', gap: 'xs', align: 'center', children: [jsx(Text, { variant: 'label', fontSize: 'sm', children: label }), mandatory && (jsx(Text, { variant: 'label', fontSize: 'sm', color: 'error', children: "*" }))] })), jsxs("div", { ref: triggerRef, className: SELECT_STYLES.root({ disabled, width, isOpen }), onClick: handleTriggerClick, role: 'button', tabIndex: disabled ? -1 : 0, "aria-expanded": isOpen, "aria-haspopup": 'listbox', children: [jsx("div", { className: SELECT_STYLES.trigger, children: jsx(Text, { variant: 'p', maxLines: 1, color: selectedOption ? 'text' : 'textSecondary', children: selectedOption ? selectedOption.label : placeholder }) }), jsx(Icon, { children: jsx(ChevronDownIcon, {}) })] }), jsx(Menu, { anchor: isOpen ? triggerRef.current : null, onClose: handleClose, width: menuWidth, children: jsx(MenuGroup, { children: options.map(option => (jsx(SelectItem, { option: option, isSelected: option.value === value, onSelect: handleSelect }, option.value))) }) })] }));
1757
+ return (jsxs(Stack, { direction: 'column', gap: 'xs', align: 'stretch', width: width ?? '100%', children: [label && (jsxs(Stack, { direction: 'row', gap: 'xs', align: 'center', children: [jsx(Text, { variant: 'label', fontSize: 'sm', id: `${selectId}-label`, children: label }), mandatory && (jsx(Text, { variant: 'label', fontSize: 'sm', color: 'error', ariaLabel: 'required', children: "*" }))] })), jsxs("div", { ref: triggerRef, className: SELECT_STYLES.root({ disabled, width, isOpen, hasError: !!error }), onClick: handleTriggerClick, onKeyDown: handleTriggerKeyDown, role: 'combobox', tabIndex: disabled ? -1 : 0, "aria-expanded": isOpen, "aria-haspopup": 'listbox', "aria-label": ariaLabel || label, "aria-required": mandatory || undefined, "aria-invalid": error ? true : undefined, "aria-errormessage": errorId, children: [jsx("div", { className: SELECT_STYLES.trigger, children: jsx(Text, { variant: 'p', maxLines: 1, color: selectedOption ? 'text' : 'textSecondary', children: selectedOption ? selectedOption.label : placeholder }) }), jsx(Icon, { children: jsx(ChevronDownIcon, {}) })] }), error && (jsx(Text, { variant: 'span', fontSize: 'sm', color: 'error', id: errorId, role: 'alert', children: error })), jsx(Menu, { anchor: isOpen ? triggerRef.current : null, onClose: handleClose, width: menuWidth, children: jsx(MenuGroup, { children: options.map(option => (jsx(SelectItem, { option: option, isSelected: option.value === value, onSelect: handleSelect }, option.value))) }) })] }));
1646
1758
  };
1647
1759
  Select.displayName = 'Select';
1648
1760
 
@@ -2808,6 +2920,11 @@ const getAlertIcon = (variant) => {
2808
2920
  * - Smooth animations
2809
2921
  * - Dynamic height calculation for proper stacking
2810
2922
  *
2923
+ * **Accessibility:**
2924
+ * - Uses `role="alert"` for screen reader announcements
2925
+ * - Error alerts use `aria-live="assertive"` for urgent announcements
2926
+ * - Other variants use `aria-live="polite"` to avoid interrupting users
2927
+ *
2811
2928
  * @example
2812
2929
  * ```tsx
2813
2930
  * <Alert
@@ -2824,6 +2941,8 @@ const Alert = memo(({ text, variant = 'default', position = 'top-right', isVisib
2824
2941
  const lastHeightRef = useRef(0);
2825
2942
  const icon = useMemo(() => getAlertIcon(variant), [variant]);
2826
2943
  const colors = useMemo(() => getAlertVariantColors(theme, variant), [theme, variant]);
2944
+ // Error alerts are urgent → assertive; others are polite
2945
+ const ariaLive = variant === 'error' ? 'assertive' : 'polite';
2827
2946
  // Report height changes to parent (only when height actually changes)
2828
2947
  useEffect(() => {
2829
2948
  if (alertRef.current && onHeightChange && isVisible && alertId) {
@@ -2835,7 +2954,7 @@ const Alert = memo(({ text, variant = 'default', position = 'top-right', isVisib
2835
2954
  }
2836
2955
  }
2837
2956
  }, [isVisible, text, maxWidth, alertId, onHeightChange]);
2838
- return (jsxs("div", { ref: alertRef, className: ALERT_STYLES.root({ variant, position, isVisible, offsetY, maxWidth }), role: 'alert', "aria-live": 'polite', children: [jsx(Icon, { size: 'sm', color: colors.iconColor, children: icon }), jsx(Text, { variant: 'span', fontSize: 'sm', color: colors.iconColor, maxLines: 4, children: text })] }));
2957
+ return (jsxs("div", { ref: alertRef, className: ALERT_STYLES.root({ variant, position, isVisible, offsetY, maxWidth }), role: 'alert', "aria-live": ariaLive, "aria-atomic": 'true', children: [jsx(Icon, { size: 'sm', color: colors.iconColor, children: icon }), jsx(Text, { variant: 'span', fontSize: 'sm', color: colors.iconColor, maxLines: 4, children: text })] }));
2839
2958
  });
2840
2959
  Alert.displayName = 'Alert';
2841
2960
 
@@ -2875,15 +2994,98 @@ const MODAL_STYLES = createStyles((theme) => ({
2875
2994
  }),
2876
2995
  }));
2877
2996
 
2997
+ const FOCUSABLE_SELECTORS = [
2998
+ 'a[href]',
2999
+ 'button:not([disabled])',
3000
+ 'input:not([disabled])',
3001
+ 'select:not([disabled])',
3002
+ 'textarea:not([disabled])',
3003
+ '[tabindex]:not([tabindex="-1"])',
3004
+ ].join(', ');
3005
+ /**
3006
+ * Modal component
3007
+ *
3008
+ * An overlay dialog for focused interactions.
3009
+ *
3010
+ * **Accessibility:**
3011
+ * - Uses `role="dialog"` and `aria-modal="true"` for screen readers
3012
+ * - `aria-labelledby` links to the modal title
3013
+ * - Focus is trapped inside the modal while it is open
3014
+ * - Focus returns to the triggering element when closed
3015
+ * - Escape key closes the modal
3016
+ *
3017
+ * @example
3018
+ * ```tsx
3019
+ * <Modal
3020
+ * isOpen={isOpen}
3021
+ * onClose={() => setIsOpen(false)}
3022
+ * label="Confirm deletion"
3023
+ * action={{ label: 'Delete', onClick: handleDelete }}
3024
+ * >
3025
+ * <Text>Are you sure you want to delete this item?</Text>
3026
+ * </Modal>
3027
+ * ```
3028
+ */
2878
3029
  const Modal = ({ isOpen, onClose, label, children, isForm, action }) => {
2879
3030
  // refs
2880
3031
  const modalRef = useRef(null);
3032
+ const contentRef = useRef(null);
3033
+ const previousFocusRef = useRef(null);
3034
+ // unique id for aria-labelledby
3035
+ const titleId = useId();
2881
3036
  // hooks
2882
3037
  const { isVisible, isFadingIn } = useTransitionRender(isOpen);
3038
+ // Save focus and trap it inside modal
3039
+ useEffect(() => {
3040
+ if (isOpen) {
3041
+ previousFocusRef.current = document.activeElement;
3042
+ // Move focus to first focusable element in modal
3043
+ setTimeout(() => {
3044
+ const content = contentRef.current;
3045
+ if (content) {
3046
+ const focusable = content.querySelectorAll(FOCUSABLE_SELECTORS);
3047
+ focusable[0]?.focus();
3048
+ }
3049
+ }, 50);
3050
+ }
3051
+ else {
3052
+ // Restore focus to the element that triggered the modal
3053
+ previousFocusRef.current?.focus();
3054
+ }
3055
+ }, [isOpen]);
3056
+ // Keyboard: Escape to close + focus trap (Tab/Shift+Tab)
2883
3057
  useEffect(() => {
2884
3058
  const handleKeyDown = (event) => {
2885
- if (event.key === 'Escape' && isOpen) {
3059
+ if (!isOpen) {
3060
+ return;
3061
+ }
3062
+ if (event.key === 'Escape') {
2886
3063
  onClose();
3064
+ return;
3065
+ }
3066
+ if (event.key === 'Tab') {
3067
+ const content = contentRef.current;
3068
+ if (!content) {
3069
+ return;
3070
+ }
3071
+ const focusable = Array.from(content.querySelectorAll(FOCUSABLE_SELECTORS));
3072
+ if (focusable.length === 0) {
3073
+ return;
3074
+ }
3075
+ const first = focusable[0];
3076
+ const last = focusable[focusable.length - 1];
3077
+ if (event.shiftKey) {
3078
+ if (document.activeElement === first) {
3079
+ event.preventDefault();
3080
+ last.focus();
3081
+ }
3082
+ }
3083
+ else {
3084
+ if (document.activeElement === last) {
3085
+ event.preventDefault();
3086
+ first.focus();
3087
+ }
3088
+ }
2887
3089
  }
2888
3090
  };
2889
3091
  document.addEventListener('keydown', handleKeyDown);
@@ -2892,13 +3094,17 @@ const Modal = ({ isOpen, onClose, label, children, isForm, action }) => {
2892
3094
  // actions
2893
3095
  const safeInvokeAction = (e) => {
2894
3096
  if (action && typeof action.onClick === 'function') {
2895
- // Cast e to required MouseEvent type (or undefined) and call
2896
3097
  action.onClick(e);
2897
3098
  }
2898
3099
  };
2899
3100
  // body of the modal
2900
- const body = (jsxs(Fragment$1, { children: [jsxs(Stack, { justify: 'space-between', height: BUTTON_SIZE, width: '100%', children: [jsx(Text, { variant: 'h3', children: label }), !action && (jsx(IconButton, { icon: jsx(CloseIcon, {}), onClick: onClose, size: 'small', variant: 'text', textColor: 'text' }))] }), children, action && (jsxs(Stack, { justify: 'flex-end', width: '100%', children: [jsx(Button, { label: 'Cancel', onClick: onClose, variant: 'outlined' }), jsx(Button, { ...action, type: isForm ? 'submit' : 'button', onClick: isForm ? undefined : action.onClick })] }))] }));
2901
- return createPortal(jsx(Fragment, { children: isVisible ? (jsx("div", { className: MODAL_STYLES.background(isFadingIn), ref: modalRef, children: jsx("div", { className: MODAL_STYLES.content(isFadingIn), children: isForm ? (jsx(Form, { onSubmit: (e) => { e.preventDefault(); safeInvokeAction(); }, children: body })) : (jsx(Fragment, { children: body })) }) })) : null }), document.body);
3101
+ const body = (jsxs(Fragment$1, { children: [jsxs(Stack, { justify: 'space-between', height: BUTTON_SIZE, width: '100%', children: [jsx(Text, { variant: 'h3', id: titleId, children: label }), !action && (jsx(IconButton, { icon: jsx(CloseIcon, {}), onClick: onClose, size: 'small', variant: 'text', textColor: 'text', ariaLabel: 'Close dialog' }))] }), children, action && (jsxs(Stack, { justify: 'flex-end', width: '100%', children: [jsx(Button, { label: 'Cancel', onClick: onClose, variant: 'outlined' }), jsx(Button, { ...action, type: isForm ? 'submit' : 'button', onClick: isForm ? undefined : action.onClick })] }))] }));
3102
+ return createPortal(jsx(Fragment, { children: isVisible ? (jsx("div", { className: MODAL_STYLES.background(isFadingIn), ref: modalRef, onClick: (e) => {
3103
+ // Close when clicking the backdrop
3104
+ if (e.target === e.currentTarget) {
3105
+ onClose();
3106
+ }
3107
+ }, children: jsx("div", { ref: contentRef, className: MODAL_STYLES.content(isFadingIn), role: 'dialog', "aria-modal": 'true', "aria-labelledby": titleId, children: isForm ? (jsx(Form, { onSubmit: (e) => { e.preventDefault(); safeInvokeAction(); }, children: body })) : (jsx(Fragment, { children: body })) }) })) : null }), document.body);
2902
3108
  };
2903
3109
  Modal.displayName = 'Modal';
2904
3110