@slipstream-ui/core 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/dist/index.d.mts +672 -52
  2. package/dist/index.d.ts +672 -52
  3. package/dist/index.js +1860 -657
  4. package/dist/index.js.map +1 -1
  5. package/dist/index.mjs +1836 -655
  6. package/dist/index.mjs.map +1 -1
  7. package/dist/manifests/app-shell.manifest.json +156 -0
  8. package/dist/manifests/auth-layout.manifest.json +99 -0
  9. package/dist/manifests/autocomplete.manifest.json +53 -5
  10. package/dist/manifests/badge.manifest.json +12 -0
  11. package/dist/manifests/button.manifest.json +6 -2
  12. package/dist/manifests/card.manifest.json +53 -6
  13. package/dist/manifests/date-picker.manifest.json +87 -0
  14. package/dist/manifests/dialog.manifest.json +49 -4
  15. package/dist/manifests/drawer.manifest.json +18 -4
  16. package/dist/manifests/empty-state.manifest.json +108 -0
  17. package/dist/manifests/file-upload.manifest.json +103 -0
  18. package/dist/manifests/filter-bar.manifest.json +100 -0
  19. package/dist/manifests/menu.manifest.json +13 -1
  20. package/dist/manifests/metric-card.manifest.json +105 -0
  21. package/dist/manifests/page-header.manifest.json +94 -0
  22. package/dist/manifests/pagination.manifest.json +62 -34
  23. package/dist/manifests/password-input.manifest.json +75 -0
  24. package/dist/manifests/select-menu.manifest.json +142 -0
  25. package/dist/manifests/select.manifest.json +49 -12
  26. package/dist/manifests/stepper.manifest.json +95 -0
  27. package/dist/manifests/table-card.manifest.json +123 -0
  28. package/dist/manifests/time-picker.manifest.json +86 -0
  29. package/dist/manifests/timeline.manifest.json +69 -0
  30. package/dist/manifests/toast.manifest.json +101 -25
  31. package/package.json +1 -1
  32. package/src/components/app-shell/AppShell.test.tsx +100 -0
  33. package/src/components/app-shell/AppShell.tsx +162 -0
  34. package/src/components/app-shell/index.ts +1 -0
  35. package/src/components/auth-layout/AuthLayout.test.tsx +34 -0
  36. package/src/components/auth-layout/AuthLayout.tsx +47 -0
  37. package/src/components/auth-layout/index.ts +1 -0
  38. package/src/components/autocomplete/Autocomplete.test.tsx +53 -0
  39. package/src/components/autocomplete/Autocomplete.tsx +160 -41
  40. package/src/components/badge/Badge.test.tsx +14 -0
  41. package/src/components/badge/Badge.tsx +10 -4
  42. package/src/components/button/Button.test.tsx +29 -0
  43. package/src/components/button/Button.tsx +10 -0
  44. package/src/components/button-group/ButtonGroup.test.tsx +6 -8
  45. package/src/components/button-group/ButtonGroup.tsx +17 -9
  46. package/src/components/card/Card.test.tsx +14 -0
  47. package/src/components/card/Card.tsx +15 -8
  48. package/src/components/date-picker/DatePicker.test.tsx +30 -0
  49. package/src/components/date-picker/DatePicker.tsx +57 -0
  50. package/src/components/date-picker/index.ts +1 -0
  51. package/src/components/dialog/Dialog.test.tsx +64 -1
  52. package/src/components/dialog/Dialog.tsx +113 -23
  53. package/src/components/dialog/index.ts +1 -2
  54. package/src/components/drawer/Drawer.test.tsx +50 -0
  55. package/src/components/drawer/Drawer.tsx +44 -6
  56. package/src/components/empty-state/EmptyState.test.tsx +30 -0
  57. package/src/components/empty-state/EmptyState.tsx +48 -0
  58. package/src/components/empty-state/index.ts +1 -0
  59. package/src/components/file-upload/FileUpload.test.tsx +43 -0
  60. package/src/components/file-upload/FileUpload.tsx +143 -0
  61. package/src/components/file-upload/index.ts +1 -0
  62. package/src/components/filter-bar/FilterBar.test.tsx +32 -0
  63. package/src/components/filter-bar/FilterBar.tsx +71 -0
  64. package/src/components/filter-bar/index.ts +1 -0
  65. package/src/components/input-group/InputGroup.test.tsx +6 -3
  66. package/src/components/input-group/InputGroup.tsx +9 -6
  67. package/src/components/menu/Menu.test.tsx +21 -0
  68. package/src/components/menu/Menu.tsx +8 -3
  69. package/src/components/metric-card/MetricCard.test.tsx +28 -0
  70. package/src/components/metric-card/MetricCard.tsx +54 -0
  71. package/src/components/metric-card/index.ts +1 -0
  72. package/src/components/page-header/PageHeader.test.tsx +31 -0
  73. package/src/components/page-header/PageHeader.tsx +37 -0
  74. package/src/components/page-header/index.ts +1 -0
  75. package/src/components/pagination/Pagination.test.tsx +25 -0
  76. package/src/components/pagination/Pagination.tsx +53 -12
  77. package/src/components/password-input/PasswordInput.test.tsx +29 -0
  78. package/src/components/password-input/PasswordInput.tsx +41 -0
  79. package/src/components/password-input/index.ts +1 -0
  80. package/src/components/select/Select.test.tsx +40 -1
  81. package/src/components/select/Select.tsx +52 -14
  82. package/src/components/select/index.ts +1 -1
  83. package/src/components/select-menu/SelectMenu.test.tsx +105 -0
  84. package/src/components/select-menu/SelectMenu.tsx +368 -0
  85. package/src/components/select-menu/index.ts +1 -0
  86. package/src/components/stepper/Stepper.test.tsx +35 -0
  87. package/src/components/stepper/Stepper.tsx +91 -0
  88. package/src/components/stepper/index.ts +1 -0
  89. package/src/components/table-card/TableCard.test.tsx +59 -0
  90. package/src/components/table-card/TableCard.tsx +48 -0
  91. package/src/components/table-card/index.ts +1 -0
  92. package/src/components/time-picker/TimePicker.test.tsx +30 -0
  93. package/src/components/time-picker/TimePicker.tsx +56 -0
  94. package/src/components/time-picker/index.ts +1 -0
  95. package/src/components/timeline/Timeline.test.tsx +26 -0
  96. package/src/components/timeline/Timeline.tsx +53 -0
  97. package/src/components/timeline/index.ts +1 -0
  98. package/src/components/toast/Toast.test.tsx +5 -0
  99. package/src/components/toast/Toast.tsx +2 -16
  100. package/src/components/toast/Toaster.test.tsx +84 -0
  101. package/src/components/toast/Toaster.tsx +116 -0
  102. package/src/components/toast/index.ts +4 -0
  103. package/src/components/toast/toast-store.ts +117 -0
  104. package/src/index.ts +14 -0
  105. package/src/manifests/app-shell.manifest.json +156 -0
  106. package/src/manifests/auth-layout.manifest.json +99 -0
  107. package/src/manifests/autocomplete.manifest.json +53 -5
  108. package/src/manifests/badge.manifest.json +12 -0
  109. package/src/manifests/button.manifest.json +6 -2
  110. package/src/manifests/card.manifest.json +53 -6
  111. package/src/manifests/date-picker.manifest.json +87 -0
  112. package/src/manifests/dialog.manifest.json +49 -4
  113. package/src/manifests/drawer.manifest.json +18 -4
  114. package/src/manifests/empty-state.manifest.json +108 -0
  115. package/src/manifests/file-upload.manifest.json +103 -0
  116. package/src/manifests/filter-bar.manifest.json +100 -0
  117. package/src/manifests/menu.manifest.json +13 -1
  118. package/src/manifests/metric-card.manifest.json +105 -0
  119. package/src/manifests/page-header.manifest.json +94 -0
  120. package/src/manifests/pagination.manifest.json +62 -34
  121. package/src/manifests/password-input.manifest.json +75 -0
  122. package/src/manifests/select-menu.manifest.json +142 -0
  123. package/src/manifests/select.manifest.json +49 -12
  124. package/src/manifests/stepper.manifest.json +95 -0
  125. package/src/manifests/table-card.manifest.json +123 -0
  126. package/src/manifests/time-picker.manifest.json +86 -0
  127. package/src/manifests/timeline.manifest.json +69 -0
  128. package/src/manifests/toast.manifest.json +101 -25
