@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
@@ -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 (like a native input's onChange). */
27
+ /** Fires on every keystroke AND on suggestion selection. */
18
28
  onValueChange?: (value: string) => void;
19
- /** Suggestion labels offered while typing; any value may still be entered free-form. */
20
- suggestions: string[];
21
- /** Shown when no suggestion matches (optional). */
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 suggestions appear
28
- * in a listbox — Arrow keys highlight, Enter accepts, Escape dismisses.
29
- * The native `autoComplete` attribute passes through (form autofill hints
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
- const list = q
53
- ? suggestions.filter((suggestion) => suggestion.toLowerCase().includes(q))
54
- : suggestions;
55
- // Never offer the exact current value back as a suggestion.
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 = (label: string) => {
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
- items.map((item, index) => (
120
- <li
121
- key={item.value}
122
- id={combobox.optionId(index)}
123
- role="option"
124
- aria-selected={combobox.activeIndex === index}
125
- onMouseDown={(event) => event.preventDefault()}
126
- onMouseEnter={() => combobox.setActiveIndex(index)}
127
- onClick={() => select(item.label)}
128
- className={cn(
129
- 'cursor-pointer rounded-sm px-3 py-1.5 text-sm outline-none',
130
- combobox.activeIndex === index
131
- ? 'bg-accent text-accent-foreground'
132
- : 'hover:bg-accent/60'
133
- )}
134
- >
135
- {item.label}
136
- </li>
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> & VariantProps<typeof badgeVariants>;
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
- <div ref={ref} className={cn(badgeVariants({ variant }), className)} {...props} />
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
- expect(group).toHaveClass('-space-x-px');
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('-space-y-px');
36
- expect(group.className).toContain('[&>*:first-child]:rounded-t-md');
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: adjacent borders collapse
11
- * (negative spacing), middle corners square off, and only the outer
12
- * corners stay rounded — a single outline around the whole group.
13
- * Use border-bearing variants (default/outline/secondary); ghost
14
- * buttons have no border and would break the joined outline.
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
- ? '-space-x-px [&>*:first-child]:rounded-l-md [&>*:last-child]:rounded-r-md'
26
- : '-space-y-px [&>*:first-child]:rounded-t-md [&>*:last-child]:rounded-b-md',
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
- <div
10
- ref={ref}
11
- className={cn('rounded-xl border border-border bg-card text-card-foreground shadow-sm', className)}
12
- {...props}
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';