@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,385 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import * as React from 'react';
|
|
3
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
4
|
+
import { render, screen, cleanup } from '@testing-library/react';
|
|
5
|
+
import { RbacProvider } from '@dashforge/rbac';
|
|
6
|
+
import type { RbacPolicy, Subject } from '@dashforge/rbac';
|
|
7
|
+
import { Badge } from './Badge.js';
|
|
8
|
+
|
|
9
|
+
void React;
|
|
10
|
+
afterEach(() => cleanup());
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Unit tests for `<Badge>`. Covers:
|
|
14
|
+
* - Content resolution (number, string, max overflow, showZero)
|
|
15
|
+
* - Dot mode
|
|
16
|
+
* - Placement (4 corners — sample 2)
|
|
17
|
+
* - Overlap (rectangular vs circular)
|
|
18
|
+
* - Color (7 intents — sample 3)
|
|
19
|
+
* - withRing toggle
|
|
20
|
+
* - invisible toggle (in DOM but hidden)
|
|
21
|
+
* - Access RBAC (hide vs always-show anchor)
|
|
22
|
+
* - visibleWhen predicate
|
|
23
|
+
* - Anchor children always render
|
|
24
|
+
* - Aria-hidden on badge slot
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const NO_ACCESS_POLICY: RbacPolicy = {
|
|
28
|
+
roles: [{ name: 'guest', permissions: [] }],
|
|
29
|
+
};
|
|
30
|
+
const ADMIN_POLICY: RbacPolicy = {
|
|
31
|
+
roles: [
|
|
32
|
+
{
|
|
33
|
+
name: 'admin',
|
|
34
|
+
permissions: [{ resource: '*', action: '*', effect: 'allow' }],
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
};
|
|
38
|
+
const guestSubject: Subject = { id: 'g', roles: ['guest'] };
|
|
39
|
+
const adminSubject: Subject = { id: 'a', roles: ['admin'] };
|
|
40
|
+
|
|
41
|
+
describe('<Badge>', () => {
|
|
42
|
+
describe('content resolution', () => {
|
|
43
|
+
it('renders numeric content', () => {
|
|
44
|
+
render(
|
|
45
|
+
<Badge content={3}>
|
|
46
|
+
<button>anchor</button>
|
|
47
|
+
</Badge>
|
|
48
|
+
);
|
|
49
|
+
expect(screen.getByText('3')).toBeTruthy();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('renders string content verbatim', () => {
|
|
53
|
+
render(
|
|
54
|
+
<Badge content="NEW">
|
|
55
|
+
<button>anchor</button>
|
|
56
|
+
</Badge>
|
|
57
|
+
);
|
|
58
|
+
expect(screen.getByText('NEW')).toBeTruthy();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('renders nothing when content omitted + no dot', () => {
|
|
62
|
+
const { container } = render(
|
|
63
|
+
<Badge>
|
|
64
|
+
<button>anchor</button>
|
|
65
|
+
</Badge>
|
|
66
|
+
);
|
|
67
|
+
// Anchor present, no badge element
|
|
68
|
+
expect(screen.getByText('anchor')).toBeTruthy();
|
|
69
|
+
expect(container.querySelector('[aria-hidden="true"]')).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('hides badge when content=0 and showZero=false (default)', () => {
|
|
73
|
+
const { container } = render(
|
|
74
|
+
<Badge content={0}>
|
|
75
|
+
<button>anchor</button>
|
|
76
|
+
</Badge>
|
|
77
|
+
);
|
|
78
|
+
expect(container.querySelector('[aria-hidden="true"]')).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('shows badge when content=0 and showZero=true', () => {
|
|
82
|
+
render(
|
|
83
|
+
<Badge content={0} showZero>
|
|
84
|
+
<button>anchor</button>
|
|
85
|
+
</Badge>
|
|
86
|
+
);
|
|
87
|
+
expect(screen.getByText('0')).toBeTruthy();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('max overflow', () => {
|
|
92
|
+
it('renders raw number when below max', () => {
|
|
93
|
+
render(
|
|
94
|
+
<Badge content={50} max={99}>
|
|
95
|
+
<button>anchor</button>
|
|
96
|
+
</Badge>
|
|
97
|
+
);
|
|
98
|
+
expect(screen.getByText('50')).toBeTruthy();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('renders "99+" when above default max=99', () => {
|
|
102
|
+
render(
|
|
103
|
+
<Badge content={1284}>
|
|
104
|
+
<button>anchor</button>
|
|
105
|
+
</Badge>
|
|
106
|
+
);
|
|
107
|
+
expect(screen.getByText('99+')).toBeTruthy();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('respects custom max', () => {
|
|
111
|
+
render(
|
|
112
|
+
<Badge content={1284} max={999}>
|
|
113
|
+
<button>anchor</button>
|
|
114
|
+
</Badge>
|
|
115
|
+
);
|
|
116
|
+
expect(screen.getByText('999+')).toBeTruthy();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('disables overflow when max=-1', () => {
|
|
120
|
+
render(
|
|
121
|
+
<Badge content={1284} max={-1}>
|
|
122
|
+
<button>anchor</button>
|
|
123
|
+
</Badge>
|
|
124
|
+
);
|
|
125
|
+
expect(screen.getByText('1284')).toBeTruthy();
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('dot mode', () => {
|
|
130
|
+
it('renders a dot (w-2 h-2) when dot=true', () => {
|
|
131
|
+
const { container } = render(
|
|
132
|
+
<Badge dot>
|
|
133
|
+
<button>anchor</button>
|
|
134
|
+
</Badge>
|
|
135
|
+
);
|
|
136
|
+
const badge = container.querySelector('[aria-hidden="true"]')!;
|
|
137
|
+
expect(badge).toBeTruthy();
|
|
138
|
+
expect(badge.className).toContain('w-2');
|
|
139
|
+
expect(badge.className).toContain('h-2');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('ignores content when dot=true', () => {
|
|
143
|
+
render(
|
|
144
|
+
<Badge dot content={5}>
|
|
145
|
+
<button>anchor</button>
|
|
146
|
+
</Badge>
|
|
147
|
+
);
|
|
148
|
+
// Badge present but no "5" text
|
|
149
|
+
expect(screen.queryByText('5')).toBeNull();
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('color axis', () => {
|
|
154
|
+
it('default color=danger → bg-danger-500', () => {
|
|
155
|
+
const { container } = render(
|
|
156
|
+
<Badge content={1}>
|
|
157
|
+
<button>x</button>
|
|
158
|
+
</Badge>
|
|
159
|
+
);
|
|
160
|
+
const badge = container.querySelector('[aria-hidden="true"]')!;
|
|
161
|
+
expect(badge.className).toContain('bg-danger-500');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('color=success → bg-success-500', () => {
|
|
165
|
+
const { container } = render(
|
|
166
|
+
<Badge content={1} color="success">
|
|
167
|
+
<button>x</button>
|
|
168
|
+
</Badge>
|
|
169
|
+
);
|
|
170
|
+
const badge = container.querySelector('[aria-hidden="true"]')!;
|
|
171
|
+
expect(badge.className).toContain('bg-success-500');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('color=neutral uses auto-inverting neutral tokens (no dark:)', () => {
|
|
175
|
+
const { container } = render(
|
|
176
|
+
<Badge content={1} color="neutral">
|
|
177
|
+
<button>x</button>
|
|
178
|
+
</Badge>
|
|
179
|
+
);
|
|
180
|
+
const badge = container.querySelector('[aria-hidden="true"]')!;
|
|
181
|
+
expect(badge.className).toContain('bg-neutral-700');
|
|
182
|
+
expect(badge.className).toContain('text-neutral-50');
|
|
183
|
+
expect(badge.className).not.toMatch(/\bdark:/);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe('placement axis', () => {
|
|
188
|
+
it('default placement=top-right → top-0 right-0', () => {
|
|
189
|
+
const { container } = render(
|
|
190
|
+
<Badge content={1}>
|
|
191
|
+
<button>x</button>
|
|
192
|
+
</Badge>
|
|
193
|
+
);
|
|
194
|
+
const badge = container.querySelector('[aria-hidden="true"]')!;
|
|
195
|
+
expect(badge.className).toContain('top-0');
|
|
196
|
+
expect(badge.className).toContain('right-0');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('placement=bottom-left → bottom-0 left-0', () => {
|
|
200
|
+
const { container } = render(
|
|
201
|
+
<Badge content={1} placement="bottom-left">
|
|
202
|
+
<button>x</button>
|
|
203
|
+
</Badge>
|
|
204
|
+
);
|
|
205
|
+
const badge = container.querySelector('[aria-hidden="true"]')!;
|
|
206
|
+
expect(badge.className).toContain('bottom-0');
|
|
207
|
+
expect(badge.className).toContain('left-0');
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe('overlap axis', () => {
|
|
212
|
+
it('default overlap=rectangular → no circular inset class', () => {
|
|
213
|
+
const { container } = render(
|
|
214
|
+
<Badge content={1}>
|
|
215
|
+
<button>x</button>
|
|
216
|
+
</Badge>
|
|
217
|
+
);
|
|
218
|
+
const badge = container.querySelector('[aria-hidden="true"]')!;
|
|
219
|
+
// No `top-[14%]` / `right-[14%]` inset for rectangular
|
|
220
|
+
expect(badge.className).not.toContain('top-[14%]');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('overlap=circular adds the 14% inset', () => {
|
|
224
|
+
const { container } = render(
|
|
225
|
+
<Badge content={1} overlap="circular">
|
|
226
|
+
<button>x</button>
|
|
227
|
+
</Badge>
|
|
228
|
+
);
|
|
229
|
+
const badge = container.querySelector('[aria-hidden="true"]')!;
|
|
230
|
+
expect(badge.className).toContain('top-[14%]');
|
|
231
|
+
expect(badge.className).toContain('right-[14%]');
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe('withRing', () => {
|
|
236
|
+
it('default withRing=true → ring-2 ring-neutral-50', () => {
|
|
237
|
+
const { container } = render(
|
|
238
|
+
<Badge content={1}>
|
|
239
|
+
<button>x</button>
|
|
240
|
+
</Badge>
|
|
241
|
+
);
|
|
242
|
+
const badge = container.querySelector('[aria-hidden="true"]')!;
|
|
243
|
+
expect(badge.className).toContain('ring-2');
|
|
244
|
+
expect(badge.className).toContain('ring-neutral-50');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('withRing=false → no ring class', () => {
|
|
248
|
+
const { container } = render(
|
|
249
|
+
<Badge content={1} withRing={false}>
|
|
250
|
+
<button>x</button>
|
|
251
|
+
</Badge>
|
|
252
|
+
);
|
|
253
|
+
const badge = container.querySelector('[aria-hidden="true"]')!;
|
|
254
|
+
expect(badge.className).not.toContain('ring-2');
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
describe('invisible', () => {
|
|
259
|
+
it('keeps badge in DOM but hidden when invisible=true', () => {
|
|
260
|
+
const { container } = render(
|
|
261
|
+
<Badge content={5} invisible>
|
|
262
|
+
<button>x</button>
|
|
263
|
+
</Badge>
|
|
264
|
+
);
|
|
265
|
+
const badge = container.querySelector('[aria-hidden="true"]')!;
|
|
266
|
+
expect(badge).toBeTruthy();
|
|
267
|
+
expect(badge.className).toContain('hidden');
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
describe('access RBAC', () => {
|
|
272
|
+
it('hides the badge for unauthorized subjects (anchor still visible)', () => {
|
|
273
|
+
const { container } = render(
|
|
274
|
+
<RbacProvider policy={NO_ACCESS_POLICY} subject={guestSubject}>
|
|
275
|
+
<Badge
|
|
276
|
+
content={5}
|
|
277
|
+
access={{
|
|
278
|
+
resource: 'admin',
|
|
279
|
+
action: 'read',
|
|
280
|
+
onUnauthorized: 'hide',
|
|
281
|
+
}}
|
|
282
|
+
>
|
|
283
|
+
<button>anchor</button>
|
|
284
|
+
</Badge>
|
|
285
|
+
</RbacProvider>
|
|
286
|
+
);
|
|
287
|
+
// Anchor renders
|
|
288
|
+
expect(screen.getByText('anchor')).toBeTruthy();
|
|
289
|
+
// Badge does not
|
|
290
|
+
expect(container.querySelector('[aria-hidden="true"]')).toBeNull();
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('shows the badge for authorized subjects', () => {
|
|
294
|
+
render(
|
|
295
|
+
<RbacProvider policy={ADMIN_POLICY} subject={adminSubject}>
|
|
296
|
+
<Badge
|
|
297
|
+
content={5}
|
|
298
|
+
access={{
|
|
299
|
+
resource: 'admin',
|
|
300
|
+
action: 'read',
|
|
301
|
+
onUnauthorized: 'hide',
|
|
302
|
+
}}
|
|
303
|
+
>
|
|
304
|
+
<button>anchor</button>
|
|
305
|
+
</Badge>
|
|
306
|
+
</RbacProvider>
|
|
307
|
+
);
|
|
308
|
+
expect(screen.getByText('5')).toBeTruthy();
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
describe('visibleWhen', () => {
|
|
313
|
+
it('renders when predicate returns true', () => {
|
|
314
|
+
render(
|
|
315
|
+
<Badge content={5} visibleWhen={() => true}>
|
|
316
|
+
<button>x</button>
|
|
317
|
+
</Badge>
|
|
318
|
+
);
|
|
319
|
+
expect(screen.getByText('5')).toBeTruthy();
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('hides badge (not anchor) when predicate returns false', () => {
|
|
323
|
+
const { container } = render(
|
|
324
|
+
<Badge content={5} visibleWhen={() => false}>
|
|
325
|
+
<button>anchor</button>
|
|
326
|
+
</Badge>
|
|
327
|
+
);
|
|
328
|
+
// Anchor still rendered
|
|
329
|
+
expect(screen.getByText('anchor')).toBeTruthy();
|
|
330
|
+
// Badge not
|
|
331
|
+
expect(container.querySelector('[aria-hidden="true"]')).toBeNull();
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
describe('anchor always renders', () => {
|
|
336
|
+
it('renders children even when badge is hidden via showZero', () => {
|
|
337
|
+
render(
|
|
338
|
+
<Badge content={0}>
|
|
339
|
+
<button data-testid="anchor">anchor</button>
|
|
340
|
+
</Badge>
|
|
341
|
+
);
|
|
342
|
+
expect(screen.getByTestId('anchor')).toBeTruthy();
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('renders children even when access denies badge', () => {
|
|
346
|
+
render(
|
|
347
|
+
<RbacProvider policy={NO_ACCESS_POLICY} subject={guestSubject}>
|
|
348
|
+
<Badge
|
|
349
|
+
content={5}
|
|
350
|
+
access={{
|
|
351
|
+
resource: 'admin',
|
|
352
|
+
action: 'read',
|
|
353
|
+
onUnauthorized: 'hide',
|
|
354
|
+
}}
|
|
355
|
+
>
|
|
356
|
+
<button data-testid="anchor">anchor</button>
|
|
357
|
+
</Badge>
|
|
358
|
+
</RbacProvider>
|
|
359
|
+
);
|
|
360
|
+
expect(screen.getByTestId('anchor')).toBeTruthy();
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
describe('overrides — sx + className + slotProps', () => {
|
|
365
|
+
it('appends sx to the root', () => {
|
|
366
|
+
const { container } = render(
|
|
367
|
+
<Badge content={1} sx="ring-4 ring-purple-500">
|
|
368
|
+
<button>x</button>
|
|
369
|
+
</Badge>
|
|
370
|
+
);
|
|
371
|
+
const root = container.firstElementChild!;
|
|
372
|
+
expect(root.className).toContain('ring-purple-500');
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('appends slotProps.badge.className to the badge', () => {
|
|
376
|
+
const { container } = render(
|
|
377
|
+
<Badge content={1} slotProps={{ badge: { className: 'custom-badge-cls' } }}>
|
|
378
|
+
<button>x</button>
|
|
379
|
+
</Badge>
|
|
380
|
+
);
|
|
381
|
+
const badge = container.querySelector('[aria-hidden="true"]')!;
|
|
382
|
+
expect(badge.className).toContain('custom-badge-cls');
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
});
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { forwardRef, useContext } from 'react';
|
|
2
|
+
import { DashFormContext, useEngineVisibility } from '@dashforge/ui-core';
|
|
3
|
+
import { cn } from '../../utils/cn.js';
|
|
4
|
+
import { useAccessState } from '../../hooks/useAccessState.js';
|
|
5
|
+
import { badgeVariants } from './badge.variants.js';
|
|
6
|
+
import type { BadgeProps } from './badge.types.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Resolve the displayed badge content based on `content`, `max`, and
|
|
10
|
+
* `showZero` props.
|
|
11
|
+
*
|
|
12
|
+
* Returns `null` when nothing should render (omitted content + no dot,
|
|
13
|
+
* or `content=0` without `showZero`). Otherwise returns the string
|
|
14
|
+
* to display (formatted overflow for numbers above `max`).
|
|
15
|
+
*
|
|
16
|
+
* @internal
|
|
17
|
+
*/
|
|
18
|
+
function resolveDisplayContent(
|
|
19
|
+
content: number | string | undefined,
|
|
20
|
+
max: number,
|
|
21
|
+
showZero: boolean,
|
|
22
|
+
dot: boolean
|
|
23
|
+
): string | null {
|
|
24
|
+
// Dot mode — content is irrelevant, badge always shows (unless
|
|
25
|
+
// gated by visibleWhen / access / invisible higher up).
|
|
26
|
+
if (dot) return '';
|
|
27
|
+
|
|
28
|
+
// No content + no dot → nothing to render.
|
|
29
|
+
if (content === undefined || content === null) return null;
|
|
30
|
+
|
|
31
|
+
// Numeric content — apply max overflow + showZero gate.
|
|
32
|
+
if (typeof content === 'number') {
|
|
33
|
+
if (content === 0 && !showZero) return null;
|
|
34
|
+
if (max >= 0 && content > max) return `${max}+`;
|
|
35
|
+
return String(content);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// String content — render verbatim. Empty string → hide
|
|
39
|
+
// (counts as "no content" — consumer should pass `dot` for plain dot).
|
|
40
|
+
return content === '' ? null : content;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* `<Badge>` — anchored indicator overlaying an anchor element.
|
|
45
|
+
*
|
|
46
|
+
* Common patterns:
|
|
47
|
+
* - Notification count on a bell IconButton
|
|
48
|
+
* - Presence dot on an Avatar
|
|
49
|
+
* - "NEW" label on a Card or feature tile
|
|
50
|
+
* - Unsaved-changes indicator on a save button
|
|
51
|
+
*
|
|
52
|
+
* For inline standalone pills (status labels, tags, removable
|
|
53
|
+
* chips), use `<Chip>` instead — Chip and Badge are complementary
|
|
54
|
+
* primitives. Chip is interactive (`onClick`, `onDelete`,
|
|
55
|
+
* `selected`); Badge is passive overlay.
|
|
56
|
+
*
|
|
57
|
+
* Bridge integration (Sprint 4.4):
|
|
58
|
+
* - `access` — hide the badge for unauthorized subjects (anchor
|
|
59
|
+
* stays visible; wrap anchor in a separate Box `access` to gate
|
|
60
|
+
* it too)
|
|
61
|
+
* - `visibleWhen` — engine-reactive predicate
|
|
62
|
+
*
|
|
63
|
+
* The wrapped anchor element ALWAYS renders; only the floating
|
|
64
|
+
* badge dot/count is gated by `access`, `visibleWhen`, `invisible`,
|
|
65
|
+
* and content-resolution logic.
|
|
66
|
+
*
|
|
67
|
+
* @example
|
|
68
|
+
* ```tsx
|
|
69
|
+
* // Notification count on bell
|
|
70
|
+
* <Badge content={unreadCount}>
|
|
71
|
+
* <IconButton aria-label="Notifications" variant="ghost">
|
|
72
|
+
* <BellIcon />
|
|
73
|
+
* </IconButton>
|
|
74
|
+
* </Badge>
|
|
75
|
+
*
|
|
76
|
+
* // Presence dot on avatar
|
|
77
|
+
* <Badge dot color="success" overlap="circular">
|
|
78
|
+
* <Avatar name="Maya Rodriguez" />
|
|
79
|
+
* </Badge>
|
|
80
|
+
*
|
|
81
|
+
* // Overflow
|
|
82
|
+
* <Badge content={1284} max={999}>
|
|
83
|
+
* <IconButton aria-label="Messages"><MailIcon /></IconButton>
|
|
84
|
+
* </Badge>
|
|
85
|
+
*
|
|
86
|
+
* // RBAC-gated
|
|
87
|
+
* <Badge
|
|
88
|
+
* content={adminQueueCount}
|
|
89
|
+
* access={{ resource: 'admin', action: 'read', onUnauthorized: 'hide' }}
|
|
90
|
+
* >
|
|
91
|
+
* <IconButton aria-label="Admin queue"><SettingsIcon /></IconButton>
|
|
92
|
+
* </Badge>
|
|
93
|
+
*
|
|
94
|
+
* // Engine-reactive
|
|
95
|
+
* <Badge
|
|
96
|
+
* dot color="warning"
|
|
97
|
+
* visibleWhen={(engine) => !!engine.getNode('unsavedChanges')?.value}
|
|
98
|
+
* >
|
|
99
|
+
* <IconButton aria-label="Save"><SaveIcon /></IconButton>
|
|
100
|
+
* </Badge>
|
|
101
|
+
* ```
|
|
102
|
+
*/
|
|
103
|
+
export const Badge = forwardRef<HTMLSpanElement, BadgeProps>(function Badge(
|
|
104
|
+
props,
|
|
105
|
+
ref
|
|
106
|
+
) {
|
|
107
|
+
const {
|
|
108
|
+
children,
|
|
109
|
+
content,
|
|
110
|
+
dot = false,
|
|
111
|
+
max = 99,
|
|
112
|
+
showZero = false,
|
|
113
|
+
invisible = false,
|
|
114
|
+
color = 'danger',
|
|
115
|
+
placement = 'top-right',
|
|
116
|
+
overlap = 'rectangular',
|
|
117
|
+
withRing = true,
|
|
118
|
+
access,
|
|
119
|
+
visibleWhen,
|
|
120
|
+
sx,
|
|
121
|
+
className,
|
|
122
|
+
slotProps,
|
|
123
|
+
} = props;
|
|
124
|
+
|
|
125
|
+
// Bridge — both hooks called unconditionally (rules-of-hooks).
|
|
126
|
+
const bridge = useContext(DashFormContext);
|
|
127
|
+
const isBridgeVisible = useEngineVisibility(bridge?.engine, visibleWhen);
|
|
128
|
+
const accessState = useAccessState(access);
|
|
129
|
+
|
|
130
|
+
// Resolve the actual content to render (string or null = hide).
|
|
131
|
+
// Computed regardless of access/visibleWhen so we can keep the
|
|
132
|
+
// hook order stable; the final "render or not" check folds them in.
|
|
133
|
+
const displayContent = resolveDisplayContent(content, max, showZero, dot);
|
|
134
|
+
|
|
135
|
+
// The wrapped anchor ALWAYS renders. Only the floating badge is
|
|
136
|
+
// gated. This is the right contract — gating the anchor too would
|
|
137
|
+
// belong to a Box `access` wrapper one level up.
|
|
138
|
+
const shouldRenderBadge =
|
|
139
|
+
isBridgeVisible &&
|
|
140
|
+
accessState.visible &&
|
|
141
|
+
displayContent !== null;
|
|
142
|
+
|
|
143
|
+
const v = badgeVariants({
|
|
144
|
+
dot,
|
|
145
|
+
invisible,
|
|
146
|
+
withRing,
|
|
147
|
+
color,
|
|
148
|
+
placement,
|
|
149
|
+
overlap,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<span
|
|
154
|
+
ref={ref}
|
|
155
|
+
className={cn(v.root(), sx, className, slotProps?.root?.className)}
|
|
156
|
+
>
|
|
157
|
+
{children}
|
|
158
|
+
{shouldRenderBadge && (
|
|
159
|
+
<span
|
|
160
|
+
className={cn(v.badge(), slotProps?.badge?.className)}
|
|
161
|
+
// Use `aria-hidden` because the count itself is decorative
|
|
162
|
+
// — consumers should pair Badge with the anchor's
|
|
163
|
+
// `aria-label` to convey the count semantically
|
|
164
|
+
// (e.g., `aria-label="Notifications, 3 unread"`).
|
|
165
|
+
aria-hidden="true"
|
|
166
|
+
>
|
|
167
|
+
{displayContent}
|
|
168
|
+
</span>
|
|
169
|
+
)}
|
|
170
|
+
</span>
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
Badge.displayName = 'Badge';
|