@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.
- package/dist/index.d.mts +672 -52
- package/dist/index.d.ts +672 -52
- package/dist/index.js +1860 -657
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1836 -655
- package/dist/index.mjs.map +1 -1
- package/dist/manifests/app-shell.manifest.json +156 -0
- package/dist/manifests/auth-layout.manifest.json +99 -0
- package/dist/manifests/autocomplete.manifest.json +53 -5
- package/dist/manifests/badge.manifest.json +12 -0
- package/dist/manifests/button.manifest.json +6 -2
- package/dist/manifests/card.manifest.json +53 -6
- package/dist/manifests/date-picker.manifest.json +87 -0
- package/dist/manifests/dialog.manifest.json +49 -4
- package/dist/manifests/drawer.manifest.json +18 -4
- package/dist/manifests/empty-state.manifest.json +108 -0
- package/dist/manifests/file-upload.manifest.json +103 -0
- package/dist/manifests/filter-bar.manifest.json +100 -0
- package/dist/manifests/menu.manifest.json +13 -1
- package/dist/manifests/metric-card.manifest.json +105 -0
- package/dist/manifests/page-header.manifest.json +94 -0
- package/dist/manifests/pagination.manifest.json +62 -34
- package/dist/manifests/password-input.manifest.json +75 -0
- package/dist/manifests/select-menu.manifest.json +142 -0
- package/dist/manifests/select.manifest.json +49 -12
- package/dist/manifests/stepper.manifest.json +95 -0
- package/dist/manifests/table-card.manifest.json +123 -0
- package/dist/manifests/time-picker.manifest.json +86 -0
- package/dist/manifests/timeline.manifest.json +69 -0
- package/dist/manifests/toast.manifest.json +101 -25
- package/package.json +1 -1
- package/src/components/app-shell/AppShell.test.tsx +100 -0
- package/src/components/app-shell/AppShell.tsx +162 -0
- package/src/components/app-shell/index.ts +1 -0
- package/src/components/auth-layout/AuthLayout.test.tsx +34 -0
- package/src/components/auth-layout/AuthLayout.tsx +47 -0
- package/src/components/auth-layout/index.ts +1 -0
- package/src/components/autocomplete/Autocomplete.test.tsx +53 -0
- package/src/components/autocomplete/Autocomplete.tsx +160 -41
- package/src/components/badge/Badge.test.tsx +14 -0
- package/src/components/badge/Badge.tsx +10 -4
- package/src/components/button/Button.test.tsx +29 -0
- package/src/components/button/Button.tsx +10 -0
- package/src/components/button-group/ButtonGroup.test.tsx +6 -8
- package/src/components/button-group/ButtonGroup.tsx +17 -9
- package/src/components/card/Card.test.tsx +14 -0
- package/src/components/card/Card.tsx +15 -8
- package/src/components/date-picker/DatePicker.test.tsx +30 -0
- package/src/components/date-picker/DatePicker.tsx +57 -0
- package/src/components/date-picker/index.ts +1 -0
- package/src/components/dialog/Dialog.test.tsx +64 -1
- package/src/components/dialog/Dialog.tsx +113 -23
- package/src/components/dialog/index.ts +1 -2
- package/src/components/drawer/Drawer.test.tsx +50 -0
- package/src/components/drawer/Drawer.tsx +44 -6
- package/src/components/empty-state/EmptyState.test.tsx +30 -0
- package/src/components/empty-state/EmptyState.tsx +48 -0
- package/src/components/empty-state/index.ts +1 -0
- package/src/components/file-upload/FileUpload.test.tsx +43 -0
- package/src/components/file-upload/FileUpload.tsx +143 -0
- package/src/components/file-upload/index.ts +1 -0
- package/src/components/filter-bar/FilterBar.test.tsx +32 -0
- package/src/components/filter-bar/FilterBar.tsx +71 -0
- package/src/components/filter-bar/index.ts +1 -0
- package/src/components/input-group/InputGroup.test.tsx +6 -3
- package/src/components/input-group/InputGroup.tsx +9 -6
- package/src/components/menu/Menu.test.tsx +21 -0
- package/src/components/menu/Menu.tsx +8 -3
- package/src/components/metric-card/MetricCard.test.tsx +28 -0
- package/src/components/metric-card/MetricCard.tsx +54 -0
- package/src/components/metric-card/index.ts +1 -0
- package/src/components/page-header/PageHeader.test.tsx +31 -0
- package/src/components/page-header/PageHeader.tsx +37 -0
- package/src/components/page-header/index.ts +1 -0
- package/src/components/pagination/Pagination.test.tsx +25 -0
- package/src/components/pagination/Pagination.tsx +53 -12
- package/src/components/password-input/PasswordInput.test.tsx +29 -0
- package/src/components/password-input/PasswordInput.tsx +41 -0
- package/src/components/password-input/index.ts +1 -0
- package/src/components/select/Select.test.tsx +40 -1
- package/src/components/select/Select.tsx +52 -14
- package/src/components/select/index.ts +1 -1
- package/src/components/select-menu/SelectMenu.test.tsx +105 -0
- package/src/components/select-menu/SelectMenu.tsx +368 -0
- package/src/components/select-menu/index.ts +1 -0
- package/src/components/stepper/Stepper.test.tsx +35 -0
- package/src/components/stepper/Stepper.tsx +91 -0
- package/src/components/stepper/index.ts +1 -0
- package/src/components/table-card/TableCard.test.tsx +59 -0
- package/src/components/table-card/TableCard.tsx +48 -0
- package/src/components/table-card/index.ts +1 -0
- package/src/components/time-picker/TimePicker.test.tsx +30 -0
- package/src/components/time-picker/TimePicker.tsx +56 -0
- package/src/components/time-picker/index.ts +1 -0
- package/src/components/timeline/Timeline.test.tsx +26 -0
- package/src/components/timeline/Timeline.tsx +53 -0
- package/src/components/timeline/index.ts +1 -0
- package/src/components/toast/Toast.test.tsx +5 -0
- package/src/components/toast/Toast.tsx +2 -16
- package/src/components/toast/Toaster.test.tsx +84 -0
- package/src/components/toast/Toaster.tsx +116 -0
- package/src/components/toast/index.ts +4 -0
- package/src/components/toast/toast-store.ts +117 -0
- package/src/index.ts +14 -0
- package/src/manifests/app-shell.manifest.json +156 -0
- package/src/manifests/auth-layout.manifest.json +99 -0
- package/src/manifests/autocomplete.manifest.json +53 -5
- package/src/manifests/badge.manifest.json +12 -0
- package/src/manifests/button.manifest.json +6 -2
- package/src/manifests/card.manifest.json +53 -6
- package/src/manifests/date-picker.manifest.json +87 -0
- package/src/manifests/dialog.manifest.json +49 -4
- package/src/manifests/drawer.manifest.json +18 -4
- package/src/manifests/empty-state.manifest.json +108 -0
- package/src/manifests/file-upload.manifest.json +103 -0
- package/src/manifests/filter-bar.manifest.json +100 -0
- package/src/manifests/menu.manifest.json +13 -1
- package/src/manifests/metric-card.manifest.json +105 -0
- package/src/manifests/page-header.manifest.json +94 -0
- package/src/manifests/pagination.manifest.json +62 -34
- package/src/manifests/password-input.manifest.json +75 -0
- package/src/manifests/select-menu.manifest.json +142 -0
- package/src/manifests/select.manifest.json +49 -12
- package/src/manifests/stepper.manifest.json +95 -0
- package/src/manifests/table-card.manifest.json +123 -0
- package/src/manifests/time-picker.manifest.json +86 -0
- package/src/manifests/timeline.manifest.json +69 -0
- 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
|
+
});
|