@@ -0,0 +1,29 @@
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 } from 'vitest';
5
+ import { PasswordInput } from './PasswordInput';
6
+
7
+ describe('PasswordInput', () => {
8
+ it('toggles visibility with an accessible pressed button', async () => {
9
+ const user = userEvent.setup();
10
+ render(<PasswordInput aria-label="Password" defaultValue="hunter2" />);
11
+ const input = screen.getByLabelText('Password');
12
+ expect(input).toHaveAttribute('type', 'password');
13
+ const toggle = screen.getByRole('button', { name: 'Show password' });
14
+ expect(toggle).toHaveAttribute('aria-pressed', 'false');
15
+ await user.click(toggle);
16
+ expect(input).toHaveAttribute('type', 'text');
17
+ expect(screen.getByRole('button', { name: 'Hide password' })).toHaveAttribute('aria-pressed', 'true');
18
+ });
19
+
20
+ it('supports custom toggle labels', () => {
21
+ render(<PasswordInput aria-label="Passphrase" showLabel="Reveal" hideLabel="Conceal" />);
22
+ expect(screen.getByRole('button', { name: 'Reveal' })).toBeInTheDocument();
23
+ });
24
+
25
+ it('has no accessibility violations', async () => {
26
+ const { container } = render(<PasswordInput aria-label="Password" />);
27
+ expect(await axe(container)).toHaveNoViolations();
28
+ });
29
+ });
@@ -0,0 +1,41 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { cn } from '../../utils/cn';
5
+ import { Button } from '../button/Button';
6
+ import { Input, type InputProps } from '../input/Input';
7
+
8
+ export type PasswordInputProps = Omit<InputProps, 'type'> & {
9
+ /** Accessible labels for the visibility toggle. */
10
+ showLabel?: string;
11
+ hideLabel?: string;
12
+ };
13
+
14
+ /**
15
+ * Password field with a show/hide toggle. The toggle is a real button with
16
+ * pressed state, so keyboard and screen-reader users can reveal the value
17
+ * without losing the focused field.
18
+ */
19
+ export const PasswordInput = React.forwardRef<HTMLInputElement, PasswordInputProps>(function PasswordInput(
20
+ { className, showLabel = 'Show password', hideLabel = 'Hide password', ...props },
21
+ ref
22
+ ) {
23
+ const [visible, setVisible] = React.useState(false);
24
+
25
+ return (
26
+ <div className={cn('relative flex items-center', className)}>
27
+ <Input ref={ref} type={visible ? 'text' : 'password'} className="pr-24" {...props} />
28
+ <Button
29
+ type="button"
30
+ variant="ghost"
31
+ size="sm"
32
+ aria-pressed={visible}
33
+ aria-label={visible ? hideLabel : showLabel}
34
+ onClick={() => setVisible((current) => !current)}
35
+ className="absolute right-1 h-8 px-2 text-xs text-muted-foreground hover:text-foreground"
36
+ >
37
+ {visible ? 'Hide' : 'Show'}
38
+ </Button>
39
+ </div>
40
+ );
41
+ });
@@ -0,0 +1 @@
1
+ export * from './PasswordInput';
@@ -29,7 +29,7 @@ describe('Select', () => {
29
29
  const user = userEvent.setup();
30
30
  const onChange = vi.fn();
31
31
  function Controlled() {
32
- const [value, setValue] = useState('default');
32
+ const [value, setValue] = useState<string | string[]>('default');
33
33
  return (
34
34
  <Select
35
35
  options={options}
@@ -59,3 +59,42 @@ describe('Select', () => {
59
59
  expect(await axe(container)).toHaveNoViolations();
60
60
  });
61
61
  });
62
+
63
+
64
+ describe('Select groups, placeholder and multiple', () => {
65
+ it('renders optgroups with headings', () => {
66
+ render(
67
+ <Select
68
+ label="Plan"
69
+ placeholder="Choose a plan"
70
+ groups={[
71
+ { label: 'Individual', options: [{ value: 'free', label: 'Free' }, { value: 'pro', label: 'Pro' }] },
72
+ { label: 'Business', options: [{ value: 'team', label: 'Team' }] },
73
+ ]}
74
+ />
75
+ );
76
+ const groups = screen.getAllByRole('group');
77
+ expect(groups).toHaveLength(2);
78
+ expect(groups[0]).toHaveAttribute('label', 'Individual');
79
+ expect(screen.getByRole('option', { name: 'Choose a plan' })).toBeDisabled();
80
+ });
81
+
82
+ it('supports native multi-select with a string[] contract', async () => {
83
+ const user = userEvent.setup();
84
+ const onChange = vi.fn();
85
+ render(
86
+ <Select
87
+ label="Channels"
88
+ multiple
89
+ value={['email']}
90
+ onChange={onChange}
91
+ options={[
92
+ { value: 'email', label: 'Email' },
93
+ { value: 'slack', label: 'Slack' },
94
+ ]}
95
+ />
96
+ );
97
+ await user.selectOptions(screen.getByRole('listbox', { name: 'Channels' }), ['slack']);
98
+ expect(onChange).toHaveBeenLastCalledWith(['email', 'slack']);
99
+ });
100
+ });
@@ -9,28 +9,49 @@ export type SelectOption = {
9
9
  disabled?: boolean;
10
10
  };
11
11
 
12
- export type SelectProps = Omit<React.SelectHTMLAttributes<HTMLSelectElement>, 'onChange' | 'value' | 'defaultValue'> & {
12
+ export type SelectOptionGroup = {
13
+ label: string;
13
14
  options: SelectOption[];
15
+ };
16
+
17
+ export type SelectProps = Omit<
18
+ React.SelectHTMLAttributes<HTMLSelectElement>,
19
+ 'onChange' | 'value' | 'defaultValue' | 'multiple'
20
+ > & {
21
+ /** Flat options. Ignored when `groups` is provided. */
22
+ options?: SelectOption[];
23
+ /** Optgroups with a heading per section. */
24
+ groups?: SelectOptionGroup[];
14
25
  label?: string;
15
- value?: string;
16
- defaultValue?: string;
17
- onChange?: (value: string) => void;
26
+ /** Renders a disabled empty option — the native "choose one" affordance. */
27
+ placeholder?: string;
28
+ /** Native multi-select: value/onChange switch to string[]. */
29
+ multiple?: boolean;
30
+ value?: string | string[];
31
+ defaultValue?: string | string[];
32
+ onChange?: (value: string | string[]) => void;
18
33
  };
19
34
 
20
35
  /**
21
36
  * Accessible select primitive built on the native <select>: full keyboard,
22
37
  * screen-reader and mobile platform behavior comes from the platform rather
23
- * than a re-implemented listbox. Controlled via `value`, or
24
- * uncontrolled via `defaultValue`.
38
+ * than a re-implemented listbox. Supports flat options, optgroups,
39
+ * placeholder rows and native multi-select (string[] contract).
25
40
  */
26
41
  export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(function Select(
27
- { options, label, value, defaultValue, onChange, id, className, ...props },
42
+ { options, groups, label, placeholder, multiple = false, value, defaultValue, onChange, id, className, ...props },
28
43
  ref,
29
44
  ) {
30
45
  const generatedId = React.useId();
31
46
  const selectId = id ?? generatedId;
32
47
  const labelId = `${selectId}-label`;
33
48
 
49
+ const renderOption = (option: SelectOption) => (
50
+ <option key={option.value} value={option.value} disabled={option.disabled}>
51
+ {option.label}
52
+ </option>
53
+ );
54
+
34
55
  return (
35
56
  <div className="flex flex-col gap-1.5">
36
57
  {label && (
@@ -41,22 +62,39 @@ export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(function
41
62
  <select
42
63
  ref={ref}
43
64
  id={selectId}
65
+ multiple={multiple}
44
66
  aria-labelledby={label ? labelId : undefined}
45
67
  className={cn(
46
68
  'rounded-md border border-input bg-background px-3 py-2 text-sm',
69
+ multiple && 'min-h-32',
47
70
  'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
71
+ 'disabled:cursor-not-allowed disabled:opacity-50',
48
72
  className
49
73
  )}
50
- value={value}
51
- defaultValue={defaultValue}
52
- onChange={(event) => onChange?.(event.target.value)}
74
+ value={value as string | string[] | undefined}
75
+ defaultValue={defaultValue as string | string[] | undefined}
76
+ onChange={(event) => {
77
+ if (!onChange) return;
78
+ if (multiple) {
79
+ onChange(Array.from(event.target.selectedOptions, (option) => option.value));
80
+ return;
81
+ }
82
+ onChange(event.target.value);
83
+ }}
53
84
  {...props}
54
85
  >
55
- {options.map((option) => (
56
- <option key={option.value} value={option.value} disabled={option.disabled}>
57
- {option.label}
86
+ {placeholder && (
87
+ <option value="" disabled>
88
+ {placeholder}
58
89
  </option>
59
- ))}
90
+ )}
91
+ {groups
92
+ ? groups.map((group) => (
93
+ <optgroup key={group.label} label={group.label}>
94
+ {group.options.map(renderOption)}
95
+ </optgroup>
96
+ ))
97
+ : (options ?? []).map(renderOption)}
60
98
  </select>
61
99
  </div>
62
100
  );
@@ -1,2 +1,2 @@
1
1
  export { Select } from './Select';
2
- export type { SelectProps, SelectOption } from './Select';
2
+ export type { SelectProps, SelectOption, SelectOptionGroup } from './Select';
@@ -0,0 +1,105 @@
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 { SelectMenu, type SelectMenuItem } from './SelectMenu';
6
+
7
+ const items: SelectMenuItem[] = [
8
+ { value: 'free', label: 'Free', description: '1 project', group: 'Individual' },
9
+ { value: 'pro', label: 'Pro', description: 'Unlimited', group: 'Individual' },
10
+ { value: 'team', label: 'Team', description: 'SSO', group: 'Business' },
11
+ { value: 'enterprise', label: 'Enterprise', disabled: true, group: 'Business' },
12
+ ];
13
+
14
+ describe('SelectMenu', () => {
15
+ it('opens a listbox with rich rows and groups', async () => {
16
+ const user = userEvent.setup();
17
+ render(<SelectMenu aria-label="Plan" items={items} placeholder="Choose a plan" />);
18
+ const trigger = screen.getByRole('button', { name: 'Plan' });
19
+ expect(trigger).toHaveAttribute('aria-haspopup', 'listbox');
20
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
21
+ await user.click(trigger);
22
+ expect(screen.getByRole('listbox')).toBeInTheDocument();
23
+ expect(screen.getByText('Individual')).toBeInTheDocument();
24
+ expect(screen.getByText('Unlimited')).toBeInTheDocument();
25
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
26
+ });
27
+
28
+ it('selects a single value and closes', async () => {
29
+ const user = userEvent.setup();
30
+ const onValueChange = vi.fn();
31
+ render(<SelectMenu aria-label="Plan" items={items} onValueChange={onValueChange} />);
32
+ await user.click(screen.getByRole('button'));
33
+ await user.click(screen.getByRole('option', { name: /Pro/ }));
34
+ expect(onValueChange).toHaveBeenCalledWith('pro');
35
+ expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
36
+ expect(screen.getByRole('button')).toHaveTextContent('Pro');
37
+ });
38
+
39
+ it('supports keyboard navigation and selection', async () => {
40
+ const user = userEvent.setup();
41
+ const onValueChange = vi.fn();
42
+ render(<SelectMenu aria-label="Plan" items={items} onValueChange={onValueChange} />);
43
+ const trigger = screen.getByRole('button');
44
+ trigger.focus();
45
+ await user.keyboard('{ArrowDown}'); // open
46
+ expect(screen.getByRole('listbox')).toBeInTheDocument();
47
+ await user.keyboard('{ArrowDown}{Enter}');
48
+ expect(onValueChange).toHaveBeenCalledWith('pro');
49
+ await user.keyboard('{Escape}');
50
+ expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
51
+ });
52
+
53
+ it('toggles values in multiple mode without closing', async () => {
54
+ const user = userEvent.setup();
55
+ const onValueChange = vi.fn();
56
+ render(<SelectMenu aria-label="Tags" multiple items={items} defaultValue={['free']} onValueChange={onValueChange} />);
57
+ const trigger = screen.getByRole('button');
58
+ expect(trigger).toHaveTextContent('Free');
59
+ await user.click(trigger);
60
+ await user.click(screen.getByRole('option', { name: /Pro/ }));
61
+ expect(onValueChange).toHaveBeenLastCalledWith(['free', 'pro']);
62
+ expect(screen.getByRole('listbox')).toBeInTheDocument();
63
+ expect(screen.getByRole('button')).toHaveTextContent('2 selected');
64
+ });
65
+
66
+ it('skips disabled options and exposes aria-disabled', async () => {
67
+ const user = userEvent.setup();
68
+ const onValueChange = vi.fn();
69
+ render(<SelectMenu aria-label="Plan" items={items} onValueChange={onValueChange} />);
70
+ await user.click(screen.getByRole('button'));
71
+ const disabled = screen.getByRole('option', { name: /Enterprise/ });
72
+ expect(disabled).toHaveAttribute('aria-disabled', 'true');
73
+ await user.click(disabled);
74
+ expect(onValueChange).not.toHaveBeenCalled();
75
+ });
76
+
77
+ it('renders custom rows and trigger content', async () => {
78
+ const user = userEvent.setup();
79
+ render(
80
+ <SelectMenu
81
+ aria-label="Plan"
82
+ items={items}
83
+ renderItem={(item) => <strong>★ {item.label}</strong>}
84
+ renderValue={(selected) => <em>{selected.length ? selected[0].label : 'Pick'}</em>}
85
+ />
86
+ );
87
+ expect(screen.getByRole('button')).toHaveTextContent('Pick');
88
+ await user.click(screen.getByRole('button'));
89
+ expect(screen.getByText('★ Free')).toBeInTheDocument();
90
+ });
91
+
92
+ it('submits hidden inputs for forms', () => {
93
+ const { container } = render(
94
+ <form aria-label="Form">
95
+ <SelectMenu aria-label="Plan" items={items} name="plan" value="pro" onValueChange={() => undefined} />
96
+ </form>
97
+ );
98
+ expect(container.querySelector('input[type="hidden"][name="plan"]')).toHaveValue('pro');
99
+ });
100
+
101
+ it('has no accessibility violations', async () => {
102
+ const { container } = render(<SelectMenu aria-label="Plan" items={items} />);
103
+ expect(await axe(container)).toHaveNoViolations();
104
+ });
105
+ });