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
@@ -2,11 +2,19 @@
2
2
 
3
3
  import { faClose } from '@fortawesome/free-solid-svg-icons';
4
4
  import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
5
- import { ComboboxInput, ComboboxOption, ComboboxOptions, Combobox as HCombobox } from '@headlessui/react';
5
+ import {
6
+ ComboboxButton,
7
+ ComboboxInput,
8
+ ComboboxOption,
9
+ ComboboxOptions,
10
+ Combobox as HCombobox,
11
+ } from '@headlessui/react';
6
12
  import { clsx } from 'clsx';
7
- import type { ComponentPropsWithoutRef, CSSProperties, ReactNode } from 'react';
8
- import { useId, useMemo, useState } from 'react';
13
+ import type { ComponentPropsWithoutRef, CSSProperties, MouseEvent, ReactNode } from 'react';
14
+ import { useCallback, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
15
+ import { OpenChangeEffect } from '../../helpers/OpenChangeEffect';
9
16
  import { MemoizedEnhancer } from '../../helpers/renderEnhancer';
17
+ import { useControllableState } from '../../helpers/useControllableState';
10
18
  import type { ButtonProps } from '../button';
11
19
  import { Button } from '../button';
12
20
  import type { FieldProps } from '../field';
@@ -46,6 +54,10 @@ export type ComboboxProps<T extends Record<string, any>> = {
46
54
  * If `null`, no option will be selected.
47
55
  */
48
56
  value?: Option<T> | null;
57
+ /**
58
+ * The initial value for uncontrolled mode. If `value` is provided, this is ignored.
59
+ */
60
+ defaultValue?: Option<T> | null;
49
61
  /**
50
62
  * The interaction handler for the Combobox. This will be called when the user selects an option from the dropdown.
51
63
  *
@@ -81,6 +93,10 @@ export type ComboboxProps<T extends Record<string, any>> = {
81
93
  * @param value
82
94
  */
83
95
  customValueToOption?: (value: string) => Option<T>;
96
+ /**
97
+ * Called when the combobox dropdown opens or closes.
98
+ */
99
+ onOpenChange?: (open: boolean) => void;
84
100
  /**
85
101
  * Whether to hide the clear button when a value is selected. This will never be hidden if the selected option's node is not a strong, because there is no other way to clear the value as of now.
86
102
  */
@@ -137,6 +153,7 @@ export type ComboboxProps<T extends Record<string, any>> = {
137
153
  export function Combobox<T extends Record<string, any> = Record<string, any>>({
138
154
  options,
139
155
  value,
156
+ defaultValue,
140
157
  onChange,
141
158
  label,
142
159
  status,
@@ -153,6 +170,7 @@ export function Combobox<T extends Record<string, any> = Record<string, any>>({
153
170
  showCustomValueOption = true,
154
171
  customValueString = 'Create "%v"',
155
172
  customValueToOption,
173
+ onOpenChange,
156
174
  hideClearButton = false,
157
175
  maxHeight = 320,
158
176
  hasOptionBorder = false,
@@ -160,8 +178,31 @@ export function Combobox<T extends Record<string, any> = Record<string, any>>({
160
178
  overrides,
161
179
  }: ComboboxProps<T>) {
162
180
  const inputID = useId();
163
- const [selectedID, setSelectedID] = useState<string | null>(value?.id || null);
181
+ const [resolvedValue, setResolvedValue] = useControllableState<Option<T> | null>({
182
+ value,
183
+ defaultValue,
184
+ onChange,
185
+ });
164
186
  const [query, setQuery] = useState('');
187
+ const containerElRef = useRef<HTMLElement | null>(null);
188
+ const inputElRef = useRef<HTMLElement | null>(null);
189
+ const [anchorOffset, setAnchorOffset] = useState(0);
190
+
191
+ const containerRef = useCallback((node: HTMLButtonElement | null) => {
192
+ containerElRef.current = node;
193
+ }, []);
194
+
195
+ const inputRef = useCallback((node: HTMLInputElement | null) => {
196
+ inputElRef.current = node;
197
+ }, []);
198
+
199
+ useLayoutEffect(() => {
200
+ if (containerElRef.current && inputElRef.current) {
201
+ const containerLeft = containerElRef.current.getBoundingClientRect().left;
202
+ const inputLeft = inputElRef.current.getBoundingClientRect().left;
203
+ setAnchorOffset(containerLeft - inputLeft);
204
+ }
205
+ }, [startEnhancer, resolvedValue]);
165
206
 
166
207
  const optionsWithCustomValue = useMemo(
167
208
  () => [...(allowCustomValue && customValueToOption ? [customValueToOption(query)] : []), ...options],
@@ -187,154 +228,168 @@ export function Combobox<T extends Record<string, any> = Record<string, any>>({
187
228
  <HCombobox
188
229
  as="div"
189
230
  immediate={!hideOptionsInitially}
190
- value={selectedID}
231
+ value={resolvedValue?.id ?? null}
191
232
  onChange={(id) => {
192
- if (onChange) {
193
- const sel = optionsWithCustomValue.find((o) => o.id === id);
194
- if (sel) {
195
- onChange(sel);
196
- setSelectedID(sel.id);
197
- } else if (id) {
198
- onChange({
199
- id: null,
200
- node: id,
201
- });
202
- }
233
+ const sel = optionsWithCustomValue.find((o) => o.id === id);
234
+ if (sel) {
235
+ setResolvedValue(sel);
236
+ } else if (id) {
237
+ setResolvedValue({ id: null, node: id } as Option<T>);
203
238
  }
204
239
  }}
205
240
  >
206
- <div
207
- data-status={disabled ? 'disabled' : status || 'default'}
208
- {...overrides?.inputContainer}
209
- className={clsx(overrides?.inputContainer?.className, inputStyles.inputContainer)}
210
- >
211
- {!!startEnhancer && (
212
- <div
213
- {...overrides?.startEnhancerContainer}
214
- className={clsx(inputStyles.enhancer, overrides?.startEnhancerContainer?.className)}
241
+ {({ open }) => (
242
+ <>
243
+ <OpenChangeEffect open={open} onOpenChange={onOpenChange} />
244
+ <ComboboxButton
245
+ as="div"
246
+ ref={containerRef}
247
+ tabIndex={-1}
215
248
  data-status={disabled ? 'disabled' : status || 'default'}
249
+ {...overrides?.inputContainer}
250
+ className={clsx(overrides?.inputContainer?.className, inputStyles.inputContainer)}
216
251
  >
217
252
  {!!startEnhancer && (
218
- <MemoizedEnhancer
219
- enhancer={startEnhancer}
220
- size={parseInt(
221
- pget('typography.styles.paragraphSmall.fontSize') ||
222
- theme.typography.styles.paragraphSmall.fontSize,
223
- 10,
253
+ <div
254
+ {...overrides?.startEnhancerContainer}
255
+ className={clsx(inputStyles.enhancer, overrides?.startEnhancerContainer?.className)}
256
+ data-status={disabled ? 'disabled' : status || 'default'}
257
+ >
258
+ {!!startEnhancer && (
259
+ <MemoizedEnhancer
260
+ enhancer={startEnhancer}
261
+ size={parseInt(
262
+ pget('typography.styles.paragraphSmall.fontSize') ||
263
+ theme.typography.styles.paragraphSmall.fontSize,
264
+ 10,
265
+ )}
266
+ />
224
267
  )}
225
- />
268
+ </div>
226
269
  )}
227
- </div>
228
- )}
229
- <div className={styles.content}>
230
- {value?.node && typeof value.node !== 'string' ? (
231
- value.node
232
- ) : (
233
- <ComboboxInput
234
- id={inputID}
235
- {...overrides?.input}
236
- placeholder={placeholder}
237
- // value={query}
238
- displayValue={() => value?.node as string}
239
- onChange={(e) => {
240
- setQuery(e.target.value);
241
- if (onInputChange) onInputChange(e.target.value);
242
- if (overrides?.input?.onChange) overrides.input.onChange(e);
243
- if (allowCustomValue && e.target.value) {
244
- onChange?.(
245
- customValueToOption?.(e.target.value) || {
246
- id: null,
247
- node: e.target.value,
248
- },
249
- );
250
- }
251
- }}
252
- aria-disabled={disabled}
253
- data-status={disabled ? 'disabled' : status || 'default'}
254
- className={clsx(overrides?.input?.className, inputStyles.input, styles.field)}
255
- />
256
- )}
257
- </div>
270
+ <div className={styles.content}>
271
+ {resolvedValue?.node && typeof resolvedValue.node !== 'string' ? (
272
+ resolvedValue.node
273
+ ) : (
274
+ <ComboboxInput
275
+ ref={inputRef}
276
+ id={inputID}
277
+ {...overrides?.input}
278
+ placeholder={placeholder}
279
+ // value={query}
280
+ displayValue={() => resolvedValue?.node as string}
281
+ onClick={(e: MouseEvent<HTMLInputElement>) => {
282
+ e.stopPropagation();
283
+ overrides?.input?.onClick?.(e);
284
+ }}
285
+ onKeyDown={(e) => {
286
+ e.stopPropagation();
287
+ overrides?.input?.onKeyDown?.(e);
288
+ }}
289
+ onChange={(e) => {
290
+ setQuery(e.target.value);
291
+ if (onInputChange) onInputChange(e.target.value);
292
+ if (overrides?.input?.onChange) overrides.input.onChange(e);
293
+ if (allowCustomValue && e.target.value) {
294
+ setResolvedValue(
295
+ customValueToOption?.(e.target.value) ||
296
+ ({
297
+ id: null,
298
+ node: e.target.value,
299
+ } as Option<T>),
300
+ );
301
+ }
302
+ }}
303
+ aria-disabled={disabled}
304
+ data-status={disabled ? 'disabled' : status || 'default'}
305
+ className={clsx(overrides?.input?.className, inputStyles.input, styles.field)}
306
+ />
307
+ )}
308
+ </div>
258
309
 
259
- {!!value && (!hideClearButton || typeof value.node !== 'string') && (
260
- <Button
261
- size="xs"
262
- shape="circle"
263
- startEnhancer={<FontAwesomeIcon icon={faClose} fontSize="10px" />}
264
- onClick={() => {
265
- if (onChange) {
266
- onChange(null);
267
- }
268
- setSelectedID(null);
269
- }}
270
- {...overrides?.clearButton}
271
- >
272
- Clear
273
- </Button>
274
- )}
275
- {!!endEnhancer && (
276
- <div
277
- {...overrides?.endEnhancerContainer}
278
- className={clsx(inputStyles.enhancer, overrides?.endEnhancerContainer?.className)}
279
- data-status={disabled ? 'disabled' : status || 'default'}
280
- >
310
+ {!!resolvedValue && (!hideClearButton || typeof resolvedValue.node !== 'string') && (
311
+ <Button
312
+ size="xs"
313
+ shape="circle"
314
+ startEnhancer={<FontAwesomeIcon icon={faClose} fontSize="10px" />}
315
+ onClick={(e) => {
316
+ e.stopPropagation();
317
+ setResolvedValue(null);
318
+ }}
319
+ {...overrides?.clearButton}
320
+ >
321
+ Clear
322
+ </Button>
323
+ )}
281
324
  {!!endEnhancer && (
282
- <MemoizedEnhancer
283
- enhancer={endEnhancer}
284
- size={parseInt(
285
- pget('typography.styles.paragraphSmall.fontSize') ||
286
- theme.typography.styles.paragraphSmall.fontSize,
287
- 10,
325
+ <div
326
+ {...overrides?.endEnhancerContainer}
327
+ className={clsx(inputStyles.enhancer, overrides?.endEnhancerContainer?.className)}
328
+ data-status={disabled ? 'disabled' : status || 'default'}
329
+ >
330
+ {!!endEnhancer && (
331
+ <MemoizedEnhancer
332
+ enhancer={endEnhancer}
333
+ size={parseInt(
334
+ pget('typography.styles.paragraphSmall.fontSize') ||
335
+ theme.typography.styles.paragraphSmall.fontSize,
336
+ 10,
337
+ )}
338
+ />
288
339
  )}
289
- />
340
+ </div>
290
341
  )}
291
- </div>
292
- )}
293
- </div>
294
- <ComboboxOptions
295
- as="ul"
296
- anchor="bottom start"
297
- transition
298
- {...overrides?.optionsContainer}
299
- className={clsx(overrides?.optionsContainer?.className, styles.options)}
300
- style={
301
- {
302
- '--options-maxHeight': `${maxHeight}px`,
303
- ...overrides?.optionsContainer?.style,
304
- } as CSSProperties
305
- }
306
- >
307
- {allowCustomValue && showCustomValueOption && !customValueToOption && query.length > 0 && (
308
- <ComboboxOption
309
- as="li"
310
- value={query}
311
- data-selected={false}
312
- className={clsx(overrides?.customValueOption?.className, styles.option)}
313
- {...overrides?.customValueOption}
342
+ </ComboboxButton>
343
+ <ComboboxOptions
344
+ as="ul"
345
+ anchor={{
346
+ to: 'bottom start',
347
+ gap: 9,
348
+ offset: anchorOffset,
349
+ }}
350
+ transition
351
+ {...overrides?.optionsContainer}
352
+ className={clsx(overrides?.optionsContainer?.className, styles.options)}
353
+ style={
354
+ {
355
+ '--options-maxHeight': `${maxHeight}px`,
356
+ ...overrides?.optionsContainer?.style,
357
+ } as CSSProperties
358
+ }
314
359
  >
315
- <Text as="span" kind="paragraphSmall">
316
- {customValueString.replace('%v', query)}
317
- </Text>
318
- </ComboboxOption>
319
- )}
320
- {(optionsWithCustomValue || []).map((option) => (
321
- <ComboboxOption
322
- as="li"
323
- key={option.id}
324
- value={option.id}
325
- {...overrides?.option}
326
- className={clsx(
327
- overrides?.option?.className,
328
- styles.option,
329
- hasOptionBorder && styles.optionBorder,
360
+ {allowCustomValue && showCustomValueOption && !customValueToOption && query.length > 0 && (
361
+ <ComboboxOption
362
+ as="li"
363
+ value={query}
364
+ data-selected={false}
365
+ className={clsx(overrides?.customValueOption?.className, styles.option)}
366
+ {...overrides?.customValueOption}
367
+ >
368
+ <Text as="span" kind="paragraphSmall">
369
+ {customValueString.replace('%v', query)}
370
+ </Text>
371
+ </ComboboxOption>
330
372
  )}
331
- >
332
- <TextWhenString as="span" kind="paragraphSmall">
333
- {option.node}
334
- </TextWhenString>
335
- </ComboboxOption>
336
- ))}
337
- </ComboboxOptions>
373
+ {(optionsWithCustomValue || []).map((option) => (
374
+ <ComboboxOption
375
+ as="li"
376
+ key={option.id}
377
+ value={option.id}
378
+ {...overrides?.option}
379
+ className={clsx(
380
+ overrides?.option?.className,
381
+ styles.option,
382
+ hasOptionBorder && styles.optionBorder,
383
+ )}
384
+ >
385
+ <TextWhenString as="span" kind="paragraphSmall">
386
+ {option.node}
387
+ </TextWhenString>
388
+ </ComboboxOption>
389
+ ))}
390
+ </ComboboxOptions>
391
+ </>
392
+ )}
338
393
  </HCombobox>
339
394
  </Field>
340
395
  );
@@ -11,8 +11,6 @@ $panelPaddingY: 20px;
11
11
  $duration: var(--pte-animations-duration-relaxed);
12
12
  $paginationDuration: var(--pte-animations-duration-normal);
13
13
  $panelAnimationDelay: var(--pte-animations-duration-fast);
14
- //$panelAnimationDelay: var(--pte-animations-duration-rapid);
15
- //$duration: var(--pte-animations-duration-normal);
16
14
 
17
15
  .root {
18
16
  position: fixed;
@@ -275,7 +273,6 @@ $panelAnimationDelay: var(--pte-animations-duration-fast);
275
273
  position: relative;
276
274
  height: 100%;
277
275
  padding: 0;
278
- //padding: $panelPaddingY $panelPaddingX;
279
276
  background-color: var(--pte-new-colors-surfacePrimary);
280
277
  overflow: auto;
281
278
 
@@ -283,7 +280,6 @@ $panelAnimationDelay: var(--pte-animations-duration-fast);
283
280
  flex-direction: column;
284
281
  justify-content: flex-start;
285
282
  align-items: stretch;
286
- //gap: $panelPaddingY;
287
283
 
288
284
  &.enterFrom {
289
285
  box-shadow: none;
@@ -339,14 +335,9 @@ $panelAnimationDelay: var(--pte-animations-duration-fast);
339
335
 
340
336
  .actionMenu {}
341
337
 
342
- .closeButton {
343
- //position: absolute;
344
- //right: $panelPaddingX;
345
- //top: $panelPaddingY - 5px;
346
- }
338
+ .closeButton {}
347
339
 
348
340
  .content {
349
- //padding: 20px;
350
341
  overflow-y: scroll;
351
342
  height: 100%;
352
343
  }
@@ -405,6 +396,42 @@ $panelAnimationDelay: var(--pte-animations-duration-fast);
405
396
  transition: $paginationDuration var(--pte-animations-timing-easeOutQuad);
406
397
  }
407
398
 
399
+ // Page transition containers
400
+ .pageStack {
401
+ display: grid;
402
+ grid-template-columns: 1fr;
403
+ min-height: 0;
404
+ }
405
+
406
+ .pageStackClip {
407
+ overflow: hidden;
408
+ }
409
+
410
+ .pageStackItem {
411
+ grid-area: 1 / 1;
412
+
413
+ &[data-active='false'] {
414
+ pointer-events: none;
415
+ }
416
+ }
417
+
418
+ // Progress bar
419
+ .progressBar {
420
+ position: relative;
421
+ width: 100%;
422
+ height: 2px;
423
+ background: var(--pte-new-colors-borderMedium);
424
+ }
425
+
426
+ .progressBarFill {
427
+ position: absolute;
428
+ top: 0;
429
+ left: 0;
430
+ height: 100%;
431
+ background: var(--pte-new-colors-contentPrimary);
432
+ transition: width $paginationDuration var(--pte-animations-timing-easeInQuad);
433
+ }
434
+
408
435
  .bottomPanel {
409
436
  border-top: 1px solid var(--pte-new-colors-borderStrong);
410
437
  position: absolute;
@@ -412,6 +439,21 @@ $panelAnimationDelay: var(--pte-animations-duration-fast);
412
439
  width: 100%;
413
440
  }
414
441
 
442
+ .bottomPanelSlots {
443
+ display: flex;
444
+ flex-direction: column;
445
+
446
+ // Auto-separator between portaled slot items
447
+ > * + * {
448
+ border-top: 1px solid var(--pte-new-colors-borderMedium);
449
+ }
450
+
451
+ // Separator between slots and base bottomPanel content that follows
452
+ &:not(:empty) ~ * {
453
+ border-top: 1px solid var(--pte-new-colors-borderMedium);
454
+ }
455
+ }
456
+
415
457
  .bottomPanelContent {
416
458
  position: relative;
417
459
  padding: 20px;
@@ -421,13 +463,12 @@ $panelAnimationDelay: var(--pte-animations-duration-fast);
421
463
  }
422
464
  }
423
465
 
424
- .bottomPanelSpacer {
466
+ .bottomPanelSlotItem {
467
+ position: relative;
425
468
  padding: 20px;
469
+ }
426
470
 
427
- &.noPadding {
428
- padding: 0;
429
- }
430
-
471
+ .bottomPanelSpacer {
431
472
  opacity: 0;
432
473
  pointer-events: none;
433
474
  }