@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.
Files changed (101) hide show
  1. package/fragments.json +1 -1
  2. package/package.json +13 -2
  3. package/src/components/Accordion/Accordion.test.tsx +171 -0
  4. package/src/components/Alert/Alert.test.tsx +127 -0
  5. package/src/components/AppShell/AppShell.module.scss +5 -5
  6. package/src/components/AppShell/AppShell.test.tsx +80 -0
  7. package/src/components/Avatar/Avatar.test.tsx +40 -0
  8. package/src/components/Avatar/index.tsx +11 -1
  9. package/src/components/Badge/Badge.test.tsx +58 -0
  10. package/src/components/Box/Box.test.tsx +43 -0
  11. package/src/components/Breadcrumbs/Breadcrumbs.test.tsx +75 -0
  12. package/src/components/Button/Button.module.scss +3 -3
  13. package/src/components/Button/Button.test.tsx +53 -0
  14. package/src/components/ButtonGroup/ButtonGroup.test.tsx +44 -0
  15. package/src/components/Card/Card.test.tsx +71 -0
  16. package/src/components/Chart/Chart.module.scss +5 -0
  17. package/src/components/Chart/Chart.test.tsx +123 -0
  18. package/src/components/Chart/index.tsx +34 -0
  19. package/src/components/Checkbox/Checkbox.test.tsx +63 -0
  20. package/src/components/Checkbox/index.tsx +29 -4
  21. package/src/components/Chip/Chip.module.scss +73 -7
  22. package/src/components/Chip/Chip.test.tsx +50 -0
  23. package/src/components/Chip/index.tsx +36 -26
  24. package/src/components/CodeBlock/CodeBlock.module.scss +6 -6
  25. package/src/components/CodeBlock/CodeBlock.test.tsx +78 -0
  26. package/src/components/CodeBlock/index.tsx +4 -1
  27. package/src/components/Collapsible/Collapsible.test.tsx +103 -0
  28. package/src/components/ColorPicker/ColorPicker.test.tsx +55 -0
  29. package/src/components/ColorPicker/index.tsx +4 -1
  30. package/src/components/Combobox/Combobox.test.tsx +202 -0
  31. package/src/components/ConversationList/ConversationList.module.scss +4 -4
  32. package/src/components/ConversationList/ConversationList.test.tsx +79 -0
  33. package/src/components/Dialog/Dialog.test.tsx +277 -0
  34. package/src/components/Dialog/index.tsx +14 -6
  35. package/src/components/EmptyState/EmptyState.module.scss +3 -3
  36. package/src/components/EmptyState/EmptyState.test.tsx +67 -0
  37. package/src/components/Field/Field.test.tsx +65 -0
  38. package/src/components/Fieldset/Fieldset.test.tsx +48 -0
  39. package/src/components/Form/Form.test.tsx +41 -0
  40. package/src/components/Grid/Grid.module.scss +3 -3
  41. package/src/components/Grid/Grid.test.tsx +65 -0
  42. package/src/components/Header/Header.module.scss +4 -4
  43. package/src/components/Header/Header.test.tsx +83 -0
  44. package/src/components/Icon/Icon.test.tsx +38 -0
  45. package/src/components/Image/Image.test.tsx +39 -0
  46. package/src/components/Input/Input.test.tsx +72 -0
  47. package/src/components/Input/index.tsx +15 -1
  48. package/src/components/Link/Link.test.tsx +37 -0
  49. package/src/components/List/List.test.tsx +57 -0
  50. package/src/components/Listbox/Listbox.module.scss +16 -5
  51. package/src/components/Listbox/Listbox.test.tsx +100 -0
  52. package/src/components/Listbox/index.tsx +186 -13
  53. package/src/components/Loading/Loading.test.tsx +38 -0
  54. package/src/components/Markdown/Markdown.module.scss +3 -3
  55. package/src/components/Markdown/Markdown.test.tsx +41 -0
  56. package/src/components/Menu/Menu.module.scss +3 -3
  57. package/src/components/Menu/Menu.test.tsx +336 -0
  58. package/src/components/Message/Message.module.scss +9 -3
  59. package/src/components/Message/Message.test.tsx +75 -0
  60. package/src/components/Popover/Popover.test.tsx +105 -0
  61. package/src/components/Popover/index.tsx +4 -1
  62. package/src/components/Progress/Progress.test.tsx +58 -0
  63. package/src/components/Prompt/Prompt.module.scss +6 -5
  64. package/src/components/Prompt/Prompt.test.tsx +89 -0
  65. package/src/components/RadioGroup/RadioGroup.test.tsx +105 -0
  66. package/src/components/RadioGroup/index.tsx +37 -4
  67. package/src/components/Select/Select.test.tsx +161 -0
  68. package/src/components/Select/index.tsx +3 -2
  69. package/src/components/Separator/Separator.test.tsx +33 -0
  70. package/src/components/Sidebar/Sidebar.module.scss +32 -22
  71. package/src/components/Sidebar/Sidebar.test.tsx +85 -0
  72. package/src/components/Sidebar/index.tsx +31 -9
  73. package/src/components/Skeleton/Skeleton.test.tsx +56 -0
  74. package/src/components/Slider/Slider.test.tsx +51 -0
  75. package/src/components/Slider/index.tsx +13 -3
  76. package/src/components/Stack/Stack.test.tsx +47 -0
  77. package/src/components/Table/Table.module.scss +20 -11
  78. package/src/components/Table/Table.test.tsx +129 -0
  79. package/src/components/Table/index.tsx +52 -30
  80. package/src/components/Tabs/Tabs.test.tsx +180 -0
  81. package/src/components/Text/Text.test.tsx +40 -0
  82. package/src/components/Textarea/Textarea.test.tsx +57 -0
  83. package/src/components/Textarea/index.tsx +22 -2
  84. package/src/components/Theme/Theme.test.tsx +114 -0
  85. package/src/components/ThinkingIndicator/ThinkingIndicator.test.tsx +54 -0
  86. package/src/components/Toast/Toast.test.tsx +192 -0
  87. package/src/components/Toast/index.tsx +124 -20
  88. package/src/components/Toggle/Toggle.test.tsx +49 -0
  89. package/src/components/ToggleGroup/ToggleGroup.fragment.tsx +96 -78
  90. package/src/components/ToggleGroup/ToggleGroup.test.tsx +90 -0
  91. package/src/components/ToggleGroup/index.tsx +70 -1
  92. package/src/components/Tooltip/Tooltip.module.scss +1 -1
  93. package/src/components/Tooltip/Tooltip.test.tsx +107 -0
  94. package/src/components/VisuallyHidden/VisuallyHidden.test.tsx +31 -0
  95. package/src/test/setup.ts +74 -0
  96. package/src/test/utils.tsx +71 -0
  97. package/src/tokens/_computed.scss +2 -0
  98. package/src/tokens/_density.scss +4 -0
  99. package/src/tokens/_derive.scss +16 -16
  100. package/src/tokens/_variables.scss +8 -2
  101. 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.set(value, children);
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
- itemsRef.current.delete(value);
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-space-3, $fui-space-3);
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-space-3, $fui-space-3);
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-space-3, $fui-space-3);
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-space-2, $fui-space-2) var(--fui-space-2, $fui-space-2);
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-space-2, $fui-space-2);
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
- justify-content: space-between;
125
- padding: var(--fui-space-1, $fui-space-1) var(--fui-space-2, $fui-space-2);
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
- :global(.collapsible-trigger),
174
- button[aria-expanded] {
179
+ .sectionTrigger {
175
180
  background: transparent;
181
+ display: flex;
182
+ align-items: center;
176
183
  justify-content: space-between;
177
- padding: var(--fui-space-1, $fui-space-1) var(--fui-space-2, $fui-space-2);
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
- width: 100%;
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-active, $fui-color-accent-active);
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-space-1, $fui-space-1) var(--fui-space-2, $fui-space-2);
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
- width: 100%;
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-space-4, $fui-space-4);
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-space-2, $fui-space-2);
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-space-1, $fui-space-1) var(--fui-space-2, $fui-space-2);
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