@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,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
|
+
}
|