@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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { fireEvent, render, screen } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
2
3
|
import { axe } from 'jest-axe';
|
|
3
4
|
import { describe, expect, it, vi } from 'vitest';
|
|
4
5
|
import { Autocomplete } from './Autocomplete';
|
|
@@ -75,3 +76,55 @@ describe('Autocomplete', () => {
|
|
|
75
76
|
expect(await axe(container)).toHaveNoViolations();
|
|
76
77
|
});
|
|
77
78
|
});
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
describe('Autocomplete options, groups, async and clear', () => {
|
|
82
|
+
it('renders grouped headings and descriptions', async () => {
|
|
83
|
+
const user = userEvent.setup();
|
|
84
|
+
render(
|
|
85
|
+
<Autocomplete
|
|
86
|
+
aria-label="Search"
|
|
87
|
+
options={[
|
|
88
|
+
{ value: 'ada', label: 'Ada Lovelace', group: 'People', description: 'Mathematician' },
|
|
89
|
+
{ value: 'alan', label: 'Alan Turing', group: 'People' },
|
|
90
|
+
{ value: 'rust', label: 'Rust', group: 'Languages' },
|
|
91
|
+
]}
|
|
92
|
+
/>
|
|
93
|
+
);
|
|
94
|
+
await user.click(screen.getByRole('combobox', { name: 'Search' }));
|
|
95
|
+
expect(screen.getByText('People')).toBeInTheDocument();
|
|
96
|
+
expect(screen.getByText('Languages')).toBeInTheDocument();
|
|
97
|
+
expect(screen.getByText('Mathematician')).toBeInTheDocument();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('uses a custom row renderer', async () => {
|
|
101
|
+
const user = userEvent.setup();
|
|
102
|
+
render(
|
|
103
|
+
<Autocomplete
|
|
104
|
+
aria-label="Search"
|
|
105
|
+
options={[{ value: 'x', label: 'Custom row' }]}
|
|
106
|
+
renderOption={(option) => <span>★ {option.label}</span>}
|
|
107
|
+
/>
|
|
108
|
+
);
|
|
109
|
+
await user.click(screen.getByRole('combobox', { name: 'Search' }));
|
|
110
|
+
expect(screen.getByText('★ Custom row')).toBeInTheDocument();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('loads options asynchronously with a loading row', async () => {
|
|
114
|
+
const user = userEvent.setup();
|
|
115
|
+
const loadOptions = vi.fn(async () => [{ value: 'remote', label: 'Remote result' }]);
|
|
116
|
+
render(<Autocomplete aria-label="Search" loadOptions={loadOptions} debounceMs={1} />);
|
|
117
|
+
await user.click(screen.getByRole('combobox', { name: 'Search' }));
|
|
118
|
+
expect(await screen.findByText('Remote result')).toBeInTheDocument();
|
|
119
|
+
expect(loadOptions).toHaveBeenCalled();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('clears the field with the clear button', async () => {
|
|
123
|
+
const user = userEvent.setup();
|
|
124
|
+
const onValueChange = vi.fn();
|
|
125
|
+
render(<Autocomplete aria-label="Search" clearable defaultValue="draft" onValueChange={onValueChange} />);
|
|
126
|
+
await user.click(screen.getByRole('button', { name: 'Clear input' }));
|
|
127
|
+
expect(onValueChange).toHaveBeenCalledWith('');
|
|
128
|
+
expect(screen.getByRole('combobox', { name: 'Search' })).toHaveValue('');
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -6,6 +6,16 @@ import { cn } from '../../utils/cn';
|
|
|
6
6
|
import { Input } from '../input/Input';
|
|
7
7
|
import { useCombobox } from '../search/use-combobox';
|
|
8
8
|
|
|
9
|
+
export type AutocompleteOption = {
|
|
10
|
+
value: string;
|
|
11
|
+
/** Display label; defaults to the value. */
|
|
12
|
+
label?: string;
|
|
13
|
+
/** Section heading the option renders under. */
|
|
14
|
+
group?: string;
|
|
15
|
+
/** Muted supporting line under the label. */
|
|
16
|
+
description?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
9
19
|
export type AutocompleteProps = Omit<
|
|
10
20
|
React.InputHTMLAttributes<HTMLInputElement>,
|
|
11
21
|
'value' | 'defaultValue' | 'onChange' | 'children' | 'size'
|
|
@@ -14,28 +24,56 @@ export type AutocompleteProps = Omit<
|
|
|
14
24
|
value?: string;
|
|
15
25
|
/** Initial text when uncontrolled. */
|
|
16
26
|
defaultValue?: string;
|
|
17
|
-
/** Fires on every keystroke AND on suggestion selection
|
|
27
|
+
/** Fires on every keystroke AND on suggestion selection. */
|
|
18
28
|
onValueChange?: (value: string) => void;
|
|
19
|
-
/**
|
|
20
|
-
|
|
21
|
-
/**
|
|
29
|
+
/** Static suggestions (strings or option objects). */
|
|
30
|
+
options?: Array<string | AutocompleteOption>;
|
|
31
|
+
/** @deprecated Use `options`. */
|
|
32
|
+
suggestions?: string[];
|
|
33
|
+
/** Async source; called with the current query (debounced) when provided. */
|
|
34
|
+
loadOptions?: (query: string) => Promise<Array<string | AutocompleteOption>>;
|
|
35
|
+
/** Custom row rendering (receives the normalized option). */
|
|
36
|
+
renderOption?: (option: NormalizedOption, state: { active: boolean }) => React.ReactNode;
|
|
37
|
+
/** Show a clear button once there is text. */
|
|
38
|
+
clearable?: boolean;
|
|
39
|
+
/** Shown when no option matches. */
|
|
22
40
|
emptyText?: string;
|
|
41
|
+
/** Shown while async options load. */
|
|
42
|
+
loadingText?: string;
|
|
43
|
+
/** Debounce for `loadOptions`, in ms. Default 200. */
|
|
44
|
+
debounceMs?: number;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
type NormalizedOption = {
|
|
48
|
+
value: string;
|
|
49
|
+
label: string;
|
|
50
|
+
group?: string;
|
|
51
|
+
description?: string;
|
|
23
52
|
};
|
|
24
53
|
|
|
54
|
+
function normalize(entry: string | AutocompleteOption): NormalizedOption {
|
|
55
|
+
if (typeof entry === 'string') return { value: entry, label: entry };
|
|
56
|
+
return { value: entry.value, label: entry.label ?? entry.value, group: entry.group, description: entry.description };
|
|
57
|
+
}
|
|
58
|
+
|
|
25
59
|
/**
|
|
26
60
|
* Type-ahead input following the HTML autocomplete model: users may enter
|
|
27
|
-
* any value (free text is first-class), while matching
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* keep working alongside the custom listbox). The listbox portals with
|
|
31
|
-
* fixed positioning so scrollable containers never clip it.
|
|
61
|
+
* any value (free text is first-class), while matching options appear in a
|
|
62
|
+
* portalled listbox (grouped, optional async loading, custom rows).
|
|
63
|
+
* Arrow keys highlight, Enter accepts, Escape dismisses.
|
|
32
64
|
*/
|
|
33
65
|
export function Autocomplete({
|
|
34
66
|
value,
|
|
35
67
|
defaultValue = '',
|
|
36
68
|
onValueChange,
|
|
69
|
+
options,
|
|
37
70
|
suggestions,
|
|
71
|
+
loadOptions,
|
|
72
|
+
renderOption,
|
|
73
|
+
clearable = false,
|
|
38
74
|
emptyText,
|
|
75
|
+
loadingText,
|
|
76
|
+
debounceMs = 200,
|
|
39
77
|
placeholder,
|
|
40
78
|
id,
|
|
41
79
|
name,
|
|
@@ -47,31 +85,71 @@ export function Autocomplete({
|
|
|
47
85
|
const [internalValue, setInternalValue] = React.useState(defaultValue);
|
|
48
86
|
const current = value ?? internalValue;
|
|
49
87
|
|
|
88
|
+
const [asyncOptions, setAsyncOptions] = React.useState<NormalizedOption[]>([]);
|
|
89
|
+
const [loading, setLoading] = React.useState(false);
|
|
90
|
+
const requestId = React.useRef(0);
|
|
91
|
+
|
|
92
|
+
const source = React.useMemo<NormalizedOption[]>(() => {
|
|
93
|
+
const list = options ?? (suggestions ?? []).map(normalize);
|
|
94
|
+
return list.map(normalize);
|
|
95
|
+
}, [options, suggestions]);
|
|
96
|
+
|
|
97
|
+
// Debounced async source. Guarded by a request id so stale responses
|
|
98
|
+
// never overwrite newer ones.
|
|
99
|
+
React.useEffect(() => {
|
|
100
|
+
if (!loadOptions) return;
|
|
101
|
+
const id = ++requestId.current;
|
|
102
|
+
setLoading(true);
|
|
103
|
+
const timer = setTimeout(async () => {
|
|
104
|
+
try {
|
|
105
|
+
const result = await loadOptions(current);
|
|
106
|
+
if (requestId.current === id) setAsyncOptions(result.map(normalize));
|
|
107
|
+
} catch {
|
|
108
|
+
if (requestId.current === id) setAsyncOptions([]);
|
|
109
|
+
} finally {
|
|
110
|
+
if (requestId.current === id) setLoading(false);
|
|
111
|
+
}
|
|
112
|
+
}, debounceMs);
|
|
113
|
+
return () => clearTimeout(timer);
|
|
114
|
+
}, [loadOptions, current, debounceMs]);
|
|
115
|
+
|
|
116
|
+
const pool = loadOptions ? asyncOptions : source;
|
|
117
|
+
|
|
50
118
|
const items = React.useMemo(() => {
|
|
51
119
|
const q = current.trim().toLowerCase();
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
return list
|
|
57
|
-
.filter((suggestion) => suggestion !== current)
|
|
58
|
-
.map((suggestion) => ({ value: suggestion, label: suggestion }));
|
|
59
|
-
}, [suggestions, current]);
|
|
120
|
+
return pool
|
|
121
|
+
.filter((option) => !q || option.label.toLowerCase().includes(q) || option.value.toLowerCase().includes(q))
|
|
122
|
+
.filter((option) => option.value !== current);
|
|
123
|
+
}, [pool, current]);
|
|
60
124
|
|
|
61
125
|
const combobox = useCombobox({
|
|
62
|
-
items,
|
|
126
|
+
items: items.map((item) => ({ value: item.value, label: item.label })),
|
|
63
127
|
onSelect: (item) => {
|
|
64
128
|
if (value === undefined) setInternalValue(item.label);
|
|
65
129
|
onValueChange?.(item.label);
|
|
66
130
|
},
|
|
67
131
|
});
|
|
68
132
|
|
|
69
|
-
const select = (
|
|
70
|
-
if (value === undefined) setInternalValue(label);
|
|
71
|
-
onValueChange?.(label);
|
|
133
|
+
const select = (option: NormalizedOption) => {
|
|
134
|
+
if (value === undefined) setInternalValue(option.label);
|
|
135
|
+
onValueChange?.(option.label);
|
|
72
136
|
combobox.setOpen(false);
|
|
73
137
|
};
|
|
74
138
|
|
|
139
|
+
// Render order with group headings derived from the option stream.
|
|
140
|
+
const grouped = React.useMemo(() => {
|
|
141
|
+
const out: Array<{ heading?: string; option?: NormalizedOption; index: number }> = [];
|
|
142
|
+
let lastGroup: string | undefined;
|
|
143
|
+
items.forEach((option, index) => {
|
|
144
|
+
if (option.group && option.group !== lastGroup) {
|
|
145
|
+
out.push({ heading: option.group, index: -1 });
|
|
146
|
+
lastGroup = option.group;
|
|
147
|
+
}
|
|
148
|
+
out.push({ option, index });
|
|
149
|
+
});
|
|
150
|
+
return out;
|
|
151
|
+
}, [items]);
|
|
152
|
+
|
|
75
153
|
return (
|
|
76
154
|
<div ref={combobox.anchorRef} className={cn('relative w-full', className)}>
|
|
77
155
|
<Input
|
|
@@ -80,6 +158,7 @@ export function Autocomplete({
|
|
|
80
158
|
placeholder={placeholder}
|
|
81
159
|
autoComplete="off"
|
|
82
160
|
value={current}
|
|
161
|
+
className={clearable && current ? 'pr-9' : undefined}
|
|
83
162
|
onChange={(event) => {
|
|
84
163
|
if (value === undefined) setInternalValue(event.target.value);
|
|
85
164
|
onValueChange?.(event.target.value);
|
|
@@ -96,12 +175,27 @@ export function Autocomplete({
|
|
|
96
175
|
{...inputProps}
|
|
97
176
|
{...combobox.inputProps}
|
|
98
177
|
/>
|
|
178
|
+
{clearable && current && (
|
|
179
|
+
<button
|
|
180
|
+
type="button"
|
|
181
|
+
onClick={() => {
|
|
182
|
+
if (value === undefined) setInternalValue('');
|
|
183
|
+
onValueChange?.('');
|
|
184
|
+
combobox.setOpen(true);
|
|
185
|
+
}}
|
|
186
|
+
aria-label="Clear input"
|
|
187
|
+
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
188
|
+
>
|
|
189
|
+
✕
|
|
190
|
+
</button>
|
|
191
|
+
)}
|
|
99
192
|
{combobox.ready &&
|
|
100
193
|
createPortal(
|
|
101
194
|
<ul
|
|
102
195
|
id={combobox.listboxId}
|
|
103
196
|
role="listbox"
|
|
104
197
|
aria-label="Suggestions"
|
|
198
|
+
aria-busy={loading || undefined}
|
|
105
199
|
style={{
|
|
106
200
|
position: 'fixed',
|
|
107
201
|
top: combobox.pos!.top,
|
|
@@ -111,30 +205,55 @@ export function Autocomplete({
|
|
|
111
205
|
}}
|
|
112
206
|
className="max-h-60 overflow-y-auto rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md"
|
|
113
207
|
>
|
|
114
|
-
{items.length === 0 ? (
|
|
208
|
+
{loading && items.length === 0 ? (
|
|
209
|
+
<li role="presentation" className="px-3 py-2 text-sm text-muted-foreground">
|
|
210
|
+
{loadingText ?? 'Loading…'}
|
|
211
|
+
</li>
|
|
212
|
+
) : items.length === 0 ? (
|
|
115
213
|
<li role="presentation" className="px-3 py-2 text-sm text-muted-foreground">
|
|
116
214
|
{emptyText ?? 'No suggestions'}
|
|
117
215
|
</li>
|
|
118
216
|
) : (
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
217
|
+
grouped.map((entry, rowIndex) =>
|
|
218
|
+
entry.heading ? (
|
|
219
|
+
<li
|
|
220
|
+
key={`group-${entry.heading}-${rowIndex}`}
|
|
221
|
+
role="presentation"
|
|
222
|
+
className="px-3 pb-1 pt-2 text-xs font-medium uppercase tracking-wide text-muted-foreground"
|
|
223
|
+
>
|
|
224
|
+
{entry.heading}
|
|
225
|
+
</li>
|
|
226
|
+
) : (
|
|
227
|
+
<li
|
|
228
|
+
key={entry.option!.value}
|
|
229
|
+
id={combobox.optionId(entry.index)}
|
|
230
|
+
role="option"
|
|
231
|
+
aria-selected={combobox.activeIndex === entry.index}
|
|
232
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
233
|
+
onMouseEnter={() => combobox.setActiveIndex(entry.index)}
|
|
234
|
+
onClick={() => select(entry.option!)}
|
|
235
|
+
className={cn(
|
|
236
|
+
'cursor-pointer rounded-sm px-3 py-1.5 text-sm outline-none',
|
|
237
|
+
combobox.activeIndex === entry.index
|
|
238
|
+
? 'bg-accent text-accent-foreground'
|
|
239
|
+
: 'hover:bg-accent/60'
|
|
240
|
+
)}
|
|
241
|
+
>
|
|
242
|
+
{renderOption ? (
|
|
243
|
+
renderOption(entry.option!, { active: combobox.activeIndex === entry.index })
|
|
244
|
+
) : (
|
|
245
|
+
<>
|
|
246
|
+
<span className="block">{entry.option!.label}</span>
|
|
247
|
+
{entry.option!.description && (
|
|
248
|
+
<span className="mt-0.5 block text-xs text-muted-foreground">
|
|
249
|
+
{entry.option!.description}
|
|
250
|
+
</span>
|
|
251
|
+
)}
|
|
252
|
+
</>
|
|
253
|
+
)}
|
|
254
|
+
</li>
|
|
255
|
+
)
|
|
256
|
+
)
|
|
138
257
|
)}
|
|
139
258
|
</ul>,
|
|
140
259
|
document.body
|
|
@@ -58,3 +58,17 @@ describe('Badge variants and sizing escape hatches', () => {
|
|
|
58
58
|
expect(container.firstChild).toHaveClass('text-foreground', 'gap-1', 'px-3');
|
|
59
59
|
});
|
|
60
60
|
});
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
describe('Badge asChild', () => {
|
|
64
|
+
it('renders a link with badge styling', () => {
|
|
65
|
+
render(
|
|
66
|
+
<Badge asChild>
|
|
67
|
+
<a href="/tags/react">React</a>
|
|
68
|
+
</Badge>
|
|
69
|
+
);
|
|
70
|
+
const link = screen.getByRole('link', { name: 'React' });
|
|
71
|
+
expect(link.tagName).toBe('A');
|
|
72
|
+
expect(link).toHaveClass('inline-flex', 'bg-primary');
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
|
+
import { Slot } from '@radix-ui/react-slot';
|
|
2
3
|
import { cva, type VariantProps } from 'class-variance-authority';
|
|
3
4
|
import { cn } from '../../utils/cn';
|
|
4
5
|
|
|
@@ -19,12 +20,17 @@ const badgeVariants = cva(
|
|
|
19
20
|
}
|
|
20
21
|
);
|
|
21
22
|
|
|
22
|
-
export type BadgeProps = React.HTMLAttributes<HTMLDivElement> &
|
|
23
|
+
export type BadgeProps = React.HTMLAttributes<HTMLDivElement> &
|
|
24
|
+
VariantProps<typeof badgeVariants> & {
|
|
25
|
+
/** Render the child element (e.g. a router Link) with badge styling. */
|
|
26
|
+
asChild?: boolean;
|
|
27
|
+
};
|
|
23
28
|
|
|
24
29
|
const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
|
|
25
|
-
({ className, variant, ...props }, ref) =>
|
|
26
|
-
|
|
27
|
-
|
|
30
|
+
({ className, variant, asChild = false, ...props }, ref) => {
|
|
31
|
+
const Comp = asChild ? Slot : 'div';
|
|
32
|
+
return <Comp ref={ref} className={cn(badgeVariants({ variant }), className)} {...props} />;
|
|
33
|
+
}
|
|
28
34
|
);
|
|
29
35
|
Badge.displayName = 'Badge';
|
|
30
36
|
|
|
@@ -101,3 +101,32 @@ describe('Button semantic theme variants', () => {
|
|
|
101
101
|
);
|
|
102
102
|
});
|
|
103
103
|
});
|
|
104
|
+
|
|
105
|
+
describe('Button outline color variants', () => {
|
|
106
|
+
it('renders each colored outline with a semantic border and label', () => {
|
|
107
|
+
render(
|
|
108
|
+
<>
|
|
109
|
+
<Button variant="outline-destructive">Delete</Button>
|
|
110
|
+
<Button variant="outline-success">Approve</Button>
|
|
111
|
+
<Button variant="outline-warning">Careful</Button>
|
|
112
|
+
<Button variant="outline-info">Info</Button>
|
|
113
|
+
</>
|
|
114
|
+
);
|
|
115
|
+
const del = screen.getByRole('button', { name: 'Delete' });
|
|
116
|
+
expect(del).toHaveClass('border', 'border-destructive/50', 'text-destructive', 'bg-background');
|
|
117
|
+
expect(del.className).toContain('hover:bg-destructive/10');
|
|
118
|
+
expect(screen.getByRole('button', { name: 'Approve' })).toHaveClass('border-success/50', 'text-success');
|
|
119
|
+
expect(screen.getByRole('button', { name: 'Careful' })).toHaveClass('border-warning/50', 'text-warning');
|
|
120
|
+
expect(screen.getByRole('button', { name: 'Info' })).toHaveClass('border-info/50', 'text-info');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('keeps colored outlines accessible as links via asChild', () => {
|
|
124
|
+
render(
|
|
125
|
+
<Button asChild variant="outline-success">
|
|
126
|
+
<a href="/ship">Ship</a>
|
|
127
|
+
</Button>
|
|
128
|
+
);
|
|
129
|
+
const link = screen.getByRole('link', { name: 'Ship' });
|
|
130
|
+
expect(link).toHaveClass('border-success/50', 'text-success');
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -16,6 +16,16 @@ const buttonVariants = cva(
|
|
|
16
16
|
warning: 'bg-warning text-warning-foreground hover:bg-warning/90',
|
|
17
17
|
info: 'bg-info text-info-foreground hover:bg-info/90',
|
|
18
18
|
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
|
19
|
+
// Colored outlines: semantic border + label on the page surface, with
|
|
20
|
+
// a faint tint on hover — mirrors the Alert variant recipe.
|
|
21
|
+
'outline-destructive':
|
|
22
|
+
'border border-destructive/50 bg-background text-destructive hover:border-destructive hover:bg-destructive/10',
|
|
23
|
+
'outline-success':
|
|
24
|
+
'border border-success/50 bg-background text-success hover:border-success hover:bg-success/10',
|
|
25
|
+
'outline-warning':
|
|
26
|
+
'border border-warning/50 bg-background text-warning hover:border-warning hover:bg-warning/10',
|
|
27
|
+
'outline-info':
|
|
28
|
+
'border border-info/50 bg-background text-info hover:border-info hover:bg-info/10',
|
|
19
29
|
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
|
20
30
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
|
21
31
|
link: 'text-primary underline-offset-4 hover:underline',
|
|
@@ -15,13 +15,12 @@ describe('ButtonGroup', () => {
|
|
|
15
15
|
</ButtonGroup>
|
|
16
16
|
);
|
|
17
17
|
const group = container.querySelector('[data-slot="button-group"]')!;
|
|
18
|
-
|
|
18
|
+
// The group owns the single outline and clips the outer radius.
|
|
19
|
+
expect(group).toHaveClass('overflow-hidden', 'rounded-md', 'border', 'border-border');
|
|
19
20
|
expect(group.querySelectorAll('button')).toHaveLength(3);
|
|
20
|
-
// First/last keep outer corners; middles square off — the join rules
|
|
21
|
-
// live as descendant tokens on the group's class.
|
|
22
|
-
expect(group.className).toContain('[&>*:first-child]:rounded-l-md');
|
|
23
|
-
expect(group.className).toContain('[&>*:last-child]:rounded-r-md');
|
|
24
21
|
expect(group.className).toContain('[&>*]:rounded-none');
|
|
22
|
+
expect(group.className).toContain('[&>*]:border-0');
|
|
23
|
+
expect(group).toHaveClass('divide-x', 'divide-border');
|
|
25
24
|
});
|
|
26
25
|
|
|
27
26
|
it('vertical orientation stacks with collapsed borders', () => {
|
|
@@ -32,9 +31,8 @@ describe('ButtonGroup', () => {
|
|
|
32
31
|
</ButtonGroup>
|
|
33
32
|
);
|
|
34
33
|
const group = container.querySelector('[data-slot="button-group"]')!;
|
|
35
|
-
expect(group).toHaveClass('-
|
|
36
|
-
expect(group
|
|
37
|
-
expect(group.className).toContain('[&>*:last-child]:rounded-b-md');
|
|
34
|
+
expect(group).toHaveClass('flex-col');
|
|
35
|
+
expect(group).toHaveClass('divide-y', 'divide-border');
|
|
38
36
|
});
|
|
39
37
|
|
|
40
38
|
it('exposes a group role and forwards aria-label', () => {
|
|
@@ -7,11 +7,16 @@ export type ButtonGroupProps = React.HTMLAttributes<HTMLDivElement> & {
|
|
|
7
7
|
};
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* Joins buttons into one segmented control
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
10
|
+
* Joins buttons into one segmented control.
|
|
11
|
+
*
|
|
12
|
+
* The group owns the outline: a single border, one outer radius (children
|
|
13
|
+
* are clipped by overflow-hidden), and 1px separators drawn between
|
|
14
|
+
* segments. Children lose their own borders and radii, so mixed variants
|
|
15
|
+
* (default/outline/secondary/ghost) still read as one instrument — a
|
|
16
|
+
* borderless variant no longer leaves the seam misaligned the way
|
|
17
|
+
* negative-margin border collapsing did.
|
|
18
|
+
*
|
|
19
|
+
* Focus rings render inset so they are never clipped at the edges.
|
|
15
20
|
*/
|
|
16
21
|
const ButtonGroup = React.forwardRef<HTMLDivElement, ButtonGroupProps>(
|
|
17
22
|
({ orientation = 'horizontal', className, ...props }, ref) => (
|
|
@@ -20,11 +25,14 @@ const ButtonGroup = React.forwardRef<HTMLDivElement, ButtonGroupProps>(
|
|
|
20
25
|
data-slot="button-group"
|
|
21
26
|
role="group"
|
|
22
27
|
className={cn(
|
|
23
|
-
'inline-flex',
|
|
28
|
+
'inline-flex overflow-hidden rounded-md border border-border bg-background',
|
|
29
|
+
'[&>*]:rounded-none [&>*]:border-0',
|
|
30
|
+
'[&>*:focus-visible]:ring-inset [&>*:focus-visible]:ring-offset-0',
|
|
31
|
+
// divide-* compiles to `.group > * ~ * { border-left/top }` — higher
|
|
32
|
+
// specificity than the lead-strip above, so seams stay exact.
|
|
24
33
|
orientation === 'horizontal'
|
|
25
|
-
? '-
|
|
26
|
-
: '-
|
|
27
|
-
'[&>*]:rounded-none [&>*]:focus-visible:relative [&>*]:focus-visible:z-10',
|
|
34
|
+
? 'divide-x divide-border'
|
|
35
|
+
: 'flex-col divide-y divide-border',
|
|
28
36
|
className
|
|
29
37
|
)}
|
|
30
38
|
{...props}
|
|
@@ -44,3 +44,17 @@ describe('Card', () => {
|
|
|
44
44
|
expect(await axe(container)).toHaveNoViolations();
|
|
45
45
|
});
|
|
46
46
|
});
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
describe('Card asChild', () => {
|
|
50
|
+
it('renders the child element with card styling', () => {
|
|
51
|
+
render(
|
|
52
|
+
<Card asChild>
|
|
53
|
+
<a href="/post">Read post</a>
|
|
54
|
+
</Card>
|
|
55
|
+
);
|
|
56
|
+
const link = screen.getByRole('link', { name: 'Read post' });
|
|
57
|
+
expect(link.tagName).toBe('A');
|
|
58
|
+
expect(link).toHaveClass('rounded-xl', 'border-border', 'bg-card');
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
|
+
import { Slot } from '@radix-ui/react-slot';
|
|
2
3
|
import { cn } from '../../utils/cn';
|
|
3
4
|
|
|
4
|
-
export type CardProps = React.HTMLAttributes<HTMLDivElement
|
|
5
|
+
export type CardProps = React.HTMLAttributes<HTMLDivElement> & {
|
|
6
|
+
/** Render the child element (e.g. a router Link) with card styling. */
|
|
7
|
+
asChild?: boolean;
|
|
8
|
+
};
|
|
5
9
|
|
|
6
10
|
/** Content container with a subtle border and surface styling. */
|
|
7
11
|
const Card = React.forwardRef<HTMLDivElement, CardProps>(
|
|
8
|
-
({ className, ...props }, ref) =>
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
({ className, asChild = false, ...props }, ref) => {
|
|
13
|
+
const Comp = asChild ? Slot : 'div';
|
|
14
|
+
return (
|
|
15
|
+
<Comp
|
|
16
|
+
ref={ref}
|
|
17
|
+
className={cn('rounded-xl border border-border bg-card text-card-foreground shadow-sm', className)}
|
|
18
|
+
{...props}
|
|
19
|
+
/>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
15
22
|
);
|
|
16
23
|
Card.displayName = 'Card';
|
|
17
24
|
|
|
@@ -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 { DatePicker } from './DatePicker';
|
|
6
|
+
|
|
7
|
+
describe('DatePicker', () => {
|
|
8
|
+
it('reports ISO value changes', async () => {
|
|
9
|
+
const user = userEvent.setup();
|
|
10
|
+
const onValueChange = vi.fn();
|
|
11
|
+
render(<DatePicker aria-label="Start date" onValueChange={onValueChange} />);
|
|
12
|
+
const input = screen.getByLabelText('Start date');
|
|
13
|
+
await user.type(input, '2026-11-03');
|
|
14
|
+
expect(onValueChange).toHaveBeenCalled();
|
|
15
|
+
expect(input).toHaveAttribute('type', 'date');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('clears the value', async () => {
|
|
19
|
+
const user = userEvent.setup();
|
|
20
|
+
const onValueChange = vi.fn();
|
|
21
|
+
render(<DatePicker aria-label="Due" clearable defaultValue="2026-11-03" onValueChange={onValueChange} />);
|
|
22
|
+
await user.click(screen.getByRole('button', { name: 'Clear date' }));
|
|
23
|
+
expect(onValueChange).toHaveBeenCalledWith('');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('has no accessibility violations', async () => {
|
|
27
|
+
const { container } = render(<DatePicker aria-label="Date" />);
|
|
28
|
+
expect(await axe(container)).toHaveNoViolations();
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { cn } from '../../utils/cn';
|
|
5
|
+
import { Input, type InputProps } from '../input/Input';
|
|
6
|
+
|
|
7
|
+
export type DatePickerProps = Omit<InputProps, 'type' | 'onChange' | 'value' | 'defaultValue'> & {
|
|
8
|
+
/** ISO date string (YYYY-MM-DD) — controlled. */
|
|
9
|
+
value?: string;
|
|
10
|
+
/** ISO date string — initial value when uncontrolled. */
|
|
11
|
+
defaultValue?: string;
|
|
12
|
+
/** Fired with the ISO string ('' when cleared). */
|
|
13
|
+
onValueChange?: (value: string) => void;
|
|
14
|
+
/** Show a clear button once a date is selected. */
|
|
15
|
+
clearable?: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Native date field with an ISO-string contract. Uses the platform picker
|
|
20
|
+
* (mobile date wheels, keyboard editing, locale formatting) rather than a
|
|
21
|
+
* custom calendar, so accessibility and input methods come for free.
|
|
22
|
+
*/
|
|
23
|
+
export const DatePicker = React.forwardRef<HTMLInputElement, DatePickerProps>(function DatePicker(
|
|
24
|
+
{ value, defaultValue, onValueChange, clearable = false, className, ...props },
|
|
25
|
+
ref
|
|
26
|
+
) {
|
|
27
|
+
const [internal, setInternal] = React.useState(defaultValue ?? '');
|
|
28
|
+
const current = value ?? internal;
|
|
29
|
+
|
|
30
|
+
const commit = (next: string) => {
|
|
31
|
+
if (value === undefined) setInternal(next);
|
|
32
|
+
onValueChange?.(next);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div className={cn('relative flex items-center', className)}>
|
|
37
|
+
<Input
|
|
38
|
+
ref={ref}
|
|
39
|
+
type="date"
|
|
40
|
+
value={current}
|
|
41
|
+
onChange={(event) => commit(event.target.value)}
|
|
42
|
+
className={clearable && current ? 'pr-9' : undefined}
|
|
43
|
+
{...props}
|
|
44
|
+
/>
|
|
45
|
+
{clearable && current && (
|
|
46
|
+
<button
|
|
47
|
+
type="button"
|
|
48
|
+
onClick={() => commit('')}
|
|
49
|
+
aria-label="Clear date"
|
|
50
|
+
className="absolute right-2 rounded p-1 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
51
|
+
>
|
|
52
|
+
✕
|
|
53
|
+
</button>
|
|
54
|
+
)}
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './DatePicker';
|