@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,408 @@
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 {
8
+ Menu,
9
+ MenuContent,
10
+ MenuItem,
11
+ MenuLabel,
12
+ MenuSeparator,
13
+ MenuSkeleton,
14
+ MenuTrigger,
15
+ } from './Menu.js';
16
+
17
+ void React;
18
+ afterEach(() => cleanup());
19
+
20
+ /**
21
+ * Unit tests for the Menu family.
22
+ *
23
+ * Testing strategy: open the menu via `defaultOpen={true}` instead
24
+ * of clicking the trigger. Radix DropdownMenu's open/close flow
25
+ * uses pointer events that don't translate cleanly to jsdom's
26
+ * `fireEvent.click` — and that logic is Radix's responsibility
27
+ * anyway (already covered by their tests). Our tests focus on:
28
+ * - Our `<MenuItem>` props (onClick / disabled / selected / color /
29
+ * icon / endIcon)
30
+ * - Bridge integration on `<MenuItem>` (access / visibleWhen)
31
+ * - Our wrapper components (`<MenuLabel>`, `<MenuSeparator>`,
32
+ * `<MenuSkeleton>`)
33
+ * - `closeOnItemClick` propagation
34
+ *
35
+ * NB: Radix DropdownMenu uses Portal — when `defaultOpen={true}`,
36
+ * the content renders to `document.body`. We assert via
37
+ * `screen.getByText` / `screen.getByRole` which scan the whole
38
+ * document.
39
+ */
40
+
41
+ const NO_ACCESS_POLICY: RbacPolicy = {
42
+ roles: [{ name: 'guest', permissions: [] }],
43
+ };
44
+ const ADMIN_POLICY: RbacPolicy = {
45
+ roles: [
46
+ {
47
+ name: 'admin',
48
+ permissions: [{ resource: '*', action: '*', effect: 'allow' }],
49
+ },
50
+ ],
51
+ };
52
+ const guestSubject: Subject = { id: 'g', roles: ['guest'] };
53
+ const adminSubject: Subject = { id: 'a', roles: ['admin'] };
54
+
55
+ describe('<Menu>', () => {
56
+ describe('rendering', () => {
57
+ it('renders the trigger', () => {
58
+ render(
59
+ <Menu>
60
+ <MenuTrigger>
61
+ <button>Open</button>
62
+ </MenuTrigger>
63
+ <MenuContent>
64
+ <MenuItem>Item</MenuItem>
65
+ </MenuContent>
66
+ </Menu>
67
+ );
68
+ expect(screen.getByRole('button', { name: /open/i })).toBeTruthy();
69
+ });
70
+
71
+ it('does NOT render content when closed (lazy mount)', () => {
72
+ render(
73
+ <Menu>
74
+ <MenuTrigger>
75
+ <button>Open</button>
76
+ </MenuTrigger>
77
+ <MenuContent>
78
+ <MenuItem>Hidden item</MenuItem>
79
+ </MenuContent>
80
+ </Menu>
81
+ );
82
+ // Without defaultOpen, Radix Portal isn't mounted.
83
+ expect(screen.queryByText('Hidden item')).toBeNull();
84
+ });
85
+
86
+ it('renders content when defaultOpen=true', () => {
87
+ render(
88
+ <Menu defaultOpen>
89
+ <MenuTrigger>
90
+ <button>Open</button>
91
+ </MenuTrigger>
92
+ <MenuContent>
93
+ <MenuItem>Visible item</MenuItem>
94
+ </MenuContent>
95
+ </Menu>
96
+ );
97
+ expect(screen.getByText('Visible item')).toBeTruthy();
98
+ });
99
+ });
100
+
101
+ describe('MenuItem rendering', () => {
102
+ it('renders icon prop', () => {
103
+ render(
104
+ <Menu defaultOpen>
105
+ <MenuTrigger>
106
+ <button>Open</button>
107
+ </MenuTrigger>
108
+ <MenuContent>
109
+ <MenuItem icon={<span data-testid="icon">i</span>}>
110
+ With icon
111
+ </MenuItem>
112
+ </MenuContent>
113
+ </Menu>
114
+ );
115
+ expect(screen.getByTestId('icon')).toBeTruthy();
116
+ });
117
+
118
+ it('renders endIcon prop', () => {
119
+ render(
120
+ <Menu defaultOpen>
121
+ <MenuTrigger>
122
+ <button>Open</button>
123
+ </MenuTrigger>
124
+ <MenuContent>
125
+ <MenuItem endIcon={<span data-testid="end">⌘K</span>}>
126
+ Command
127
+ </MenuItem>
128
+ </MenuContent>
129
+ </Menu>
130
+ );
131
+ expect(screen.getByTestId('end')).toBeTruthy();
132
+ });
133
+
134
+ it('renders the children as label', () => {
135
+ render(
136
+ <Menu defaultOpen>
137
+ <MenuTrigger>
138
+ <button>Open</button>
139
+ </MenuTrigger>
140
+ <MenuContent>
141
+ <MenuItem>My label</MenuItem>
142
+ </MenuContent>
143
+ </Menu>
144
+ );
145
+ expect(screen.getByText('My label')).toBeTruthy();
146
+ });
147
+ });
148
+
149
+ describe('MenuItem disabled', () => {
150
+ it('marks the item as disabled', () => {
151
+ render(
152
+ <Menu defaultOpen>
153
+ <MenuTrigger>
154
+ <button>Open</button>
155
+ </MenuTrigger>
156
+ <MenuContent>
157
+ <MenuItem disabled>Save</MenuItem>
158
+ </MenuContent>
159
+ </Menu>
160
+ );
161
+ const item = screen.getByText('Save').closest('[role="menuitem"]');
162
+ expect(item?.getAttribute('data-disabled')).not.toBeNull();
163
+ });
164
+ });
165
+
166
+ describe('MenuItem selected visual', () => {
167
+ it('applies primary bg classes when selected', () => {
168
+ render(
169
+ <Menu defaultOpen>
170
+ <MenuTrigger>
171
+ <button>Open</button>
172
+ </MenuTrigger>
173
+ <MenuContent>
174
+ <MenuItem selected>Theme: light</MenuItem>
175
+ </MenuContent>
176
+ </Menu>
177
+ );
178
+ const item = screen.getByText('Theme: light').closest('[role="menuitem"]');
179
+ expect(item?.className).toContain('bg-primary-50');
180
+ });
181
+
182
+ it('does NOT apply primary bg when not selected', () => {
183
+ render(
184
+ <Menu defaultOpen>
185
+ <MenuTrigger>
186
+ <button>Open</button>
187
+ </MenuTrigger>
188
+ <MenuContent>
189
+ <MenuItem>Theme: light</MenuItem>
190
+ </MenuContent>
191
+ </Menu>
192
+ );
193
+ const item = screen.getByText('Theme: light').closest('[role="menuitem"]');
194
+ expect(item?.className).not.toContain('bg-primary-50');
195
+ });
196
+ });
197
+
198
+ describe('MenuItem color="danger"', () => {
199
+ it('applies danger text class', () => {
200
+ render(
201
+ <Menu defaultOpen>
202
+ <MenuTrigger>
203
+ <button>Open</button>
204
+ </MenuTrigger>
205
+ <MenuContent>
206
+ <MenuItem color="danger">Delete</MenuItem>
207
+ </MenuContent>
208
+ </Menu>
209
+ );
210
+ const item = screen.getByText('Delete').closest('[role="menuitem"]');
211
+ expect(item?.className).toContain('text-danger-700');
212
+ });
213
+
214
+ it('default color does NOT apply danger class', () => {
215
+ render(
216
+ <Menu defaultOpen>
217
+ <MenuTrigger>
218
+ <button>Open</button>
219
+ </MenuTrigger>
220
+ <MenuContent>
221
+ <MenuItem>Default</MenuItem>
222
+ </MenuContent>
223
+ </Menu>
224
+ );
225
+ const item = screen.getByText('Default').closest('[role="menuitem"]');
226
+ expect(item?.className).not.toContain('text-danger-700');
227
+ });
228
+ });
229
+
230
+ describe('MenuItem access RBAC', () => {
231
+ it('hides the item for unauthorized subjects (onUnauthorized="hide")', () => {
232
+ render(
233
+ <RbacProvider policy={NO_ACCESS_POLICY} subject={guestSubject}>
234
+ <Menu defaultOpen>
235
+ <MenuTrigger>
236
+ <button>Open</button>
237
+ </MenuTrigger>
238
+ <MenuContent>
239
+ <MenuItem>Public</MenuItem>
240
+ <MenuItem
241
+ access={{
242
+ resource: 'admin',
243
+ action: 'manage',
244
+ onUnauthorized: 'hide',
245
+ }}
246
+ >
247
+ Admin-only
248
+ </MenuItem>
249
+ </MenuContent>
250
+ </Menu>
251
+ </RbacProvider>
252
+ );
253
+ expect(screen.getByText('Public')).toBeTruthy();
254
+ expect(screen.queryByText('Admin-only')).toBeNull();
255
+ });
256
+
257
+ it('renders for authorized subjects', () => {
258
+ render(
259
+ <RbacProvider policy={ADMIN_POLICY} subject={adminSubject}>
260
+ <Menu defaultOpen>
261
+ <MenuTrigger>
262
+ <button>Open</button>
263
+ </MenuTrigger>
264
+ <MenuContent>
265
+ <MenuItem
266
+ access={{
267
+ resource: 'admin',
268
+ action: 'manage',
269
+ onUnauthorized: 'hide',
270
+ }}
271
+ >
272
+ Admin-only
273
+ </MenuItem>
274
+ </MenuContent>
275
+ </Menu>
276
+ </RbacProvider>
277
+ );
278
+ expect(screen.getByText('Admin-only')).toBeTruthy();
279
+ });
280
+ });
281
+
282
+ describe('MenuItem visibleWhen', () => {
283
+ it('renders when predicate returns true', () => {
284
+ render(
285
+ <Menu defaultOpen>
286
+ <MenuTrigger>
287
+ <button>Open</button>
288
+ </MenuTrigger>
289
+ <MenuContent>
290
+ <MenuItem visibleWhen={() => true}>Shown</MenuItem>
291
+ </MenuContent>
292
+ </Menu>
293
+ );
294
+ expect(screen.getByText('Shown')).toBeTruthy();
295
+ });
296
+
297
+ it('returns null when predicate returns false', () => {
298
+ render(
299
+ <Menu defaultOpen>
300
+ <MenuTrigger>
301
+ <button>Open</button>
302
+ </MenuTrigger>
303
+ <MenuContent>
304
+ <MenuItem>Always shown</MenuItem>
305
+ <MenuItem visibleWhen={() => false}>Hidden</MenuItem>
306
+ </MenuContent>
307
+ </Menu>
308
+ );
309
+ expect(screen.getByText('Always shown')).toBeTruthy();
310
+ expect(screen.queryByText('Hidden')).toBeNull();
311
+ });
312
+ });
313
+
314
+ describe('MenuLabel + MenuSeparator', () => {
315
+ it('renders MenuLabel content', () => {
316
+ render(
317
+ <Menu defaultOpen>
318
+ <MenuTrigger>
319
+ <button>Open</button>
320
+ </MenuTrigger>
321
+ <MenuContent>
322
+ <MenuLabel>Section heading</MenuLabel>
323
+ <MenuItem>Item</MenuItem>
324
+ </MenuContent>
325
+ </Menu>
326
+ );
327
+ expect(screen.getByText('Section heading')).toBeTruthy();
328
+ });
329
+
330
+ it('renders MenuSeparator with role="separator"', () => {
331
+ const { baseElement } = render(
332
+ <Menu defaultOpen>
333
+ <MenuTrigger>
334
+ <button>Open</button>
335
+ </MenuTrigger>
336
+ <MenuContent>
337
+ <MenuItem>Above</MenuItem>
338
+ <MenuSeparator />
339
+ <MenuItem>Below</MenuItem>
340
+ </MenuContent>
341
+ </Menu>
342
+ );
343
+ const sep = baseElement.querySelector('[role="separator"]');
344
+ expect(sep).toBeTruthy();
345
+ });
346
+ });
347
+
348
+ describe('overrides — sx + className', () => {
349
+ it('applies sx to a MenuItem', () => {
350
+ render(
351
+ <Menu defaultOpen>
352
+ <MenuTrigger>
353
+ <button>Open</button>
354
+ </MenuTrigger>
355
+ <MenuContent>
356
+ <MenuItem sx="ring-2 ring-purple-500">x</MenuItem>
357
+ </MenuContent>
358
+ </Menu>
359
+ );
360
+ const item = screen.getByText('x').closest('[role="menuitem"]');
361
+ expect(item?.className).toContain('ring-purple-500');
362
+ });
363
+ });
364
+ });
365
+
366
+ // ─── MenuSkeleton — standalone tests (no Menu wrapper) ───────────
367
+
368
+ describe('<MenuSkeleton>', () => {
369
+ it('renders the default 3 placeholder rows', () => {
370
+ const { container } = render(<MenuSkeleton />);
371
+ // Each row is a div with the inline pulse classes; we match on
372
+ // the unique "h-3 flex-1 rounded-sm bg-neutral-200" inline bone.
373
+ const rows = container.querySelectorAll('.h-3.flex-1.rounded-sm');
374
+ expect(rows.length).toBe(3);
375
+ });
376
+
377
+ it('renders custom count', () => {
378
+ const { container } = render(<MenuSkeleton count={7} />);
379
+ const rows = container.querySelectorAll('.h-3.flex-1.rounded-sm');
380
+ expect(rows.length).toBe(7);
381
+ });
382
+
383
+ it('renders a skeleton heading when withHeading=true', () => {
384
+ const { container } = render(<MenuSkeleton withHeading />);
385
+ // Heading is the .uppercase wrapper (matches the MenuLabel style).
386
+ expect(container.querySelector('.uppercase')).toBeTruthy();
387
+ });
388
+
389
+ it('omits the heading by default', () => {
390
+ const { container } = render(<MenuSkeleton />);
391
+ expect(container.querySelector('.uppercase')).toBeNull();
392
+ });
393
+
394
+ it('has aria-hidden="true" (decorative)', () => {
395
+ const { container } = render(<MenuSkeleton />);
396
+ expect(container.firstElementChild?.getAttribute('aria-hidden')).toBe(
397
+ 'true'
398
+ );
399
+ });
400
+
401
+ it('uses animate-pulse class (motion-reduce-safe)', () => {
402
+ const { container } = render(<MenuSkeleton />);
403
+ expect(container.firstElementChild?.className).toContain('animate-pulse');
404
+ expect(container.firstElementChild?.className).toContain(
405
+ 'motion-reduce:animate-none'
406
+ );
407
+ });
408
+ });