@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.
- package/CHANGELOG.md +234 -12
- package/COVERAGE.md +2 -2
- package/PARITY.md +7 -9
- package/PERFORMANCE.md +3 -4
- package/README.md +74 -15
- package/THEME-AUDIT.md +3 -4
- package/dashforge-tw-1.0.0.tgz +0 -0
- package/dist/index.esm.js +3127 -748
- package/dist/src/components/Alert/Alert.d.ts +65 -0
- package/dist/src/components/Alert/Alert.d.ts.map +1 -0
- package/dist/src/components/Alert/alert.types.d.ts +130 -0
- package/dist/src/components/Alert/alert.types.d.ts.map +1 -0
- package/dist/src/components/Alert/alert.variants.d.ts +85 -0
- package/dist/src/components/Alert/alert.variants.d.ts.map +1 -0
- package/dist/src/components/Avatar/Avatar.d.ts +48 -0
- package/dist/src/components/Avatar/Avatar.d.ts.map +1 -0
- package/dist/src/components/Avatar/avatar.types.d.ts +162 -0
- package/dist/src/components/Avatar/avatar.types.d.ts.map +1 -0
- package/dist/src/components/Avatar/avatar.variants.d.ts +208 -0
- package/dist/src/components/Avatar/avatar.variants.d.ts.map +1 -0
- package/dist/src/components/Badge/Badge.d.ts +63 -0
- package/dist/src/components/Badge/Badge.d.ts.map +1 -0
- package/dist/src/components/Badge/badge.types.d.ts +124 -0
- package/dist/src/components/Badge/badge.types.d.ts.map +1 -0
- package/dist/src/components/Badge/badge.variants.d.ts +262 -0
- package/dist/src/components/Badge/badge.variants.d.ts.map +1 -0
- package/dist/src/components/Box/Box.d.ts.map +1 -1
- package/dist/src/components/Box/box.types.d.ts +53 -0
- package/dist/src/components/Box/box.types.d.ts.map +1 -1
- package/dist/src/components/Button/Button.d.ts.map +1 -1
- package/dist/src/components/Button/button.types.d.ts +35 -0
- package/dist/src/components/Button/button.types.d.ts.map +1 -1
- package/dist/src/components/Card/Card.d.ts +107 -0
- package/dist/src/components/Card/Card.d.ts.map +1 -0
- package/dist/src/components/Card/card.types.d.ts +119 -0
- package/dist/src/components/Card/card.types.d.ts.map +1 -0
- package/dist/src/components/Chip/Chip.d.ts +67 -0
- package/dist/src/components/Chip/Chip.d.ts.map +1 -0
- package/dist/src/components/Chip/chip.types.d.ts +113 -0
- package/dist/src/components/Chip/chip.types.d.ts.map +1 -0
- package/dist/src/components/Chip/chip.variants.d.ts +155 -0
- package/dist/src/components/Chip/chip.variants.d.ts.map +1 -0
- package/dist/src/components/IconButton/IconButton.d.ts +57 -0
- package/dist/src/components/IconButton/IconButton.d.ts.map +1 -0
- package/dist/src/components/IconButton/iconButton.types.d.ts +96 -0
- package/dist/src/components/IconButton/iconButton.types.d.ts.map +1 -0
- package/dist/src/components/IconButton/iconButton.variants.d.ts +26 -0
- package/dist/src/components/IconButton/iconButton.variants.d.ts.map +1 -0
- package/dist/src/components/Menu/Menu.d.ts +118 -0
- package/dist/src/components/Menu/Menu.d.ts.map +1 -0
- package/dist/src/components/Menu/menu.types.d.ts +179 -0
- package/dist/src/components/Menu/menu.types.d.ts.map +1 -0
- package/dist/src/components/Menu/menu.variants.d.ts +129 -0
- package/dist/src/components/Menu/menu.variants.d.ts.map +1 -0
- package/dist/src/components/Snackbar/Snackbar.d.ts.map +1 -1
- package/dist/src/components/Snackbar/snackbar.types.d.ts +40 -3
- package/dist/src/components/Snackbar/snackbar.types.d.ts.map +1 -1
- package/dist/src/components/Snackbar/snackbar.variants.d.ts +12 -57
- package/dist/src/components/Snackbar/snackbar.variants.d.ts.map +1 -1
- package/dist/src/components/Spinner/Spinner.d.ts +44 -0
- package/dist/src/components/Spinner/Spinner.d.ts.map +1 -0
- package/dist/src/components/Spinner/spinner.types.d.ts +89 -0
- package/dist/src/components/Spinner/spinner.types.d.ts.map +1 -0
- package/dist/src/components/Spinner/spinner.variants.d.ts +111 -0
- package/dist/src/components/Spinner/spinner.variants.d.ts.map +1 -0
- package/dist/src/components/Table/cells/RenderChip.d.ts +15 -72
- package/dist/src/components/Table/cells/RenderChip.d.ts.map +1 -1
- package/dist/src/components/_shared/severity/index.d.ts +21 -0
- package/dist/src/components/_shared/severity/index.d.ts.map +1 -0
- package/dist/src/components/_shared/severity/severity.types.d.ts +48 -0
- package/dist/src/components/_shared/severity/severity.types.d.ts.map +1 -0
- package/dist/src/components/_shared/severity/severityIcons.d.ts +45 -0
- package/dist/src/components/_shared/severity/severityIcons.d.ts.map +1 -0
- package/dist/src/components/_shared/severity/severityVariants.d.ts +22 -0
- package/dist/src/components/_shared/severity/severityVariants.d.ts.map +1 -0
- package/dist/src/index.d.ts +27 -2
- package/dist/src/index.d.ts.map +1 -1
- package/package.json +8 -7
- package/src/components/Alert/Alert.test.tsx +302 -0
- package/src/components/Alert/Alert.tsx +186 -0
- package/src/components/Alert/alert.types.ts +144 -0
- package/src/components/Alert/alert.variants.ts +71 -0
- package/src/components/Autocomplete/autocomplete.variants.ts +1 -1
- package/src/components/Avatar/Avatar.test.tsx +287 -0
- package/src/components/Avatar/Avatar.tsx +304 -0
- package/src/components/Avatar/avatar.types.ts +205 -0
- package/src/components/Avatar/avatar.variants.ts +194 -0
- package/src/components/Badge/Badge.test.tsx +385 -0
- package/src/components/Badge/Badge.tsx +174 -0
- package/src/components/Badge/badge.types.ts +154 -0
- package/src/components/Badge/badge.variants.ts +161 -0
- package/src/components/Box/Box.test.tsx +21 -0
- package/src/components/Box/Box.tsx +32 -3
- package/src/components/Box/box.types.ts +55 -0
- package/src/components/Button/Button.test.tsx +21 -0
- package/src/components/Button/Button.tsx +28 -27
- package/src/components/Button/button.types.ts +36 -0
- package/src/components/Card/Card.test.tsx +291 -0
- package/src/components/Card/Card.tsx +241 -0
- package/src/components/Card/card.types.ts +132 -0
- package/src/components/Chip/Chip.test.tsx +316 -0
- package/src/components/Chip/Chip.tsx +224 -0
- package/src/components/Chip/chip.types.ts +128 -0
- package/src/components/Chip/chip.variants.ts +173 -0
- package/src/components/DataGrid/visibility/ColumnVisibilityMenu.tsx +1 -1
- package/src/components/IconButton/IconButton.test.tsx +367 -0
- package/src/components/IconButton/IconButton.tsx +159 -0
- package/src/components/IconButton/iconButton.types.ts +106 -0
- package/src/components/IconButton/iconButton.variants.ts +31 -0
- package/src/components/LeftNav/leftNav.variants.ts +2 -2
- package/src/components/Menu/Menu.test.tsx +408 -0
- package/src/components/Menu/Menu.tsx +335 -0
- package/src/components/Menu/menu.types.ts +221 -0
- package/src/components/Menu/menu.variants.ts +132 -0
- package/src/components/Pagination/pagination.variants.ts +1 -1
- package/src/components/Snackbar/Snackbar.tsx +46 -28
- package/src/components/Snackbar/snackbar.types.ts +47 -3
- package/src/components/Snackbar/snackbar.variants.ts +29 -22
- package/src/components/Spinner/Spinner.test.tsx +199 -0
- package/src/components/Spinner/Spinner.tsx +158 -0
- package/src/components/Spinner/spinner.types.ts +113 -0
- package/src/components/Spinner/spinner.variants.ts +83 -0
- package/src/components/Table/cells/RenderChip.tsx +24 -87
- package/src/components/Table/cells/RowActionsMenu.tsx +1 -1
- package/src/components/TextField/TextField.test.tsx +5 -1
- package/src/components/_shared/severity/index.ts +33 -0
- package/src/components/_shared/severity/severity.types.ts +50 -0
- package/src/components/_shared/severity/severityIcons.tsx +104 -0
- package/src/components/_shared/severity/severityVariants.test.ts +136 -0
- package/src/components/_shared/severity/severityVariants.ts +115 -0
- package/src/index.ts +163 -1
- package/vite.config.ts +8 -0
- package/vitest.config.mts +8 -1
- 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
|
+
});
|