@dashforge/tw 0.11.0-beta → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/CHANGELOG.md +234 -12
  2. package/COVERAGE.md +2 -2
  3. package/PARITY.md +7 -9
  4. package/PERFORMANCE.md +3 -4
  5. package/README.md +74 -15
  6. package/THEME-AUDIT.md +3 -4
  7. package/dashforge-tw-1.0.0.tgz +0 -0
  8. package/dist/index.esm.js +3127 -748
  9. package/dist/src/components/Alert/Alert.d.ts +65 -0
  10. package/dist/src/components/Alert/Alert.d.ts.map +1 -0
  11. package/dist/src/components/Alert/alert.types.d.ts +130 -0
  12. package/dist/src/components/Alert/alert.types.d.ts.map +1 -0
  13. package/dist/src/components/Alert/alert.variants.d.ts +85 -0
  14. package/dist/src/components/Alert/alert.variants.d.ts.map +1 -0
  15. package/dist/src/components/Avatar/Avatar.d.ts +48 -0
  16. package/dist/src/components/Avatar/Avatar.d.ts.map +1 -0
  17. package/dist/src/components/Avatar/avatar.types.d.ts +162 -0
  18. package/dist/src/components/Avatar/avatar.types.d.ts.map +1 -0
  19. package/dist/src/components/Avatar/avatar.variants.d.ts +208 -0
  20. package/dist/src/components/Avatar/avatar.variants.d.ts.map +1 -0
  21. package/dist/src/components/Badge/Badge.d.ts +63 -0
  22. package/dist/src/components/Badge/Badge.d.ts.map +1 -0
  23. package/dist/src/components/Badge/badge.types.d.ts +124 -0
  24. package/dist/src/components/Badge/badge.types.d.ts.map +1 -0
  25. package/dist/src/components/Badge/badge.variants.d.ts +262 -0
  26. package/dist/src/components/Badge/badge.variants.d.ts.map +1 -0
  27. package/dist/src/components/Box/Box.d.ts.map +1 -1
  28. package/dist/src/components/Box/box.types.d.ts +53 -0
  29. package/dist/src/components/Box/box.types.d.ts.map +1 -1
  30. package/dist/src/components/Button/Button.d.ts.map +1 -1
  31. package/dist/src/components/Button/button.types.d.ts +35 -0
  32. package/dist/src/components/Button/button.types.d.ts.map +1 -1
  33. package/dist/src/components/Card/Card.d.ts +107 -0
  34. package/dist/src/components/Card/Card.d.ts.map +1 -0
  35. package/dist/src/components/Card/card.types.d.ts +119 -0
  36. package/dist/src/components/Card/card.types.d.ts.map +1 -0
  37. package/dist/src/components/Chip/Chip.d.ts +67 -0
  38. package/dist/src/components/Chip/Chip.d.ts.map +1 -0
  39. package/dist/src/components/Chip/chip.types.d.ts +113 -0
  40. package/dist/src/components/Chip/chip.types.d.ts.map +1 -0
  41. package/dist/src/components/Chip/chip.variants.d.ts +155 -0
  42. package/dist/src/components/Chip/chip.variants.d.ts.map +1 -0
  43. package/dist/src/components/IconButton/IconButton.d.ts +57 -0
  44. package/dist/src/components/IconButton/IconButton.d.ts.map +1 -0
  45. package/dist/src/components/IconButton/iconButton.types.d.ts +96 -0
  46. package/dist/src/components/IconButton/iconButton.types.d.ts.map +1 -0
  47. package/dist/src/components/IconButton/iconButton.variants.d.ts +26 -0
  48. package/dist/src/components/IconButton/iconButton.variants.d.ts.map +1 -0
  49. package/dist/src/components/Menu/Menu.d.ts +118 -0
  50. package/dist/src/components/Menu/Menu.d.ts.map +1 -0
  51. package/dist/src/components/Menu/menu.types.d.ts +179 -0
  52. package/dist/src/components/Menu/menu.types.d.ts.map +1 -0
  53. package/dist/src/components/Menu/menu.variants.d.ts +129 -0
  54. package/dist/src/components/Menu/menu.variants.d.ts.map +1 -0
  55. package/dist/src/components/Snackbar/Snackbar.d.ts.map +1 -1
  56. package/dist/src/components/Snackbar/snackbar.types.d.ts +40 -3
  57. package/dist/src/components/Snackbar/snackbar.types.d.ts.map +1 -1
  58. package/dist/src/components/Snackbar/snackbar.variants.d.ts +12 -57
  59. package/dist/src/components/Snackbar/snackbar.variants.d.ts.map +1 -1
  60. package/dist/src/components/Spinner/Spinner.d.ts +44 -0
  61. package/dist/src/components/Spinner/Spinner.d.ts.map +1 -0
  62. package/dist/src/components/Spinner/spinner.types.d.ts +89 -0
  63. package/dist/src/components/Spinner/spinner.types.d.ts.map +1 -0
  64. package/dist/src/components/Spinner/spinner.variants.d.ts +111 -0
  65. package/dist/src/components/Spinner/spinner.variants.d.ts.map +1 -0
  66. package/dist/src/components/Table/cells/RenderChip.d.ts +15 -72
  67. package/dist/src/components/Table/cells/RenderChip.d.ts.map +1 -1
  68. package/dist/src/components/_shared/severity/index.d.ts +21 -0
  69. package/dist/src/components/_shared/severity/index.d.ts.map +1 -0
  70. package/dist/src/components/_shared/severity/severity.types.d.ts +48 -0
  71. package/dist/src/components/_shared/severity/severity.types.d.ts.map +1 -0
  72. package/dist/src/components/_shared/severity/severityIcons.d.ts +45 -0
  73. package/dist/src/components/_shared/severity/severityIcons.d.ts.map +1 -0
  74. package/dist/src/components/_shared/severity/severityVariants.d.ts +22 -0
  75. package/dist/src/components/_shared/severity/severityVariants.d.ts.map +1 -0
  76. package/dist/src/index.d.ts +27 -2
  77. package/dist/src/index.d.ts.map +1 -1
  78. package/package.json +8 -7
  79. package/src/components/Alert/Alert.test.tsx +302 -0
  80. package/src/components/Alert/Alert.tsx +186 -0
  81. package/src/components/Alert/alert.types.ts +144 -0
  82. package/src/components/Alert/alert.variants.ts +71 -0
  83. package/src/components/Autocomplete/autocomplete.variants.ts +1 -1
  84. package/src/components/Avatar/Avatar.test.tsx +287 -0
  85. package/src/components/Avatar/Avatar.tsx +304 -0
  86. package/src/components/Avatar/avatar.types.ts +205 -0
  87. package/src/components/Avatar/avatar.variants.ts +194 -0
  88. package/src/components/Badge/Badge.test.tsx +385 -0
  89. package/src/components/Badge/Badge.tsx +174 -0
  90. package/src/components/Badge/badge.types.ts +154 -0
  91. package/src/components/Badge/badge.variants.ts +161 -0
  92. package/src/components/Box/Box.test.tsx +21 -0
  93. package/src/components/Box/Box.tsx +32 -3
  94. package/src/components/Box/box.types.ts +55 -0
  95. package/src/components/Button/Button.test.tsx +21 -0
  96. package/src/components/Button/Button.tsx +28 -27
  97. package/src/components/Button/button.types.ts +36 -0
  98. package/src/components/Card/Card.test.tsx +291 -0
  99. package/src/components/Card/Card.tsx +241 -0
  100. package/src/components/Card/card.types.ts +132 -0
  101. package/src/components/Chip/Chip.test.tsx +316 -0
  102. package/src/components/Chip/Chip.tsx +224 -0
  103. package/src/components/Chip/chip.types.ts +128 -0
  104. package/src/components/Chip/chip.variants.ts +173 -0
  105. package/src/components/DataGrid/visibility/ColumnVisibilityMenu.tsx +1 -1
  106. package/src/components/IconButton/IconButton.test.tsx +367 -0
  107. package/src/components/IconButton/IconButton.tsx +159 -0
  108. package/src/components/IconButton/iconButton.types.ts +106 -0
  109. package/src/components/IconButton/iconButton.variants.ts +31 -0
  110. package/src/components/LeftNav/leftNav.variants.ts +2 -2
  111. package/src/components/Menu/Menu.test.tsx +408 -0
  112. package/src/components/Menu/Menu.tsx +335 -0
  113. package/src/components/Menu/menu.types.ts +221 -0
  114. package/src/components/Menu/menu.variants.ts +132 -0
  115. package/src/components/Pagination/pagination.variants.ts +1 -1
  116. package/src/components/Snackbar/Snackbar.tsx +46 -28
  117. package/src/components/Snackbar/snackbar.types.ts +47 -3
  118. package/src/components/Snackbar/snackbar.variants.ts +29 -22
  119. package/src/components/Spinner/Spinner.test.tsx +199 -0
  120. package/src/components/Spinner/Spinner.tsx +158 -0
  121. package/src/components/Spinner/spinner.types.ts +113 -0
  122. package/src/components/Spinner/spinner.variants.ts +83 -0
  123. package/src/components/Table/cells/RenderChip.tsx +24 -87
  124. package/src/components/Table/cells/RowActionsMenu.tsx +1 -1
  125. package/src/components/TextField/TextField.test.tsx +5 -1
  126. package/src/components/_shared/severity/index.ts +33 -0
  127. package/src/components/_shared/severity/severity.types.ts +50 -0
  128. package/src/components/_shared/severity/severityIcons.tsx +104 -0
  129. package/src/components/_shared/severity/severityVariants.test.ts +136 -0
  130. package/src/components/_shared/severity/severityVariants.ts +115 -0
  131. package/src/index.ts +163 -1
  132. package/vite.config.ts +8 -0
  133. package/vitest.config.mts +8 -1
  134. package/LICENSE +0 -21
