@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,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';