@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,316 @@
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 { Chip } from './Chip.js';
8
+
9
+ void React;
10
+ afterEach(() => cleanup());
11
+
12
+ /**
13
+ * `<Chip>` unit tests. Covers:
14
+ * - static vs clickable rendering (span vs button)
15
+ * - sample variant × color cells (token-driven, no hardcoded hex)
16
+ * - icon / avatar leading slots (avatar wins when both set)
17
+ * - onClick (clickable) and onDelete (delete button) — separate
18
+ * event paths, delete button stops propagation
19
+ * - selected → aria-pressed
20
+ * - disabled state (explicit + access.disabled)
21
+ * - access RBAC: hide / disable
22
+ * - className / sx overrides
23
+ */
24
+
25
+ const FULL_ACCESS_POLICY: RbacPolicy = {
26
+ roles: [
27
+ {
28
+ name: 'admin',
29
+ permissions: [{ resource: '*', action: '*', effect: 'allow' }],
30
+ },
31
+ ],
32
+ };
33
+ const NO_ACCESS_POLICY: RbacPolicy = {
34
+ roles: [{ name: 'guest', permissions: [] }],
35
+ };
36
+ const adminSubject: Subject = { id: 'a', roles: ['admin'] };
37
+ const guestSubject: Subject = { id: 'g', roles: ['guest'] };
38
+
39
+ describe('<Chip>', () => {
40
+ describe('rendering — root element', () => {
41
+ it('renders as <span> by default (static)', () => {
42
+ const { container } = render(<Chip label="Active" />);
43
+ expect(container.querySelector('span')).toBeTruthy();
44
+ expect(container.querySelector('button')).toBeNull();
45
+ });
46
+
47
+ it('renders as <button> when clickable=true', () => {
48
+ render(<Chip label="Filter" clickable />);
49
+ expect(screen.getByRole('button', { name: /filter/i })).toBeTruthy();
50
+ });
51
+
52
+ it('renders as <button> when onClick is provided (implicit clickable)', () => {
53
+ render(<Chip label="Click me" onClick={() => {}} />);
54
+ expect(screen.getByRole('button', { name: /click me/i })).toBeTruthy();
55
+ });
56
+
57
+ it('renders the label content', () => {
58
+ render(<Chip label="Active" />);
59
+ expect(screen.getByText('Active')).toBeTruthy();
60
+ });
61
+ });
62
+
63
+ describe('variant × color matrix (sample, token-driven)', () => {
64
+ it('renders soft + primary with token classes (no hardcoded hex)', () => {
65
+ const { container } = render(
66
+ <Chip label="x" variant="soft" color="primary" />
67
+ );
68
+ const root = container.firstChild as HTMLElement;
69
+ expect(root.className).toContain('bg-primary-100');
70
+ expect(root.className).toContain('text-primary-700');
71
+ expect(root.className).not.toMatch(/#[0-9a-fA-F]{3,8}/);
72
+ });
73
+
74
+ it('renders solid + danger with full bg', () => {
75
+ const { container } = render(
76
+ <Chip label="x" variant="solid" color="danger" />
77
+ );
78
+ const root = container.firstChild as HTMLElement;
79
+ expect(root.className).toContain('bg-danger-500');
80
+ expect(root.className).toContain('text-white');
81
+ });
82
+
83
+ it('renders outline + info with border', () => {
84
+ const { container } = render(
85
+ <Chip label="x" variant="outline" color="info" />
86
+ );
87
+ const root = container.firstChild as HTMLElement;
88
+ expect(root.className).toContain('border-info-500');
89
+ expect(root.className).toContain('text-info-700');
90
+ });
91
+
92
+ it('defaults to soft + neutral when omitted', () => {
93
+ const { container } = render(<Chip label="x" />);
94
+ const root = container.firstChild as HTMLElement;
95
+ expect(root.className).toContain('bg-neutral-100');
96
+ expect(root.className).toContain('text-neutral-700');
97
+ });
98
+ });
99
+
100
+ describe('size axis', () => {
101
+ it('applies h-5 + text-xs for size="sm" (default)', () => {
102
+ const { container } = render(<Chip label="x" />);
103
+ const root = container.firstChild as HTMLElement;
104
+ expect(root.className).toContain('h-5');
105
+ expect(root.className).toContain('text-xs');
106
+ });
107
+
108
+ it('applies h-6 for size="md"', () => {
109
+ const { container } = render(<Chip label="x" size="md" />);
110
+ const root = container.firstChild as HTMLElement;
111
+ expect(root.className).toContain('h-6');
112
+ });
113
+ });
114
+
115
+ describe('leading slots — icon / avatar', () => {
116
+ it('renders icon when provided', () => {
117
+ render(
118
+ <Chip label="x" icon={<span data-testid="icon">i</span>} />
119
+ );
120
+ expect(screen.getByTestId('icon')).toBeTruthy();
121
+ });
122
+
123
+ it('renders avatar when provided', () => {
124
+ render(
125
+ <Chip label="x" avatar={<span data-testid="avatar">A</span>} />
126
+ );
127
+ expect(screen.getByTestId('avatar')).toBeTruthy();
128
+ });
129
+
130
+ it('avatar wins over icon when both set (MUI parity)', () => {
131
+ render(
132
+ <Chip
133
+ label="x"
134
+ icon={<span data-testid="icon">i</span>}
135
+ avatar={<span data-testid="avatar">A</span>}
136
+ />
137
+ );
138
+ expect(screen.getByTestId('avatar')).toBeTruthy();
139
+ expect(screen.queryByTestId('icon')).toBeNull();
140
+ });
141
+ });
142
+
143
+ describe('onClick (clickable chip)', () => {
144
+ it('fires onClick when clicked', () => {
145
+ const onClick = vi.fn();
146
+ render(<Chip label="x" onClick={onClick} />);
147
+ fireEvent.click(screen.getByRole('button'));
148
+ expect(onClick).toHaveBeenCalledTimes(1);
149
+ });
150
+
151
+ it('does NOT fire onClick when disabled', () => {
152
+ const onClick = vi.fn();
153
+ render(<Chip label="x" onClick={onClick} disabled />);
154
+ fireEvent.click(screen.getByRole('button'));
155
+ expect(onClick).not.toHaveBeenCalled();
156
+ });
157
+ });
158
+
159
+ describe('onDelete (delete button)', () => {
160
+ it('renders a delete button when onDelete is provided', () => {
161
+ render(<Chip label="tag" onDelete={() => {}} />);
162
+ expect(screen.getByRole('button', { name: /^remove$/i })).toBeTruthy();
163
+ });
164
+
165
+ it('does NOT render a delete button when onDelete is omitted', () => {
166
+ render(<Chip label="tag" />);
167
+ expect(screen.queryByRole('button', { name: /^remove$/i })).toBeNull();
168
+ });
169
+
170
+ it('respects custom deleteLabel for aria-label', () => {
171
+ render(<Chip label="tag" onDelete={() => {}} deleteLabel="Detach tag" />);
172
+ expect(screen.getByRole('button', { name: /detach tag/i })).toBeTruthy();
173
+ });
174
+
175
+ it('fires onDelete when delete button clicked', () => {
176
+ const onDelete = vi.fn();
177
+ render(<Chip label="tag" onDelete={onDelete} />);
178
+ fireEvent.click(screen.getByRole('button', { name: /remove/i }));
179
+ expect(onDelete).toHaveBeenCalledTimes(1);
180
+ });
181
+
182
+ it('delete button does NOT trigger chip onClick (stopPropagation)', () => {
183
+ const onClick = vi.fn();
184
+ const onDelete = vi.fn();
185
+ render(
186
+ <Chip label="tag" onClick={onClick} onDelete={onDelete} />
187
+ );
188
+ // Click on the delete button
189
+ fireEvent.click(screen.getByRole('button', { name: /remove/i }));
190
+ expect(onDelete).toHaveBeenCalledTimes(1);
191
+ expect(onClick).not.toHaveBeenCalled();
192
+ });
193
+ });
194
+
195
+ describe('selected state', () => {
196
+ it('applies aria-pressed=true when selected on a clickable chip', () => {
197
+ render(<Chip label="x" clickable selected />);
198
+ const btn = screen.getByRole('button');
199
+ expect(btn.getAttribute('aria-pressed')).toBe('true');
200
+ });
201
+
202
+ it('applies the selected ring class', () => {
203
+ const { container } = render(<Chip label="x" clickable selected />);
204
+ const root = container.firstChild as HTMLElement;
205
+ expect(root.className).toContain('ring-2');
206
+ });
207
+
208
+ it('does NOT set aria-pressed when not selected', () => {
209
+ render(<Chip label="x" clickable />);
210
+ const btn = screen.getByRole('button');
211
+ expect(btn.getAttribute('aria-pressed')).toBeNull();
212
+ });
213
+ });
214
+
215
+ describe('disabled state', () => {
216
+ it('respects the explicit disabled prop on clickable chip', () => {
217
+ render(<Chip label="x" clickable disabled />);
218
+ const btn = screen.getByRole('button') as HTMLButtonElement;
219
+ expect(btn.disabled).toBe(true);
220
+ });
221
+
222
+ it('applies opacity-50 + cursor-not-allowed to root when disabled', () => {
223
+ const { container } = render(<Chip label="x" disabled />);
224
+ const root = container.firstChild as HTMLElement;
225
+ expect(root.className).toContain('opacity-50');
226
+ });
227
+ });
228
+
229
+ describe('access RBAC integration', () => {
230
+ it('hides the chip when access.onUnauthorized=hide and subject lacks permission', () => {
231
+ const { container } = render(
232
+ <RbacProvider policy={NO_ACCESS_POLICY} subject={guestSubject}>
233
+ <Chip
234
+ label="x"
235
+ access={{
236
+ resource: 'admin-panel',
237
+ action: 'read',
238
+ onUnauthorized: 'hide',
239
+ }}
240
+ />
241
+ </RbacProvider>
242
+ );
243
+ expect(container.firstChild).toBeNull();
244
+ });
245
+
246
+ it('disables a clickable chip when access.onUnauthorized=disable', () => {
247
+ render(
248
+ <RbacProvider policy={NO_ACCESS_POLICY} subject={guestSubject}>
249
+ <Chip
250
+ label="x"
251
+ clickable
252
+ access={{
253
+ resource: 'admin-panel',
254
+ action: 'read',
255
+ onUnauthorized: 'disable',
256
+ }}
257
+ />
258
+ </RbacProvider>
259
+ );
260
+ const btn = screen.getByRole('button') as HTMLButtonElement;
261
+ expect(btn.disabled).toBe(true);
262
+ });
263
+
264
+ it('renders normally for admin subject', () => {
265
+ render(
266
+ <RbacProvider policy={FULL_ACCESS_POLICY} subject={adminSubject}>
267
+ <Chip
268
+ label="visible"
269
+ access={{
270
+ resource: 'admin-panel',
271
+ action: 'read',
272
+ onUnauthorized: 'hide',
273
+ }}
274
+ />
275
+ </RbacProvider>
276
+ );
277
+ expect(screen.getByText('visible')).toBeTruthy();
278
+ });
279
+ });
280
+
281
+ describe('bridge integration — visibleWhen', () => {
282
+ it('renders when visibleWhen returns true (no DashForm context)', () => {
283
+ const { container } = render(
284
+ <Chip label="shown" visibleWhen={() => true} />
285
+ );
286
+ expect(container.firstChild).toBeTruthy();
287
+ expect(screen.getByText('shown')).toBeTruthy();
288
+ });
289
+
290
+ it('returns null when visibleWhen returns false', () => {
291
+ const { container } = render(
292
+ <Chip label="hidden" visibleWhen={() => false} />
293
+ );
294
+ expect(container.firstChild).toBeNull();
295
+ });
296
+
297
+ it('renders normally when visibleWhen is omitted', () => {
298
+ const { container } = render(<Chip label="always" />);
299
+ expect(container.firstChild).toBeTruthy();
300
+ });
301
+ });
302
+
303
+ describe('overrides — sx + className', () => {
304
+ it('appends sx to the root className', () => {
305
+ const { container } = render(<Chip label="x" sx="ring-2 ring-purple-500" />);
306
+ const root = container.firstChild as HTMLElement;
307
+ expect(root.className).toContain('ring-purple-500');
308
+ });
309
+
310
+ it('appends className to the root', () => {
311
+ const { container } = render(<Chip label="x" className="my-custom-chip" />);
312
+ const root = container.firstChild as HTMLElement;
313
+ expect(root.className).toContain('my-custom-chip');
314
+ });
315
+ });
316
+ });
@@ -0,0 +1,224 @@
1
+ import { forwardRef, useContext, type KeyboardEvent, type MouseEvent } 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 { chipVariants } from './chip.variants.js';
6
+ import type { ChipProps } from './chip.types.js';
7
+
8
+ /**
9
+ * Default delete-button glyph — inline stroke SVG. Inherits the
10
+ * chip's text colour via `currentColor`. No icon-library dep.
11
+ *
12
+ * @internal
13
+ */
14
+ function DefaultDeleteIcon() {
15
+ return (
16
+ <svg
17
+ width="0.875em"
18
+ height="0.875em"
19
+ viewBox="0 0 14 14"
20
+ fill="none"
21
+ stroke="currentColor"
22
+ strokeWidth={1.75}
23
+ strokeLinecap="round"
24
+ strokeLinejoin="round"
25
+ aria-hidden="true"
26
+ >
27
+ <path d="m4 4 6 6M10 4l-6 6" />
28
+ </svg>
29
+ );
30
+ }
31
+
32
+ /**
33
+ * Dashforge TW Chip — status / filter / tag pill.
34
+ *
35
+ * Promoted from the internal `Table/cells/RenderChip` (Sprint 4.4):
36
+ * preserves the 3 × 7 variant × color matrix byte-identical and adds
37
+ * the public API (label, icon, avatar, clickable, onClick, onDelete,
38
+ * selected, access).
39
+ *
40
+ * **Rendering modes** (chosen at runtime from props):
41
+ * - **Static** (default) → `<span>`. Pure display.
42
+ * - **Clickable** (`clickable` true OR `onClick` provided) →
43
+ * `<button type="button">` with focus ring + hover state.
44
+ * - **Deletable** (`onDelete` provided) → root + trailing `<button>`
45
+ * for the delete glyph. Static or clickable root depending on
46
+ * whether `onClick` is also set.
47
+ *
48
+ * **Variant axis** is `soft | solid | outline` (Dashforge chip
49
+ * vocabulary, default `'soft'`). Distinct from Button's
50
+ * (`solid | outline | ghost | link`) and Alert's
51
+ * (`standard | filled | outlined`) by design — see CHANGELOG.
52
+ *
53
+ * **A11y**:
54
+ * - Static chip → no role (it's a `<span>`).
55
+ * - Clickable chip → native `<button>` semantics.
56
+ * - `selected` → `aria-pressed="true"` (so it announces as a toggle
57
+ * button in pressed state).
58
+ * - Delete button → its own `<button>` with `aria-label`
59
+ * (defaults to `'Remove'`).
60
+ *
61
+ * @example
62
+ * ```tsx
63
+ * // Static status chip
64
+ * <Chip label="Active" color="success" />
65
+ *
66
+ * // With icon
67
+ * import { CheckIcon } from 'lucide-react';
68
+ * <Chip label="Verified" color="success" icon={<CheckIcon size={14} />} />
69
+ *
70
+ * // Filter chip (clickable + toggle via selected)
71
+ * <Chip
72
+ * label="Last 7 days"
73
+ * color="primary"
74
+ * clickable
75
+ * selected={range === '7d'}
76
+ * onClick={() => setRange('7d')}
77
+ * />
78
+ *
79
+ * // Removable tag
80
+ * <Chip
81
+ * label="design-system"
82
+ * color="info"
83
+ * variant="outline"
84
+ * onDelete={() => removeTag('design-system')}
85
+ * />
86
+ *
87
+ * // RBAC-gated admin chip
88
+ * <Chip
89
+ * label="Admin"
90
+ * color="danger"
91
+ * variant="solid"
92
+ * access={{ resource: 'role', action: 'read', onUnauthorized: 'hide' }}
93
+ * />
94
+ * ```
95
+ */
96
+ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
97
+ props,
98
+ ref
99
+ ) {
100
+ const {
101
+ label,
102
+ icon,
103
+ avatar,
104
+ variant,
105
+ color,
106
+ size,
107
+ clickable: clickableProp,
108
+ onClick,
109
+ onDelete,
110
+ deleteIcon,
111
+ deleteLabel = 'Remove',
112
+ selected,
113
+ disabled: disabledProp,
114
+ visibleWhen,
115
+ access,
116
+ sx,
117
+ className,
118
+ } = props;
119
+
120
+ // Bridge — both hooks called unconditionally (rules-of-hooks).
121
+ // Inside a `<DashForm>`, `useEngineVisibility` subscribes to engine
122
+ // state and re-evaluates the predicate on changes; outside a form,
123
+ // the predicate is called with `null` engine (consumer captures
124
+ // external state in the closure).
125
+ const bridge = useContext(DashFormContext);
126
+ const isVisible = useEngineVisibility(bridge?.engine, visibleWhen);
127
+ const accessState = useAccessState(access);
128
+
129
+ if (!isVisible || !accessState.visible) return null;
130
+
131
+ // Effective interactivity / disabled — mirrors Button semantics.
132
+ const isClickable = Boolean(clickableProp ?? onClick);
133
+ const isDisabled =
134
+ Boolean(disabledProp) || accessState.disabled || accessState.readonly;
135
+
136
+ const classes = cn(
137
+ chipVariants({
138
+ color,
139
+ variant,
140
+ size,
141
+ clickable: isClickable || undefined,
142
+ selected: selected || undefined,
143
+ disabled: isDisabled || undefined,
144
+ }),
145
+ sx,
146
+ className
147
+ );
148
+
149
+ // ─── Inner content — leading slot + label + delete ────────────
150
+ // Avatar wins over icon when both provided (MUI parity).
151
+ // Label is rendered as a direct text node (no wrapping span) so
152
+ // the chip DOM stays minimal and consumers that target the root
153
+ // via `getByText(label)` continue working. The root's
154
+ // `inline-flex items-center` provides vertical alignment with
155
+ // any leading / trailing slots.
156
+ const leading = avatar ?? icon;
157
+ const innerContent = (
158
+ <>
159
+ {leading != null && (
160
+ <span className="-ml-0.5 inline-flex items-center shrink-0">
161
+ {leading}
162
+ </span>
163
+ )}
164
+ {label}
165
+ {onDelete != null && (
166
+ <button
167
+ type="button"
168
+ aria-label={deleteLabel}
169
+ onClick={(e: MouseEvent<HTMLButtonElement>) => {
170
+ // Stop propagation so the chip's own onClick doesn't fire
171
+ // when the user clicks the × — MUI matches this behaviour.
172
+ e.stopPropagation();
173
+ if (!isDisabled) onDelete(e);
174
+ }}
175
+ disabled={isDisabled}
176
+ className={cn(
177
+ 'ml-0.5 -mr-1 inline-flex items-center justify-center',
178
+ 'h-4 w-4 rounded-full opacity-70 hover:opacity-100',
179
+ 'outline-none focus-visible:ring-2 focus-visible:ring-current',
180
+ 'transition-opacity'
181
+ )}
182
+ >
183
+ {deleteIcon ?? <DefaultDeleteIcon />}
184
+ </button>
185
+ )}
186
+ </>
187
+ );
188
+
189
+ // ─── Interactive root (clickable) ─────────────────────────────
190
+ if (isClickable) {
191
+ return (
192
+ <button
193
+ ref={ref as React.Ref<HTMLButtonElement>}
194
+ type="button"
195
+ className={classes}
196
+ disabled={isDisabled}
197
+ aria-pressed={selected ? true : undefined}
198
+ onClick={(e: MouseEvent<HTMLButtonElement>) => {
199
+ if (isDisabled) return;
200
+ onClick?.(e);
201
+ }}
202
+ onKeyDown={(e: KeyboardEvent<HTMLButtonElement>) => {
203
+ // Native <button> handles Enter + Space already; the keydown
204
+ // is here for future extension (e.g. Delete key to fire
205
+ // onDelete when focused) — currently no-op.
206
+ void e;
207
+ }}
208
+ >
209
+ {innerContent}
210
+ </button>
211
+ );
212
+ }
213
+
214
+ // ─── Static root (display only) ───────────────────────────────
215
+ return (
216
+ <span
217
+ ref={ref as React.Ref<HTMLSpanElement>}
218
+ className={classes}
219
+ aria-disabled={isDisabled || undefined}
220
+ >
221
+ {innerContent}
222
+ </span>
223
+ );
224
+ });
@@ -0,0 +1,128 @@
1
+ import type { 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 { ChipVariants } from './chip.variants.js';
6
+
7
+ /**
8
+ * Props for `<Chip>` — status / filter / tag pill.
9
+ *
10
+ * Mirrors MUI Chip's API surface where it makes sense (label, icon,
11
+ * avatar, clickable, onDelete) and extends with Dashforge-specific
12
+ * affordances (`selected` for filter chips, `access` for RBAC).
13
+ *
14
+ * Variant axis (`soft | solid | outline`) is the **Dashforge chip
15
+ * vocabulary** — distinct from Button's (`solid | outline | ghost |
16
+ * link`) and Alert's (`standard | filled | outlined`). The asymmetry
17
+ * is intentional and category-scoped: each component family uses the
18
+ * vocabulary that maps to its design needs (see CHANGELOG +
19
+ * MIGRATION.md for the design note).
20
+ *
21
+ * @see https://mui.com/material-ui/api/chip/
22
+ */
23
+ export interface ChipProps
24
+ extends Pick<
25
+ ChipVariants,
26
+ 'color' | 'variant' | 'size' | 'selected' | 'disabled'
27
+ > {
28
+ /**
29
+ * The chip's textual content. Named `label` for MUI parity — most
30
+ * design systems (MUI, Radix Themes, Mantine, Tremor) use `label`
31
+ * as the canonical content prop on Chip / Badge / Pill primitives.
32
+ */
33
+ label: ReactNode;
34
+
35
+ /**
36
+ * Leading icon — typically a small SVG (16px). Consumer brings
37
+ * their iconography. Rendered inside the chip BEFORE the label.
38
+ * Falls back to `avatar` if not provided.
39
+ */
40
+ icon?: ReactNode;
41
+
42
+ /**
43
+ * Leading avatar — alternative to `icon`, typically a small
44
+ * circular image (Avatar component or `<img>`). When BOTH `icon`
45
+ * and `avatar` are set, `avatar` wins (matches MUI behaviour).
46
+ */
47
+ avatar?: ReactNode;
48
+
49
+ /**
50
+ * Makes the chip interactive — renders as `<button>` (instead of
51
+ * `<span>`), adds focus ring + hover state. Set automatically to
52
+ * `true` when `onClick` is provided.
53
+ */
54
+ clickable?: boolean;
55
+
56
+ /**
57
+ * Click handler. Implies `clickable=true`. Fires on chip-body
58
+ * click (NOT on the delete button — `onDelete` is the dedicated
59
+ * path for that).
60
+ */
61
+ onClick?: (event: React.MouseEvent<HTMLElement>) => void;
62
+
63
+ /**
64
+ * When set, renders a trailing × delete button. The handler fires
65
+ * on delete-button click and stops propagation so the chip's
66
+ * `onClick` doesn't also fire.
67
+ */
68
+ onDelete?: (event: React.MouseEvent<HTMLElement>) => void;
69
+
70
+ /**
71
+ * Custom delete icon. Defaults to an inline stroke-style × SVG.
72
+ * `currentColor` flows through, so the icon inherits the chip's
73
+ * text colour.
74
+ */
75
+ deleteIcon?: ReactNode;
76
+
77
+ /**
78
+ * `aria-label` for the delete button. Required for screen readers
79
+ * since the × glyph alone is not semantic.
80
+ * @default 'Remove'
81
+ */
82
+ deleteLabel?: string;
83
+
84
+ /**
85
+ * Reactive visibility predicate. Re-evaluated on every engine state
86
+ * change when the chip is mounted inside a `<DashForm>`; outside
87
+ * a form, evaluated as a plain predicate (the consumer captures
88
+ * any external state in the closure).
89
+ *
90
+ * When the predicate returns `false`, the component renders `null`
91
+ * — same contract as `<Alert>` / `<TextField>` / other bridge-aware
92
+ * components. Useful for status chips that should appear / disappear
93
+ * based on form values without forcing JSX guards on the consumer.
94
+ *
95
+ * @example
96
+ * ```tsx
97
+ * // Inside a DashForm — engine-reactive
98
+ * <Chip
99
+ * label="Premium"
100
+ * color="primary"
101
+ * visibleWhen={(engine) => engine.getNode('plan')?.value === 'premium'}
102
+ * />
103
+ *
104
+ * // Outside a form — closure over external state
105
+ * <Chip
106
+ * label={`${count} results`}
107
+ * visibleWhen={() => count > 0}
108
+ * />
109
+ * ```
110
+ */
111
+ visibleWhen?: (engine: Engine) => boolean;
112
+
113
+ /**
114
+ * RBAC requirement. Same contract as `<Button>` / `<IconButton>` —
115
+ * `onUnauthorized: 'hide' | 'disable' | 'readonly'`.
116
+ * (`readonly` maps to `disabled` for chips, matching Button.)
117
+ */
118
+ access?: AccessRequirement;
119
+
120
+ /**
121
+ * Root-element class shortcut (string or clsx-compatible value).
122
+ * Last-wins via `tailwind-merge` inside `cn()`.
123
+ */
124
+ sx?: ClassValue;
125
+
126
+ /** Standard React `className` — appended to the root via `cn()`. */
127
+ className?: string;
128
+ }