@slipstream-ui/core 0.3.0 → 0.4.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 (128) hide show
  1. package/dist/index.d.mts +672 -52
  2. package/dist/index.d.ts +672 -52
  3. package/dist/index.js +1860 -657
  4. package/dist/index.js.map +1 -1
  5. package/dist/index.mjs +1836 -655
  6. package/dist/index.mjs.map +1 -1
  7. package/dist/manifests/app-shell.manifest.json +156 -0
  8. package/dist/manifests/auth-layout.manifest.json +99 -0
  9. package/dist/manifests/autocomplete.manifest.json +53 -5
  10. package/dist/manifests/badge.manifest.json +12 -0
  11. package/dist/manifests/button.manifest.json +6 -2
  12. package/dist/manifests/card.manifest.json +53 -6
  13. package/dist/manifests/date-picker.manifest.json +87 -0
  14. package/dist/manifests/dialog.manifest.json +49 -4
  15. package/dist/manifests/drawer.manifest.json +18 -4
  16. package/dist/manifests/empty-state.manifest.json +108 -0
  17. package/dist/manifests/file-upload.manifest.json +103 -0
  18. package/dist/manifests/filter-bar.manifest.json +100 -0
  19. package/dist/manifests/menu.manifest.json +13 -1
  20. package/dist/manifests/metric-card.manifest.json +105 -0
  21. package/dist/manifests/page-header.manifest.json +94 -0
  22. package/dist/manifests/pagination.manifest.json +62 -34
  23. package/dist/manifests/password-input.manifest.json +75 -0
  24. package/dist/manifests/select-menu.manifest.json +142 -0
  25. package/dist/manifests/select.manifest.json +49 -12
  26. package/dist/manifests/stepper.manifest.json +95 -0
  27. package/dist/manifests/table-card.manifest.json +123 -0
  28. package/dist/manifests/time-picker.manifest.json +86 -0
  29. package/dist/manifests/timeline.manifest.json +69 -0
  30. package/dist/manifests/toast.manifest.json +101 -25
  31. package/package.json +1 -1
  32. package/src/components/app-shell/AppShell.test.tsx +100 -0
  33. package/src/components/app-shell/AppShell.tsx +162 -0
  34. package/src/components/app-shell/index.ts +1 -0
  35. package/src/components/auth-layout/AuthLayout.test.tsx +34 -0
  36. package/src/components/auth-layout/AuthLayout.tsx +47 -0
  37. package/src/components/auth-layout/index.ts +1 -0
  38. package/src/components/autocomplete/Autocomplete.test.tsx +53 -0
  39. package/src/components/autocomplete/Autocomplete.tsx +160 -41
  40. package/src/components/badge/Badge.test.tsx +14 -0
  41. package/src/components/badge/Badge.tsx +10 -4
  42. package/src/components/button/Button.test.tsx +29 -0
  43. package/src/components/button/Button.tsx +10 -0
  44. package/src/components/button-group/ButtonGroup.test.tsx +6 -8
  45. package/src/components/button-group/ButtonGroup.tsx +17 -9
  46. package/src/components/card/Card.test.tsx +14 -0
  47. package/src/components/card/Card.tsx +15 -8
  48. package/src/components/date-picker/DatePicker.test.tsx +30 -0
  49. package/src/components/date-picker/DatePicker.tsx +57 -0
  50. package/src/components/date-picker/index.ts +1 -0
  51. package/src/components/dialog/Dialog.test.tsx +64 -1
  52. package/src/components/dialog/Dialog.tsx +113 -23
  53. package/src/components/dialog/index.ts +1 -2
  54. package/src/components/drawer/Drawer.test.tsx +50 -0
  55. package/src/components/drawer/Drawer.tsx +44 -6
  56. package/src/components/empty-state/EmptyState.test.tsx +30 -0
  57. package/src/components/empty-state/EmptyState.tsx +48 -0
  58. package/src/components/empty-state/index.ts +1 -0
  59. package/src/components/file-upload/FileUpload.test.tsx +43 -0
  60. package/src/components/file-upload/FileUpload.tsx +143 -0
  61. package/src/components/file-upload/index.ts +1 -0
  62. package/src/components/filter-bar/FilterBar.test.tsx +32 -0
  63. package/src/components/filter-bar/FilterBar.tsx +71 -0
  64. package/src/components/filter-bar/index.ts +1 -0
  65. package/src/components/input-group/InputGroup.test.tsx +6 -3
  66. package/src/components/input-group/InputGroup.tsx +9 -6
  67. package/src/components/menu/Menu.test.tsx +21 -0
  68. package/src/components/menu/Menu.tsx +8 -3
  69. package/src/components/metric-card/MetricCard.test.tsx +28 -0
  70. package/src/components/metric-card/MetricCard.tsx +54 -0
  71. package/src/components/metric-card/index.ts +1 -0
  72. package/src/components/page-header/PageHeader.test.tsx +31 -0
  73. package/src/components/page-header/PageHeader.tsx +37 -0
  74. package/src/components/page-header/index.ts +1 -0
  75. package/src/components/pagination/Pagination.test.tsx +25 -0
  76. package/src/components/pagination/Pagination.tsx +53 -12
  77. package/src/components/password-input/PasswordInput.test.tsx +29 -0
  78. package/src/components/password-input/PasswordInput.tsx +41 -0
  79. package/src/components/password-input/index.ts +1 -0
  80. package/src/components/select/Select.test.tsx +40 -1
  81. package/src/components/select/Select.tsx +52 -14
  82. package/src/components/select/index.ts +1 -1
  83. package/src/components/select-menu/SelectMenu.test.tsx +105 -0
  84. package/src/components/select-menu/SelectMenu.tsx +368 -0
  85. package/src/components/select-menu/index.ts +1 -0
  86. package/src/components/stepper/Stepper.test.tsx +35 -0
  87. package/src/components/stepper/Stepper.tsx +91 -0
  88. package/src/components/stepper/index.ts +1 -0
  89. package/src/components/table-card/TableCard.test.tsx +59 -0
  90. package/src/components/table-card/TableCard.tsx +48 -0
  91. package/src/components/table-card/index.ts +1 -0
  92. package/src/components/time-picker/TimePicker.test.tsx +30 -0
  93. package/src/components/time-picker/TimePicker.tsx +56 -0
  94. package/src/components/time-picker/index.ts +1 -0
  95. package/src/components/timeline/Timeline.test.tsx +26 -0
  96. package/src/components/timeline/Timeline.tsx +53 -0
  97. package/src/components/timeline/index.ts +1 -0
  98. package/src/components/toast/Toast.test.tsx +5 -0
  99. package/src/components/toast/Toast.tsx +2 -16
  100. package/src/components/toast/Toaster.test.tsx +84 -0
  101. package/src/components/toast/Toaster.tsx +116 -0
  102. package/src/components/toast/index.ts +4 -0
  103. package/src/components/toast/toast-store.ts +117 -0
  104. package/src/index.ts +14 -0
  105. package/src/manifests/app-shell.manifest.json +156 -0
  106. package/src/manifests/auth-layout.manifest.json +99 -0
  107. package/src/manifests/autocomplete.manifest.json +53 -5
  108. package/src/manifests/badge.manifest.json +12 -0
  109. package/src/manifests/button.manifest.json +6 -2
  110. package/src/manifests/card.manifest.json +53 -6
  111. package/src/manifests/date-picker.manifest.json +87 -0
  112. package/src/manifests/dialog.manifest.json +49 -4
  113. package/src/manifests/drawer.manifest.json +18 -4
  114. package/src/manifests/empty-state.manifest.json +108 -0
  115. package/src/manifests/file-upload.manifest.json +103 -0
  116. package/src/manifests/filter-bar.manifest.json +100 -0
  117. package/src/manifests/menu.manifest.json +13 -1
  118. package/src/manifests/metric-card.manifest.json +105 -0
  119. package/src/manifests/page-header.manifest.json +94 -0
  120. package/src/manifests/pagination.manifest.json +62 -34
  121. package/src/manifests/password-input.manifest.json +75 -0
  122. package/src/manifests/select-menu.manifest.json +142 -0
  123. package/src/manifests/select.manifest.json +49 -12
  124. package/src/manifests/stepper.manifest.json +95 -0
  125. package/src/manifests/table-card.manifest.json +123 -0
  126. package/src/manifests/time-picker.manifest.json +86 -0
  127. package/src/manifests/timeline.manifest.json +69 -0
  128. package/src/manifests/toast.manifest.json +101 -25
