uikit-react-public 0.49.4 → 0.49.6
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/components/Overlay/Overlay.d.ts +2 -2
- package/dist/components/Overlay/Overlay.stories.d.ts +13 -1
- package/dist/components/Select/index.d.ts +1 -1
- package/dist/components/Select/subcomponents/Panel.d.ts +1 -2
- package/dist/components/ToggleGroup/ToggleGroup.d.ts +15 -0
- package/dist/components/ToggleGroup/ToggleGroup.stories.d.ts +18 -0
- package/dist/components/ToggleGroup/ToggleGroupContext.d.ts +8 -0
- package/dist/components/ToggleGroup/__tests__/ToggleGroup.test.d.ts +1 -0
- package/dist/components/ToggleGroup/index.d.ts +4 -0
- package/dist/components/ToggleGroup/subcomponents/Item.d.ts +11 -0
- package/dist/components/ToggleGroup/subcomponents/Item.stories.d.ts +331 -0
- package/dist/components/ToggleGroup/subcomponents/__tests__/Item.test.d.ts +1 -0
- package/dist/components/index.d.ts +2 -0
- package/dist/index.js +8351 -8121
- package/lib/components/Icon/svgImports.ts +4 -2
- package/lib/components/Overlay/Overlay.tsx +9 -3
- package/lib/components/Select/Select.mdx +1 -0
- package/lib/components/Select/__tests__/Select.test.tsx +137 -4
- package/lib/components/Select/index.ts +5 -1
- package/lib/components/Select/subcomponents/CustomSelect.tsx +42 -43
- package/lib/components/Select/subcomponents/Panel.tsx +2 -8
- package/lib/components/ToggleGroup/ToggleGroup.mdx +31 -0
- package/lib/components/ToggleGroup/ToggleGroup.stories.tsx +120 -0
- package/lib/components/ToggleGroup/ToggleGroup.tsx +144 -0
- package/lib/components/ToggleGroup/ToggleGroupContext.tsx +25 -0
- package/lib/components/ToggleGroup/__tests__/ToggleGroup.test.tsx +468 -0
- package/lib/components/ToggleGroup/index.ts +4 -0
- package/lib/components/ToggleGroup/subcomponents/Item.stories.tsx +63 -0
- package/lib/components/ToggleGroup/subcomponents/Item.tsx +233 -0
- package/lib/components/ToggleGroup/subcomponents/__tests__/Item.test.tsx +195 -0
- package/lib/components/index.ts +3 -0
- package/package.json +3 -3
- package/dist/components/Icon/svgImports.d.ts +0 -8
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Children,
|
|
4
|
+
isValidElement,
|
|
5
|
+
KeyboardEvent,
|
|
6
|
+
useId,
|
|
7
|
+
useState,
|
|
8
|
+
} from 'react';
|
|
9
|
+
|
|
10
|
+
import { css } from '@emotion/css';
|
|
11
|
+
import useTheme from '../../theme/useTheme';
|
|
12
|
+
import useMediaQuery from '../../hooks/useMediaQuery';
|
|
13
|
+
import Select, { NonClearableSelectProps } from '../Select';
|
|
14
|
+
import ToggleGroupContext from './ToggleGroupContext';
|
|
15
|
+
import Item, { ItemProps } from './subcomponents/Item';
|
|
16
|
+
|
|
17
|
+
export interface ToggleGroupProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
18
|
+
children: React.ReactNode;
|
|
19
|
+
value?: string;
|
|
20
|
+
defaultValue?: string;
|
|
21
|
+
onValueChange?: (value: string) => void;
|
|
22
|
+
selectProps?: Omit<
|
|
23
|
+
NonClearableSelectProps<string>,
|
|
24
|
+
'options' | 'value' | 'onValueChange'
|
|
25
|
+
>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const MOBILE_SELECT_ITEM_THRESHOLD = 2;
|
|
29
|
+
|
|
30
|
+
const NAVIGATION_KEYS = ['ArrowRight', 'ArrowLeft', 'Home', 'End'];
|
|
31
|
+
|
|
32
|
+
const getEnabledItems = (container: HTMLElement) =>
|
|
33
|
+
Array.from(
|
|
34
|
+
container.querySelectorAll<HTMLInputElement>(
|
|
35
|
+
'[role="radio"]:not(:disabled)'
|
|
36
|
+
)
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const getItemsFromChildren = (
|
|
40
|
+
children: React.ReactNode
|
|
41
|
+
): React.ReactElement<ItemProps>[] =>
|
|
42
|
+
Children.toArray(children).flatMap((child) => {
|
|
43
|
+
if (!isValidElement<{ children?: React.ReactNode }>(child)) {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
if (child.type === Item) {
|
|
47
|
+
return [child as React.ReactElement<ItemProps>];
|
|
48
|
+
}
|
|
49
|
+
return getItemsFromChildren(child.props.children);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const ToggleGroup = ({
|
|
53
|
+
children,
|
|
54
|
+
value,
|
|
55
|
+
defaultValue,
|
|
56
|
+
onValueChange,
|
|
57
|
+
selectProps,
|
|
58
|
+
}: ToggleGroupProps) => {
|
|
59
|
+
const [theme] = useTheme();
|
|
60
|
+
const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);
|
|
61
|
+
const isControlled = value !== undefined;
|
|
62
|
+
const selectedValue = value ?? uncontrolledValue;
|
|
63
|
+
const isMobile = !useMediaQuery(`(min-width: ${theme.breakpoints.tablet}px)`);
|
|
64
|
+
const items = getItemsFromChildren(children);
|
|
65
|
+
const name = useId();
|
|
66
|
+
|
|
67
|
+
const setValue = (nextValue: string) => {
|
|
68
|
+
if (!isControlled) {
|
|
69
|
+
setUncontrolledValue(nextValue);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
onValueChange?.(nextValue);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
|
76
|
+
if (!NAVIGATION_KEYS.includes(event.key)) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const enabledItems = getEnabledItems(event.currentTarget);
|
|
81
|
+
const currentIndex = enabledItems.indexOf(
|
|
82
|
+
document.activeElement as HTMLInputElement
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
if (currentIndex === -1) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
event.preventDefault();
|
|
90
|
+
|
|
91
|
+
const nextIndex =
|
|
92
|
+
{
|
|
93
|
+
ArrowRight: (currentIndex + 1) % enabledItems.length,
|
|
94
|
+
ArrowLeft:
|
|
95
|
+
(currentIndex - 1 + enabledItems.length) % enabledItems.length,
|
|
96
|
+
Home: 0,
|
|
97
|
+
End: enabledItems.length - 1,
|
|
98
|
+
}[event.key] ?? currentIndex;
|
|
99
|
+
|
|
100
|
+
const nextItem = enabledItems[nextIndex];
|
|
101
|
+
nextItem.focus();
|
|
102
|
+
setValue(nextItem.dataset.uclToggleGroupValue as string);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const toggleGroupStyle = css`
|
|
106
|
+
display: flex;
|
|
107
|
+
gap: -1px;
|
|
108
|
+
padding: ${theme.padding.p4};
|
|
109
|
+
background-color: ${theme.colour.fill.subtle};
|
|
110
|
+
border-radius: ${theme.radius.r4};
|
|
111
|
+
`;
|
|
112
|
+
|
|
113
|
+
if (isMobile && items.length > MOBILE_SELECT_ITEM_THRESHOLD) {
|
|
114
|
+
return (
|
|
115
|
+
<Select
|
|
116
|
+
{...selectProps}
|
|
117
|
+
options={items.map((item) => ({
|
|
118
|
+
label: item.props.label,
|
|
119
|
+
value: item.props.value,
|
|
120
|
+
}))}
|
|
121
|
+
value={selectedValue}
|
|
122
|
+
onValueChange={setValue}
|
|
123
|
+
/>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<ToggleGroupContext.Provider
|
|
129
|
+
value={{ value: selectedValue, setValue, name }}
|
|
130
|
+
>
|
|
131
|
+
<div
|
|
132
|
+
className={toggleGroupStyle}
|
|
133
|
+
role='radiogroup'
|
|
134
|
+
onKeyDown={handleKeyDown}
|
|
135
|
+
>
|
|
136
|
+
{children}
|
|
137
|
+
</div>
|
|
138
|
+
</ToggleGroupContext.Provider>
|
|
139
|
+
);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
ToggleGroup.Item = Item;
|
|
143
|
+
|
|
144
|
+
export default ToggleGroup;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createContext, use } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface ToggleGroupContextType {
|
|
4
|
+
value: string | undefined;
|
|
5
|
+
setValue: (value: string) => void;
|
|
6
|
+
name: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const ToggleGroupContext = createContext<ToggleGroupContextType | undefined>(
|
|
10
|
+
undefined
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
export const useToggleGroupContext = () => {
|
|
14
|
+
const context = use(ToggleGroupContext);
|
|
15
|
+
|
|
16
|
+
if (!context) {
|
|
17
|
+
throw new Error(
|
|
18
|
+
'ToggleGroupItem must be used within a ToggleGroup component'
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return context;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export default ToggleGroupContext;
|
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
|
|
2
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
3
|
+
import userEvent from '@testing-library/user-event';
|
|
4
|
+
import ToggleGroup from '../ToggleGroup';
|
|
5
|
+
import ToggleGroupItem from '../subcomponents/Item';
|
|
6
|
+
import { ThemeContextProvider } from '../../../theme/useTheme';
|
|
7
|
+
|
|
8
|
+
const mockMatchMedia = (matchesDesktop: boolean) => {
|
|
9
|
+
globalThis.matchMedia = vi.fn().mockImplementation((query) => ({
|
|
10
|
+
matches: query.includes('min-width') ? matchesDesktop : false,
|
|
11
|
+
media: query,
|
|
12
|
+
addListener: vi.fn(),
|
|
13
|
+
removeListener: vi.fn(),
|
|
14
|
+
addEventListener: vi.fn(),
|
|
15
|
+
removeEventListener: vi.fn(),
|
|
16
|
+
dispatchEvent: vi.fn(),
|
|
17
|
+
}));
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const wrap = (ui: React.ReactElement) =>
|
|
21
|
+
render(<ThemeContextProvider>{ui}</ThemeContextProvider>);
|
|
22
|
+
|
|
23
|
+
describe('ToggleGroup', () => {
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
mockMatchMedia(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('renders a radiogroup with selectable radio items', () => {
|
|
29
|
+
const onValueChange = vi.fn();
|
|
30
|
+
|
|
31
|
+
wrap(
|
|
32
|
+
<ToggleGroup
|
|
33
|
+
defaultValue='option-1'
|
|
34
|
+
onValueChange={onValueChange}
|
|
35
|
+
>
|
|
36
|
+
<ToggleGroupItem
|
|
37
|
+
value='option-1'
|
|
38
|
+
label='Option 1'
|
|
39
|
+
/>
|
|
40
|
+
<ToggleGroupItem
|
|
41
|
+
value='option-2'
|
|
42
|
+
label='Option 2'
|
|
43
|
+
/>
|
|
44
|
+
</ToggleGroup>
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
expect(screen.getByRole('radiogroup')).toBeInTheDocument();
|
|
48
|
+
expect(screen.getByRole('radio', { name: 'Option 1' })).toHaveAttribute(
|
|
49
|
+
'aria-checked',
|
|
50
|
+
'true'
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
fireEvent.click(screen.getByRole('radio', { name: 'Option 2' }));
|
|
54
|
+
|
|
55
|
+
expect(onValueChange).toHaveBeenCalledWith('option-2');
|
|
56
|
+
expect(screen.getByRole('radio', { name: 'Option 2' })).toHaveAttribute(
|
|
57
|
+
'aria-checked',
|
|
58
|
+
'true'
|
|
59
|
+
);
|
|
60
|
+
expect(screen.getByRole('radio', { name: 'Option 1' })).toHaveAttribute(
|
|
61
|
+
'aria-checked',
|
|
62
|
+
'false'
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('only ever has one selected item at a time', () => {
|
|
67
|
+
wrap(
|
|
68
|
+
<ToggleGroup defaultValue='option-1'>
|
|
69
|
+
<ToggleGroupItem
|
|
70
|
+
value='option-1'
|
|
71
|
+
label='Option 1'
|
|
72
|
+
/>
|
|
73
|
+
<ToggleGroupItem
|
|
74
|
+
value='option-2'
|
|
75
|
+
label='Option 2'
|
|
76
|
+
/>
|
|
77
|
+
<ToggleGroupItem
|
|
78
|
+
value='option-3'
|
|
79
|
+
label='Option 3'
|
|
80
|
+
/>
|
|
81
|
+
</ToggleGroup>
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
fireEvent.click(screen.getByRole('radio', { name: 'Option 3' }));
|
|
85
|
+
|
|
86
|
+
const checked = screen
|
|
87
|
+
.getAllByRole('radio')
|
|
88
|
+
.filter((item) => item.getAttribute('aria-checked') === 'true');
|
|
89
|
+
|
|
90
|
+
expect(checked).toHaveLength(1);
|
|
91
|
+
expect(checked[0]).toHaveAccessibleName('Option 3');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('behaves as a controlled component when value is provided', () => {
|
|
95
|
+
const onValueChange = vi.fn();
|
|
96
|
+
|
|
97
|
+
const { rerender } = wrap(
|
|
98
|
+
<ToggleGroup
|
|
99
|
+
value='option-1'
|
|
100
|
+
onValueChange={onValueChange}
|
|
101
|
+
>
|
|
102
|
+
<ToggleGroupItem
|
|
103
|
+
value='option-1'
|
|
104
|
+
label='Option 1'
|
|
105
|
+
/>
|
|
106
|
+
<ToggleGroupItem
|
|
107
|
+
value='option-2'
|
|
108
|
+
label='Option 2'
|
|
109
|
+
/>
|
|
110
|
+
</ToggleGroup>
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
fireEvent.click(screen.getByRole('radio', { name: 'Option 2' }));
|
|
114
|
+
|
|
115
|
+
expect(onValueChange).toHaveBeenCalledWith('option-2');
|
|
116
|
+
// Selection does not change without the caller updating the value prop
|
|
117
|
+
expect(screen.getByRole('radio', { name: 'Option 1' })).toHaveAttribute(
|
|
118
|
+
'aria-checked',
|
|
119
|
+
'true'
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
rerender(
|
|
123
|
+
<ThemeContextProvider>
|
|
124
|
+
<ToggleGroup
|
|
125
|
+
value='option-2'
|
|
126
|
+
onValueChange={onValueChange}
|
|
127
|
+
>
|
|
128
|
+
<ToggleGroupItem
|
|
129
|
+
value='option-1'
|
|
130
|
+
label='Option 1'
|
|
131
|
+
/>
|
|
132
|
+
<ToggleGroupItem
|
|
133
|
+
value='option-2'
|
|
134
|
+
label='Option 2'
|
|
135
|
+
/>
|
|
136
|
+
</ToggleGroup>
|
|
137
|
+
</ThemeContextProvider>
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
expect(screen.getByRole('radio', { name: 'Option 2' })).toHaveAttribute(
|
|
141
|
+
'aria-checked',
|
|
142
|
+
'true'
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('moves focus and selection with arrow keys', () => {
|
|
147
|
+
wrap(
|
|
148
|
+
<ToggleGroup defaultValue='option-1'>
|
|
149
|
+
<ToggleGroupItem
|
|
150
|
+
value='option-1'
|
|
151
|
+
label='Option 1'
|
|
152
|
+
/>
|
|
153
|
+
<ToggleGroupItem
|
|
154
|
+
value='option-2'
|
|
155
|
+
label='Option 2'
|
|
156
|
+
/>
|
|
157
|
+
<ToggleGroupItem
|
|
158
|
+
value='option-3'
|
|
159
|
+
label='Option 3'
|
|
160
|
+
/>
|
|
161
|
+
</ToggleGroup>
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const option1 = screen.getByRole('radio', { name: 'Option 1' });
|
|
165
|
+
const option2 = screen.getByRole('radio', { name: 'Option 2' });
|
|
166
|
+
const option3 = screen.getByRole('radio', { name: 'Option 3' });
|
|
167
|
+
|
|
168
|
+
option1.focus();
|
|
169
|
+
fireEvent.keyDown(screen.getByRole('radiogroup'), { key: 'ArrowRight' });
|
|
170
|
+
|
|
171
|
+
expect(option2).toHaveFocus();
|
|
172
|
+
expect(option2).toHaveAttribute('aria-checked', 'true');
|
|
173
|
+
|
|
174
|
+
fireEvent.keyDown(screen.getByRole('radiogroup'), { key: 'End' });
|
|
175
|
+
|
|
176
|
+
expect(option3).toHaveFocus();
|
|
177
|
+
expect(option3).toHaveAttribute('aria-checked', 'true');
|
|
178
|
+
|
|
179
|
+
fireEvent.keyDown(screen.getByRole('radiogroup'), { key: 'Home' });
|
|
180
|
+
|
|
181
|
+
expect(option1).toHaveFocus();
|
|
182
|
+
expect(option1).toHaveAttribute('aria-checked', 'true');
|
|
183
|
+
|
|
184
|
+
fireEvent.keyDown(screen.getByRole('radiogroup'), { key: 'ArrowLeft' });
|
|
185
|
+
|
|
186
|
+
expect(option3).toHaveFocus();
|
|
187
|
+
expect(option3).toHaveAttribute('aria-checked', 'true');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('skips disabled items when navigating with arrow keys', () => {
|
|
191
|
+
wrap(
|
|
192
|
+
<ToggleGroup defaultValue='option-1'>
|
|
193
|
+
<ToggleGroupItem
|
|
194
|
+
value='option-1'
|
|
195
|
+
label='Option 1'
|
|
196
|
+
/>
|
|
197
|
+
<ToggleGroupItem
|
|
198
|
+
value='option-2'
|
|
199
|
+
label='Option 2'
|
|
200
|
+
disabled
|
|
201
|
+
/>
|
|
202
|
+
<ToggleGroupItem
|
|
203
|
+
value='option-3'
|
|
204
|
+
label='Option 3'
|
|
205
|
+
/>
|
|
206
|
+
</ToggleGroup>
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
const option1 = screen.getByRole('radio', { name: 'Option 1' });
|
|
210
|
+
const option3 = screen.getByRole('radio', { name: 'Option 3' });
|
|
211
|
+
|
|
212
|
+
option1.focus();
|
|
213
|
+
fireEvent.keyDown(screen.getByRole('radiogroup'), { key: 'ArrowRight' });
|
|
214
|
+
|
|
215
|
+
expect(option3).toHaveFocus();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('ignores non-navigation keys', () => {
|
|
219
|
+
wrap(
|
|
220
|
+
<ToggleGroup defaultValue='option-1'>
|
|
221
|
+
<ToggleGroupItem
|
|
222
|
+
value='option-1'
|
|
223
|
+
label='Option 1'
|
|
224
|
+
/>
|
|
225
|
+
<ToggleGroupItem
|
|
226
|
+
value='option-2'
|
|
227
|
+
label='Option 2'
|
|
228
|
+
/>
|
|
229
|
+
</ToggleGroup>
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
const option1 = screen.getByRole('radio', { name: 'Option 1' });
|
|
233
|
+
|
|
234
|
+
option1.focus();
|
|
235
|
+
fireEvent.keyDown(screen.getByRole('radiogroup'), { key: 'a' });
|
|
236
|
+
|
|
237
|
+
expect(option1).toHaveFocus();
|
|
238
|
+
expect(option1).toHaveAttribute('aria-checked', 'true');
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
describe('ToggleGroup mobile select fallback', () => {
|
|
243
|
+
beforeAll(() => {
|
|
244
|
+
// CustomOption scrolls the selected option into view; jsdom has no layout engine.
|
|
245
|
+
Object.defineProperty(Element.prototype, 'scrollIntoView', {
|
|
246
|
+
value: vi.fn(),
|
|
247
|
+
writable: true,
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
beforeEach(() => {
|
|
252
|
+
mockMatchMedia(false);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test('renders the radiogroup on mobile when item count is at or below the threshold', () => {
|
|
256
|
+
wrap(
|
|
257
|
+
<ToggleGroup defaultValue='option-1'>
|
|
258
|
+
<ToggleGroupItem
|
|
259
|
+
value='option-1'
|
|
260
|
+
label='Option 1'
|
|
261
|
+
/>
|
|
262
|
+
<ToggleGroupItem
|
|
263
|
+
value='option-2'
|
|
264
|
+
label='Option 2'
|
|
265
|
+
/>
|
|
266
|
+
</ToggleGroup>
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
expect(screen.getByRole('radiogroup')).toBeInTheDocument();
|
|
270
|
+
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test('renders a Select instead of the radiogroup once item count exceeds the threshold', () => {
|
|
274
|
+
wrap(
|
|
275
|
+
<ToggleGroup defaultValue='option-1'>
|
|
276
|
+
<ToggleGroupItem
|
|
277
|
+
value='option-1'
|
|
278
|
+
label='Option 1'
|
|
279
|
+
/>
|
|
280
|
+
<ToggleGroupItem
|
|
281
|
+
value='option-2'
|
|
282
|
+
label='Option 2'
|
|
283
|
+
/>
|
|
284
|
+
<ToggleGroupItem
|
|
285
|
+
value='option-3'
|
|
286
|
+
label='Option 3'
|
|
287
|
+
/>
|
|
288
|
+
</ToggleGroup>
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
expect(screen.queryByRole('radiogroup')).not.toBeInTheDocument();
|
|
292
|
+
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test('counts items nested in fragments toward the mobile select threshold', async () => {
|
|
296
|
+
const user = userEvent.setup();
|
|
297
|
+
|
|
298
|
+
wrap(
|
|
299
|
+
<ToggleGroup defaultValue='option-1'>
|
|
300
|
+
<>
|
|
301
|
+
<ToggleGroupItem
|
|
302
|
+
value='option-1'
|
|
303
|
+
label='Option 1'
|
|
304
|
+
/>
|
|
305
|
+
<ToggleGroupItem
|
|
306
|
+
value='option-2'
|
|
307
|
+
label='Option 2'
|
|
308
|
+
/>
|
|
309
|
+
</>
|
|
310
|
+
<ToggleGroupItem
|
|
311
|
+
value='option-3'
|
|
312
|
+
label='Option 3'
|
|
313
|
+
/>
|
|
314
|
+
</ToggleGroup>
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
|
318
|
+
|
|
319
|
+
await user.click(screen.getByRole('combobox'));
|
|
320
|
+
|
|
321
|
+
expect(
|
|
322
|
+
screen.getByRole('option', { name: 'Option 1' })
|
|
323
|
+
).toBeInTheDocument();
|
|
324
|
+
expect(
|
|
325
|
+
screen.getByRole('option', { name: 'Option 2' })
|
|
326
|
+
).toBeInTheDocument();
|
|
327
|
+
expect(
|
|
328
|
+
screen.getByRole('option', { name: 'Option 3' })
|
|
329
|
+
).toBeInTheDocument();
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test('updates the selected value when an option is chosen from the mobile select', async () => {
|
|
333
|
+
const user = userEvent.setup();
|
|
334
|
+
const onValueChange = vi.fn();
|
|
335
|
+
|
|
336
|
+
wrap(
|
|
337
|
+
<ToggleGroup
|
|
338
|
+
defaultValue='option-1'
|
|
339
|
+
onValueChange={onValueChange}
|
|
340
|
+
>
|
|
341
|
+
<ToggleGroupItem
|
|
342
|
+
value='option-1'
|
|
343
|
+
label='Option 1'
|
|
344
|
+
/>
|
|
345
|
+
<ToggleGroupItem
|
|
346
|
+
value='option-2'
|
|
347
|
+
label='Option 2'
|
|
348
|
+
/>
|
|
349
|
+
<ToggleGroupItem
|
|
350
|
+
value='option-3'
|
|
351
|
+
label='Option 3'
|
|
352
|
+
/>
|
|
353
|
+
</ToggleGroup>
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
await user.click(screen.getByRole('combobox'));
|
|
357
|
+
await user.click(screen.getByRole('option', { name: 'Option 2' }));
|
|
358
|
+
|
|
359
|
+
expect(onValueChange).toHaveBeenCalledWith('option-2');
|
|
360
|
+
expect(screen.getByRole('combobox')).toHaveTextContent('Option 2');
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// The Select fallback only forwards `label`/`value` for each item, so a disabled
|
|
364
|
+
// ToggleGroupItem's disabled state is not carried over to the mobile Select option.
|
|
365
|
+
test('does not prevent selection of a disabled item in the mobile select', async () => {
|
|
366
|
+
const user = userEvent.setup();
|
|
367
|
+
const onValueChange = vi.fn();
|
|
368
|
+
|
|
369
|
+
wrap(
|
|
370
|
+
<ToggleGroup
|
|
371
|
+
defaultValue='option-1'
|
|
372
|
+
onValueChange={onValueChange}
|
|
373
|
+
>
|
|
374
|
+
<ToggleGroupItem
|
|
375
|
+
value='option-1'
|
|
376
|
+
label='Option 1'
|
|
377
|
+
/>
|
|
378
|
+
<ToggleGroupItem
|
|
379
|
+
value='option-2'
|
|
380
|
+
label='Option 2'
|
|
381
|
+
disabled
|
|
382
|
+
/>
|
|
383
|
+
<ToggleGroupItem
|
|
384
|
+
value='option-3'
|
|
385
|
+
label='Option 3'
|
|
386
|
+
/>
|
|
387
|
+
</ToggleGroup>
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
await user.click(screen.getByRole('combobox'));
|
|
391
|
+
await user.click(screen.getByRole('option', { name: 'Option 2' }));
|
|
392
|
+
|
|
393
|
+
expect(onValueChange).toHaveBeenCalledWith('option-2');
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test('renders a native <select> with matching options when selectProps requests the native variant', () => {
|
|
397
|
+
wrap(
|
|
398
|
+
<ToggleGroup
|
|
399
|
+
defaultValue='option-1'
|
|
400
|
+
selectProps={{
|
|
401
|
+
native: true,
|
|
402
|
+
nativeHtmlAttributes: { onChange: vi.fn() },
|
|
403
|
+
}}
|
|
404
|
+
>
|
|
405
|
+
<ToggleGroupItem
|
|
406
|
+
value='option-1'
|
|
407
|
+
label='Option 1'
|
|
408
|
+
/>
|
|
409
|
+
<ToggleGroupItem
|
|
410
|
+
value='option-2'
|
|
411
|
+
label='Option 2'
|
|
412
|
+
/>
|
|
413
|
+
<ToggleGroupItem
|
|
414
|
+
value='option-3'
|
|
415
|
+
label='Option 3'
|
|
416
|
+
/>
|
|
417
|
+
</ToggleGroup>
|
|
418
|
+
);
|
|
419
|
+
|
|
420
|
+
const select = screen.getByTestId(
|
|
421
|
+
'ucl-uikit-select--native'
|
|
422
|
+
) as HTMLSelectElement;
|
|
423
|
+
|
|
424
|
+
expect(select.tagName).toBe('SELECT');
|
|
425
|
+
expect(select.value).toBe('option-1');
|
|
426
|
+
expect(
|
|
427
|
+
Array.from(select.querySelectorAll('option')).map(
|
|
428
|
+
(option) => option.textContent
|
|
429
|
+
)
|
|
430
|
+
).toEqual(['Option 1', 'Option 2', 'Option 3']);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test('relies on the caller-supplied nativeHtmlAttributes.onChange rather than onValueChange for native selects', () => {
|
|
434
|
+
const onValueChange = vi.fn();
|
|
435
|
+
const nativeOnChange = vi.fn();
|
|
436
|
+
|
|
437
|
+
wrap(
|
|
438
|
+
<ToggleGroup
|
|
439
|
+
defaultValue='option-1'
|
|
440
|
+
onValueChange={onValueChange}
|
|
441
|
+
selectProps={{
|
|
442
|
+
native: true,
|
|
443
|
+
nativeHtmlAttributes: { onChange: nativeOnChange },
|
|
444
|
+
}}
|
|
445
|
+
>
|
|
446
|
+
<ToggleGroupItem
|
|
447
|
+
value='option-1'
|
|
448
|
+
label='Option 1'
|
|
449
|
+
/>
|
|
450
|
+
<ToggleGroupItem
|
|
451
|
+
value='option-2'
|
|
452
|
+
label='Option 2'
|
|
453
|
+
/>
|
|
454
|
+
<ToggleGroupItem
|
|
455
|
+
value='option-3'
|
|
456
|
+
label='Option 3'
|
|
457
|
+
/>
|
|
458
|
+
</ToggleGroup>
|
|
459
|
+
);
|
|
460
|
+
|
|
461
|
+
fireEvent.change(screen.getByTestId('ucl-uikit-select--native'), {
|
|
462
|
+
target: { value: 'option-2' },
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
expect(nativeOnChange).toHaveBeenCalled();
|
|
466
|
+
expect(onValueChange).not.toHaveBeenCalled();
|
|
467
|
+
});
|
|
468
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
|
|
3
|
+
import ToggleGroup from '../ToggleGroup';
|
|
4
|
+
import Item from './Item';
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: 'Components/ToggleGroup/Item',
|
|
8
|
+
component: Item,
|
|
9
|
+
parameters: {
|
|
10
|
+
layout: 'centered',
|
|
11
|
+
},
|
|
12
|
+
tags: ['autodocs'],
|
|
13
|
+
decorators: [
|
|
14
|
+
(Story) => (
|
|
15
|
+
<ToggleGroup defaultValue='option-1'>
|
|
16
|
+
<Story />
|
|
17
|
+
</ToggleGroup>
|
|
18
|
+
),
|
|
19
|
+
],
|
|
20
|
+
} satisfies Meta<typeof Item>;
|
|
21
|
+
|
|
22
|
+
export default meta;
|
|
23
|
+
|
|
24
|
+
type Story = StoryObj<typeof meta>;
|
|
25
|
+
|
|
26
|
+
export const Selected: Story = {
|
|
27
|
+
args: {
|
|
28
|
+
value: 'option-1',
|
|
29
|
+
label: 'Selected',
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const Unselected: Story = {
|
|
34
|
+
args: {
|
|
35
|
+
value: 'option-2',
|
|
36
|
+
label: 'Unselected',
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const WithIcon: Story = {
|
|
41
|
+
args: {
|
|
42
|
+
value: 'option-2',
|
|
43
|
+
label: 'With icon',
|
|
44
|
+
icon: 'Calendar',
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const WithSublabel: Story = {
|
|
49
|
+
args: {
|
|
50
|
+
value: 'option-2',
|
|
51
|
+
label: 'With sublabel',
|
|
52
|
+
sublabel: 'Sublabel text',
|
|
53
|
+
icon: 'User',
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const Disabled: Story = {
|
|
58
|
+
args: {
|
|
59
|
+
value: 'option-2',
|
|
60
|
+
label: 'Disabled',
|
|
61
|
+
disabled: true,
|
|
62
|
+
},
|
|
63
|
+
};
|