@object-ui/components 2.0.0 → 3.0.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 (34) hide show
  1. package/.turbo/turbo-build.log +12 -12
  2. package/CHANGELOG.md +19 -0
  3. package/dist/index.css +1 -1
  4. package/dist/index.js +19610 -19344
  5. package/dist/index.umd.cjs +29 -29
  6. package/dist/src/custom/index.d.ts +2 -0
  7. package/dist/src/custom/view-skeleton.d.ts +37 -0
  8. package/dist/src/custom/view-states.d.ts +33 -0
  9. package/package.json +17 -17
  10. package/src/__tests__/__snapshots__/snapshot-critical.test.tsx.snap +811 -0
  11. package/src/__tests__/__snapshots__/snapshot.test.tsx.snap +327 -0
  12. package/src/__tests__/accessibility.test.tsx +137 -0
  13. package/src/__tests__/api-consistency.test.tsx +596 -0
  14. package/src/__tests__/color-contrast.test.tsx +212 -0
  15. package/src/__tests__/edge-cases.test.tsx +285 -0
  16. package/src/__tests__/snapshot-critical.test.tsx +317 -0
  17. package/src/__tests__/snapshot.test.tsx +205 -0
  18. package/src/__tests__/wcag-audit.test.tsx +493 -0
  19. package/src/custom/index.ts +2 -0
  20. package/src/custom/view-skeleton.tsx +243 -0
  21. package/src/custom/view-states.tsx +153 -0
  22. package/src/renderers/complex/data-table.tsx +28 -13
  23. package/src/renderers/complex/resizable.tsx +20 -17
  24. package/src/renderers/data-display/list.tsx +1 -1
  25. package/src/renderers/data-display/table.tsx +1 -1
  26. package/src/renderers/data-display/tree-view.tsx +2 -1
  27. package/src/renderers/form/form.tsx +10 -6
  28. package/src/renderers/layout/aspect-ratio.tsx +1 -1
  29. package/src/stories-json/Accessibility.mdx +297 -0
  30. package/src/stories-json/EdgeCases.stories.tsx +160 -0
  31. package/src/stories-json/GettingStarted.mdx +89 -0
  32. package/src/stories-json/Introduction.mdx +127 -0
  33. package/src/ui/slider.tsx +6 -2
  34. 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
+ });