@@ -0,0 +1,368 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { createPortal } from 'react-dom';
5
+ import { cn } from '../../utils/cn';
6
+
7
+ export type SelectMenuItem = {
8
+ value: string;
9
+ label: string;
10
+ /** Muted supporting line under the label. */
11
+ description?: string;
12
+ icon?: React.ReactNode;
13
+ disabled?: boolean;
14
+ /** Section heading the item renders under. */
15
+ group?: string;
16
+ };
17
+
18
+ export type SelectMenuProps = Omit<
19
+ React.ButtonHTMLAttributes<HTMLButtonElement>,
20
+ 'value' | 'defaultValue' | 'onChange' | 'children' | 'type'
21
+ > & {
22
+ items: SelectMenuItem[];
23
+ /** Selected value(s): a string, or string[] when multiple. */
24
+ value?: string | string[];
25
+ defaultValue?: string | string[];
26
+ onValueChange?: (value: string | string[]) => void;
27
+ /** Allow choosing several items (the listbox stays open). */
28
+ multiple?: boolean;
29
+ placeholder?: string;
30
+ /** Name for hidden form inputs (renders one input per selected value). */
31
+ name?: string;
32
+ /** Custom trigger content for the current selection. */
33
+ renderValue?: (selected: SelectMenuItem[]) => React.ReactNode;
34
+ /** Custom option row. */
35
+ renderItem?: (item: SelectMenuItem, state: { selected: boolean; active: boolean }) => React.ReactNode;
36
+ emptyText?: string;
37
+ };
38
+
39
+ function useOutsideClose(open: boolean, close: () => void, refs: Array<React.RefObject<HTMLElement | null>>) {
40
+ React.useEffect(() => {
41
+ if (!open) return;
42
+ const onPointerDown = (event: MouseEvent) => {
43
+ const target = event.target as Node;
44
+ if (refs.some((ref) => ref.current?.contains(target))) return;
45
+ close();
46
+ };
47
+ document.addEventListener('mousedown', onPointerDown);
48
+ return () => document.removeEventListener('mousedown', onPointerDown);
49
+ }, [open, close, refs]);
50
+ }
51
+
52
+ /**
53
+ * Custom dropdown select for rich options: descriptions, icons, grouped
54
+ * headings, multi-select and custom row rendering. Unlike `Select` (native
55
+ * `<select>`), this renders a real listbox, so option content is not limited
56
+ * to text. Keyboard model follows the button/listbox pattern: arrows move the
57
+ * active option, Home/End jump, Enter/Space selects, Escape closes, and
58
+ * first-letter typeahead jumps to matching options.
59
+ */
60
+ export const SelectMenu = React.forwardRef<HTMLButtonElement, SelectMenuProps>(function SelectMenu(
61
+ {
62
+ items,
63
+ value,
64
+ defaultValue,
65
+ onValueChange,
66
+ multiple = false,
67
+ placeholder = 'Select…',
68
+ name,
69
+ renderValue,
70
+ renderItem,
71
+ emptyText = 'No options',
72
+ className,
73
+ disabled,
74
+ onKeyDown,
75
+ ...buttonProps
76
+ },
77
+ ref
78
+ ) {
79
+ const [internal, setInternal] = React.useState<string[]>(() => {
80
+ if (defaultValue === undefined) return [];
81
+ return Array.isArray(defaultValue) ? defaultValue : [defaultValue];
82
+ });
83
+ const selectedValues = React.useMemo(() => {
84
+ if (value === undefined) return internal;
85
+ return Array.isArray(value) ? value : [value];
86
+ }, [value, internal]);
87
+
88
+ const [open, setOpen] = React.useState(false);
89
+ const [activeIndex, setActiveIndex] = React.useState(-1);
90
+ const [position, setPosition] = React.useState<{ top: number; left: number; width: number } | null>(null);
91
+ const triggerRef = React.useRef<HTMLButtonElement | null>(null);
92
+ const listRef = React.useRef<HTMLDivElement | null>(null);
93
+ const listboxId = React.useId();
94
+ const labelId = React.useId();
95
+ const typeahead = React.useRef({ query: '', at: 0 });
96
+
97
+ const setTriggerRef = React.useCallback(
98
+ (node: HTMLButtonElement | null) => {
99
+ triggerRef.current = node;
100
+ if (typeof ref === 'function') ref(node);
101
+ else if (ref) ref.current = node;
102
+ },
103
+ [ref]
104
+ );
105
+
106
+ const close = React.useCallback(() => setOpen(false), []);
107
+ useOutsideClose(open, close, [triggerRef, listRef]);
108
+
109
+ const measure = React.useCallback(() => {
110
+ const node = triggerRef.current;
111
+ if (!node) return;
112
+ const rect = node.getBoundingClientRect();
113
+ setPosition({ top: rect.bottom + 4, left: rect.left, width: Math.max(rect.width, 200) });
114
+ }, []);
115
+
116
+ React.useEffect(() => {
117
+ if (!open) return;
118
+ measure();
119
+ const onScroll = () => measure();
120
+ window.addEventListener('resize', onScroll);
121
+ window.addEventListener('scroll', onScroll, true);
122
+ return () => {
123
+ window.removeEventListener('resize', onScroll);
124
+ window.removeEventListener('scroll', onScroll, true);
125
+ };
126
+ }, [open, measure]);
127
+
128
+ // Index of the first enabled option for initial highlight.
129
+ const firstEnabled = React.useMemo(() => items.findIndex((item) => !item.disabled), [items]);
130
+
131
+ const openMenu = () => {
132
+ if (disabled) return;
133
+ setOpen(true);
134
+ const selectedIndex = items.findIndex((item) => selectedValues.includes(item.value) && !item.disabled);
135
+ setActiveIndex(selectedIndex >= 0 ? selectedIndex : firstEnabled);
136
+ };
137
+
138
+ const commit = (next: string[]) => {
139
+ if (value === undefined) setInternal(next);
140
+ onValueChange?.(multiple ? next : next[0] ?? '');
141
+ };
142
+
143
+ const select = (item: SelectMenuItem) => {
144
+ if (item.disabled) return;
145
+ if (multiple) {
146
+ const next = selectedValues.includes(item.value)
147
+ ? selectedValues.filter((entry) => entry !== item.value)
148
+ : [...selectedValues, item.value];
149
+ commit(next);
150
+ return;
151
+ }
152
+ commit([item.value]);
153
+ setOpen(false);
154
+ };
155
+
156
+ const move = (direction: 1 | -1) => {
157
+ if (items.length === 0) return;
158
+ let index = activeIndex;
159
+ for (let step = 0; step < items.length; step += 1) {
160
+ index = (index + direction + items.length) % items.length;
161
+ if (!items[index]?.disabled) {
162
+ setActiveIndex(index);
163
+ return;
164
+ }
165
+ }
166
+ };
167
+
168
+ const handleKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
169
+ onKeyDown?.(event);
170
+ if (event.defaultPrevented) return;
171
+
172
+ if (!open) {
173
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Enter' || event.key === ' ') {
174
+ event.preventDefault();
175
+ openMenu();
176
+ }
177
+ return;
178
+ }
179
+
180
+ switch (event.key) {
181
+ case 'ArrowDown':
182
+ event.preventDefault();
183
+ move(1);
184
+ break;
185
+ case 'ArrowUp':
186
+ event.preventDefault();
187
+ move(-1);
188
+ break;
189
+ case 'Home':
190
+ event.preventDefault();
191
+ setActiveIndex(firstEnabled);
192
+ break;
193
+ case 'End': {
194
+ event.preventDefault();
195
+ for (let i = items.length - 1; i >= 0; i -= 1) {
196
+ if (!items[i]?.disabled) {
197
+ setActiveIndex(i);
198
+ break;
199
+ }
200
+ }
201
+ break;
202
+ }
203
+ case 'Enter':
204
+ case ' ':
205
+ event.preventDefault();
206
+ if (activeIndex >= 0 && items[activeIndex]) select(items[activeIndex]);
207
+ break;
208
+ case 'Escape':
209
+ event.preventDefault();
210
+ setOpen(false);
211
+ break;
212
+ case 'Tab':
213
+ setOpen(false);
214
+ break;
215
+ default: {
216
+ if (event.key.length !== 1 || event.metaKey || event.ctrlKey || event.altKey) return;
217
+ const now = Date.now();
218
+ const buffer = now - typeahead.current.at > 600 ? event.key : typeahead.current.query + event.key;
219
+ typeahead.current = { query: buffer, at: now };
220
+ const query = buffer.toLowerCase();
221
+ const index = items.findIndex((item) => !item.disabled && item.label.toLowerCase().startsWith(query));
222
+ if (index >= 0) setActiveIndex(index);
223
+ }
224
+ }
225
+ };
226
+
227
+ const selectedItems = items.filter((item) => selectedValues.includes(item.value));
228
+ const triggerContent = renderValue ? (
229
+ renderValue(selectedItems)
230
+ ) : selectedItems.length === 0 ? (
231
+ <span className="text-muted-foreground">{placeholder}</span>
232
+ ) : multiple ? (
233
+ <span className="truncate">
234
+ {selectedItems.length === 1 ? selectedItems[0].label : `${selectedItems.length} selected`}
235
+ </span>
236
+ ) : (
237
+ <span className="truncate">{selectedItems[0].label}</span>
238
+ );
239
+
240
+ const activeOptionId = activeIndex >= 0 ? `${listboxId}-option-${activeIndex}` : undefined;
241
+
242
+ // Group headings, keeping the flat index aligned with `items`.
243
+ const rows = React.useMemo(() => {
244
+ const out: Array<{ heading?: string; item?: SelectMenuItem; index: number }> = [];
245
+ let lastGroup: string | undefined;
246
+ items.forEach((item, index) => {
247
+ if (item.group && item.group !== lastGroup) {
248
+ out.push({ heading: item.group, index: -1 });
249
+ lastGroup = item.group;
250
+ }
251
+ out.push({ item, index });
252
+ });
253
+ return out;
254
+ }, [items]);
255
+
256
+ return (
257
+ <>
258
+ <span id={labelId} className="sr-only">
259
+ {typeof buttonProps['aria-label'] === 'string' ? buttonProps['aria-label'] : 'Select'}
260
+ </span>
261
+ {name &&
262
+ selectedValues.map((selected) => (
263
+ <input key={selected} type="hidden" name={name} value={selected} />
264
+ ))}
265
+ <button
266
+ ref={setTriggerRef}
267
+ type="button"
268
+ aria-haspopup="listbox"
269
+ aria-expanded={open}
270
+ aria-controls={open ? listboxId : undefined}
271
+ aria-activedescendant={open ? activeOptionId : undefined}
272
+ aria-labelledby={labelId}
273
+ disabled={disabled}
274
+ onClick={() => (open ? setOpen(false) : openMenu())}
275
+ onKeyDown={handleKeyDown}
276
+ className={cn(
277
+ 'flex h-10 w-full items-center justify-between gap-2 rounded-md border border-input bg-background px-3 py-2 text-left text-sm',
278
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
279
+ 'disabled:cursor-not-allowed disabled:opacity-50',
280
+ className
281
+ )}
282
+ {...buttonProps}
283
+ >
284
+ <span className="min-w-0 flex-1">{triggerContent}</span>
285
+ <span aria-hidden="true" className="text-muted-foreground">▾</span>
286
+ </button>
287
+
288
+ {open &&
289
+ position &&
290
+ createPortal(
291
+ <div
292
+ ref={listRef}
293
+ id={listboxId}
294
+ role="listbox"
295
+ aria-labelledby={labelId}
296
+ aria-multiselectable={multiple || undefined}
297
+ style={{
298
+ position: 'fixed',
299
+ top: position.top,
300
+ left: position.left,
301
+ width: position.width,
302
+ zIndex: 50,
303
+ }}
304
+ className="max-h-64 overflow-y-auto rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md"
305
+ >
306
+ {items.length === 0 ? (
307
+ <p role="presentation" className="px-3 py-2 text-sm text-muted-foreground">
308
+ {emptyText}
309
+ </p>
310
+ ) : (
311
+ rows.map((row, rowIndex) =>
312
+ row.heading ? (
313
+ <p
314
+ key={`heading-${row.heading}-${rowIndex}`}
315
+ role="presentation"
316
+ className="px-3 pb-1 pt-2 text-xs font-medium uppercase tracking-wide text-muted-foreground"
317
+ >
318
+ {row.heading}
319
+ </p>
320
+ ) : (
321
+ <div
322
+ key={row.item!.value}
323
+ id={`${listboxId}-option-${row.index}`}
324
+ role="option"
325
+ aria-selected={selectedValues.includes(row.item!.value)}
326
+ aria-disabled={row.item!.disabled || undefined}
327
+ onMouseDown={(event) => event.preventDefault()}
328
+ onMouseEnter={() => !row.item!.disabled && setActiveIndex(row.index)}
329
+ onClick={() => select(row.item!)}
330
+ className={cn(
331
+ 'flex cursor-pointer items-start gap-2 rounded-sm px-3 py-2 text-sm outline-none',
332
+ row.index === activeIndex ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/60',
333
+ row.item!.disabled && 'pointer-events-none opacity-50'
334
+ )}
335
+ >
336
+ {renderItem ? (
337
+ renderItem(row.item!, {
338
+ selected: selectedValues.includes(row.item!.value),
339
+ active: row.index === activeIndex,
340
+ })
341
+ ) : (
342
+ <>
343
+ {row.item!.icon && <span className="mt-0.5 shrink-0 [&_svg]:size-4">{row.item!.icon}</span>}
344
+ <span className="min-w-0 flex-1">
345
+ <span className="block">{row.item!.label}</span>
346
+ {row.item!.description && (
347
+ <span className="mt-0.5 block text-xs text-muted-foreground">
348
+ {row.item!.description}
349
+ </span>
350
+ )}
351
+ </span>
352
+ {multiple && selectedValues.includes(row.item!.value) && (
353
+ <span aria-hidden="true" className="ml-auto text-primary">
354
+ ✓
355
+ </span>
356
+ )}
357
+ </>
358
+ )}
359
+ </div>
360
+ )
361
+ )
362
+ )}
363
+ </div>,
364
+ document.body
365
+ )}
366
+ </>
367
+ );
368
+ });
@@ -0,0 +1 @@
1
+ export * from './SelectMenu';
@@ -0,0 +1,35 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { axe } from 'jest-axe';
4
+ import { describe, expect, it, vi } from 'vitest';
5
+ import { Stepper } from './Stepper';
6
+
7
+ const steps = [{ label: 'Account' }, { label: 'Details', description: 'Studio info' }, { label: 'Review' }];
8
+
9
+ describe('Stepper', () => {
10
+ it('marks the active step with aria-current and states progress in text', () => {
11
+ render(<Stepper steps={steps} current={1} />);
12
+ const current = screen.getByText('Details').closest('[aria-current]');
13
+ expect(current).toHaveAttribute('aria-current', 'step');
14
+ expect(screen.getByText('(completed)')).toBeInTheDocument();
15
+ expect(screen.getByText('(upcoming)')).toBeInTheDocument();
16
+ });
17
+
18
+ it('makes steps clickable when onStepClick is provided', async () => {
19
+ const user = userEvent.setup();
20
+ const onStepClick = vi.fn();
21
+ render(<Stepper steps={steps} current={0} onStepClick={onStepClick} />);
22
+ await user.click(screen.getByRole('button', { name: /Review/ }));
23
+ expect(onStepClick).toHaveBeenCalledWith(2);
24
+ });
25
+
26
+ it('supports vertical orientation', () => {
27
+ const { container } = render(<Stepper steps={steps} current={0} orientation="vertical" />);
28
+ expect(container.querySelector('ol')).toHaveClass('flex-col');
29
+ });
30
+
31
+ it('has no accessibility violations', async () => {
32
+ const { container } = render(<Stepper steps={steps} current={1} />);
33
+ expect(await axe(container)).toHaveNoViolations();
34
+ });
35
+ });
@@ -0,0 +1,91 @@
1
+ import * as React from 'react';
2
+ import { cn } from '../../utils/cn';
3
+
4
+ export type StepperStep = {
5
+ label: string;
6
+ description?: string;
7
+ };
8
+
9
+ export type StepperProps = Omit<React.OlHTMLAttributes<HTMLOListElement>, 'onClick'> & {
10
+ steps: StepperStep[];
11
+ /** Zero-based index of the active step. */
12
+ current: number;
13
+ orientation?: 'horizontal' | 'vertical';
14
+ /** Makes completed/upcoming steps clickable. */
15
+ onStepClick?: (index: number) => void;
16
+ };
17
+
18
+ /**
19
+ * Progress through a multi-step flow with proper semantics: an ordered list
20
+ * where the active step carries aria-current="step" and each marker states
21
+ * complete/current/upcoming in text, not only color.
22
+ */
23
+ export const Stepper = React.forwardRef<HTMLOListElement, StepperProps>(function Stepper(
24
+ { steps, current, orientation = 'horizontal', onStepClick, className, ...props },
25
+ ref
26
+ ) {
27
+ return (
28
+ <ol
29
+ ref={ref}
30
+ className={cn(
31
+ 'flex gap-4',
32
+ orientation === 'vertical' ? 'flex-col' : 'flex-wrap items-start',
33
+ className
34
+ )}
35
+ {...props}
36
+ >
37
+ {steps.map((step, index) => {
38
+ const state = index < current ? 'complete' : index === current ? 'current' : 'upcoming';
39
+ const interactive = Boolean(onStepClick);
40
+ const content = (
41
+ <>
42
+ <span
43
+ aria-hidden="true"
44
+ className={cn(
45
+ 'flex h-7 w-7 shrink-0 items-center justify-center rounded-full border text-xs font-medium',
46
+ state === 'complete' && 'border-primary bg-primary text-primary-foreground',
47
+ state === 'current' && 'border-primary text-primary',
48
+ state === 'upcoming' && 'border-border text-muted-foreground'
49
+ )}
50
+ >
51
+ {state === 'complete' ? '✓' : index + 1}
52
+ </span>
53
+ <span className="min-w-0">
54
+ <span className={cn('block text-sm', state === 'upcoming' ? 'text-muted-foreground' : 'font-medium')}>
55
+ {step.label}
56
+ </span>
57
+ {step.description && (
58
+ <span className="block text-xs text-muted-foreground">{step.description}</span>
59
+ )}
60
+ <span className="sr-only">
61
+ {state === 'complete' ? ' (completed)' : state === 'current' ? ' (current step)' : ' (upcoming)'}
62
+ </span>
63
+ </span>
64
+ </>
65
+ );
66
+
67
+ return (
68
+ <li key={step.label} className="flex min-w-0 items-start gap-2">
69
+ {interactive ? (
70
+ <button
71
+ type="button"
72
+ onClick={() => onStepClick?.(index)}
73
+ aria-current={state === 'current' ? 'step' : undefined}
74
+ className="flex items-start gap-2 rounded-md text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
75
+ >
76
+ {content}
77
+ </button>
78
+ ) : (
79
+ <div aria-current={state === 'current' ? 'step' : undefined} className="flex items-start gap-2">
80
+ {content}
81
+ </div>
82
+ )}
83
+ {orientation === 'horizontal' && index < steps.length - 1 && (
84
+ <span aria-hidden="true" className="mt-3.5 hidden h-px w-8 bg-border sm:block" />
85
+ )}
86
+ </li>
87
+ );
88
+ })}
89
+ </ol>
90
+ );
91
+ });
@@ -0,0 +1 @@
1
+ export * from './Stepper';
@@ -0,0 +1,59 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { axe } from 'jest-axe';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { TableCard } from './TableCard';
5
+
6
+ describe('TableCard', () => {
7
+ it('renders header, toolbar, body and footer', () => {
8
+ render(
9
+ <TableCard
10
+ title="Classes"
11
+ description="12 active"
12
+ actions={<button type="button">New</button>}
13
+ toolbar={<span>filters</span>}
14
+ footer={<span>Showing 1–25 of 40</span>}
15
+ >
16
+ <table aria-label="Classes table">
17
+ <tbody>
18
+ <tr>
19
+ <td>Yoga</td>
20
+ </tr>
21
+ </tbody>
22
+ </table>
23
+ </TableCard>
24
+ );
25
+ expect(screen.getByRole('heading', { name: 'Classes' })).toBeInTheDocument();
26
+ expect(screen.getByRole('button', { name: 'New' })).toBeInTheDocument();
27
+ expect(screen.getByText('filters')).toBeInTheDocument();
28
+ expect(screen.getByRole('table', { name: 'Classes table' })).toBeInTheDocument();
29
+ expect(screen.getByText('Showing 1–25 of 40')).toBeInTheDocument();
30
+ });
31
+
32
+ it('renders without chrome when no header props are given', () => {
33
+ const { container } = render(
34
+ <TableCard>
35
+ <span>body</span>
36
+ </TableCard>
37
+ );
38
+ expect(container.querySelector('h2')).toBeNull();
39
+ expect(screen.getByText('body')).toBeInTheDocument();
40
+ });
41
+
42
+ it('supports padded (non-flush) content', () => {
43
+ const { container } = render(
44
+ <TableCard flush={false}>
45
+ <span>padded</span>
46
+ </TableCard>
47
+ );
48
+ expect(container.querySelector('.p-4')).not.toBeNull();
49
+ });
50
+
51
+ it('has no accessibility violations', async () => {
52
+ const { container } = render(
53
+ <TableCard title="Empty">
54
+ <p>Nothing to show</p>
55
+ </TableCard>
56
+ );
57
+ expect(await axe(container)).toHaveNoViolations();
58
+ });
59
+ });
@@ -0,0 +1,48 @@
1
+ import * as React from 'react';
2
+ import { cn } from '../../utils/cn';
3
+
4
+ export type TableCardProps = React.HTMLAttributes<HTMLDivElement> & {
5
+ title?: string;
6
+ description?: string;
7
+ /** Right-aligned header actions (create, export, column settings). */
8
+ actions?: React.ReactNode;
9
+ /** Row between the header and the table (filters, bulk actions). */
10
+ toolbar?: React.ReactNode;
11
+ /** Table/DataTable content. */
12
+ children: React.ReactNode;
13
+ /** Rendered under the table (pagination, summary). */
14
+ footer?: React.ReactNode;
15
+ /** Remove the inner padding so table edges reach the card border. */
16
+ flush?: boolean;
17
+ };
18
+
19
+ /**
20
+ * Table shell: title/description + actions, an optional toolbar, an
21
+ * edge-to-edge table body and a footer row. Standardizes the chrome every
22
+ * list page grows around a Table or DataTable.
23
+ */
24
+ export const TableCard = React.forwardRef<HTMLDivElement, TableCardProps>(function TableCard(
25
+ { title, description, actions, toolbar, children, footer, flush = true, className, ...props },
26
+ ref
27
+ ) {
28
+ return (
29
+ <div
30
+ ref={ref}
31
+ className={cn('overflow-hidden rounded-xl border border-border bg-card text-card-foreground', className)}
32
+ {...props}
33
+ >
34
+ {(title || description || actions) && (
35
+ <div className="flex flex-wrap items-start justify-between gap-3 border-b border-border px-4 py-3">
36
+ <div className="min-w-0">
37
+ {title && <h2 className="text-sm font-semibold">{title}</h2>}
38
+ {description && <p className="mt-0.5 text-sm text-muted-foreground">{description}</p>}
39
+ </div>
40
+ {actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
41
+ </div>
42
+ )}
43
+ {toolbar && <div className="border-b border-border px-4 py-2">{toolbar}</div>}
44
+ <div className={cn(flush ? 'overflow-x-auto' : 'p-4')}>{children}</div>
45
+ {footer && <div className="border-t border-border px-4 py-3">{footer}</div>}
46
+ </div>
47
+ );
48
+ });
@@ -0,0 +1 @@
1
+ export * from './TableCard';
@@ -0,0 +1,30 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { axe } from 'jest-axe';
4
+ import { describe, expect, it, vi } from 'vitest';
5
+ import { TimePicker } from './TimePicker';
6
+
7
+ describe('TimePicker', () => {
8
+ it('renders a time input and reports changes', async () => {
9
+ const user = userEvent.setup();
10
+ const onValueChange = vi.fn();
11
+ render(<TimePicker aria-label="Starts at" onValueChange={onValueChange} />);
12
+ const input = screen.getByLabelText('Starts at');
13
+ expect(input).toHaveAttribute('type', 'time');
14
+ await user.type(input, '09:30');
15
+ expect(onValueChange).toHaveBeenCalled();
16
+ });
17
+
18
+ it('clears the value', async () => {
19
+ const user = userEvent.setup();
20
+ const onValueChange = vi.fn();
21
+ render(<TimePicker aria-label="Ends" clearable defaultValue="17:00" onValueChange={onValueChange} />);
22
+ await user.click(screen.getByRole('button', { name: 'Clear time' }));
23
+ expect(onValueChange).toHaveBeenCalledWith('');
24
+ });
25
+
26
+ it('has no accessibility violations', async () => {
27
+ const { container } = render(<TimePicker aria-label="Time" />);
28
+ expect(await axe(container)).toHaveNoViolations();
29
+ });
30
+ });