paris 0.21.2 → 0.22.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/CHANGELOG.md +39 -0
- package/package.json +1 -1
- package/src/helpers/OpenChangeEffect.tsx +21 -0
- package/src/helpers/useControllableState.test.ts +88 -0
- package/src/helpers/useControllableState.ts +59 -0
- package/src/stories/accordionselect/AccordionSelect.test.tsx +72 -0
- package/src/stories/accordionselect/AccordionSelect.tsx +22 -12
- package/src/stories/checkbox/Checkbox.test.tsx +53 -0
- package/src/stories/checkbox/Checkbox.tsx +21 -6
- package/src/stories/combobox/Combobox.test.tsx +111 -0
- package/src/stories/combobox/Combobox.tsx +192 -137
- package/src/stories/drawer/Drawer.module.scss +56 -15
- package/src/stories/drawer/Drawer.stories.tsx +287 -109
- package/src/stories/drawer/Drawer.test.tsx +486 -11
- package/src/stories/drawer/Drawer.tsx +366 -240
- package/src/stories/drawer/DrawerActions.tsx +28 -0
- package/src/stories/drawer/DrawerBottomPanel.tsx +55 -0
- package/src/stories/drawer/DrawerContext.tsx +31 -0
- package/src/stories/drawer/DrawerPage.tsx +37 -0
- package/src/stories/drawer/DrawerPageContext.tsx +35 -0
- package/src/stories/drawer/DrawerPaginationContext.tsx +22 -0
- package/src/stories/drawer/DrawerProgressBar.tsx +72 -0
- package/src/stories/drawer/DrawerSlotContext.tsx +172 -0
- package/src/stories/drawer/DrawerTitle.tsx +35 -0
- package/src/stories/drawer/index.ts +9 -0
- package/src/stories/menu/Menu.test.tsx +43 -0
- package/src/stories/menu/Menu.tsx +13 -2
- package/src/stories/popover/Popover.tsx +8 -5
- package/src/stories/select/Select.module.scss +1 -1
- package/src/stories/select/Select.test.tsx +108 -0
- package/src/stories/select/Select.tsx +121 -92
- package/src/test/render.tsx +2 -2
|
@@ -230,4 +230,112 @@ describe('Select', () => {
|
|
|
230
230
|
expect(handleChange).toHaveBeenCalledWith('2');
|
|
231
231
|
});
|
|
232
232
|
});
|
|
233
|
+
|
|
234
|
+
describe('uncontrolled mode', () => {
|
|
235
|
+
it('renders with defaultValue', () => {
|
|
236
|
+
render(<Select options={options} defaultValue="2" />);
|
|
237
|
+
expect(screen.getByText('EP')).toBeInTheDocument();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('renders with placeholder when no defaultValue', () => {
|
|
241
|
+
render(<Select options={options} placeholder="Pick one" />);
|
|
242
|
+
expect(screen.getByText('Pick one')).toBeInTheDocument();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('updates selection without external state (listbox)', async () => {
|
|
246
|
+
const { user } = render(<Select options={options} defaultValue={null} />);
|
|
247
|
+
|
|
248
|
+
await user.click(screen.getByText('Select an option'));
|
|
249
|
+
await waitFor(() => {
|
|
250
|
+
expect(screen.getByText('EP')).toBeInTheDocument();
|
|
251
|
+
});
|
|
252
|
+
await user.click(screen.getByText('EP'));
|
|
253
|
+
|
|
254
|
+
await waitFor(() => {
|
|
255
|
+
const button = screen.getByRole('button', { expanded: false });
|
|
256
|
+
expect(button).toHaveTextContent('EP');
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('calls onChange in uncontrolled mode', async () => {
|
|
261
|
+
const handleChange = vi.fn();
|
|
262
|
+
const { user } = render(<Select options={options} defaultValue={null} onChange={handleChange} />);
|
|
263
|
+
|
|
264
|
+
await user.click(screen.getByText('Select an option'));
|
|
265
|
+
await waitFor(() => {
|
|
266
|
+
expect(screen.getByText('EP')).toBeInTheDocument();
|
|
267
|
+
});
|
|
268
|
+
await user.click(screen.getByText('EP'));
|
|
269
|
+
|
|
270
|
+
expect(handleChange).toHaveBeenCalledWith('2');
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('updates selection without external state (radio)', async () => {
|
|
274
|
+
const { user } = render(<Select options={options} kind="radio" defaultValue={null} />);
|
|
275
|
+
|
|
276
|
+
await user.click(screen.getByText('EP'));
|
|
277
|
+
|
|
278
|
+
await waitFor(() => {
|
|
279
|
+
const radio = screen.getByRole('radio', { name: 'EP' });
|
|
280
|
+
expect(radio).toHaveAttribute('aria-checked', 'true');
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('updates selection without external state (card)', async () => {
|
|
285
|
+
const { user } = render(<Select options={options} kind="card" defaultValue={null} />);
|
|
286
|
+
|
|
287
|
+
await user.click(screen.getByText('EP'));
|
|
288
|
+
|
|
289
|
+
await waitFor(() => {
|
|
290
|
+
const radio = screen.getByRole('radio', { name: 'EP' });
|
|
291
|
+
expect(radio).toHaveAttribute('aria-checked', 'true');
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('updates selection without external state (segmented)', async () => {
|
|
296
|
+
const { user } = render(<Select options={options} kind="segmented" defaultValue={null} />);
|
|
297
|
+
|
|
298
|
+
// Segmented defaults to first option when no value
|
|
299
|
+
const firstRadio = screen.getByRole('radio', { name: 'Single' });
|
|
300
|
+
expect(firstRadio).toHaveAttribute('aria-checked', 'true');
|
|
301
|
+
|
|
302
|
+
await user.click(screen.getByText('EP'));
|
|
303
|
+
|
|
304
|
+
await waitFor(() => {
|
|
305
|
+
const radio = screen.getByRole('radio', { name: 'EP' });
|
|
306
|
+
expect(radio).toHaveAttribute('aria-checked', 'true');
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
describe('onOpenChange', () => {
|
|
312
|
+
it('calls onOpenChange when the listbox opens', async () => {
|
|
313
|
+
const handleOpenChange = vi.fn();
|
|
314
|
+
const { user } = render(
|
|
315
|
+
<Select options={options} onOpenChange={handleOpenChange} placeholder="Pick one" />,
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
await user.click(screen.getByText('Pick one'));
|
|
319
|
+
|
|
320
|
+
await waitFor(() => {
|
|
321
|
+
expect(handleOpenChange).toHaveBeenCalledWith(true);
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('calls onOpenChange when the listbox closes', async () => {
|
|
326
|
+
const handleOpenChange = vi.fn();
|
|
327
|
+
const { user } = render(<ControlledSelect onOpenChange={handleOpenChange} />);
|
|
328
|
+
|
|
329
|
+
await user.click(screen.getByText('Select an option'));
|
|
330
|
+
await waitFor(() => {
|
|
331
|
+
expect(screen.getByText('EP')).toBeInTheDocument();
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
await user.click(screen.getByText('EP'));
|
|
335
|
+
|
|
336
|
+
await waitFor(() => {
|
|
337
|
+
expect(handleOpenChange).toHaveBeenCalledWith(false);
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
});
|
|
233
341
|
});
|
|
@@ -7,7 +7,9 @@ import { clsx } from 'clsx';
|
|
|
7
7
|
import { motion } from 'framer-motion';
|
|
8
8
|
import type { ComponentPropsWithoutRef, CSSProperties, ForwardedRef, ReactNode } from 'react';
|
|
9
9
|
import { forwardRef, useId } from 'react';
|
|
10
|
+
import { OpenChangeEffect } from '../../helpers/OpenChangeEffect';
|
|
10
11
|
import { MemoizedEnhancer } from '../../helpers/renderEnhancer';
|
|
12
|
+
import { useControllableState } from '../../helpers/useControllableState';
|
|
11
13
|
import { Field } from '../field';
|
|
12
14
|
import { Check, Icon } from '../icon';
|
|
13
15
|
import type { InputProps } from '../input';
|
|
@@ -47,6 +49,10 @@ export type CommonSelectProps<T = Record<string, any>> = {
|
|
|
47
49
|
* @default compact
|
|
48
50
|
*/
|
|
49
51
|
segmentedHeight?: 'compact' | 'tall';
|
|
52
|
+
/**
|
|
53
|
+
* Called when the listbox dropdown opens or closes. Only applicable to kind="listbox".
|
|
54
|
+
*/
|
|
55
|
+
onOpenChange?: (open: boolean) => void;
|
|
50
56
|
/**
|
|
51
57
|
* Prop overrides for other rendered elements. Overrides for the input itself should be passed directly to the component.
|
|
52
58
|
*/
|
|
@@ -69,6 +75,8 @@ export type SingleSelectProps<T = Record<string, any>> = {
|
|
|
69
75
|
* This should exactly match the option IDs passed in the `options` prop. If `null`, no option will be selected.
|
|
70
76
|
*/
|
|
71
77
|
value?: Option<T>['id'] | null;
|
|
78
|
+
/** The initial value for uncontrolled mode. If `value` is provided, this is ignored. */
|
|
79
|
+
defaultValue?: Option<T>['id'] | null;
|
|
72
80
|
/**
|
|
73
81
|
* The interaction handler for the Select.
|
|
74
82
|
*/
|
|
@@ -101,6 +109,8 @@ export type MultiSelectProps<T = Record<string, any>> = {
|
|
|
101
109
|
* For multiselect, should be a string[] that matches the option IDs passed in the `options` prop. If `null`, no option will be selected.
|
|
102
110
|
*/
|
|
103
111
|
value?: Option<T>['id'][] | null;
|
|
112
|
+
/** The initial value for uncontrolled multi-select. If `value` is provided, this is ignored. */
|
|
113
|
+
defaultValue?: Option<T>['id'][] | null;
|
|
104
114
|
/**
|
|
105
115
|
* The interaction handler for the Select.
|
|
106
116
|
*/
|
|
@@ -126,6 +136,7 @@ export const Select = forwardRef(
|
|
|
126
136
|
{
|
|
127
137
|
options,
|
|
128
138
|
value,
|
|
139
|
+
defaultValue,
|
|
129
140
|
onChange,
|
|
130
141
|
label,
|
|
131
142
|
status,
|
|
@@ -143,6 +154,7 @@ export const Select = forwardRef(
|
|
|
143
154
|
multiple = false,
|
|
144
155
|
multipleItemsName,
|
|
145
156
|
segmentedHeight = 'compact',
|
|
157
|
+
onOpenChange,
|
|
146
158
|
overrides,
|
|
147
159
|
}: SelectProps<T>,
|
|
148
160
|
ref: ForwardedRef<any>,
|
|
@@ -150,27 +162,33 @@ export const Select = forwardRef(
|
|
|
150
162
|
const inputID = useId();
|
|
151
163
|
const multiItems = multipleItemsName || 'items';
|
|
152
164
|
|
|
165
|
+
const [resolvedValue, setResolvedValue] = useControllableState<string | string[] | null>({
|
|
166
|
+
value: value as string | string[] | null | undefined,
|
|
167
|
+
defaultValue: defaultValue ?? (multiple ? ([] as string[]) : null),
|
|
168
|
+
onChange: onChange as ((value: string | string[] | null) => void) | undefined,
|
|
169
|
+
});
|
|
170
|
+
|
|
153
171
|
// TypeScript can't track discriminated union correlation through destructuring and JSX conditionals.
|
|
154
172
|
// For Listbox: supports both single and multi via overloads, needs explicit union types
|
|
155
173
|
// For RadioGroup: only supports single-select, needs narrowed types
|
|
156
|
-
const listboxValue =
|
|
157
|
-
const listboxOnChange =
|
|
158
|
-
const singleValue =
|
|
159
|
-
const singleOnChange =
|
|
174
|
+
const listboxValue = resolvedValue as string | string[] | null | undefined;
|
|
175
|
+
const listboxOnChange = setResolvedValue as (value: string | string[] | null) => void;
|
|
176
|
+
const singleValue = resolvedValue as string | null | undefined;
|
|
177
|
+
const singleOnChange = setResolvedValue as (value: string | null) => void;
|
|
160
178
|
const buttonText = () => {
|
|
161
|
-
if (!
|
|
179
|
+
if (!resolvedValue || (resolvedValue as string | string[]).length === 0) {
|
|
162
180
|
return placeholder || 'Select an option';
|
|
163
181
|
}
|
|
164
182
|
if (!multiple) {
|
|
165
|
-
return options?.find((o) => o.id ===
|
|
183
|
+
return options?.find((o) => o.id === resolvedValue)?.node;
|
|
166
184
|
}
|
|
167
|
-
if (
|
|
168
|
-
return options?.find((o) => o.id ===
|
|
185
|
+
if (resolvedValue && (resolvedValue as string[]).length === 1) {
|
|
186
|
+
return options?.find((o) => o.id === (resolvedValue as string[])[0])?.node;
|
|
169
187
|
}
|
|
170
|
-
if (
|
|
188
|
+
if ((resolvedValue as string[]).length === options.length) {
|
|
171
189
|
return `All ${multiItems}`;
|
|
172
190
|
}
|
|
173
|
-
return `${
|
|
191
|
+
return `${(resolvedValue as string[]).length} ${multiItems}`;
|
|
174
192
|
};
|
|
175
193
|
return (
|
|
176
194
|
<Field
|
|
@@ -189,95 +207,106 @@ export const Select = forwardRef(
|
|
|
189
207
|
>
|
|
190
208
|
{kind === 'listbox' && (
|
|
191
209
|
<Listbox as="div" ref={ref} value={listboxValue} onChange={listboxOnChange} multiple={multiple}>
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
inputStyles.inputContainer,
|
|
200
|
-
styles.listboxButton,
|
|
201
|
-
styles.field,
|
|
202
|
-
)}
|
|
203
|
-
>
|
|
204
|
-
{!!startEnhancer && (
|
|
205
|
-
<div
|
|
206
|
-
{...overrides?.startEnhancerContainer}
|
|
207
|
-
className={clsx(inputStyles.enhancer, overrides?.startEnhancerContainer?.className)}
|
|
210
|
+
{({ open }) => (
|
|
211
|
+
<>
|
|
212
|
+
<OpenChangeEffect open={open} onOpenChange={onOpenChange} />
|
|
213
|
+
<ListboxButton
|
|
214
|
+
id={inputID}
|
|
215
|
+
{...overrides?.selectInput}
|
|
216
|
+
aria-disabled={disabled}
|
|
208
217
|
data-status={disabled ? 'disabled' : status || 'default'}
|
|
218
|
+
className={clsx(
|
|
219
|
+
overrides?.selectInput?.className,
|
|
220
|
+
inputStyles.inputContainer,
|
|
221
|
+
styles.listboxButton,
|
|
222
|
+
styles.field,
|
|
223
|
+
)}
|
|
209
224
|
>
|
|
210
225
|
{!!startEnhancer && (
|
|
211
|
-
<
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
10,
|
|
226
|
+
<div
|
|
227
|
+
{...overrides?.startEnhancerContainer}
|
|
228
|
+
className={clsx(
|
|
229
|
+
inputStyles.enhancer,
|
|
230
|
+
overrides?.startEnhancerContainer?.className,
|
|
217
231
|
)}
|
|
218
|
-
|
|
232
|
+
data-status={disabled ? 'disabled' : status || 'default'}
|
|
233
|
+
>
|
|
234
|
+
{!!startEnhancer && (
|
|
235
|
+
<MemoizedEnhancer
|
|
236
|
+
enhancer={startEnhancer}
|
|
237
|
+
size={parseInt(
|
|
238
|
+
pget('typography.styles.paragraphSmall.fontSize') ||
|
|
239
|
+
theme.typography.styles.paragraphSmall.fontSize,
|
|
240
|
+
10,
|
|
241
|
+
)}
|
|
242
|
+
/>
|
|
243
|
+
)}
|
|
244
|
+
</div>
|
|
219
245
|
)}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
246
|
+
{buttonText()}
|
|
247
|
+
{endEnhancer ? (
|
|
248
|
+
<div
|
|
249
|
+
{...overrides?.endEnhancerContainer}
|
|
250
|
+
className={clsx(
|
|
251
|
+
inputStyles.enhancer,
|
|
252
|
+
overrides?.endEnhancerContainer?.className,
|
|
253
|
+
)}
|
|
254
|
+
data-status={disabled ? 'disabled' : status || 'default'}
|
|
255
|
+
>
|
|
256
|
+
{!!endEnhancer && (
|
|
257
|
+
<MemoizedEnhancer
|
|
258
|
+
enhancer={endEnhancer}
|
|
259
|
+
size={parseInt(
|
|
260
|
+
pget('typography.styles.paragraphSmall.fontSize') ||
|
|
261
|
+
theme.typography.styles.paragraphSmall.fontSize,
|
|
262
|
+
10,
|
|
263
|
+
)}
|
|
264
|
+
/>
|
|
236
265
|
)}
|
|
266
|
+
</div>
|
|
267
|
+
) : (
|
|
268
|
+
<FontAwesomeIcon
|
|
269
|
+
className={clsx(inputStyles.enhancer, styles.chevron)}
|
|
270
|
+
data-status={disabled ? 'disabled' : status || 'default'}
|
|
271
|
+
width="10px"
|
|
272
|
+
icon={faChevronDown}
|
|
237
273
|
/>
|
|
238
274
|
)}
|
|
239
|
-
</
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
<ListboxOptions
|
|
250
|
-
anchor="bottom start"
|
|
251
|
-
transition
|
|
252
|
-
className={clsx(overrides?.optionsContainer, styles.options)}
|
|
253
|
-
style={
|
|
254
|
-
{
|
|
255
|
-
'--options-maxHeight': `${maxHeight}px`,
|
|
256
|
-
} as CSSProperties
|
|
257
|
-
}
|
|
258
|
-
>
|
|
259
|
-
{(options || []).map((option) => (
|
|
260
|
-
<ListboxOption
|
|
261
|
-
key={option.id}
|
|
262
|
-
value={option.id}
|
|
263
|
-
className={clsx(
|
|
264
|
-
overrides?.option,
|
|
265
|
-
styles.option,
|
|
266
|
-
hasOptionBorder && styles.optionBorder,
|
|
267
|
-
)}
|
|
268
|
-
disabled={option.disabled || false}
|
|
275
|
+
</ListboxButton>
|
|
276
|
+
<ListboxOptions
|
|
277
|
+
anchor="bottom start"
|
|
278
|
+
transition
|
|
279
|
+
className={clsx(overrides?.optionsContainer, styles.options)}
|
|
280
|
+
style={
|
|
281
|
+
{
|
|
282
|
+
'--options-maxHeight': `${maxHeight}px`,
|
|
283
|
+
} as CSSProperties
|
|
284
|
+
}
|
|
269
285
|
>
|
|
270
|
-
{
|
|
271
|
-
<
|
|
272
|
-
{option.
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
286
|
+
{(options || []).map((option) => (
|
|
287
|
+
<ListboxOption
|
|
288
|
+
key={option.id}
|
|
289
|
+
value={option.id}
|
|
290
|
+
className={clsx(
|
|
291
|
+
overrides?.option,
|
|
292
|
+
styles.option,
|
|
293
|
+
hasOptionBorder && styles.optionBorder,
|
|
294
|
+
)}
|
|
295
|
+
disabled={option.disabled || false}
|
|
296
|
+
>
|
|
297
|
+
{typeof option.node === 'string' ? (
|
|
298
|
+
<Text as="span" kind="paragraphSmall">
|
|
299
|
+
{option.node}
|
|
300
|
+
</Text>
|
|
301
|
+
) : (
|
|
302
|
+
option.node
|
|
303
|
+
)}
|
|
304
|
+
<Icon icon={Check} size={12} className={styles.check} />
|
|
305
|
+
</ListboxOption>
|
|
306
|
+
))}
|
|
307
|
+
</ListboxOptions>
|
|
308
|
+
</>
|
|
309
|
+
)}
|
|
281
310
|
</Listbox>
|
|
282
311
|
)}
|
|
283
312
|
{kind === 'radio' && (
|
|
@@ -334,7 +363,7 @@ export const Select = forwardRef(
|
|
|
334
363
|
ref={ref}
|
|
335
364
|
as="div"
|
|
336
365
|
className={styles.segmentedContainer}
|
|
337
|
-
value={singleValue
|
|
366
|
+
value={singleValue ?? options[0].id}
|
|
338
367
|
onChange={singleOnChange}
|
|
339
368
|
>
|
|
340
369
|
{options.map((option) => (
|
|
@@ -346,7 +375,7 @@ export const Select = forwardRef(
|
|
|
346
375
|
disabled={option.disabled || false}
|
|
347
376
|
data-status={disabled ? 'disabled' : status || 'default'}
|
|
348
377
|
>
|
|
349
|
-
{(option.id ===
|
|
378
|
+
{(option.id === resolvedValue || (!resolvedValue && option.id === options[0].id)) && (
|
|
350
379
|
<motion.div
|
|
351
380
|
className={styles.segmentedBackground}
|
|
352
381
|
layoutId={`${inputID}-segmented-selected`}
|
package/src/test/render.tsx
CHANGED
|
@@ -15,6 +15,6 @@ export { act, screen, waitFor, within } from '@testing-library/react';
|
|
|
15
15
|
* Queries the close button rendered by the Paris Button component with shape="circle".
|
|
16
16
|
* Circle-shaped buttons render children as `aria-details` instead of visible text.
|
|
17
17
|
*/
|
|
18
|
-
export function getCloseButton() {
|
|
19
|
-
return document.querySelector(
|
|
18
|
+
export function getCloseButton(label = 'Close dialog') {
|
|
19
|
+
return document.querySelector(`[aria-details="${label}"]`);
|
|
20
20
|
}
|