@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,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
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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.
|
|
24
|
-
*
|
|
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) =>
|
|
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
|
-
{
|
|
56
|
-
<option
|
|
57
|
-
{
|
|
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
|
+
});
|