@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,291 @@
1
+ // @vitest-environment jsdom
2
+ import * as React from 'react';
3
+ import { describe, it, expect, vi, afterEach } from 'vitest';
4
+ import { render, screen, fireEvent, cleanup } from '@testing-library/react';
5
+ import { RbacProvider } from '@dashforge/rbac';
6
+ import type { RbacPolicy, Subject } from '@dashforge/rbac';
7
+ import { Card, CardContent, CardActionArea } from './Card.js';
8
+
9
+ void React;
10
+ afterEach(() => cleanup());
11
+
12
+ /**
13
+ * Unit tests for the Card family. Covers:
14
+ * - <Card>: thin Box wrapper with restricted variant axis, inherits
15
+ * access + visibleWhen from Box (verified at integration level)
16
+ * - <CardContent>: padded inner box with default p=4 and override
17
+ * - <CardActionArea>: clickable wrapper (button or asChild), focus
18
+ * ring, selected aria-pressed, disabled state, access RBAC,
19
+ * visibleWhen predicate
20
+ */
21
+
22
+ const ADMIN_POLICY: RbacPolicy = {
23
+ roles: [
24
+ {
25
+ name: 'admin',
26
+ permissions: [{ resource: '*', action: '*', effect: 'allow' }],
27
+ },
28
+ ],
29
+ };
30
+ const NO_ACCESS_POLICY: RbacPolicy = {
31
+ roles: [{ name: 'guest', permissions: [] }],
32
+ };
33
+ const adminSubject: Subject = { id: 'a', roles: ['admin'] };
34
+ const guestSubject: Subject = { id: 'g', roles: ['guest'] };
35
+
36
+ describe('<Card>', () => {
37
+ describe('rendering', () => {
38
+ it('renders children', () => {
39
+ render(<Card data-testid="card">hello</Card>);
40
+ expect(screen.getByTestId('card')).toBeTruthy();
41
+ expect(screen.getByText('hello')).toBeTruthy();
42
+ });
43
+
44
+ it('applies default outlined variant (border class from Box)', () => {
45
+ const { container } = render(<Card>x</Card>);
46
+ const root = container.firstElementChild!;
47
+ expect(root.className).toContain('border');
48
+ });
49
+
50
+ it('applies default rounded="lg"', () => {
51
+ const { container } = render(<Card>x</Card>);
52
+ const root = container.firstElementChild!;
53
+ expect(root.className).toContain('rounded-lg');
54
+ });
55
+
56
+ it('overrides default variant to elevated', () => {
57
+ const { container } = render(<Card variant="elevated">x</Card>);
58
+ const root = container.firstElementChild!;
59
+ // Elevated variant uses bg-white in light mode
60
+ expect(root.className).toContain('bg-white');
61
+ });
62
+
63
+ it('respects elevation override', () => {
64
+ const { container } = render(<Card elevation={3}>x</Card>);
65
+ const root = container.firstElementChild!;
66
+ expect(root.className).toContain('shadow-md');
67
+ });
68
+
69
+ it('inherits visibleWhen from Box', () => {
70
+ const { container } = render(
71
+ <Card visibleWhen={() => false}>hidden</Card>
72
+ );
73
+ expect(container.firstChild).toBeNull();
74
+ });
75
+
76
+ it('inherits access from Box (hide mode)', () => {
77
+ const { container } = render(
78
+ <RbacProvider policy={NO_ACCESS_POLICY} subject={guestSubject}>
79
+ <Card
80
+ access={{
81
+ resource: 'admin',
82
+ action: 'read',
83
+ onUnauthorized: 'hide',
84
+ }}
85
+ >
86
+ admin-only
87
+ </Card>
88
+ </RbacProvider>
89
+ );
90
+ expect(container.firstChild).toBeNull();
91
+ });
92
+
93
+ it('inherits access from Box (visible for authorized subject)', () => {
94
+ const { container } = render(
95
+ <RbacProvider policy={ADMIN_POLICY} subject={adminSubject}>
96
+ <Card
97
+ access={{
98
+ resource: 'admin',
99
+ action: 'read',
100
+ onUnauthorized: 'hide',
101
+ }}
102
+ >
103
+ shown
104
+ </Card>
105
+ </RbacProvider>
106
+ );
107
+ expect(container.firstChild).toBeTruthy();
108
+ expect(screen.getByText('shown')).toBeTruthy();
109
+ });
110
+ });
111
+ });
112
+
113
+ describe('<CardContent>', () => {
114
+ it('renders children inside a Box with default p=4', () => {
115
+ const { container } = render(
116
+ <CardContent>content</CardContent>
117
+ );
118
+ const root = container.firstElementChild!;
119
+ expect(root.className).toContain('p-4');
120
+ expect(screen.getByText('content')).toBeTruthy();
121
+ });
122
+
123
+ it('respects custom p value', () => {
124
+ const { container } = render(
125
+ <CardContent p={6}>spacious</CardContent>
126
+ );
127
+ const root = container.firstElementChild!;
128
+ expect(root.className).toContain('p-6');
129
+ });
130
+
131
+ it('respects p={0} for zero padding', () => {
132
+ const { container } = render(
133
+ <CardContent p={0}>flush</CardContent>
134
+ );
135
+ const root = container.firstElementChild!;
136
+ expect(root.className).toContain('p-0');
137
+ });
138
+
139
+ it('appends className', () => {
140
+ const { container } = render(
141
+ <CardContent className="my-custom-content">x</CardContent>
142
+ );
143
+ const root = container.firstElementChild!;
144
+ expect(root.className).toContain('my-custom-content');
145
+ });
146
+ });
147
+
148
+ describe('<CardActionArea>', () => {
149
+ describe('rendering', () => {
150
+ it('renders as <button> by default', () => {
151
+ render(<CardActionArea>click me</CardActionArea>);
152
+ expect(screen.getByRole('button', { name: /click me/i })).toBeTruthy();
153
+ });
154
+
155
+ it('renders as the immediate child when asChild=true', () => {
156
+ const { container } = render(
157
+ <CardActionArea asChild>
158
+ <a href="/x" data-testid="anchor-child">link content</a>
159
+ </CardActionArea>
160
+ );
161
+ const anchor = container.querySelector('a[data-testid="anchor-child"]');
162
+ expect(anchor).toBeTruthy();
163
+ expect(container.querySelector('button')).toBeNull();
164
+ });
165
+
166
+ it('applies base layout (block w-full)', () => {
167
+ const { container } = render(<CardActionArea>x</CardActionArea>);
168
+ const root = container.firstElementChild!;
169
+ expect(root.className).toContain('block');
170
+ expect(root.className).toContain('w-full');
171
+ });
172
+ });
173
+
174
+ describe('selected state', () => {
175
+ it('applies aria-pressed=true when selected', () => {
176
+ render(<CardActionArea selected>x</CardActionArea>);
177
+ expect(screen.getByRole('button').getAttribute('aria-pressed')).toBe('true');
178
+ });
179
+
180
+ it('does NOT set aria-pressed when not selected', () => {
181
+ render(<CardActionArea>x</CardActionArea>);
182
+ expect(screen.getByRole('button').getAttribute('aria-pressed')).toBeNull();
183
+ });
184
+
185
+ it('applies primary ring class when selected', () => {
186
+ const { container } = render(<CardActionArea selected>x</CardActionArea>);
187
+ const btn = container.firstElementChild!;
188
+ expect(btn.className).toContain('ring-primary-500');
189
+ });
190
+ });
191
+
192
+ describe('disabled state', () => {
193
+ it('respects explicit disabled prop', () => {
194
+ render(<CardActionArea disabled>x</CardActionArea>);
195
+ const btn = screen.getByRole('button') as HTMLButtonElement;
196
+ expect(btn.disabled).toBe(true);
197
+ });
198
+
199
+ it('does NOT fire onClick when disabled', () => {
200
+ const onClick = vi.fn();
201
+ render(<CardActionArea disabled onClick={onClick}>x</CardActionArea>);
202
+ fireEvent.click(screen.getByRole('button'));
203
+ expect(onClick).not.toHaveBeenCalled();
204
+ });
205
+
206
+ it('applies opacity-50 + cursor-not-allowed when disabled', () => {
207
+ const { container } = render(<CardActionArea disabled>x</CardActionArea>);
208
+ const btn = container.firstElementChild!;
209
+ expect(btn.className).toContain('opacity-50');
210
+ });
211
+ });
212
+
213
+ describe('click handler', () => {
214
+ it('fires onClick when clicked', () => {
215
+ const onClick = vi.fn();
216
+ render(<CardActionArea onClick={onClick}>x</CardActionArea>);
217
+ fireEvent.click(screen.getByRole('button'));
218
+ expect(onClick).toHaveBeenCalledTimes(1);
219
+ });
220
+ });
221
+
222
+ describe('access RBAC', () => {
223
+ it('hides when access.onUnauthorized=hide and subject lacks permission', () => {
224
+ const { container } = render(
225
+ <RbacProvider policy={NO_ACCESS_POLICY} subject={guestSubject}>
226
+ <CardActionArea
227
+ access={{
228
+ resource: 'admin',
229
+ action: 'read',
230
+ onUnauthorized: 'hide',
231
+ }}
232
+ >
233
+ admin-cta
234
+ </CardActionArea>
235
+ </RbacProvider>
236
+ );
237
+ expect(container.firstChild).toBeNull();
238
+ });
239
+
240
+ it('disables when access.onUnauthorized=disable', () => {
241
+ render(
242
+ <RbacProvider policy={NO_ACCESS_POLICY} subject={guestSubject}>
243
+ <CardActionArea
244
+ access={{
245
+ resource: 'admin',
246
+ action: 'read',
247
+ onUnauthorized: 'disable',
248
+ }}
249
+ >
250
+ disable-cta
251
+ </CardActionArea>
252
+ </RbacProvider>
253
+ );
254
+ const btn = screen.getByRole('button') as HTMLButtonElement;
255
+ expect(btn.disabled).toBe(true);
256
+ });
257
+
258
+ it('renders normally for authorized subject', () => {
259
+ render(
260
+ <RbacProvider policy={ADMIN_POLICY} subject={adminSubject}>
261
+ <CardActionArea
262
+ access={{
263
+ resource: 'admin',
264
+ action: 'read',
265
+ onUnauthorized: 'hide',
266
+ }}
267
+ >
268
+ admin-cta
269
+ </CardActionArea>
270
+ </RbacProvider>
271
+ );
272
+ expect(screen.getByText('admin-cta')).toBeTruthy();
273
+ });
274
+ });
275
+
276
+ describe('visibleWhen', () => {
277
+ it('renders when predicate returns true', () => {
278
+ render(
279
+ <CardActionArea visibleWhen={() => true}>shown</CardActionArea>
280
+ );
281
+ expect(screen.getByText('shown')).toBeTruthy();
282
+ });
283
+
284
+ it('returns null when predicate returns false', () => {
285
+ const { container } = render(
286
+ <CardActionArea visibleWhen={() => false}>hidden</CardActionArea>
287
+ );
288
+ expect(container.firstChild).toBeNull();
289
+ });
290
+ });
291
+ });
@@ -0,0 +1,241 @@
1
+ import { forwardRef, useContext } from 'react';
2
+ import { Slot } from '@radix-ui/react-slot';
3
+ import { DashFormContext, useEngineVisibility } from '@dashforge/ui-core';
4
+ import { cn } from '../../utils/cn.js';
5
+ import { useAccessState } from '../../hooks/useAccessState.js';
6
+ import { Box } from '../Box/Box.js';
7
+ import type {
8
+ CardActionAreaProps,
9
+ CardContentProps,
10
+ CardProps,
11
+ } from './card.types.js';
12
+
13
+ /**
14
+ * `<Card>` — opinionated surface preset over `<Box>`.
15
+ *
16
+ * Thin alias that pre-fills card-shaped Box defaults (`outlined`,
17
+ * `rounded='lg'`, `elevation=1`, no padding). Reads as `<Card>` in
18
+ * consumer code while reusing every Box affordance: surface variants,
19
+ * 7 intent colors, spacing scale, polymorphism, and the bridge
20
+ * integration (`access` + `visibleWhen`) retrofitted to Box in 1.1.0.
21
+ *
22
+ * Restricted variant axis: only `'outlined' | 'elevated' | 'plain'`.
23
+ * `'soft'` and `'solid'` are deliberately excluded — they belong to
24
+ * Box (as `<Alert>`-shaped callouts) and to banners, not to cards.
25
+ *
26
+ * @example
27
+ * ```tsx
28
+ * // Most common — outlined card with content padding via CardContent
29
+ * <Card>
30
+ * <CardContent>
31
+ * <Typography variant="h3">Title</Typography>
32
+ * <Typography variant="body">Body</Typography>
33
+ * </CardContent>
34
+ * </Card>
35
+ *
36
+ * // Elevated, clickable, gated by RBAC (inherits access from Box)
37
+ * <Card
38
+ * variant="elevated"
39
+ * elevation={2}
40
+ * access={{ resource: 'billing', action: 'read', onUnauthorized: 'hide' }}
41
+ * >
42
+ * <CardActionArea onClick={openBilling}>
43
+ * <CardContent>
44
+ * <Typography variant="h3">Revenue</Typography>
45
+ * </CardContent>
46
+ * </CardActionArea>
47
+ * </Card>
48
+ * ```
49
+ */
50
+ export const Card = forwardRef<HTMLElement, CardProps>(function Card(
51
+ props,
52
+ ref
53
+ ) {
54
+ const {
55
+ variant = 'outlined',
56
+ rounded = 'lg',
57
+ elevation = 1,
58
+ p = 0,
59
+ children,
60
+ ...rest
61
+ } = props;
62
+
63
+ return (
64
+ <Box
65
+ ref={ref}
66
+ variant={variant}
67
+ rounded={rounded}
68
+ elevation={elevation}
69
+ p={p}
70
+ {...rest}
71
+ >
72
+ {children}
73
+ </Box>
74
+ );
75
+ });
76
+
77
+ Card.displayName = 'Card';
78
+
79
+ /**
80
+ * `<CardContent>` — padded inner section of a Card.
81
+ *
82
+ * Semantic alias for an inner `<Box p={4}>`. The default padding step
83
+ * (4) maps to `--df-tw-spacing-4` (1rem) via the dashforgePreset,
84
+ * matching common card-content paddings across MUI / shadcn / Chakra.
85
+ * Override `p` to dial up/down per the spacing token scale.
86
+ *
87
+ * @example
88
+ * ```tsx
89
+ * <Card>
90
+ * <CardContent p={6}> {/* bigger inner padding *}/}
91
+ * <Typography variant="h3">Spacious card</Typography>
92
+ * </CardContent>
93
+ * </Card>
94
+ * ```
95
+ */
96
+ export function CardContent({
97
+ p = 4,
98
+ children,
99
+ className,
100
+ sx,
101
+ }: CardContentProps) {
102
+ return (
103
+ <Box p={p} sx={cn(className, sx as string | undefined)}>
104
+ {children}
105
+ </Box>
106
+ );
107
+ }
108
+
109
+ CardContent.displayName = 'CardContent';
110
+
111
+ /**
112
+ * `<CardActionArea>` — clickable wrapper for Card content.
113
+ *
114
+ * Adds focus ring + hover tint + Radix Slot polymorphism. Renders as
115
+ * `<button type="button">` by default, or as the immediate child via
116
+ * `asChild` (typical pattern: wrap a router `<Link>`).
117
+ *
118
+ * Bridge integration is local — CardActionArea is an action trigger
119
+ * (not a surface), so it carries `access` + `visibleWhen` directly
120
+ * (rather than relying on the wrapping Card's props). This lets the
121
+ * consumer differentiate "the card is visible but not clickable" from
122
+ * "the card is not visible at all".
123
+ *
124
+ * @example
125
+ * ```tsx
126
+ * // Card-as-CTA
127
+ * <Card>
128
+ * <CardActionArea onClick={navigate}>
129
+ * <CardContent>
130
+ * <Typography variant="h3">Open dashboard</Typography>
131
+ * <Typography variant="body">Hover to see the focus ring.</Typography>
132
+ * </CardContent>
133
+ * </CardActionArea>
134
+ * </Card>
135
+ *
136
+ * // Polymorphic — wrap a router Link
137
+ * <Card>
138
+ * <CardActionArea asChild>
139
+ * <Link to="/billing">
140
+ * <CardContent>
141
+ * <Typography variant="h3">Billing</Typography>
142
+ * </CardContent>
143
+ * </Link>
144
+ * </CardActionArea>
145
+ * </Card>
146
+ *
147
+ * // Selectable card (filter / option pattern)
148
+ * <CardActionArea
149
+ * selected={selected === 'monthly'}
150
+ * onClick={() => setSelected('monthly')}
151
+ * >
152
+ * <CardContent>Monthly plan</CardContent>
153
+ * </CardActionArea>
154
+ * ```
155
+ */
156
+ export const CardActionArea = forwardRef<HTMLButtonElement, CardActionAreaProps>(
157
+ function CardActionArea(props, ref) {
158
+ const {
159
+ children,
160
+ asChild,
161
+ selected,
162
+ access,
163
+ visibleWhen,
164
+ disabled,
165
+ sx,
166
+ className,
167
+ onClick,
168
+ ...rest
169
+ } = props;
170
+
171
+ // Bridge — both hooks called unconditionally (rules-of-hooks).
172
+ const bridge = useContext(DashFormContext);
173
+ const isVisible = useEngineVisibility(bridge?.engine, visibleWhen);
174
+ const accessState = useAccessState(access);
175
+
176
+ if (!isVisible || !accessState.visible) return null;
177
+
178
+ const isDisabled =
179
+ Boolean(disabled) || accessState.disabled || accessState.readonly;
180
+
181
+ // The action area must SPAN the parent Card so the entire surface
182
+ // is clickable. Includes focus ring, hover, and a subtle pressed
183
+ // tint via `data-pressed`. Token-driven via dashforgePreset.
184
+ const classes = cn(
185
+ 'block w-full text-left',
186
+ 'outline-none rounded-[inherit]',
187
+ 'transition-colors',
188
+ // Hover — gentle bg tint on top of the card surface. Uses
189
+ // currentColor with 5% alpha so it composes with any Card
190
+ // variant/color without hardcoding.
191
+ 'hover:bg-current/5',
192
+ // Focus ring — keyboard-navigable, primary tone, inset slightly
193
+ // so it traces the card's inherited radius.
194
+ 'focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-1',
195
+ // Selected — adds a primary tone ring + soft tint so the card
196
+ // visually reads as "active option" without changing the parent
197
+ // Card's variant.
198
+ selected && 'ring-2 ring-primary-500 ring-offset-1 bg-primary-50/60 dark:bg-primary-950/40',
199
+ // Disabled — dims content and suppresses clicks.
200
+ isDisabled && 'opacity-50 cursor-not-allowed pointer-events-none',
201
+ sx,
202
+ className
203
+ );
204
+
205
+ const handleClick: React.MouseEventHandler<HTMLButtonElement> = (e) => {
206
+ if (isDisabled) return;
207
+ onClick?.(e);
208
+ };
209
+
210
+ if (asChild) {
211
+ return (
212
+ <Slot
213
+ ref={ref}
214
+ className={classes}
215
+ data-disabled={isDisabled || undefined}
216
+ aria-disabled={isDisabled || undefined}
217
+ aria-pressed={selected ? true : undefined}
218
+ onClick={handleClick}
219
+ >
220
+ {children}
221
+ </Slot>
222
+ );
223
+ }
224
+
225
+ return (
226
+ <button
227
+ ref={ref}
228
+ type="button"
229
+ className={classes}
230
+ disabled={isDisabled}
231
+ aria-pressed={selected ? true : undefined}
232
+ onClick={handleClick}
233
+ {...rest}
234
+ >
235
+ {children}
236
+ </button>
237
+ );
238
+ }
239
+ );
240
+
241
+ CardActionArea.displayName = 'CardActionArea';
@@ -0,0 +1,132 @@
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 { BoxProps } from '../Box/box.types.js';
6
+
7
+ /**
8
+ * `<Card>` — opinionated surface preset over `<Box>`.
9
+ *
10
+ * Architectural note: `<Box>` already implements the full surface
11
+ * vocabulary (variant × color, elevation, rounded, spacing). `<Card>`
12
+ * exists for **two reasons** on top of Box:
13
+ *
14
+ * 1. **MUI mental model** — devs migrating from `@mui/material/Card`
15
+ * reach for `<Card>` reflexively. Having the component (as a
16
+ * thin Box alias with card-shaped defaults) removes adoption
17
+ * friction without duplicating the surface system.
18
+ * 2. **Restricted variant axis** — Card excludes `'soft'` and
19
+ * `'solid'` from Box's 5 variants. A "solid card" is semantically
20
+ * a banner; a "soft card" is a callout (use `<Alert>`). Restricting
21
+ * to `'outlined' | 'elevated' | 'plain'` is opinionated typing
22
+ * that catches misuse at compile time.
23
+ *
24
+ * Card inherits `access` + `visibleWhen` from Box (Sprint 4.4 surface
25
+ * alignment) automatically — no per-component wiring needed.
26
+ *
27
+ * Defaults set by Card (override at the call site):
28
+ * - `variant='outlined'` → soft border + bg tint (most card-like)
29
+ * - `rounded='lg'` → 8px radius
30
+ * - `elevation={1}` → faint shadow for visual weight
31
+ * - `p={0}` → no inner padding (use `<CardContent>`)
32
+ */
33
+ export interface CardProps
34
+ extends Omit<BoxProps, 'variant'> {
35
+ /**
36
+ * Restricted variant — only the three card-appropriate Box variants.
37
+ * - `'outlined'` (default) — bordered surface with subtle tint.
38
+ * - `'elevated'` — surface with shadow, no border.
39
+ * - `'plain'` — bare surface (background + radius), no border, no
40
+ * shadow. Use when wrapping content that already provides chrome.
41
+ *
42
+ * @default 'outlined'
43
+ */
44
+ variant?: 'outlined' | 'elevated' | 'plain';
45
+ }
46
+
47
+ /**
48
+ * `<CardContent>` — padded inner section of a Card.
49
+ *
50
+ * A semantic alias for `<Box p={4}>` (or the equivalent spacing
51
+ * step). Reads better in starter-kit code than the equivalent Box
52
+ * call — the consumer expresses "this is the content area of the
53
+ * card" rather than "this is a box with padding-4 inside a card".
54
+ *
55
+ * No surface props — CardContent inherits its background from the
56
+ * parent Card. The only configurable axis is the padding step.
57
+ */
58
+ export interface CardContentProps {
59
+ /**
60
+ * Inner padding token step (same scale as `<Box p>`).
61
+ * @default 4
62
+ */
63
+ p?: BoxProps['p'];
64
+
65
+ /** Content of the section. */
66
+ children: ReactNode;
67
+
68
+ /** Standard React `className` — appended to the root via `cn()`. */
69
+ className?: string;
70
+
71
+ /** Root-element class shortcut (string or clsx-compatible value). */
72
+ sx?: ClassValue;
73
+ }
74
+
75
+ /**
76
+ * `<CardActionArea>` — clickable wrapper inside (or around) a Card.
77
+ *
78
+ * The single value-add over a raw `<Box>` wrapper: focus ring +
79
+ * hover state + Radix Slot polymorphism so the wrapper can become a
80
+ * router `<Link>` without nesting `<a>` inside `<button>` (a11y
81
+ * violation).
82
+ *
83
+ * Use cases:
84
+ * - "Card-as-CTA" patterns where the whole card surface should be
85
+ * clickable (e.g. "Open dashboard" tile)
86
+ * - Card-shaped router links in dashboards / navigation grids
87
+ * - Selectable cards in galleries (with `selected` + `onClick`)
88
+ *
89
+ * Always renders as `<button>` (or the asChild element) — focusable,
90
+ * keyboard-accessible by default. `aria-pressed` when `selected=true`
91
+ * for the toggle pattern (filter card / option card).
92
+ */
93
+ export interface CardActionAreaProps
94
+ extends Omit<ComponentPropsWithoutRef<'button'>, 'className' | 'color'> {
95
+ /** The card body / content to wrap. */
96
+ children: ReactNode;
97
+
98
+ /**
99
+ * Render-as-child via Radix Slot. The CardActionArea styles paint
100
+ * onto the immediate child element (typically a router `<Link>`).
101
+ * No `<button>` DOM emitted.
102
+ */
103
+ asChild?: boolean;
104
+
105
+ /**
106
+ * Selected state — for toggle / option-card patterns. Adds
107
+ * `aria-pressed="true"` and a primary-toned focus ring even when
108
+ * not focused.
109
+ */
110
+ selected?: boolean;
111
+
112
+ /**
113
+ * RBAC requirement — controls clickability via the centralized
114
+ * `@dashforge/rbac` policy engine.
115
+ * - `onUnauthorized: 'hide'` → action area does not render
116
+ * - `onUnauthorized: 'disable'` → clicks suppressed + dimmed
117
+ * - `onUnauthorized: 'readonly'` → mapped to disabled
118
+ */
119
+ access?: AccessRequirement;
120
+
121
+ /**
122
+ * Engine-reactive visibility predicate — Sprint 4.4 alignment.
123
+ * When the predicate returns `false`, the action area renders `null`.
124
+ */
125
+ visibleWhen?: (engine: Engine) => boolean;
126
+
127
+ /** Root-element class shortcut. */
128
+ sx?: ClassValue;
129
+
130
+ /** Standard React className — appended via `cn()`. */
131
+ className?: string;
132
+ }