@@ -0,0 +1,186 @@
1
+ import { useContext } from 'react';
2
+ import { DashFormContext, useEngineVisibility } from '@dashforge/ui-core';
3
+ import { cn } from '../../utils/cn.js';
4
+ import { useAccessState } from '../../hooks/useAccessState.js';
5
+ import {
6
+ getSeverityClasses,
7
+ getSeverityRole,
8
+ } from '../_shared/severity/severityVariants.js';
9
+ import { getDefaultSeverityIcon } from '../_shared/severity/severityIcons.js';
10
+ import { alertVariants } from './alert.variants.js';
11
+ import type { AlertProps, AlertTitleProps } from './alert.types.js';
12
+
13
+ /**
14
+ * `<AlertTitle>` — optional title block, rendered as the first child
15
+ * of `<Alert>`. Pattern mirrors MUI exactly (sub-component, not a
16
+ * prop), so the JSX shape ports 1:1 from `@mui/material`.
17
+ *
18
+ * @example
19
+ * <Alert severity="warning">
20
+ * <AlertTitle>Subscription expires soon</AlertTitle>
21
+ * Renew before 2026-06-20 to keep access.
22
+ * </Alert>
23
+ */
24
+ export function AlertTitle({ children, className }: AlertTitleProps) {
25
+ const { titleSlot } = alertVariants();
26
+ return (
27
+ <div className={cn(titleSlot(), className)}>
28
+ {children}
29
+ </div>
30
+ );
31
+ }
32
+
33
+ /**
34
+ * `<Alert>` — inline persistent status surface.
35
+ *
36
+ * **API**: full MUI Alert parity (`severity`, `variant`, `icon`,
37
+ * `onClose`, `action`, `role`, `<AlertTitle>` sub-component). One
38
+ * deliberate divergence: `severity="danger"` instead of MUI's
39
+ * `severity="error"` — aligned to the `danger.*` token palette.
40
+ *
41
+ * **Bridge integration** (Dashforge extras):
42
+ * - `visibleWhen` — engine-reactive predicate (re-evaluated on
43
+ * every engine state change when mounted inside a `<DashForm>`)
44
+ * - `access` — RBAC gating via `@dashforge/rbac`
45
+ *
46
+ * **Token-driven**: every color class flows from `dashforgePreset()`
47
+ * via CSS variables. No hardcoded hex, no `dark:` variants. The
48
+ * 3×4 color matrix lives in `_shared/severity/severityVariants.ts`
49
+ * and is shared with the refactored `<Snackbar>` (and future Banner).
50
+ *
51
+ * **A11y**: `role="alert"` for assertive severities (warning, danger)
52
+ * and `role="status"` for polite ones (info, success). Override via
53
+ * the `role` prop. The icon is `aria-hidden` — the message text is
54
+ * the semantic carrier.
55
+ *
56
+ * @example
57
+ * // Default — soft tinted surface, default icon
58
+ * <Alert severity="info">New feature: export to CSV is now in beta.</Alert>
59
+ *
60
+ * // With title (sub-component) + close button
61
+ * <Alert severity="warning" onClose={dismiss}>
62
+ * <AlertTitle>Subscription expires soon</AlertTitle>
63
+ * Your Pro plan ends in 3 days.
64
+ * </Alert>
65
+ *
66
+ * // Custom icon
67
+ * import { Bell } from 'lucide-react';
68
+ * <Alert severity="info" icon={<Bell size={20} />}>3 new notifications</Alert>
69
+ *
70
+ * // No icon — colored surface alone carries the severity
71
+ * <Alert severity="success" icon={false}>Saved 2 minutes ago.</Alert>
72
+ *
73
+ * // Custom action — CTA replaces the auto-close button
74
+ * <Alert severity="warning" action={<button onClick={renew}>Renew</button>}>
75
+ * Trial expires in 3 days.
76
+ * </Alert>
77
+ *
78
+ * // Filled variant — for high-priority callouts
79
+ * <Alert severity="danger" variant="filled">
80
+ * Couldn't save — 3 fields below contain validation errors.
81
+ * </Alert>
82
+ */
83
+ export function Alert({
84
+ severity,
85
+ variant = 'standard',
86
+ children,
87
+ icon: iconProp,
88
+ onClose,
89
+ closeText = 'Close',
90
+ action,
91
+ role: roleProp,
92
+ visibleWhen,
93
+ access,
94
+ sx,
95
+ className,
96
+ slotProps,
97
+ }: AlertProps) {
98
+ // Bridge — both hooks safely return defaults outside a DashForm /
99
+ // RbacProvider, matching the TextField pattern.
100
+ const bridge = useContext(DashFormContext);
101
+ const isVisible = useEngineVisibility(bridge?.engine, visibleWhen);
102
+ const accessState = useAccessState(access);
103
+
104
+ if (!isVisible || !accessState.visible) return null;
105
+
106
+ const v = alertVariants();
107
+ const severityClasses = getSeverityClasses(variant, severity);
108
+ const role = roleProp ?? getSeverityRole(severity);
109
+
110
+ // Icon resolution:
111
+ // - false → no icon
112
+ // - undefined → default per severity (lib's inline SVG)
113
+ // - ReactNode → consumer's custom icon
114
+ const renderedIcon =
115
+ iconProp === false
116
+ ? null
117
+ : iconProp !== undefined
118
+ ? iconProp
119
+ : getDefaultSeverityIcon(severity);
120
+
121
+ return (
122
+ <div
123
+ role={role}
124
+ className={cn(
125
+ v.root(),
126
+ severityClasses.surface,
127
+ severityClasses.border,
128
+ sx,
129
+ className,
130
+ slotProps?.root?.className
131
+ )}
132
+ {...slotProps?.root}
133
+ >
134
+ {renderedIcon !== null && (
135
+ <span
136
+ className={cn(
137
+ v.icon(),
138
+ severityClasses.icon,
139
+ slotProps?.icon?.className
140
+ )}
141
+ {...slotProps?.icon}
142
+ >
143
+ {renderedIcon}
144
+ </span>
145
+ )}
146
+
147
+ <div
148
+ className={cn(v.content(), slotProps?.content?.className)}
149
+ {...slotProps?.content}
150
+ >
151
+ {children}
152
+ </div>
153
+
154
+ {action ? (
155
+ <div
156
+ className={cn(v.action(), slotProps?.action?.className)}
157
+ {...slotProps?.action}
158
+ >
159
+ {action}
160
+ </div>
161
+ ) : onClose ? (
162
+ <button
163
+ type="button"
164
+ aria-label={closeText}
165
+ onClick={onClose}
166
+ className={cn(v.closeButton(), slotProps?.closeButton?.className)}
167
+ {...slotProps?.closeButton}
168
+ >
169
+ <svg
170
+ width="1em"
171
+ height="1em"
172
+ viewBox="0 0 20 20"
173
+ fill="none"
174
+ stroke="currentColor"
175
+ strokeWidth={1.5}
176
+ strokeLinecap="round"
177
+ strokeLinejoin="round"
178
+ aria-hidden="true"
179
+ >
180
+ <path d="m6 6 8 8M14 6l-8 8" />
181
+ </svg>
182
+ </button>
183
+ ) : null}
184
+ </div>
185
+ );
186
+ }
@@ -0,0 +1,144 @@
1
+ import type { ComponentPropsWithoutRef, ReactNode } from 'react';
2
+ import type { ClassValue } from 'tailwind-variants';
3
+ import type { Engine } from '@dashforge/ui-core';
4
+ import type { AccessRequirement } from '@dashforge/rbac';
5
+ import type { Severity, SeverityVariant } from '../_shared/severity/severity.types.js';
6
+
7
+ /**
8
+ * Per-slot override props for `<Alert>`. Mirrors the standard
9
+ * `slotProps` contract used across `@dashforge/tw` components.
10
+ */
11
+ export interface AlertSlotProps {
12
+ root?: ComponentPropsWithoutRef<'div'>;
13
+ icon?: ComponentPropsWithoutRef<'span'>;
14
+ content?: ComponentPropsWithoutRef<'div'>;
15
+ action?: ComponentPropsWithoutRef<'div'>;
16
+ closeButton?: ComponentPropsWithoutRef<'button'>;
17
+ }
18
+
19
+ /**
20
+ * Props for the `<Alert>` component.
21
+ *
22
+ * Mirrors the MUI Alert API surface 1:1 (drop-in muscle memory) with
23
+ * a single deliberate divergence: `severity` uses `'danger'` instead of
24
+ * MUI's `'error'` for alignment with the `danger.*` token palette in
25
+ * `@dashforge/tw-tokens`.
26
+ *
27
+ * In addition to the MUI surface, Dashforge adds:
28
+ * - `visibleWhen` — engine-reactive visibility predicate (same
29
+ * contract as `<TextField>` / `<Checkbox>` / etc.)
30
+ * - `access` — RBAC integration via `@dashforge/rbac`
31
+ *
32
+ * @see https://mui.com/material-ui/api/alert/
33
+ */
34
+ export interface AlertProps {
35
+ /**
36
+ * Visual severity. Drives the color treatment and the default ARIA
37
+ * role (`alert` for warning / danger, `status` for info / success).
38
+ *
39
+ * Note: this is `danger`, not MUI's `error`. The runtime behaviour
40
+ * is identical; the naming follows the Dashforge token palette.
41
+ */
42
+ severity: Severity;
43
+
44
+ /**
45
+ * Variant of the severity surface. Mirrors MUI's three-way axis.
46
+ *
47
+ * - `'standard'` (default) — tinted soft surface, severity-toned
48
+ * text. The "all-purpose" reading.
49
+ * - `'filled'` — solid colored surface, light text. High visual
50
+ * weight, for blocking / critical messages.
51
+ * - `'outlined'` — transparent surface, severity border + text.
52
+ * Minimal weight, for dense layouts.
53
+ *
54
+ * @default 'standard'
55
+ */
56
+ variant?: SeverityVariant;
57
+
58
+ /** Body content of the alert. */
59
+ children?: ReactNode;
60
+
61
+ /**
62
+ * Icon control with full MUI parity.
63
+ *
64
+ * - omitted / `undefined` → default per-severity icon (lib's inline
65
+ * stroke SVG)
66
+ * - `ReactNode` → consumer-provided custom icon (Lucide, Phosphor,
67
+ * Tabler, custom SVG — bring your own iconography)
68
+ * - `false` → no icon rendered (the colored surface carries the
69
+ * severity signal; use when icons compete with the layout)
70
+ */
71
+ icon?: ReactNode | false;
72
+
73
+ /**
74
+ * When defined, the alert renders a trailing close button that
75
+ * fires this callback on click. Ignored if `action` is provided
76
+ * (`action` slot wins, matching MUI behaviour).
77
+ */
78
+ onClose?: () => void;
79
+
80
+ /**
81
+ * `aria-label` for the close button.
82
+ * @default 'Close'
83
+ */
84
+ closeText?: string;
85
+
86
+ /**
87
+ * Custom trailing slot — CTA buttons, links, inline forms. When
88
+ * provided, replaces the auto-rendered close button. The consumer
89
+ * is responsible for wiring any close behaviour into the custom
90
+ * action node.
91
+ */
92
+ action?: ReactNode;
93
+
94
+ /**
95
+ * Override the auto-derived ARIA role. Defaults to `'alert'` for
96
+ * `severity={'warning' | 'danger'}` (assertive announcement) and
97
+ * `'status'` for `severity={'info' | 'success'}` (polite).
98
+ */
99
+ role?: 'alert' | 'status';
100
+
101
+ /**
102
+ * Reactive visibility predicate. Re-evaluated on every engine state
103
+ * change when the alert is mounted inside a `<DashForm>`; outside a
104
+ * form, evaluated as a plain predicate (the consumer captures any
105
+ * external state in the closure).
106
+ *
107
+ * When the predicate returns `false`, the component renders `null`
108
+ * — same contract as form fields.
109
+ */
110
+ visibleWhen?: (engine: Engine) => boolean;
111
+
112
+ /**
113
+ * RBAC requirement. When the current subject does not satisfy the
114
+ * requirement, the alert is hidden (or disabled / read-only,
115
+ * per `onUnauthorized`).
116
+ */
117
+ access?: AccessRequirement;
118
+
119
+ /** Root-element class shortcut (string or `clsx`-compatible value). */
120
+ sx?: ClassValue;
121
+
122
+ /** Standard React `className` — appended to the root via `cn()`. */
123
+ className?: string;
124
+
125
+ /** Per-slot overrides — typed handles for the inner elements. */
126
+ slotProps?: AlertSlotProps;
127
+ }
128
+
129
+ /**
130
+ * Props for the `<AlertTitle>` sub-component.
131
+ *
132
+ * AlertTitle is a separate exported component, not a prop on Alert —
133
+ * follows MUI's pattern. Pass it as the first child of `<Alert>`:
134
+ *
135
+ * @example
136
+ * <Alert severity="warning">
137
+ * <AlertTitle>Subscription expires soon</AlertTitle>
138
+ * Your Pro plan ends on 2026-06-20.
139
+ * </Alert>
140
+ */
141
+ export interface AlertTitleProps {
142
+ children: ReactNode;
143
+ className?: string;
144
+ }
@@ -0,0 +1,71 @@
1
+ import { tv, type VariantProps } from 'tailwind-variants';
2
+
3
+ /**
4
+ * Tailwind-variants recipe for `<Alert>`.
5
+ *
6
+ * Slots:
7
+ * - `root` — outer surface, severity + variant color classes
8
+ * come from `_shared/severity/severityVariants`
9
+ * (NOT baked into this recipe, to keep the matrix
10
+ * shared across Alert / Snackbar / future Banner)
11
+ * - `icon` — leading severity icon container
12
+ * - `titleSlot` — `<AlertTitle>` rendered as the first content row
13
+ * - `content` — body text container
14
+ * - `action` — trailing action / close button container
15
+ * - `closeButton` — built-in close `×` (when `onClose` is defined)
16
+ *
17
+ * The severity color classes are merged at render time via `cn()` in
18
+ * `Alert.tsx` — see the call sites of `getSeverityClasses(variant,
19
+ * severity)`. This recipe owns layout + spacing + typography only.
20
+ */
21
+ export const alertVariants = tv({
22
+ slots: {
23
+ root: [
24
+ 'relative flex items-start gap-3',
25
+ 'px-4 py-3',
26
+ 'rounded-lg border',
27
+ 'text-sm',
28
+ // Smooth visibility transition (opacity only) — matches the
29
+ // Snackbar entry timing, gated on motion-reduce per WCAG 2.3.3.
30
+ 'transition-opacity duration-150 motion-reduce:transition-none motion-reduce:duration-0',
31
+ ],
32
+ icon: [
33
+ 'shrink-0 mt-0.5',
34
+ 'inline-flex items-center justify-center',
35
+ ],
36
+ titleSlot: [
37
+ 'block font-medium leading-snug mb-1',
38
+ ],
39
+ content: [
40
+ 'flex-1 min-w-0',
41
+ 'leading-relaxed',
42
+ ],
43
+ action: [
44
+ 'shrink-0 inline-flex items-center',
45
+ 'ml-auto',
46
+ ],
47
+ closeButton: [
48
+ 'shrink-0 inline-flex items-center justify-center w-6 h-6',
49
+ 'rounded-md',
50
+ 'opacity-70 hover:opacity-100',
51
+ 'outline-none focus-visible:ring-2 focus-visible:ring-current',
52
+ 'transition-opacity',
53
+ ],
54
+ },
55
+ variants: {
56
+ /**
57
+ * Layout density. Defaults to comfortable spacing; `compact`
58
+ * tightens the vertical padding for inline contexts (form
59
+ * validation summary at the top of a tight modal, etc.).
60
+ */
61
+ density: {
62
+ comfortable: { root: 'px-4 py-3' },
63
+ compact: { root: 'px-3 py-2 text-[13px]' },
64
+ },
65
+ },
66
+ defaultVariants: {
67
+ density: 'comfortable',
68
+ },
69
+ });
70
+
71
+ export type AlertVariantProps = VariantProps<typeof alertVariants>;
@@ -61,7 +61,7 @@ export const autocompleteVariants = tv({
61
61
  'rounded-md border bg-neutral-50 border-neutral-300 shadow-lg',
62
62
  'min-w-[var(--trigger-width)]',
63
63
  ],
64
- listBox: 'outline-none py-1',
64
+ listBox: 'outline-none py-1 list-none pl-0 m-0',
65
65
  listItem: [
66
66
  'cursor-pointer select-none px-3 py-2 text-sm text-neutral-900',
67
67
  'outline-none',
@@ -0,0 +1,287 @@
1
+ // @vitest-environment jsdom
2
+ import * as React from 'react';
3
+ import { describe, it, expect, afterEach } from 'vitest';
4
+ import { fireEvent, render, screen, cleanup } from '@testing-library/react';
5
+ import { Avatar, AvatarGroup } from './Avatar.js';
6
+
7
+ void React;
8
+ afterEach(() => cleanup());
9
+
10
+ /**
11
+ * Unit tests for the Avatar family. Covers:
12
+ * - Image rendering (src + alt)
13
+ * - Auto-initials from name (1 word, 2 words, edge cases)
14
+ * - Fallback chain (src fail → initials → custom icon → generic)
15
+ * - Shape ↔ radius interaction (radius wins when both set)
16
+ * - Size axis (5 enum values map to spacing classes)
17
+ * - Color × tone resolution (soft default vs explicit tone)
18
+ * - AvatarGroup slicing + overflow indicator + size inheritance
19
+ */
20
+
21
+ describe('<Avatar>', () => {
22
+ describe('image rendering', () => {
23
+ it('renders an <img> when src is set', () => {
24
+ const { container } = render(
25
+ <Avatar src="/avatar.png" alt="Maya" />
26
+ );
27
+ expect(container.querySelector('img')).toBeTruthy();
28
+ });
29
+
30
+ it('applies the alt attribute', () => {
31
+ render(<Avatar src="/x.png" alt="Maya Rodriguez" />);
32
+ const img = screen.getByRole('img', { name: /maya rodriguez/i });
33
+ expect(img).toBeTruthy();
34
+ });
35
+
36
+ it('falls back to initials when the image fails to load', () => {
37
+ const { container } = render(<Avatar src="/broken.png" name="Maya Rodriguez" />);
38
+ const img = container.querySelector('img')!;
39
+ fireEvent.error(img);
40
+ // After onError the img is gone, initials shown.
41
+ expect(container.querySelector('img')).toBeNull();
42
+ expect(screen.getByText('MR')).toBeTruthy();
43
+ });
44
+ });
45
+
46
+ describe('auto-initials from name', () => {
47
+ it('extracts MR from "Maya Rodriguez"', () => {
48
+ render(<Avatar name="Maya Rodriguez" />);
49
+ expect(screen.getByText('MR')).toBeTruthy();
50
+ });
51
+
52
+ it('extracts C from single-word name "Cher"', () => {
53
+ render(<Avatar name="Cher" />);
54
+ expect(screen.getByText('C')).toBeTruthy();
55
+ });
56
+
57
+ it('uppercases the initials', () => {
58
+ render(<Avatar name="maya rodriguez" />);
59
+ expect(screen.getByText('MR')).toBeTruthy();
60
+ });
61
+
62
+ it('handles extra whitespace', () => {
63
+ render(<Avatar name=" Maya Rodriguez " />);
64
+ expect(screen.getByText('MR')).toBeTruthy();
65
+ });
66
+
67
+ it('renders a generic icon when name is empty', () => {
68
+ const { container } = render(<Avatar name="" />);
69
+ expect(container.querySelector('svg')).toBeTruthy();
70
+ });
71
+
72
+ it('renders a generic icon when name is omitted entirely', () => {
73
+ const { container } = render(<Avatar />);
74
+ expect(container.querySelector('svg')).toBeTruthy();
75
+ });
76
+
77
+ it('renders fallbackIcon over the generic icon when set', () => {
78
+ render(<Avatar fallbackIcon={<span data-testid="custom-icon">★</span>} />);
79
+ expect(screen.getByTestId('custom-icon')).toBeTruthy();
80
+ });
81
+
82
+ it('initials win over fallbackIcon when name is present', () => {
83
+ render(
84
+ <Avatar
85
+ name="Maya Rodriguez"
86
+ fallbackIcon={<span data-testid="custom-icon">★</span>}
87
+ />
88
+ );
89
+ expect(screen.getByText('MR')).toBeTruthy();
90
+ expect(screen.queryByTestId('custom-icon')).toBeNull();
91
+ });
92
+ });
93
+
94
+ describe('shape ↔ radius', () => {
95
+ it('default shape=circle → rounded-full', () => {
96
+ const { container } = render(<Avatar name="x" />);
97
+ const root = container.firstElementChild!;
98
+ expect(root.className).toContain('rounded-full');
99
+ });
100
+
101
+ it('shape="rounded" → rounded-lg', () => {
102
+ const { container } = render(<Avatar name="x" shape="rounded" />);
103
+ const root = container.firstElementChild!;
104
+ expect(root.className).toContain('rounded-lg');
105
+ });
106
+
107
+ it('shape="square" → rounded-none', () => {
108
+ const { container } = render(<Avatar name="x" shape="square" />);
109
+ const root = container.firstElementChild!;
110
+ expect(root.className).toContain('rounded-none');
111
+ });
112
+
113
+ it('explicit radius wins over shape', () => {
114
+ const { container } = render(
115
+ <Avatar name="x" shape="circle" radius="md" />
116
+ );
117
+ const root = container.firstElementChild!;
118
+ expect(root.className).toContain('rounded-md');
119
+ expect(root.className).not.toContain('rounded-full');
120
+ });
121
+ });
122
+
123
+ describe('size axis', () => {
124
+ it.each([
125
+ ['xs', 'w-5'],
126
+ ['sm', 'w-7'],
127
+ ['md', 'w-9'],
128
+ ['lg', 'w-12'],
129
+ ['xl', 'w-16'],
130
+ ] as const)('size=%s → %s', (size, expected) => {
131
+ const { container } = render(<Avatar name="x" size={size} />);
132
+ const root = container.firstElementChild!;
133
+ expect(root.className).toContain(expected);
134
+ });
135
+ });
136
+
137
+ describe('color × tone resolution', () => {
138
+ it('default color=neutral + no tone → bg-neutral-100', () => {
139
+ const { container } = render(<Avatar name="x" />);
140
+ const root = container.firstElementChild!;
141
+ expect(root.className).toContain('bg-neutral-100');
142
+ });
143
+
144
+ it('color=primary + no tone → soft default bg-primary-100', () => {
145
+ const { container } = render(<Avatar name="x" color="primary" />);
146
+ const root = container.firstElementChild!;
147
+ expect(root.className).toContain('bg-primary-100');
148
+ });
149
+
150
+ it('color=success + tone=500 → bg-success-500 text-success-50', () => {
151
+ const { container } = render(<Avatar name="x" color="success" tone={500} />);
152
+ const root = container.firstElementChild!;
153
+ expect(root.className).toContain('bg-success-500');
154
+ expect(root.className).toContain('text-success-50');
155
+ });
156
+
157
+ it('color=danger + tone=700 → bg-danger-700 text-danger-50', () => {
158
+ const { container } = render(<Avatar name="x" color="danger" tone={700} />);
159
+ const root = container.firstElementChild!;
160
+ expect(root.className).toContain('bg-danger-700');
161
+ });
162
+
163
+ it('color=warning + tone=50 → bg-warning-50 text-warning-900 (dark text)', () => {
164
+ const { container } = render(<Avatar name="x" color="warning" tone={50} />);
165
+ const root = container.firstElementChild!;
166
+ expect(root.className).toContain('bg-warning-50');
167
+ expect(root.className).toContain('text-warning-900');
168
+ });
169
+
170
+ it('color classes are NOT applied when image loads', () => {
171
+ const { container } = render(<Avatar src="/x.png" alt="x" color="primary" />);
172
+ const root = container.firstElementChild!;
173
+ // Image surface — no fallback bg paint underneath (transparent
174
+ // PNGs would peek through otherwise).
175
+ expect(root.className).not.toContain('bg-primary-100');
176
+ });
177
+ });
178
+
179
+ describe('overrides', () => {
180
+ it('appends sx to the root', () => {
181
+ const { container } = render(<Avatar name="x" sx="ring-4 ring-purple-500" />);
182
+ const root = container.firstElementChild!;
183
+ expect(root.className).toContain('ring-purple-500');
184
+ });
185
+
186
+ it('appends className to the root', () => {
187
+ const { container } = render(<Avatar name="x" className="my-custom" />);
188
+ const root = container.firstElementChild!;
189
+ expect(root.className).toContain('my-custom');
190
+ });
191
+ });
192
+ });
193
+
194
+ describe('<AvatarGroup>', () => {
195
+ it('renders all children when total ≤ max', () => {
196
+ const { container } = render(
197
+ <AvatarGroup max={5}>
198
+ <Avatar name="A" />
199
+ <Avatar name="B" />
200
+ <Avatar name="C" />
201
+ </AvatarGroup>
202
+ );
203
+ // 3 avatars rendered, no overflow indicator
204
+ const avatars = container.querySelectorAll('[role="img"]');
205
+ expect(avatars.length).toBe(3);
206
+ });
207
+
208
+ it('slices to max and renders overflow indicator', () => {
209
+ const { container } = render(
210
+ <AvatarGroup max={3}>
211
+ <Avatar name="A" />
212
+ <Avatar name="B" />
213
+ <Avatar name="C" />
214
+ <Avatar name="D" />
215
+ <Avatar name="E" />
216
+ </AvatarGroup>
217
+ );
218
+ // 3 visible + 1 overflow indicator
219
+ const avatars = container.querySelectorAll('[role="img"]');
220
+ expect(avatars.length).toBe(4);
221
+ expect(screen.getByText('+2')).toBeTruthy();
222
+ });
223
+
224
+ it('default max=4 — 5th avatar collapses', () => {
225
+ render(
226
+ <AvatarGroup>
227
+ <Avatar name="A" />
228
+ <Avatar name="B" />
229
+ <Avatar name="C" />
230
+ <Avatar name="D" />
231
+ <Avatar name="E" />
232
+ </AvatarGroup>
233
+ );
234
+ expect(screen.getByText('+1')).toBeTruthy();
235
+ });
236
+
237
+ it('applies negative spacing class to the wrapper', () => {
238
+ const { container } = render(
239
+ <AvatarGroup spacing="md">
240
+ <Avatar name="A" />
241
+ <Avatar name="B" />
242
+ </AvatarGroup>
243
+ );
244
+ const wrapper = container.firstElementChild!;
245
+ expect(wrapper.className).toContain('-space-x-3');
246
+ });
247
+
248
+ it('propagates size to children when they do not override', () => {
249
+ const { container } = render(
250
+ <AvatarGroup size="xl">
251
+ <Avatar name="A" />
252
+ <Avatar name="B" size="sm" />
253
+ </AvatarGroup>
254
+ );
255
+ const avatars = container.querySelectorAll('[role="img"]');
256
+ // First child inherits xl
257
+ expect(avatars[0]!.className).toContain('w-16');
258
+ // Second child preserves sm
259
+ expect(avatars[1]!.className).toContain('w-7');
260
+ });
261
+
262
+ it('applies ring-2 halo when withRing=true (default)', () => {
263
+ const { container } = render(
264
+ <AvatarGroup>
265
+ <Avatar name="A" />
266
+ <Avatar name="B" />
267
+ </AvatarGroup>
268
+ );
269
+ const avatars = container.querySelectorAll('[role="img"]');
270
+ expect(avatars[0]!.className).toContain('ring-2');
271
+ // Ring uses the auto-inverting neutral-50 token (NOT
272
+ // `ring-white dark:ring-neutral-N` — that would violate the
273
+ // theme-identity rule for the neutral palette).
274
+ expect(avatars[0]!.className).toContain('ring-neutral-50');
275
+ });
276
+
277
+ it('omits ring when withRing=false', () => {
278
+ const { container } = render(
279
+ <AvatarGroup withRing={false}>
280
+ <Avatar name="A" />
281
+ <Avatar name="B" />
282
+ </AvatarGroup>
283
+ );
284
+ const avatars = container.querySelectorAll('[role="img"]');
285
+ expect(avatars[0]!.className).not.toContain('ring-2');
286
+ });
287
+ });