@fragments-sdk/ui 0.7.3 → 0.7.5
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/fragments.json +1 -1
- package/package.json +13 -2
- package/src/components/Accordion/Accordion.test.tsx +171 -0
- package/src/components/Alert/Alert.test.tsx +127 -0
- package/src/components/AppShell/AppShell.module.scss +5 -5
- package/src/components/AppShell/AppShell.test.tsx +80 -0
- package/src/components/Avatar/Avatar.test.tsx +40 -0
- package/src/components/Avatar/index.tsx +11 -1
- package/src/components/Badge/Badge.test.tsx +58 -0
- package/src/components/Box/Box.test.tsx +43 -0
- package/src/components/Breadcrumbs/Breadcrumbs.test.tsx +75 -0
- package/src/components/Button/Button.module.scss +3 -3
- package/src/components/Button/Button.test.tsx +53 -0
- package/src/components/ButtonGroup/ButtonGroup.test.tsx +44 -0
- package/src/components/Card/Card.test.tsx +71 -0
- package/src/components/Chart/Chart.module.scss +5 -0
- package/src/components/Chart/Chart.test.tsx +123 -0
- package/src/components/Chart/index.tsx +34 -0
- package/src/components/Checkbox/Checkbox.test.tsx +63 -0
- package/src/components/Checkbox/index.tsx +29 -4
- package/src/components/Chip/Chip.module.scss +73 -7
- package/src/components/Chip/Chip.test.tsx +50 -0
- package/src/components/Chip/index.tsx +36 -26
- package/src/components/CodeBlock/CodeBlock.module.scss +6 -6
- package/src/components/CodeBlock/CodeBlock.test.tsx +78 -0
- package/src/components/CodeBlock/index.tsx +4 -1
- package/src/components/Collapsible/Collapsible.test.tsx +103 -0
- package/src/components/ColorPicker/ColorPicker.test.tsx +55 -0
- package/src/components/ColorPicker/index.tsx +4 -1
- package/src/components/Combobox/Combobox.test.tsx +202 -0
- package/src/components/ConversationList/ConversationList.module.scss +4 -4
- package/src/components/ConversationList/ConversationList.test.tsx +79 -0
- package/src/components/Dialog/Dialog.test.tsx +277 -0
- package/src/components/Dialog/index.tsx +14 -6
- package/src/components/EmptyState/EmptyState.module.scss +3 -3
- package/src/components/EmptyState/EmptyState.test.tsx +67 -0
- package/src/components/Field/Field.test.tsx +65 -0
- package/src/components/Fieldset/Fieldset.test.tsx +48 -0
- package/src/components/Form/Form.test.tsx +41 -0
- package/src/components/Grid/Grid.module.scss +3 -3
- package/src/components/Grid/Grid.test.tsx +65 -0
- package/src/components/Header/Header.module.scss +4 -4
- package/src/components/Header/Header.test.tsx +83 -0
- package/src/components/Icon/Icon.test.tsx +38 -0
- package/src/components/Image/Image.test.tsx +39 -0
- package/src/components/Input/Input.test.tsx +72 -0
- package/src/components/Input/index.tsx +15 -1
- package/src/components/Link/Link.test.tsx +37 -0
- package/src/components/List/List.test.tsx +57 -0
- package/src/components/Listbox/Listbox.module.scss +16 -5
- package/src/components/Listbox/Listbox.test.tsx +100 -0
- package/src/components/Listbox/index.tsx +186 -13
- package/src/components/Loading/Loading.test.tsx +38 -0
- package/src/components/Markdown/Markdown.module.scss +3 -3
- package/src/components/Markdown/Markdown.test.tsx +41 -0
- package/src/components/Menu/Menu.module.scss +3 -3
- package/src/components/Menu/Menu.test.tsx +336 -0
- package/src/components/Message/Message.module.scss +9 -3
- package/src/components/Message/Message.test.tsx +75 -0
- package/src/components/Popover/Popover.test.tsx +105 -0
- package/src/components/Popover/index.tsx +4 -1
- package/src/components/Progress/Progress.test.tsx +58 -0
- package/src/components/Prompt/Prompt.module.scss +6 -5
- package/src/components/Prompt/Prompt.test.tsx +89 -0
- package/src/components/RadioGroup/RadioGroup.test.tsx +105 -0
- package/src/components/RadioGroup/index.tsx +37 -4
- package/src/components/Select/Select.test.tsx +161 -0
- package/src/components/Select/index.tsx +3 -2
- package/src/components/Separator/Separator.test.tsx +33 -0
- package/src/components/Sidebar/Sidebar.module.scss +32 -22
- package/src/components/Sidebar/Sidebar.test.tsx +85 -0
- package/src/components/Sidebar/index.tsx +31 -9
- package/src/components/Skeleton/Skeleton.test.tsx +56 -0
- package/src/components/Slider/Slider.test.tsx +51 -0
- package/src/components/Slider/index.tsx +13 -3
- package/src/components/Stack/Stack.test.tsx +47 -0
- package/src/components/Table/Table.module.scss +20 -11
- package/src/components/Table/Table.test.tsx +129 -0
- package/src/components/Table/index.tsx +52 -30
- package/src/components/Tabs/Tabs.test.tsx +180 -0
- package/src/components/Text/Text.test.tsx +40 -0
- package/src/components/Textarea/Textarea.test.tsx +57 -0
- package/src/components/Textarea/index.tsx +22 -2
- package/src/components/Theme/Theme.test.tsx +114 -0
- package/src/components/ThinkingIndicator/ThinkingIndicator.test.tsx +54 -0
- package/src/components/Toast/Toast.test.tsx +192 -0
- package/src/components/Toast/index.tsx +124 -20
- package/src/components/Toggle/Toggle.test.tsx +49 -0
- package/src/components/ToggleGroup/ToggleGroup.fragment.tsx +96 -78
- package/src/components/ToggleGroup/ToggleGroup.test.tsx +90 -0
- package/src/components/ToggleGroup/index.tsx +70 -1
- package/src/components/Tooltip/Tooltip.module.scss +1 -1
- package/src/components/Tooltip/Tooltip.test.tsx +107 -0
- package/src/components/VisuallyHidden/VisuallyHidden.test.tsx +31 -0
- package/src/test/setup.ts +74 -0
- package/src/test/utils.tsx +71 -0
- package/src/tokens/_computed.scss +2 -0
- package/src/tokens/_density.scss +4 -0
- package/src/tokens/_derive.scss +16 -16
- package/src/tokens/_variables.scss +8 -2
- package/src/utils/a11y.test.tsx +79 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { render, screen, userEvent, expectNoA11yViolations } from '../../test/utils';
|
|
3
|
+
import { Prompt } from './index';
|
|
4
|
+
|
|
5
|
+
function renderPrompt(props: {
|
|
6
|
+
onSubmit?: (v: string) => void;
|
|
7
|
+
placeholder?: string;
|
|
8
|
+
disabled?: boolean;
|
|
9
|
+
defaultValue?: string;
|
|
10
|
+
} = {}) {
|
|
11
|
+
return render(
|
|
12
|
+
<Prompt
|
|
13
|
+
placeholder={props.placeholder ?? 'Ask something...'}
|
|
14
|
+
onSubmit={props.onSubmit}
|
|
15
|
+
disabled={props.disabled}
|
|
16
|
+
defaultValue={props.defaultValue}
|
|
17
|
+
>
|
|
18
|
+
<Prompt.Textarea />
|
|
19
|
+
<Prompt.Toolbar>
|
|
20
|
+
<Prompt.Actions>
|
|
21
|
+
<Prompt.ActionButton aria-label="Attach file">Attach</Prompt.ActionButton>
|
|
22
|
+
</Prompt.Actions>
|
|
23
|
+
</Prompt.Toolbar>
|
|
24
|
+
<Prompt.Submit />
|
|
25
|
+
</Prompt>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('Prompt', () => {
|
|
30
|
+
it('renders a textarea with placeholder', () => {
|
|
31
|
+
renderPrompt({ placeholder: 'Type here...' });
|
|
32
|
+
expect(screen.getByPlaceholderText('Type here...')).toBeInTheDocument();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('renders compound sub-components (Toolbar, Actions)', () => {
|
|
36
|
+
renderPrompt();
|
|
37
|
+
expect(screen.getByRole('button', { name: /attach file/i })).toBeInTheDocument();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('renders submit button', () => {
|
|
41
|
+
renderPrompt();
|
|
42
|
+
expect(screen.getByRole('button', { name: /submit/i })).toBeInTheDocument();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('disables submit when value is empty', () => {
|
|
46
|
+
renderPrompt();
|
|
47
|
+
expect(screen.getByRole('button', { name: /submit/i })).toBeDisabled();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('enables submit when user types text', async () => {
|
|
51
|
+
const user = userEvent.setup();
|
|
52
|
+
renderPrompt();
|
|
53
|
+
const textarea = screen.getByPlaceholderText('Ask something...');
|
|
54
|
+
await user.type(textarea, 'Hello');
|
|
55
|
+
expect(screen.getByRole('button', { name: /submit/i })).not.toBeDisabled();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('calls onSubmit when submit button is clicked', async () => {
|
|
59
|
+
const user = userEvent.setup();
|
|
60
|
+
const handleSubmit = vi.fn();
|
|
61
|
+
renderPrompt({ onSubmit: handleSubmit, defaultValue: 'Test message' });
|
|
62
|
+
|
|
63
|
+
await user.click(screen.getByRole('button', { name: /submit/i }));
|
|
64
|
+
expect(handleSubmit).toHaveBeenCalledWith('Test message');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('submits on Enter key by default (submitOnEnter)', async () => {
|
|
68
|
+
const user = userEvent.setup();
|
|
69
|
+
const handleSubmit = vi.fn();
|
|
70
|
+
renderPrompt({ onSubmit: handleSubmit });
|
|
71
|
+
|
|
72
|
+
const textarea = screen.getByPlaceholderText('Ask something...');
|
|
73
|
+
await user.type(textarea, 'Hello');
|
|
74
|
+
await user.keyboard('{Enter}');
|
|
75
|
+
expect(handleSubmit).toHaveBeenCalledWith('Hello');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('disables all controls when disabled prop is true', () => {
|
|
79
|
+
renderPrompt({ disabled: true });
|
|
80
|
+
expect(screen.getByPlaceholderText('Ask something...')).toBeDisabled();
|
|
81
|
+
expect(screen.getByRole('button', { name: /attach file/i })).toBeDisabled();
|
|
82
|
+
expect(screen.getByRole('button', { name: /submit/i })).toBeDisabled();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('has no accessibility violations', async () => {
|
|
86
|
+
const { container } = renderPrompt();
|
|
87
|
+
await expectNoA11yViolations(container);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { render, screen, userEvent, expectNoA11yViolations } from '../../test/utils';
|
|
3
|
+
import { RadioGroup } from './index';
|
|
4
|
+
|
|
5
|
+
describe('RadioGroup', () => {
|
|
6
|
+
it('renders a radiogroup role', () => {
|
|
7
|
+
render(
|
|
8
|
+
<RadioGroup label="Color">
|
|
9
|
+
<RadioGroup.Item value="red" label="Red" />
|
|
10
|
+
<RadioGroup.Item value="blue" label="Blue" />
|
|
11
|
+
</RadioGroup>
|
|
12
|
+
);
|
|
13
|
+
expect(screen.getByRole('radiogroup')).toBeInTheDocument();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('renders radio items', () => {
|
|
17
|
+
render(
|
|
18
|
+
<RadioGroup label="Color">
|
|
19
|
+
<RadioGroup.Item value="red" label="Red" />
|
|
20
|
+
<RadioGroup.Item value="blue" label="Blue" />
|
|
21
|
+
</RadioGroup>
|
|
22
|
+
);
|
|
23
|
+
expect(screen.getAllByRole('radio')).toHaveLength(2);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('selects a radio on click', async () => {
|
|
27
|
+
const user = userEvent.setup();
|
|
28
|
+
render(
|
|
29
|
+
<RadioGroup label="Color">
|
|
30
|
+
<RadioGroup.Item value="red" label="Red" />
|
|
31
|
+
<RadioGroup.Item value="blue" label="Blue" />
|
|
32
|
+
</RadioGroup>
|
|
33
|
+
);
|
|
34
|
+
const radios = screen.getAllByRole('radio');
|
|
35
|
+
await user.click(radios[1]);
|
|
36
|
+
expect(radios[1]).toBeChecked();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('renders the group label', () => {
|
|
40
|
+
render(
|
|
41
|
+
<RadioGroup label="Choose a color">
|
|
42
|
+
<RadioGroup.Item value="red" label="Red" />
|
|
43
|
+
</RadioGroup>
|
|
44
|
+
);
|
|
45
|
+
expect(screen.getByText('Choose a color')).toBeInTheDocument();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('sets defaultValue as the initially selected radio', () => {
|
|
49
|
+
render(
|
|
50
|
+
<RadioGroup label="Color" defaultValue="blue">
|
|
51
|
+
<RadioGroup.Item value="red" label="Red" />
|
|
52
|
+
<RadioGroup.Item value="blue" label="Blue" />
|
|
53
|
+
</RadioGroup>
|
|
54
|
+
);
|
|
55
|
+
const radios = screen.getAllByRole('radio');
|
|
56
|
+
expect(radios[1]).toBeChecked();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('disables all items when disabled prop is set', () => {
|
|
60
|
+
render(
|
|
61
|
+
<RadioGroup label="Color" disabled>
|
|
62
|
+
<RadioGroup.Item value="red" label="Red" />
|
|
63
|
+
<RadioGroup.Item value="blue" label="Blue" />
|
|
64
|
+
</RadioGroup>
|
|
65
|
+
);
|
|
66
|
+
const radios = screen.getAllByRole('radio');
|
|
67
|
+
radios.forEach((radio) => expect(radio).toHaveAttribute('aria-disabled', 'true'));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('disables individual items', () => {
|
|
71
|
+
render(
|
|
72
|
+
<RadioGroup label="Color">
|
|
73
|
+
<RadioGroup.Item value="red" label="Red" disabled />
|
|
74
|
+
<RadioGroup.Item value="blue" label="Blue" />
|
|
75
|
+
</RadioGroup>
|
|
76
|
+
);
|
|
77
|
+
const radios = screen.getAllByRole('radio');
|
|
78
|
+
expect(radios[0]).toHaveAttribute('aria-disabled', 'true');
|
|
79
|
+
expect(radios[1]).not.toHaveAttribute('aria-disabled', 'true');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('calls onValueChange with the selected value', async () => {
|
|
83
|
+
const handleChange = vi.fn();
|
|
84
|
+
const user = userEvent.setup();
|
|
85
|
+
render(
|
|
86
|
+
<RadioGroup label="Color" onValueChange={handleChange}>
|
|
87
|
+
<RadioGroup.Item value="red" label="Red" />
|
|
88
|
+
<RadioGroup.Item value="blue" label="Blue" />
|
|
89
|
+
</RadioGroup>
|
|
90
|
+
);
|
|
91
|
+
await user.click(screen.getAllByRole('radio')[0]);
|
|
92
|
+
expect(handleChange).toHaveBeenCalled();
|
|
93
|
+
expect(handleChange.mock.calls[0][0]).toBe('red');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('has no accessibility violations', async () => {
|
|
97
|
+
const { container } = render(
|
|
98
|
+
<RadioGroup label="Accessible group">
|
|
99
|
+
<RadioGroup.Item value="a" label="Option A" />
|
|
100
|
+
<RadioGroup.Item value="b" label="Option B" />
|
|
101
|
+
</RadioGroup>
|
|
102
|
+
);
|
|
103
|
+
await expectNoA11yViolations(container);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -41,10 +41,21 @@ export interface RadioItemProps {
|
|
|
41
41
|
description?: string;
|
|
42
42
|
/** Whether this item is disabled */
|
|
43
43
|
disabled?: boolean;
|
|
44
|
+
/** Accessible name for icon-only mode */
|
|
45
|
+
'aria-label'?: string;
|
|
46
|
+
/** Accessible labelled-by relationship */
|
|
47
|
+
'aria-labelledby'?: string;
|
|
48
|
+
/** Accessible described-by relationship */
|
|
49
|
+
'aria-describedby'?: string;
|
|
44
50
|
/** Additional class name */
|
|
45
51
|
className?: string;
|
|
46
52
|
}
|
|
47
53
|
|
|
54
|
+
function mergeAriaIds(...ids: Array<string | undefined>): string | undefined {
|
|
55
|
+
const merged = ids.filter(Boolean).join(' ').trim();
|
|
56
|
+
return merged.length > 0 ? merged : undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
48
59
|
// ============================================
|
|
49
60
|
// Context for size
|
|
50
61
|
// ============================================
|
|
@@ -60,9 +71,15 @@ function RadioItem({
|
|
|
60
71
|
label,
|
|
61
72
|
description,
|
|
62
73
|
disabled = false,
|
|
74
|
+
'aria-label': ariaLabel,
|
|
75
|
+
'aria-labelledby': ariaLabelledBy,
|
|
76
|
+
'aria-describedby': ariaDescribedBy,
|
|
63
77
|
className,
|
|
64
78
|
}: RadioItemProps) {
|
|
65
79
|
const size = React.useContext(RadioSizeContext);
|
|
80
|
+
const id = React.useId();
|
|
81
|
+
const labelId = label ? `radio-label-${id}` : undefined;
|
|
82
|
+
const descriptionId = description ? `radio-desc-${id}` : undefined;
|
|
66
83
|
|
|
67
84
|
const radioClasses = [
|
|
68
85
|
styles.radio,
|
|
@@ -84,6 +101,9 @@ function RadioItem({
|
|
|
84
101
|
<BaseRadio.Root
|
|
85
102
|
value={value}
|
|
86
103
|
disabled={disabled}
|
|
104
|
+
aria-label={ariaLabel}
|
|
105
|
+
aria-labelledby={ariaLabelledBy}
|
|
106
|
+
aria-describedby={ariaDescribedBy}
|
|
87
107
|
className={[radioClasses, className].filter(Boolean).join(' ')}
|
|
88
108
|
>
|
|
89
109
|
<BaseRadio.Indicator className={styles.indicator} />
|
|
@@ -96,14 +116,17 @@ function RadioItem({
|
|
|
96
116
|
<BaseRadio.Root
|
|
97
117
|
value={value}
|
|
98
118
|
disabled={disabled}
|
|
119
|
+
aria-label={ariaLabel}
|
|
120
|
+
aria-labelledby={mergeAriaIds(ariaLabelledBy, labelId)}
|
|
121
|
+
aria-describedby={mergeAriaIds(ariaDescribedBy, descriptionId)}
|
|
99
122
|
className={radioClasses}
|
|
100
123
|
>
|
|
101
124
|
<BaseRadio.Indicator className={styles.indicator} />
|
|
102
125
|
</BaseRadio.Root>
|
|
103
126
|
<div className={styles.content}>
|
|
104
|
-
<span className={labelClasses}>{label}</span>
|
|
127
|
+
<span id={labelId} className={labelClasses}>{label}</span>
|
|
105
128
|
{description && (
|
|
106
|
-
<span className={styles.description}>{description}</span>
|
|
129
|
+
<span id={descriptionId} className={styles.description}>{description}</span>
|
|
107
130
|
)}
|
|
108
131
|
</div>
|
|
109
132
|
</label>
|
|
@@ -126,8 +149,15 @@ function RadioGroupRoot({
|
|
|
126
149
|
size = 'md',
|
|
127
150
|
children,
|
|
128
151
|
className,
|
|
152
|
+
'aria-label': ariaLabel,
|
|
153
|
+
'aria-labelledby': ariaLabelledBy,
|
|
154
|
+
'aria-describedby': ariaDescribedBy,
|
|
129
155
|
...htmlProps
|
|
130
156
|
}: RadioGroupProps) {
|
|
157
|
+
const groupId = React.useId();
|
|
158
|
+
const labelId = label ? `radio-group-label-${groupId}` : undefined;
|
|
159
|
+
const errorId = error ? `radio-group-error-${groupId}` : undefined;
|
|
160
|
+
|
|
131
161
|
const groupClasses = [
|
|
132
162
|
styles.group,
|
|
133
163
|
styles[orientation],
|
|
@@ -137,18 +167,21 @@ function RadioGroupRoot({
|
|
|
137
167
|
return (
|
|
138
168
|
<RadioSizeContext.Provider value={size}>
|
|
139
169
|
<div {...htmlProps} className={styles.wrapper}>
|
|
140
|
-
{label && <span className={styles.groupLabel}>{label}</span>}
|
|
170
|
+
{label && <span id={labelId} className={styles.groupLabel}>{label}</span>}
|
|
141
171
|
<BaseRadioGroup
|
|
142
172
|
value={value}
|
|
143
173
|
defaultValue={defaultValue}
|
|
144
174
|
onValueChange={onValueChange}
|
|
145
175
|
disabled={disabled}
|
|
146
176
|
name={name}
|
|
177
|
+
aria-label={ariaLabel}
|
|
178
|
+
aria-labelledby={mergeAriaIds(ariaLabelledBy, labelId)}
|
|
179
|
+
aria-describedby={mergeAriaIds(ariaDescribedBy, errorId)}
|
|
147
180
|
className={groupClasses}
|
|
148
181
|
>
|
|
149
182
|
{children}
|
|
150
183
|
</BaseRadioGroup>
|
|
151
|
-
{error && <span className={styles.error}>{error}</span>}
|
|
184
|
+
{error && <span id={errorId} className={styles.error}>{error}</span>}
|
|
152
185
|
</div>
|
|
153
186
|
</RadioSizeContext.Provider>
|
|
154
187
|
);
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { render, screen, userEvent, expectNoA11yViolations } from '../../test/utils';
|
|
3
|
+
import { Select } from './index';
|
|
4
|
+
|
|
5
|
+
function renderSelect(props: { onValueChange?: (v: string | null) => void; disabled?: boolean; value?: string; placeholder?: string } = {}) {
|
|
6
|
+
return render(
|
|
7
|
+
<Select
|
|
8
|
+
placeholder={props.placeholder ?? 'Pick one'}
|
|
9
|
+
onValueChange={props.onValueChange}
|
|
10
|
+
disabled={props.disabled}
|
|
11
|
+
value={props.value}
|
|
12
|
+
>
|
|
13
|
+
<Select.Trigger />
|
|
14
|
+
<Select.Content>
|
|
15
|
+
<Select.Item value="apple">Apple</Select.Item>
|
|
16
|
+
<Select.Item value="banana">Banana</Select.Item>
|
|
17
|
+
<Select.Item value="cherry" disabled>Cherry</Select.Item>
|
|
18
|
+
</Select.Content>
|
|
19
|
+
</Select>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('Select', () => {
|
|
24
|
+
it('renders a trigger button', () => {
|
|
25
|
+
renderSelect();
|
|
26
|
+
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('shows placeholder text when no value selected', () => {
|
|
30
|
+
renderSelect({ placeholder: 'Choose fruit' });
|
|
31
|
+
expect(screen.getByText('Choose fruit')).toBeInTheDocument();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('opens dropdown and selects an option on click', async () => {
|
|
35
|
+
const user = userEvent.setup();
|
|
36
|
+
const onChange = vi.fn();
|
|
37
|
+
renderSelect({ onValueChange: onChange });
|
|
38
|
+
|
|
39
|
+
await user.click(screen.getByRole('combobox'));
|
|
40
|
+
const option = await screen.findByRole('option', { name: 'Apple' });
|
|
41
|
+
await user.click(option);
|
|
42
|
+
expect(onChange).toHaveBeenCalledWith('apple');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('renders with a controlled value', async () => {
|
|
46
|
+
const user = userEvent.setup();
|
|
47
|
+
renderSelect({ value: 'banana' });
|
|
48
|
+
// The trigger should display the selected item label
|
|
49
|
+
await user.click(screen.getByRole('combobox'));
|
|
50
|
+
const bananaOption = await screen.findByRole('option', { name: 'Banana' });
|
|
51
|
+
expect(bananaOption).toHaveAttribute('aria-selected', 'true');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('disables the trigger when disabled prop is true', () => {
|
|
55
|
+
renderSelect({ disabled: true });
|
|
56
|
+
expect(screen.getByRole('combobox')).toBeDisabled();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('renders groups and group labels', async () => {
|
|
60
|
+
const user = userEvent.setup();
|
|
61
|
+
render(
|
|
62
|
+
<Select placeholder="Pick">
|
|
63
|
+
<Select.Trigger />
|
|
64
|
+
<Select.Content>
|
|
65
|
+
<Select.Group>
|
|
66
|
+
<Select.GroupLabel>Fruits</Select.GroupLabel>
|
|
67
|
+
<Select.Item value="apple">Apple</Select.Item>
|
|
68
|
+
</Select.Group>
|
|
69
|
+
</Select.Content>
|
|
70
|
+
</Select>
|
|
71
|
+
);
|
|
72
|
+
await user.click(screen.getByRole('combobox'));
|
|
73
|
+
expect(await screen.findByText('Fruits')).toBeInTheDocument();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('has no accessibility violations', async () => {
|
|
77
|
+
const { container } = render(
|
|
78
|
+
<Select placeholder="Pick one">
|
|
79
|
+
<Select.Trigger aria-label="Select a fruit" />
|
|
80
|
+
<Select.Content>
|
|
81
|
+
<Select.Item value="apple">Apple</Select.Item>
|
|
82
|
+
</Select.Content>
|
|
83
|
+
</Select>
|
|
84
|
+
);
|
|
85
|
+
await expectNoA11yViolations(container);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('keyboard & focus', () => {
|
|
89
|
+
it('ArrowDown opens the dropdown from focused trigger', async () => {
|
|
90
|
+
const user = userEvent.setup();
|
|
91
|
+
renderSelect();
|
|
92
|
+
|
|
93
|
+
await user.tab(); // focus trigger
|
|
94
|
+
await user.keyboard('{ArrowDown}');
|
|
95
|
+
expect(await screen.findByRole('option', { name: 'Apple' })).toBeInTheDocument();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('listbox receives focus when dropdown opens', async () => {
|
|
99
|
+
const user = userEvent.setup();
|
|
100
|
+
renderSelect();
|
|
101
|
+
|
|
102
|
+
await user.click(screen.getByRole('combobox'));
|
|
103
|
+
const listbox = await screen.findByRole('listbox');
|
|
104
|
+
expect(listbox).toBeInTheDocument();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('all options are rendered in the listbox', async () => {
|
|
108
|
+
const user = userEvent.setup();
|
|
109
|
+
renderSelect();
|
|
110
|
+
|
|
111
|
+
await user.click(screen.getByRole('combobox'));
|
|
112
|
+
expect(await screen.findByRole('option', { name: 'Apple' })).toBeInTheDocument();
|
|
113
|
+
expect(screen.getByRole('option', { name: 'Banana' })).toBeInTheDocument();
|
|
114
|
+
expect(screen.getByRole('option', { name: 'Cherry' })).toBeInTheDocument();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('clicking an option selects it', async () => {
|
|
118
|
+
const user = userEvent.setup();
|
|
119
|
+
const onChange = vi.fn();
|
|
120
|
+
renderSelect({ onValueChange: onChange });
|
|
121
|
+
|
|
122
|
+
await user.click(screen.getByRole('combobox'));
|
|
123
|
+
await user.click(await screen.findByRole('option', { name: 'Banana' }));
|
|
124
|
+
expect(onChange).toHaveBeenCalledWith('banana');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('Escape closes the dropdown', async () => {
|
|
128
|
+
const user = userEvent.setup();
|
|
129
|
+
renderSelect();
|
|
130
|
+
|
|
131
|
+
await user.click(screen.getByRole('combobox'));
|
|
132
|
+
await screen.findByRole('option', { name: 'Apple' });
|
|
133
|
+
|
|
134
|
+
await user.keyboard('{Escape}');
|
|
135
|
+
expect(screen.queryByRole('option')).not.toBeInTheDocument();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('trigger is focusable after closing dropdown', async () => {
|
|
139
|
+
const user = userEvent.setup();
|
|
140
|
+
renderSelect();
|
|
141
|
+
|
|
142
|
+
const trigger = screen.getByRole('combobox');
|
|
143
|
+
await user.click(trigger);
|
|
144
|
+
await screen.findByRole('option', { name: 'Apple' });
|
|
145
|
+
|
|
146
|
+
await user.keyboard('{Escape}');
|
|
147
|
+
// After closing, the trigger should still be accessible
|
|
148
|
+
expect(trigger).toBeInTheDocument();
|
|
149
|
+
expect(trigger).not.toBeDisabled();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('disabled option has aria-disabled attribute', async () => {
|
|
153
|
+
const user = userEvent.setup();
|
|
154
|
+
renderSelect();
|
|
155
|
+
|
|
156
|
+
await user.click(screen.getByRole('combobox'));
|
|
157
|
+
const cherry = await screen.findByRole('option', { name: 'Cherry' });
|
|
158
|
+
expect(cherry).toHaveAttribute('aria-disabled', 'true');
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
});
|
|
@@ -268,11 +268,12 @@ function SelectItem({ children, value, disabled, className }: SelectItemProps) {
|
|
|
268
268
|
|
|
269
269
|
// Register this item's children in the registry so the trigger can display them
|
|
270
270
|
React.useEffect(() => {
|
|
271
|
-
itemsRef.current
|
|
271
|
+
const items = itemsRef.current;
|
|
272
|
+
items.set(value, children);
|
|
272
273
|
// Trigger re-render of trigger to show the registered content
|
|
273
274
|
incrementItemsVersion();
|
|
274
275
|
return () => {
|
|
275
|
-
|
|
276
|
+
items.delete(value);
|
|
276
277
|
};
|
|
277
278
|
}, [itemsRef, incrementItemsVersion, value, children]);
|
|
278
279
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { render, screen, expectNoA11yViolations } from '../../test/utils';
|
|
3
|
+
import { Separator } from './index';
|
|
4
|
+
|
|
5
|
+
describe('Separator', () => {
|
|
6
|
+
it('renders with role="separator"', () => {
|
|
7
|
+
render(<Separator />);
|
|
8
|
+
expect(screen.getByRole('separator')).toBeInTheDocument();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('applies horizontal class by default', () => {
|
|
12
|
+
render(<Separator />);
|
|
13
|
+
expect(screen.getByRole('separator')).toHaveClass('horizontal');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('applies vertical class and orientation', () => {
|
|
17
|
+
render(<Separator orientation="vertical" />);
|
|
18
|
+
const sep = screen.getByRole('separator');
|
|
19
|
+
expect(sep).toHaveClass('vertical');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('renders labeled separator', () => {
|
|
23
|
+
render(<Separator label="OR" />);
|
|
24
|
+
const sep = screen.getByRole('separator');
|
|
25
|
+
expect(sep).toHaveClass('withLabel');
|
|
26
|
+
expect(screen.getByText('OR')).toBeInTheDocument();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('has no accessibility violations', async () => {
|
|
30
|
+
const { container } = render(<Separator />);
|
|
31
|
+
await expectNoA11yViolations(container);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
|
|
32
32
|
.header {
|
|
33
33
|
justify-content: center;
|
|
34
|
-
padding: var(--fui-
|
|
34
|
+
padding: var(--fui-padding-container-sm, $fui-padding-container-sm);
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
.sectionLabel {
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
|
|
47
47
|
.item {
|
|
48
48
|
justify-content: center;
|
|
49
|
-
padding: var(--fui-
|
|
49
|
+
padding: var(--fui-padding-container-sm, $fui-padding-container-sm);
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
.itemIcon {
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
|
|
56
56
|
.footer {
|
|
57
57
|
justify-content: center;
|
|
58
|
-
padding: var(--fui-
|
|
58
|
+
padding: var(--fui-padding-container-sm, $fui-padding-container-sm);
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
.collapseToggle {
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
display: flex;
|
|
93
93
|
align-items: center;
|
|
94
94
|
gap: var(--fui-space-3, $fui-space-3);
|
|
95
|
-
padding: var(--fui-
|
|
95
|
+
padding: var(--fui-padding-item-sm, $fui-padding-item-sm) var(--fui-padding-item-sm, $fui-padding-item-sm);
|
|
96
96
|
flex-shrink: 0;
|
|
97
97
|
height: var(--appshell-header-height, 56px);
|
|
98
98
|
}
|
|
@@ -105,7 +105,7 @@
|
|
|
105
105
|
flex: 1;
|
|
106
106
|
overflow-y: auto;
|
|
107
107
|
overflow-x: hidden;
|
|
108
|
-
padding: var(--fui-
|
|
108
|
+
padding: var(--fui-padding-item-sm, $fui-padding-item-sm);
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
// ============================================
|
|
@@ -121,13 +121,19 @@
|
|
|
121
121
|
.sectionHeader {
|
|
122
122
|
display: flex;
|
|
123
123
|
align-items: center;
|
|
124
|
-
|
|
125
|
-
padding: var(--fui-
|
|
124
|
+
gap: var(--fui-space-2, $fui-space-2);
|
|
125
|
+
padding: var(--fui-padding-item-xs, $fui-padding-item-xs) var(--fui-padding-item-sm, $fui-padding-item-sm);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.sectionTrigger {
|
|
129
|
+
flex: 1;
|
|
126
130
|
}
|
|
127
131
|
|
|
128
132
|
.sectionLabel {
|
|
129
133
|
@include helper-text;
|
|
130
134
|
|
|
135
|
+
flex: 1;
|
|
136
|
+
min-width: 0;
|
|
131
137
|
font-weight: var(--fui-font-weight-medium, $fui-font-weight-medium);
|
|
132
138
|
text-transform: uppercase;
|
|
133
139
|
letter-spacing: 0.05em;
|
|
@@ -170,11 +176,14 @@
|
|
|
170
176
|
// Collapsible section styles (using Collapsible component)
|
|
171
177
|
.sectionCollapsible {
|
|
172
178
|
// Override Collapsible trigger styles for sidebar context
|
|
173
|
-
|
|
174
|
-
button[aria-expanded] {
|
|
179
|
+
.sectionTrigger {
|
|
175
180
|
background: transparent;
|
|
181
|
+
display: flex;
|
|
182
|
+
align-items: center;
|
|
176
183
|
justify-content: space-between;
|
|
177
|
-
|
|
184
|
+
width: 100%;
|
|
185
|
+
padding: var(--fui-padding-item-xs, $fui-padding-item-xs) var(--fui-padding-item-sm, $fui-padding-item-sm);
|
|
186
|
+
border-radius: var(--fui-radius-md, $fui-radius-md);
|
|
178
187
|
|
|
179
188
|
&:hover {
|
|
180
189
|
background-color: var(--fui-bg-hover, $fui-bg-hover);
|
|
@@ -199,11 +208,10 @@
|
|
|
199
208
|
@include interactive-base;
|
|
200
209
|
@include text-base;
|
|
201
210
|
|
|
202
|
-
display: flex;
|
|
211
|
+
display: inline-flex;
|
|
203
212
|
align-items: center;
|
|
204
213
|
gap: var(--fui-space-3, $fui-space-3);
|
|
205
|
-
|
|
206
|
-
padding: var(--fui-space-1, $fui-space-1) var(--fui-space-2, $fui-space-2);
|
|
214
|
+
padding: var(--fui-padding-item-xs, $fui-padding-item-xs) var(--fui-padding-item-sm, $fui-padding-item-sm);
|
|
207
215
|
border-radius: var(--fui-radius-md, $fui-radius-md);
|
|
208
216
|
color: var(--fui-text-secondary, $fui-text-secondary);
|
|
209
217
|
text-decoration: none;
|
|
@@ -212,6 +220,7 @@
|
|
|
212
220
|
&:hover:not(.itemDisabled) {
|
|
213
221
|
background-color: var(--fui-bg-hover, $fui-bg-hover);
|
|
214
222
|
color: var(--fui-text-primary, $fui-text-primary);
|
|
223
|
+
text-decoration: none;
|
|
215
224
|
}
|
|
216
225
|
|
|
217
226
|
&:active:not(.itemDisabled) {
|
|
@@ -225,8 +234,9 @@
|
|
|
225
234
|
color: var(--fui-text-inverse, $fui-text-inverse);
|
|
226
235
|
|
|
227
236
|
&:hover {
|
|
228
|
-
background-color: var(--fui-color-accent-
|
|
229
|
-
color: var(--fui-text-inverse, $fui-text-inverse);
|
|
237
|
+
background-color: var(--fui-color-accent-hover, $fui-color-accent-hover) !important;
|
|
238
|
+
color: var(--fui-text-inverse, $fui-text-inverse) !important;
|
|
239
|
+
text-decoration: none;
|
|
230
240
|
}
|
|
231
241
|
|
|
232
242
|
.itemIcon {
|
|
@@ -270,7 +280,7 @@
|
|
|
270
280
|
display: flex;
|
|
271
281
|
align-items: center;
|
|
272
282
|
justify-content: center;
|
|
273
|
-
padding: var(--fui-
|
|
283
|
+
padding: var(--fui-padding-item-xs, $fui-padding-item-xs) var(--fui-padding-item-sm, $fui-padding-item-sm);
|
|
274
284
|
font-size: var(--fui-font-size-2xs, $fui-font-size-2xs);
|
|
275
285
|
font-weight: var(--fui-font-weight-medium, $fui-font-weight-medium);
|
|
276
286
|
// Use accent-hover for WCAG AA contrast (4.5:1 minimum)
|
|
@@ -341,10 +351,9 @@
|
|
|
341
351
|
@include interactive-base;
|
|
342
352
|
@include text-base;
|
|
343
353
|
|
|
344
|
-
display: flex;
|
|
354
|
+
display: inline-flex;
|
|
345
355
|
align-items: center;
|
|
346
|
-
|
|
347
|
-
padding: var(--fui-space-1, $fui-space-1) var(--fui-space-2, $fui-space-2);
|
|
356
|
+
padding: var(--fui-padding-item-xs, $fui-padding-item-xs) var(--fui-padding-item-sm, $fui-padding-item-sm);
|
|
348
357
|
border-radius: var(--fui-radius-md, $fui-radius-md);
|
|
349
358
|
color: var(--fui-text-secondary, $fui-text-secondary);
|
|
350
359
|
text-decoration: none;
|
|
@@ -369,6 +378,7 @@
|
|
|
369
378
|
&:hover:not(.subItemDisabled) {
|
|
370
379
|
background-color: var(--fui-bg-hover, $fui-bg-hover);
|
|
371
380
|
color: var(--fui-text-primary, $fui-text-primary);
|
|
381
|
+
text-decoration: none;
|
|
372
382
|
}
|
|
373
383
|
|
|
374
384
|
&:active:not(.subItemDisabled) {
|
|
@@ -399,7 +409,7 @@
|
|
|
399
409
|
display: flex;
|
|
400
410
|
flex-direction: column;
|
|
401
411
|
gap: var(--fui-space-2, $fui-space-2);
|
|
402
|
-
padding: var(--fui-
|
|
412
|
+
padding: var(--fui-padding-container-md, $fui-padding-container-md);
|
|
403
413
|
margin-top: auto;
|
|
404
414
|
flex-shrink: 0;
|
|
405
415
|
}
|
|
@@ -563,14 +573,14 @@
|
|
|
563
573
|
display: flex;
|
|
564
574
|
flex-direction: column;
|
|
565
575
|
gap: var(--fui-space-2, $fui-space-2);
|
|
566
|
-
padding: var(--fui-
|
|
576
|
+
padding: var(--fui-padding-item-sm, $fui-padding-item-sm);
|
|
567
577
|
}
|
|
568
578
|
|
|
569
579
|
.skeletonItem {
|
|
570
580
|
display: flex;
|
|
571
581
|
align-items: center;
|
|
572
582
|
gap: var(--fui-space-3, $fui-space-3);
|
|
573
|
-
padding: var(--fui-
|
|
583
|
+
padding: var(--fui-padding-item-xs, $fui-padding-item-xs) var(--fui-padding-item-sm, $fui-padding-item-sm);
|
|
574
584
|
min-height: $fui-sidebar-item-height;
|
|
575
585
|
}
|
|
576
586
|
|