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.
Files changed (32) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/package.json +1 -1
  3. package/src/helpers/OpenChangeEffect.tsx +21 -0
  4. package/src/helpers/useControllableState.test.ts +88 -0
  5. package/src/helpers/useControllableState.ts +59 -0
  6. package/src/stories/accordionselect/AccordionSelect.test.tsx +72 -0
  7. package/src/stories/accordionselect/AccordionSelect.tsx +22 -12
  8. package/src/stories/checkbox/Checkbox.test.tsx +53 -0
  9. package/src/stories/checkbox/Checkbox.tsx +21 -6
  10. package/src/stories/combobox/Combobox.test.tsx +111 -0
  11. package/src/stories/combobox/Combobox.tsx +192 -137
  12. package/src/stories/drawer/Drawer.module.scss +56 -15
  13. package/src/stories/drawer/Drawer.stories.tsx +287 -109
  14. package/src/stories/drawer/Drawer.test.tsx +486 -11
  15. package/src/stories/drawer/Drawer.tsx +366 -240
  16. package/src/stories/drawer/DrawerActions.tsx +28 -0
  17. package/src/stories/drawer/DrawerBottomPanel.tsx +55 -0
  18. package/src/stories/drawer/DrawerContext.tsx +31 -0
  19. package/src/stories/drawer/DrawerPage.tsx +37 -0
  20. package/src/stories/drawer/DrawerPageContext.tsx +35 -0
  21. package/src/stories/drawer/DrawerPaginationContext.tsx +22 -0
  22. package/src/stories/drawer/DrawerProgressBar.tsx +72 -0
  23. package/src/stories/drawer/DrawerSlotContext.tsx +172 -0
  24. package/src/stories/drawer/DrawerTitle.tsx +35 -0
  25. package/src/stories/drawer/index.ts +9 -0
  26. package/src/stories/menu/Menu.test.tsx +43 -0
  27. package/src/stories/menu/Menu.tsx +13 -2
  28. package/src/stories/popover/Popover.tsx +8 -5
  29. package/src/stories/select/Select.module.scss +1 -1
  30. package/src/stories/select/Select.test.tsx +108 -0
  31. package/src/stories/select/Select.tsx +121 -92
  32. 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 = value as string | string[] | null | undefined;
157
- const listboxOnChange = onChange as ((value: string | string[] | null) => void) | undefined;
158
- const singleValue = value as string | null | undefined;
159
- const singleOnChange = onChange as ((value: string | null) => void) | undefined;
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 (!value || value.length === 0) {
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 === value)?.node;
183
+ return options?.find((o) => o.id === resolvedValue)?.node;
166
184
  }
167
- if (value && value.length === 1) {
168
- return options?.find((o) => o.id === value[0])?.node;
185
+ if (resolvedValue && (resolvedValue as string[]).length === 1) {
186
+ return options?.find((o) => o.id === (resolvedValue as string[])[0])?.node;
169
187
  }
170
- if (value.length === options.length) {
188
+ if ((resolvedValue as string[]).length === options.length) {
171
189
  return `All ${multiItems}`;
172
190
  }
173
- return `${value.length} ${multiItems}`;
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
- <ListboxButton
193
- id={inputID}
194
- {...overrides?.selectInput}
195
- aria-disabled={disabled}
196
- data-status={disabled ? 'disabled' : status || 'default'}
197
- className={clsx(
198
- overrides?.selectInput?.className,
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
- <MemoizedEnhancer
212
- enhancer={startEnhancer}
213
- size={parseInt(
214
- pget('typography.styles.paragraphSmall.fontSize') ||
215
- theme.typography.styles.paragraphSmall.fontSize,
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
- </div>
221
- )}
222
- {buttonText()}
223
- {endEnhancer ? (
224
- <div
225
- {...overrides?.endEnhancerContainer}
226
- className={clsx(inputStyles.enhancer, overrides?.endEnhancerContainer?.className)}
227
- data-status={disabled ? 'disabled' : status || 'default'}
228
- >
229
- {!!endEnhancer && (
230
- <MemoizedEnhancer
231
- enhancer={endEnhancer}
232
- size={parseInt(
233
- pget('typography.styles.paragraphSmall.fontSize') ||
234
- theme.typography.styles.paragraphSmall.fontSize,
235
- 10,
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
- </div>
240
- ) : (
241
- <FontAwesomeIcon
242
- className={clsx(inputStyles.enhancer, styles.chevron)}
243
- data-status={disabled ? 'disabled' : status || 'default'}
244
- width="10px"
245
- icon={faChevronDown}
246
- />
247
- )}
248
- </ListboxButton>
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
- {typeof option.node === 'string' ? (
271
- <Text as="span" kind="paragraphSmall">
272
- {option.node}
273
- </Text>
274
- ) : (
275
- option.node
276
- )}
277
- <Icon icon={Check} size={12} className={styles.check} />
278
- </ListboxOption>
279
- ))}
280
- </ListboxOptions>
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 || options[0].id}
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 === value || (!value && option.id === options[0].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`}
@@ -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('[aria-details="Close dialog"]');
18
+ export function getCloseButton(label = 'Close dialog') {
19
+ return document.querySelector(`[aria-details="${label}"]`);
20
20
  }