@object-ui/components 2.0.0 → 3.0.1
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/.turbo/turbo-build.log +12 -12
- package/CHANGELOG.md +28 -0
- package/dist/index.css +1 -1
- package/dist/index.js +19610 -19344
- package/dist/index.umd.cjs +29 -29
- package/dist/src/custom/index.d.ts +2 -0
- package/dist/src/custom/view-skeleton.d.ts +37 -0
- package/dist/src/custom/view-states.d.ts +33 -0
- package/package.json +17 -17
- package/src/__tests__/__snapshots__/snapshot-critical.test.tsx.snap +811 -0
- package/src/__tests__/__snapshots__/snapshot.test.tsx.snap +327 -0
- package/src/__tests__/accessibility.test.tsx +137 -0
- package/src/__tests__/api-consistency.test.tsx +596 -0
- package/src/__tests__/color-contrast.test.tsx +212 -0
- package/src/__tests__/edge-cases.test.tsx +285 -0
- package/src/__tests__/snapshot-critical.test.tsx +317 -0
- package/src/__tests__/snapshot.test.tsx +205 -0
- package/src/__tests__/wcag-audit.test.tsx +493 -0
- package/src/custom/index.ts +2 -0
- package/src/custom/view-skeleton.tsx +243 -0
- package/src/custom/view-states.tsx +153 -0
- package/src/renderers/complex/data-table.tsx +28 -13
- package/src/renderers/complex/resizable.tsx +20 -17
- package/src/renderers/data-display/list.tsx +1 -1
- package/src/renderers/data-display/table.tsx +1 -1
- package/src/renderers/data-display/tree-view.tsx +2 -1
- package/src/renderers/form/form.tsx +10 -6
- package/src/renderers/layout/aspect-ratio.tsx +1 -1
- package/src/stories-json/Accessibility.mdx +297 -0
- package/src/stories-json/EdgeCases.stories.tsx +160 -0
- package/src/stories-json/GettingStarted.mdx +89 -0
- package/src/stories-json/Introduction.mdx +127 -0
- package/src/ui/slider.tsx +6 -2
- package/src/stories/Introduction.mdx +0 -61
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* P3.1 Component Quality Audit - API Consistency
|
|
11
|
+
*
|
|
12
|
+
* Comprehensive audit verifying all exported UI components follow
|
|
13
|
+
* consistent patterns:
|
|
14
|
+
* - data-slot attribute for custom components
|
|
15
|
+
* - className prop acceptance and forwarding via cn()
|
|
16
|
+
* - React.forwardRef for primitive components
|
|
17
|
+
* - displayName set on forwardRef components
|
|
18
|
+
* - Consistent prop naming (variant/size, not variants/sizes)
|
|
19
|
+
* - All exported types are defined
|
|
20
|
+
* - Source-level pattern scanning for cn() usage
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { describe, it, expect } from 'vitest';
|
|
24
|
+
import React from 'react';
|
|
25
|
+
import { render, screen } from '@testing-library/react';
|
|
26
|
+
import '@testing-library/jest-dom';
|
|
27
|
+
import * as fs from 'node:fs';
|
|
28
|
+
import * as path from 'node:path';
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
Badge,
|
|
32
|
+
Button,
|
|
33
|
+
Card,
|
|
34
|
+
CardHeader,
|
|
35
|
+
CardTitle,
|
|
36
|
+
CardDescription,
|
|
37
|
+
CardContent,
|
|
38
|
+
CardFooter,
|
|
39
|
+
Input,
|
|
40
|
+
Label,
|
|
41
|
+
Separator,
|
|
42
|
+
Skeleton,
|
|
43
|
+
Progress,
|
|
44
|
+
Alert,
|
|
45
|
+
AlertTitle,
|
|
46
|
+
AlertDescription,
|
|
47
|
+
Textarea,
|
|
48
|
+
} from '../ui';
|
|
49
|
+
|
|
50
|
+
import {
|
|
51
|
+
Kbd,
|
|
52
|
+
KbdGroup,
|
|
53
|
+
Empty,
|
|
54
|
+
EmptyHeader,
|
|
55
|
+
EmptyTitle,
|
|
56
|
+
EmptyDescription,
|
|
57
|
+
EmptyContent,
|
|
58
|
+
EmptyMedia,
|
|
59
|
+
ButtonGroup,
|
|
60
|
+
Item,
|
|
61
|
+
ItemGroup,
|
|
62
|
+
ItemContent,
|
|
63
|
+
ItemTitle,
|
|
64
|
+
ItemDescription as ItemDesc,
|
|
65
|
+
ItemActions,
|
|
66
|
+
ItemMedia,
|
|
67
|
+
Spinner,
|
|
68
|
+
DataLoadingState,
|
|
69
|
+
DataEmptyState,
|
|
70
|
+
DataErrorState,
|
|
71
|
+
} from '../custom';
|
|
72
|
+
|
|
73
|
+
import { cn } from '../lib/utils';
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Helpers
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
const UI_DIR = path.resolve(__dirname, '..', 'ui');
|
|
80
|
+
const CUSTOM_DIR = path.resolve(__dirname, '..', 'custom');
|
|
81
|
+
|
|
82
|
+
/** Read a source file from either ui/ or custom/ directory. */
|
|
83
|
+
function readSource(dir: string, filename: string): string {
|
|
84
|
+
return fs.readFileSync(path.join(dir, filename), 'utf-8');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** List .tsx component files in a directory (excluding index.ts). */
|
|
88
|
+
function listComponentFiles(dir: string): string[] {
|
|
89
|
+
return fs
|
|
90
|
+
.readdirSync(dir)
|
|
91
|
+
.filter((f) => f.endsWith('.tsx') && f !== 'index.tsx');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// 1. data-slot attribute pattern (custom components)
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
describe('P3.1 API Consistency Audit', () => {
|
|
98
|
+
describe('data-slot attribute on custom components', () => {
|
|
99
|
+
const customComponentsWithSlot = [
|
|
100
|
+
{ name: 'Kbd', Component: Kbd, slot: 'kbd', children: 'K' },
|
|
101
|
+
{ name: 'KbdGroup', Component: KbdGroup, slot: 'kbd-group', children: 'G' },
|
|
102
|
+
{ name: 'Empty', Component: Empty, slot: 'empty', children: 'E' },
|
|
103
|
+
{ name: 'EmptyHeader', Component: EmptyHeader, slot: 'empty-header', children: 'H' },
|
|
104
|
+
{ name: 'EmptyTitle', Component: EmptyTitle, slot: 'empty-title', children: 'T' },
|
|
105
|
+
{ name: 'EmptyDescription', Component: EmptyDescription, slot: 'empty-description', children: 'D' },
|
|
106
|
+
{ name: 'EmptyContent', Component: EmptyContent, slot: 'empty-content', children: 'C' },
|
|
107
|
+
{ name: 'ButtonGroup', Component: ButtonGroup, slot: 'button-group', children: 'B' },
|
|
108
|
+
{ name: 'Item', Component: Item, slot: 'item', children: 'I' },
|
|
109
|
+
{ name: 'ItemGroup', Component: ItemGroup, slot: 'item-group', children: 'G' },
|
|
110
|
+
{ name: 'ItemContent', Component: ItemContent, slot: 'item-content', children: 'C' },
|
|
111
|
+
{ name: 'ItemTitle', Component: ItemTitle, slot: 'item-title', children: 'T' },
|
|
112
|
+
{ name: 'ItemDesc', Component: ItemDesc, slot: 'item-description', children: 'D' },
|
|
113
|
+
{ name: 'ItemActions', Component: ItemActions, slot: 'item-actions', children: 'A' },
|
|
114
|
+
{ name: 'DataLoadingState', Component: DataLoadingState, slot: 'data-loading-state', children: undefined },
|
|
115
|
+
{ name: 'DataEmptyState', Component: DataEmptyState, slot: 'data-empty-state', children: undefined },
|
|
116
|
+
{ name: 'DataErrorState', Component: DataErrorState, slot: 'data-error-state', children: undefined },
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
it.each(customComponentsWithSlot)(
|
|
120
|
+
'$name renders data-slot="$slot"',
|
|
121
|
+
({ Component, slot, children }) => {
|
|
122
|
+
const { container } = render(
|
|
123
|
+
<Component>{children}</Component>
|
|
124
|
+
);
|
|
125
|
+
const el = container.querySelector(`[data-slot="${slot}"]`);
|
|
126
|
+
expect(el).toBeInTheDocument();
|
|
127
|
+
}
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// 2. className prop acceptance and forwarding
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
describe('className prop forwarding', () => {
|
|
135
|
+
const CUSTOM_CLASS = 'oui-test-custom-class';
|
|
136
|
+
|
|
137
|
+
const classNameTests = [
|
|
138
|
+
{ name: 'Badge', render: () => render(<Badge className={CUSTOM_CLASS}>tag</Badge>) },
|
|
139
|
+
{ name: 'Button', render: () => render(<Button className={CUSTOM_CLASS}>btn</Button>) },
|
|
140
|
+
{ name: 'Card', render: () => render(<Card className={CUSTOM_CLASS}>card</Card>) },
|
|
141
|
+
{ name: 'CardHeader', render: () => render(<CardHeader className={CUSTOM_CLASS} />) },
|
|
142
|
+
{ name: 'CardTitle', render: () => render(<CardTitle className={CUSTOM_CLASS}>T</CardTitle>) },
|
|
143
|
+
{ name: 'CardDescription', render: () => render(<CardDescription className={CUSTOM_CLASS}>D</CardDescription>) },
|
|
144
|
+
{ name: 'CardContent', render: () => render(<CardContent className={CUSTOM_CLASS} />) },
|
|
145
|
+
{ name: 'CardFooter', render: () => render(<CardFooter className={CUSTOM_CLASS} />) },
|
|
146
|
+
{ name: 'Input', render: () => render(<Input className={CUSTOM_CLASS} data-testid="inp" />) },
|
|
147
|
+
{ name: 'Textarea', render: () => render(<Textarea className={CUSTOM_CLASS} data-testid="ta" />) },
|
|
148
|
+
{ name: 'Label', render: () => render(<Label className={CUSTOM_CLASS}>L</Label>) },
|
|
149
|
+
{ name: 'Separator', render: () => render(<Separator className={CUSTOM_CLASS} />) },
|
|
150
|
+
{ name: 'Skeleton', render: () => render(<Skeleton className={CUSTOM_CLASS} />) },
|
|
151
|
+
{ name: 'Alert', render: () => render(<Alert className={CUSTOM_CLASS}>A</Alert>) },
|
|
152
|
+
{ name: 'AlertTitle', render: () => render(<AlertTitle className={CUSTOM_CLASS}>T</AlertTitle>) },
|
|
153
|
+
{ name: 'AlertDescription', render: () => render(<AlertDescription className={CUSTOM_CLASS}>D</AlertDescription>) },
|
|
154
|
+
{ name: 'Kbd', render: () => render(<Kbd className={CUSTOM_CLASS}>K</Kbd>) },
|
|
155
|
+
{ name: 'Empty', render: () => render(<Empty className={CUSTOM_CLASS}>E</Empty>) },
|
|
156
|
+
{ name: 'ButtonGroup', render: () => render(<ButtonGroup className={CUSTOM_CLASS}>B</ButtonGroup>) },
|
|
157
|
+
{ name: 'Item', render: () => render(<Item className={CUSTOM_CLASS}>I</Item>) },
|
|
158
|
+
{ name: 'Spinner', render: () => render(<Spinner className={CUSTOM_CLASS} />) },
|
|
159
|
+
];
|
|
160
|
+
|
|
161
|
+
it.each(classNameTests)(
|
|
162
|
+
'$name accepts and forwards className',
|
|
163
|
+
({ render: doRender }) => {
|
|
164
|
+
const { container } = doRender();
|
|
165
|
+
const el = container.querySelector(`.${CUSTOM_CLASS}`);
|
|
166
|
+
expect(el).toBeInTheDocument();
|
|
167
|
+
}
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// 3. Source files use cn() utility for class merging
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
describe('cn() utility usage in source files', () => {
|
|
175
|
+
const uiFiles = listComponentFiles(UI_DIR);
|
|
176
|
+
const customFiles = listComponentFiles(CUSTOM_DIR);
|
|
177
|
+
|
|
178
|
+
// Representative UI files that must import cn
|
|
179
|
+
const representativeUiFiles = [
|
|
180
|
+
'button.tsx',
|
|
181
|
+
'card.tsx',
|
|
182
|
+
'input.tsx',
|
|
183
|
+
'badge.tsx',
|
|
184
|
+
'alert.tsx',
|
|
185
|
+
'separator.tsx',
|
|
186
|
+
'label.tsx',
|
|
187
|
+
'textarea.tsx',
|
|
188
|
+
'progress.tsx',
|
|
189
|
+
'typography.tsx',
|
|
190
|
+
].filter((f) => uiFiles.includes(f));
|
|
191
|
+
|
|
192
|
+
it.each(representativeUiFiles)(
|
|
193
|
+
'ui/%s imports and uses cn()',
|
|
194
|
+
(file) => {
|
|
195
|
+
const src = readSource(UI_DIR, file);
|
|
196
|
+
expect(src).toMatch(/import\s*\{[^}]*\bcn\b[^}]*\}\s*from/);
|
|
197
|
+
expect(src).toContain('cn(');
|
|
198
|
+
}
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
const representativeCustomFiles = [
|
|
202
|
+
'empty.tsx',
|
|
203
|
+
'kbd.tsx',
|
|
204
|
+
'button-group.tsx',
|
|
205
|
+
'item.tsx',
|
|
206
|
+
'spinner.tsx',
|
|
207
|
+
'view-states.tsx',
|
|
208
|
+
].filter((f) => customFiles.includes(f));
|
|
209
|
+
|
|
210
|
+
it.each(representativeCustomFiles)(
|
|
211
|
+
'custom/%s imports and uses cn()',
|
|
212
|
+
(file) => {
|
|
213
|
+
const src = readSource(CUSTOM_DIR, file);
|
|
214
|
+
expect(src).toMatch(/import\s*\{[^}]*\bcn\b[^}]*\}\s*from/);
|
|
215
|
+
expect(src).toContain('cn(');
|
|
216
|
+
}
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
// 4. React.forwardRef on primitive UI components
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
describe('React.forwardRef on primitive components', () => {
|
|
224
|
+
const forwardRefFiles = [
|
|
225
|
+
'button.tsx',
|
|
226
|
+
'card.tsx',
|
|
227
|
+
'input.tsx',
|
|
228
|
+
'textarea.tsx',
|
|
229
|
+
'label.tsx',
|
|
230
|
+
'separator.tsx',
|
|
231
|
+
'progress.tsx',
|
|
232
|
+
'alert.tsx',
|
|
233
|
+
];
|
|
234
|
+
|
|
235
|
+
it.each(forwardRefFiles)(
|
|
236
|
+
'ui/%s uses React.forwardRef',
|
|
237
|
+
(file) => {
|
|
238
|
+
const src = readSource(UI_DIR, file);
|
|
239
|
+
expect(src).toContain('forwardRef');
|
|
240
|
+
}
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
// Runtime verification: refs actually resolve
|
|
244
|
+
const refTests = [
|
|
245
|
+
{ name: 'Button', ref: React.createRef<HTMLButtonElement>(), el: () => <Button ref={React.createRef<HTMLButtonElement>()}>B</Button>, instanceOf: HTMLButtonElement },
|
|
246
|
+
{ name: 'Input', ref: React.createRef<HTMLInputElement>(), el: () => <Input ref={React.createRef<HTMLInputElement>()} />, instanceOf: HTMLInputElement },
|
|
247
|
+
{ name: 'Card', ref: React.createRef<HTMLDivElement>(), el: () => <Card ref={React.createRef<HTMLDivElement>()}>C</Card>, instanceOf: HTMLDivElement },
|
|
248
|
+
{ name: 'Textarea', ref: React.createRef<HTMLTextAreaElement>(), el: () => <Textarea ref={React.createRef<HTMLTextAreaElement>()} />, instanceOf: HTMLTextAreaElement },
|
|
249
|
+
{ name: 'Alert', ref: React.createRef<HTMLDivElement>(), el: () => <Alert ref={React.createRef<HTMLDivElement>()}>A</Alert>, instanceOf: HTMLDivElement },
|
|
250
|
+
];
|
|
251
|
+
|
|
252
|
+
it.each(refTests)(
|
|
253
|
+
'$name forwards ref to correct DOM element',
|
|
254
|
+
({ name, instanceOf }) => {
|
|
255
|
+
const ref = React.createRef<any>();
|
|
256
|
+
const components: Record<string, JSX.Element> = {
|
|
257
|
+
Button: <Button ref={ref}>B</Button>,
|
|
258
|
+
Input: <Input ref={ref} />,
|
|
259
|
+
Card: <Card ref={ref}>C</Card>,
|
|
260
|
+
Textarea: <Textarea ref={ref} />,
|
|
261
|
+
Alert: <Alert ref={ref}>A</Alert>,
|
|
262
|
+
};
|
|
263
|
+
render(components[name]);
|
|
264
|
+
expect(ref.current).toBeInstanceOf(instanceOf);
|
|
265
|
+
}
|
|
266
|
+
);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
// 5. displayName set on forwardRef components
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
describe('displayName on forwardRef components', () => {
|
|
273
|
+
const filesWithForwardRef = [
|
|
274
|
+
'button.tsx',
|
|
275
|
+
'card.tsx',
|
|
276
|
+
'input.tsx',
|
|
277
|
+
'textarea.tsx',
|
|
278
|
+
'label.tsx',
|
|
279
|
+
'separator.tsx',
|
|
280
|
+
'progress.tsx',
|
|
281
|
+
'alert.tsx',
|
|
282
|
+
'typography.tsx',
|
|
283
|
+
];
|
|
284
|
+
|
|
285
|
+
it.each(filesWithForwardRef)(
|
|
286
|
+
'ui/%s sets displayName on every forwardRef component',
|
|
287
|
+
(file) => {
|
|
288
|
+
const src = readSource(UI_DIR, file);
|
|
289
|
+
const forwardRefCount = (src.match(/forwardRef/g) || []).length;
|
|
290
|
+
const displayNameCount = (src.match(/\.displayName\s*=/g) || []).length;
|
|
291
|
+
// Each forwardRef call should have a corresponding displayName assignment
|
|
292
|
+
expect(displayNameCount).toBeGreaterThanOrEqual(forwardRefCount);
|
|
293
|
+
}
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
// Runtime check: exported components have displayName
|
|
297
|
+
const namedComponents = [
|
|
298
|
+
{ name: 'Button', component: Button },
|
|
299
|
+
{ name: 'Input', component: Input },
|
|
300
|
+
{ name: 'Card', component: Card },
|
|
301
|
+
{ name: 'CardHeader', component: CardHeader },
|
|
302
|
+
{ name: 'CardTitle', component: CardTitle },
|
|
303
|
+
{ name: 'CardDescription', component: CardDescription },
|
|
304
|
+
{ name: 'CardContent', component: CardContent },
|
|
305
|
+
{ name: 'CardFooter', component: CardFooter },
|
|
306
|
+
{ name: 'Textarea', component: Textarea },
|
|
307
|
+
{ name: 'Label', component: Label },
|
|
308
|
+
{ name: 'Alert', component: Alert },
|
|
309
|
+
];
|
|
310
|
+
|
|
311
|
+
it.each(namedComponents)(
|
|
312
|
+
'$name has a displayName set',
|
|
313
|
+
({ component }) => {
|
|
314
|
+
// forwardRef components expose displayName on the component object
|
|
315
|
+
expect((component as any).displayName).toBeTruthy();
|
|
316
|
+
}
|
|
317
|
+
);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
// 6. Prop naming conventions: variant/size (singular), not variants/sizes
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
describe('prop naming conventions', () => {
|
|
324
|
+
const allSourceFiles = [
|
|
325
|
+
...listComponentFiles(UI_DIR).map((f) => ({ dir: UI_DIR, file: f })),
|
|
326
|
+
...listComponentFiles(CUSTOM_DIR).map((f) => ({ dir: CUSTOM_DIR, file: f })),
|
|
327
|
+
];
|
|
328
|
+
|
|
329
|
+
it('no component source uses plural "variants:" as a CVA key (should be "variant:")', () => {
|
|
330
|
+
for (const { dir, file } of allSourceFiles) {
|
|
331
|
+
const src = readSource(dir, file);
|
|
332
|
+
// Detect if "variants" appears as a destructured prop in function args.
|
|
333
|
+
// CVA config legitimately uses `variants: { variant: ... }` at the top
|
|
334
|
+
// level — we only flag files that destructure `variants` from component
|
|
335
|
+
// props, which would indicate an incorrect plural prop name.
|
|
336
|
+
const destructuresVariantsProp = /\{\s*(?:.*,\s*)?variants\s*[,}]/.test(src);
|
|
337
|
+
const hasCvaVariantsBlock = /variants\s*:\s*\{/.test(src);
|
|
338
|
+
const hasPluralPropDestructure = destructuresVariantsProp && !hasCvaVariantsBlock;
|
|
339
|
+
expect(hasPluralPropDestructure).toBe(false);
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('no component source uses plural "sizes" as a prop name', () => {
|
|
344
|
+
for (const { dir, file } of allSourceFiles) {
|
|
345
|
+
const src = readSource(dir, file);
|
|
346
|
+
// "sizes" should not appear as a destructured prop
|
|
347
|
+
expect(src).not.toMatch(/\(\s*\{[^}]*\bsizes\b/);
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
// CVA definitions should use "variant" and "size" (singular) as variant keys
|
|
352
|
+
it('CVA variant keys use singular "variant" not "variants"', () => {
|
|
353
|
+
const cvaFiles = allSourceFiles.filter(({ dir, file }) =>
|
|
354
|
+
readSource(dir, file).includes('cva(')
|
|
355
|
+
);
|
|
356
|
+
for (const { dir, file } of cvaFiles) {
|
|
357
|
+
const src = readSource(dir, file);
|
|
358
|
+
// Inside the CVA config, look for `variants: { variant:` pattern
|
|
359
|
+
// This confirms variant keys are singular inside the CVA variants object
|
|
360
|
+
if (src.includes('variant:')) {
|
|
361
|
+
expect(src).toMatch(/variants\s*:\s*\{[^}]*\bvariant\b\s*:/s);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// ---------------------------------------------------------------------------
|
|
368
|
+
// 7. Exported types and values are defined
|
|
369
|
+
// ---------------------------------------------------------------------------
|
|
370
|
+
describe('exported types and values are defined', () => {
|
|
371
|
+
it('cn utility is exported and functional', () => {
|
|
372
|
+
expect(typeof cn).toBe('function');
|
|
373
|
+
expect(cn('a', 'b')).toBe('a b');
|
|
374
|
+
// Tailwind merge deduplication
|
|
375
|
+
expect(cn('p-4', 'p-2')).toBe('p-2');
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it('all UI components are exported as functions or objects', () => {
|
|
379
|
+
const uiComponents = [
|
|
380
|
+
Badge, Button, Card, CardHeader, CardTitle, CardDescription,
|
|
381
|
+
CardContent, CardFooter, Input, Label, Separator, Skeleton,
|
|
382
|
+
Progress, Alert, AlertTitle, AlertDescription, Textarea,
|
|
383
|
+
];
|
|
384
|
+
for (const comp of uiComponents) {
|
|
385
|
+
expect(typeof comp).toMatch(/^(function|object)$/);
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it('all custom components are exported as functions', () => {
|
|
390
|
+
const customComponents = [
|
|
391
|
+
Kbd, KbdGroup, Empty, EmptyHeader, EmptyTitle, EmptyDescription,
|
|
392
|
+
EmptyContent, EmptyMedia, ButtonGroup, Item, ItemGroup,
|
|
393
|
+
ItemContent, ItemTitle, ItemDesc, ItemActions, ItemMedia,
|
|
394
|
+
Spinner, DataLoadingState, DataEmptyState, DataErrorState,
|
|
395
|
+
];
|
|
396
|
+
for (const comp of customComponents) {
|
|
397
|
+
expect(typeof comp).toBe('function');
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it('ui/index.ts re-exports all component files', () => {
|
|
402
|
+
const indexSrc = fs.readFileSync(path.join(UI_DIR, 'index.ts'), 'utf-8');
|
|
403
|
+
const uiFiles = listComponentFiles(UI_DIR);
|
|
404
|
+
// toast.tsx is an internal module re-exported through sonner.tsx, not
|
|
405
|
+
// listed in ui/index.ts directly. Exclude it from the re-export check.
|
|
406
|
+
const internalModules = new Set(['toast']);
|
|
407
|
+
const missingExports: string[] = [];
|
|
408
|
+
for (const file of uiFiles) {
|
|
409
|
+
const moduleName = file.replace('.tsx', '');
|
|
410
|
+
if (internalModules.has(moduleName)) continue;
|
|
411
|
+
if (!indexSrc.includes(`'./${moduleName}'`) && !indexSrc.includes(`"./${moduleName}"`)) {
|
|
412
|
+
missingExports.push(moduleName);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
expect(missingExports).toEqual([]);
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
// ---------------------------------------------------------------------------
|
|
420
|
+
// Variant support (runtime)
|
|
421
|
+
// ---------------------------------------------------------------------------
|
|
422
|
+
describe('variant support', () => {
|
|
423
|
+
it('Badge renders default variant without explicit prop', () => {
|
|
424
|
+
const { container } = render(<Badge>tag</Badge>);
|
|
425
|
+
expect(container.firstElementChild!.className).toContain('bg-primary');
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it('Badge renders destructive variant', () => {
|
|
429
|
+
const { container } = render(<Badge variant="destructive">err</Badge>);
|
|
430
|
+
expect(container.firstElementChild!.className).toContain('bg-destructive');
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it('Badge renders outline variant', () => {
|
|
434
|
+
const { container } = render(<Badge variant="outline">out</Badge>);
|
|
435
|
+
expect(container.firstElementChild!.className).toContain('text-foreground');
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it('Button renders default variant without explicit prop', () => {
|
|
439
|
+
render(<Button>Click</Button>);
|
|
440
|
+
expect(screen.getByRole('button').className).toContain('bg-primary');
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it('Button renders destructive variant', () => {
|
|
444
|
+
render(<Button variant="destructive">Del</Button>);
|
|
445
|
+
expect(screen.getByRole('button').className).toContain('bg-destructive');
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it('Button renders ghost variant', () => {
|
|
449
|
+
render(<Button variant="ghost">G</Button>);
|
|
450
|
+
expect(screen.getByRole('button').className).toContain('hover:bg-accent');
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it('Button renders outline variant', () => {
|
|
454
|
+
render(<Button variant="outline">O</Button>);
|
|
455
|
+
expect(screen.getByRole('button').className).toContain('border');
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it('Alert renders default variant', () => {
|
|
459
|
+
render(<Alert>info</Alert>);
|
|
460
|
+
expect(screen.getByRole('alert').className).toContain('bg-background');
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it('Alert renders destructive variant', () => {
|
|
464
|
+
render(<Alert variant="destructive">err</Alert>);
|
|
465
|
+
expect(screen.getByRole('alert').className).toContain('border-destructive');
|
|
466
|
+
});
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
// ---------------------------------------------------------------------------
|
|
470
|
+
// Button size variants
|
|
471
|
+
// ---------------------------------------------------------------------------
|
|
472
|
+
describe('Button size variants', () => {
|
|
473
|
+
it('renders default size', () => {
|
|
474
|
+
render(<Button>D</Button>);
|
|
475
|
+
expect(screen.getByRole('button').className).toContain('h-10');
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it('renders sm size', () => {
|
|
479
|
+
render(<Button size="sm">S</Button>);
|
|
480
|
+
expect(screen.getByRole('button').className).toContain('h-9');
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it('renders lg size', () => {
|
|
484
|
+
render(<Button size="lg">L</Button>);
|
|
485
|
+
expect(screen.getByRole('button').className).toContain('h-11');
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it('renders icon size', () => {
|
|
489
|
+
render(<Button size="icon">I</Button>);
|
|
490
|
+
expect(screen.getByRole('button').className).toContain('w-10');
|
|
491
|
+
});
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
// ---------------------------------------------------------------------------
|
|
495
|
+
// HTML attribute pass-through
|
|
496
|
+
// ---------------------------------------------------------------------------
|
|
497
|
+
describe('HTML attribute pass-through', () => {
|
|
498
|
+
it('Button supports disabled attribute', () => {
|
|
499
|
+
render(<Button disabled>Dis</Button>);
|
|
500
|
+
expect(screen.getByRole('button')).toBeDisabled();
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it('Button supports type attribute', () => {
|
|
504
|
+
render(<Button type="submit">Sub</Button>);
|
|
505
|
+
expect(screen.getByRole('button')).toHaveAttribute('type', 'submit');
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it('Input supports placeholder', () => {
|
|
509
|
+
render(<Input placeholder="Enter..." data-testid="inp" />);
|
|
510
|
+
expect(screen.getByTestId('inp')).toHaveAttribute('placeholder', 'Enter...');
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it('Input supports disabled', () => {
|
|
514
|
+
render(<Input disabled data-testid="inp" />);
|
|
515
|
+
expect(screen.getByTestId('inp')).toBeDisabled();
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it('Badge supports data-* attributes', () => {
|
|
519
|
+
const { container } = render(<Badge data-testid="b1">tag</Badge>);
|
|
520
|
+
expect(container.querySelector('[data-testid="b1"]')).toBeInTheDocument();
|
|
521
|
+
});
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
// ---------------------------------------------------------------------------
|
|
525
|
+
// Card composition pattern
|
|
526
|
+
// ---------------------------------------------------------------------------
|
|
527
|
+
describe('Card composition pattern', () => {
|
|
528
|
+
it('renders full Card composition', () => {
|
|
529
|
+
const { container } = render(
|
|
530
|
+
<Card>
|
|
531
|
+
<CardHeader>
|
|
532
|
+
<CardTitle>Title</CardTitle>
|
|
533
|
+
<CardDescription>Description</CardDescription>
|
|
534
|
+
</CardHeader>
|
|
535
|
+
<CardContent>Body</CardContent>
|
|
536
|
+
<CardFooter>Footer</CardFooter>
|
|
537
|
+
</Card>
|
|
538
|
+
);
|
|
539
|
+
expect(container.textContent).toContain('Title');
|
|
540
|
+
expect(container.textContent).toContain('Description');
|
|
541
|
+
expect(container.textContent).toContain('Body');
|
|
542
|
+
expect(container.textContent).toContain('Footer');
|
|
543
|
+
});
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
// ---------------------------------------------------------------------------
|
|
547
|
+
// Alert composition pattern
|
|
548
|
+
// ---------------------------------------------------------------------------
|
|
549
|
+
describe('Alert composition pattern', () => {
|
|
550
|
+
it('renders Alert with title and description', () => {
|
|
551
|
+
render(
|
|
552
|
+
<Alert>
|
|
553
|
+
<AlertTitle>Heads up!</AlertTitle>
|
|
554
|
+
<AlertDescription>You can add components.</AlertDescription>
|
|
555
|
+
</Alert>
|
|
556
|
+
);
|
|
557
|
+
expect(screen.getByRole('alert')).toBeInTheDocument();
|
|
558
|
+
expect(screen.getByText('Heads up!')).toBeInTheDocument();
|
|
559
|
+
expect(screen.getByText('You can add components.')).toBeInTheDocument();
|
|
560
|
+
});
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
// ---------------------------------------------------------------------------
|
|
564
|
+
// Consistent defaults
|
|
565
|
+
// ---------------------------------------------------------------------------
|
|
566
|
+
describe('consistent defaults', () => {
|
|
567
|
+
it('Separator defaults to horizontal orientation', () => {
|
|
568
|
+
const { container } = render(<Separator />);
|
|
569
|
+
const sep = container.firstElementChild!;
|
|
570
|
+
expect(sep.getAttribute('data-orientation')).toBe('horizontal');
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
it('Separator can be vertical', () => {
|
|
574
|
+
const { container } = render(<Separator orientation="vertical" />);
|
|
575
|
+
const sep = container.firstElementChild!;
|
|
576
|
+
expect(sep.getAttribute('data-orientation')).toBe('vertical');
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
it('Progress renders with zero value by default', () => {
|
|
580
|
+
render(<Progress data-testid="prog" />);
|
|
581
|
+
const prog = screen.getByTestId('prog');
|
|
582
|
+
expect(prog).toBeInTheDocument();
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
it('Progress accepts value prop', () => {
|
|
586
|
+
render(<Progress value={50} data-testid="prog" />);
|
|
587
|
+
expect(screen.getByTestId('prog')).toBeInTheDocument();
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
it('Skeleton renders as a div', () => {
|
|
591
|
+
const { container } = render(<Skeleton />);
|
|
592
|
+
expect(container.firstElementChild!.tagName).toBe('DIV');
|
|
593
|
+
expect(container.firstElementChild!.className).toContain('animate-pulse');
|
|
594
|
+
});
|
|
595
|
+
});
|
|
596
|
+
});
|