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
@@ -1,6 +1,16 @@
1
+ import { renderHook } from '@testing-library/react';
1
2
  import { describe, expect, it, vi } from 'vitest';
2
3
  import { getCloseButton, render, screen, waitFor } from '../../test/render';
4
+ import { usePagination } from '../pagination';
3
5
  import { Drawer } from './Drawer';
6
+ import { DrawerActions } from './DrawerActions';
7
+ import { DrawerBottomPanel } from './DrawerBottomPanel';
8
+ import { useDrawer } from './DrawerContext';
9
+ import { DrawerPage } from './DrawerPage';
10
+ import { DrawerPageProvider, useIsPageActive } from './DrawerPageContext';
11
+ import { useDrawerPagination } from './DrawerPaginationContext';
12
+ import { DrawerProgressBar } from './DrawerProgressBar';
13
+ import { DrawerTitle } from './DrawerTitle';
4
14
 
5
15
  describe('Drawer', () => {
6
16
  it('renders when isOpen is true', async () => {
@@ -69,7 +79,7 @@ describe('Drawer', () => {
69
79
  );
70
80
 
71
81
  await waitFor(() => {
72
- expect(getCloseButton()).toBeInTheDocument();
82
+ expect(getCloseButton('Close drawer')).toBeInTheDocument();
73
83
  });
74
84
  });
75
85
 
@@ -84,7 +94,7 @@ describe('Drawer', () => {
84
94
  expect(screen.getByRole('dialog')).toBeInTheDocument();
85
95
  });
86
96
 
87
- expect(getCloseButton()).not.toBeInTheDocument();
97
+ expect(getCloseButton('Close drawer')).not.toBeInTheDocument();
88
98
  });
89
99
 
90
100
  it('calls onClose when the close button is clicked', async () => {
@@ -96,10 +106,10 @@ describe('Drawer', () => {
96
106
  );
97
107
 
98
108
  await waitFor(() => {
99
- expect(getCloseButton()).toBeInTheDocument();
109
+ expect(getCloseButton('Close drawer')).toBeInTheDocument();
100
110
  });
101
111
 
102
- const closeButton = getCloseButton()!;
112
+ const closeButton = getCloseButton('Close drawer')!;
103
113
  await user.click(closeButton);
104
114
 
105
115
  expect(onClose).toHaveBeenCalledWith(false);
@@ -153,15 +163,13 @@ describe('Drawer', () => {
153
163
  });
154
164
  });
155
165
 
156
- it('renders a bottom panel', async () => {
166
+ it('renders a bottom panel via DrawerBottomPanel', async () => {
157
167
  render(
158
- <Drawer
159
- isOpen={true}
160
- title="Test Drawer"
161
- onClose={vi.fn()}
162
- bottomPanel={<button type="button">Save</button>}
163
- >
168
+ <Drawer isOpen={true} title="Test Drawer" onClose={vi.fn()}>
164
169
  Content
170
+ <DrawerBottomPanel>
171
+ <button type="button">Save</button>
172
+ </DrawerBottomPanel>
165
173
  </Drawer>,
166
174
  );
167
175
 
@@ -256,4 +264,471 @@ describe('Drawer', () => {
256
264
  expect(screen.getByTestId('custom-title')).toBeInTheDocument();
257
265
  });
258
266
  });
267
+
268
+ describe('useDrawer', () => {
269
+ function DrawerConsumer() {
270
+ const { close, isOpen } = useDrawer();
271
+ return (
272
+ <div>
273
+ <span data-testid="is-open">{String(isOpen)}</span>
274
+ <button type="button" onClick={close}>
275
+ Close via context
276
+ </button>
277
+ </div>
278
+ );
279
+ }
280
+
281
+ it('provides isOpen and close to children', async () => {
282
+ render(
283
+ <Drawer isOpen={true} title="Context Drawer" onClose={vi.fn()}>
284
+ <DrawerConsumer />
285
+ </Drawer>,
286
+ );
287
+
288
+ await waitFor(() => {
289
+ expect(screen.getByTestId('is-open')).toHaveTextContent('true');
290
+ });
291
+ });
292
+
293
+ it('calls onClose(false) when close is invoked from context', async () => {
294
+ const onClose = vi.fn();
295
+ const { user } = render(
296
+ <Drawer isOpen={true} title="Context Drawer" onClose={onClose}>
297
+ <DrawerConsumer />
298
+ </Drawer>,
299
+ );
300
+
301
+ await waitFor(() => {
302
+ expect(screen.getByText('Close via context')).toBeInTheDocument();
303
+ });
304
+
305
+ await user.click(screen.getByText('Close via context'));
306
+
307
+ expect(onClose).toHaveBeenCalledWith(false);
308
+ });
309
+
310
+ it('throws when useDrawer is used outside of a Drawer', () => {
311
+ // Suppress React error boundary console output
312
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
313
+ expect(() => render(<DrawerConsumer />)).toThrow('useDrawer must be used within a Drawer component');
314
+ consoleSpy.mockRestore();
315
+ });
316
+ });
317
+
318
+ describe('useDrawerPagination', () => {
319
+ function PaginationConsumer() {
320
+ const pagination = useDrawerPagination();
321
+ return (
322
+ <div>
323
+ <span data-testid="pagination-value">{pagination ? pagination.currentPage : 'null'}</span>
324
+ </div>
325
+ );
326
+ }
327
+
328
+ it('provides pagination state to children in a paginated drawer', async () => {
329
+ const Wrapper = () => {
330
+ const pages = ['step1'] as const;
331
+ const pagination = usePagination<typeof pages>('step1');
332
+ return (
333
+ <Drawer isOpen={true} title="Paginated Drawer" onClose={vi.fn()} pagination={pagination}>
334
+ <DrawerPage id="step1">
335
+ <PaginationConsumer />
336
+ </DrawerPage>
337
+ </Drawer>
338
+ );
339
+ };
340
+
341
+ render(<Wrapper />);
342
+
343
+ await waitFor(() => {
344
+ expect(screen.getByTestId('pagination-value')).toHaveTextContent('step1');
345
+ });
346
+ });
347
+
348
+ it('returns null when no pagination is provided', async () => {
349
+ render(
350
+ <Drawer isOpen={true} title="Non-paginated Drawer" onClose={vi.fn()}>
351
+ <PaginationConsumer />
352
+ </Drawer>,
353
+ );
354
+
355
+ await waitFor(() => {
356
+ expect(screen.getByTestId('pagination-value')).toHaveTextContent('null');
357
+ });
358
+ });
359
+ });
360
+
361
+ describe('useIsPageActive', () => {
362
+ it('returns true when page is active', () => {
363
+ const { result } = renderHook(() => useIsPageActive(), {
364
+ wrapper: ({ children }) => (
365
+ <DrawerPageProvider isActive={true} pageID="page1">
366
+ {children}
367
+ </DrawerPageProvider>
368
+ ),
369
+ });
370
+
371
+ expect(result.current).toBe(true);
372
+ });
373
+
374
+ it('returns false when page is not active', () => {
375
+ const { result } = renderHook(() => useIsPageActive(), {
376
+ wrapper: ({ children }) => (
377
+ <DrawerPageProvider isActive={false} pageID="page1">
378
+ {children}
379
+ </DrawerPageProvider>
380
+ ),
381
+ });
382
+
383
+ expect(result.current).toBe(false);
384
+ });
385
+
386
+ it('returns true when used outside DrawerPageProvider', () => {
387
+ const { result } = renderHook(() => useIsPageActive());
388
+
389
+ expect(result.current).toBe(true);
390
+ });
391
+ });
392
+
393
+ describe('DrawerTitle', () => {
394
+ it('overrides the drawer title prop', async () => {
395
+ render(
396
+ <Drawer isOpen={true} title="Fallback Title" onClose={vi.fn()}>
397
+ <DrawerTitle>Custom Title</DrawerTitle>
398
+ </Drawer>,
399
+ );
400
+
401
+ await waitFor(() => {
402
+ expect(screen.getByText('Custom Title')).toBeInTheDocument();
403
+ });
404
+
405
+ // Fallback title stays in DOM (visually hidden) for aria-labelledby
406
+ expect(screen.getByText('Fallback Title')).toBeInTheDocument();
407
+ });
408
+
409
+ it('shows fallback title when no DrawerTitle is used', async () => {
410
+ render(
411
+ <Drawer isOpen={true} title="Fallback Title" onClose={vi.fn()}>
412
+ Content
413
+ </Drawer>,
414
+ );
415
+
416
+ await waitFor(() => {
417
+ expect(screen.getByText('Fallback Title')).toBeInTheDocument();
418
+ });
419
+ });
420
+
421
+ it('only shows active page DrawerTitle in paginated drawer', async () => {
422
+ const Wrapper = () => {
423
+ const pages = ['a', 'b'] as const;
424
+ const pagination = usePagination<typeof pages>('a');
425
+ return (
426
+ <Drawer isOpen={true} title="Fallback" onClose={vi.fn()} pagination={pagination}>
427
+ <DrawerPage id="a">
428
+ <DrawerTitle>Title A</DrawerTitle>
429
+ Page A
430
+ </DrawerPage>
431
+ <DrawerPage id="b">
432
+ <DrawerTitle>Title B</DrawerTitle>
433
+ Page B
434
+ </DrawerPage>
435
+ </Drawer>
436
+ );
437
+ };
438
+
439
+ render(<Wrapper />);
440
+
441
+ await waitFor(() => {
442
+ expect(screen.getByText('Title A')).toBeInTheDocument();
443
+ });
444
+
445
+ expect(screen.queryByText('Title B')).not.toBeInTheDocument();
446
+ });
447
+ });
448
+
449
+ describe('DrawerActions', () => {
450
+ it('renders actions via slot component', async () => {
451
+ render(
452
+ <Drawer isOpen={true} title="Test" onClose={vi.fn()}>
453
+ <DrawerActions>
454
+ <button type="button">Slot Action</button>
455
+ </DrawerActions>
456
+ Content
457
+ </Drawer>,
458
+ );
459
+
460
+ await waitFor(() => {
461
+ expect(screen.getByText('Slot Action')).toBeInTheDocument();
462
+ });
463
+ });
464
+
465
+ it('falls back to additionalActions prop when no DrawerActions slot', async () => {
466
+ render(
467
+ <Drawer
468
+ isOpen={true}
469
+ title="Test"
470
+ onClose={vi.fn()}
471
+ additionalActions={<button type="button">Prop Action</button>}
472
+ >
473
+ Content
474
+ </Drawer>,
475
+ );
476
+
477
+ await waitFor(() => {
478
+ expect(screen.getByText('Prop Action')).toBeInTheDocument();
479
+ });
480
+ });
481
+ });
482
+
483
+ describe('DrawerPage', () => {
484
+ it('renders children inside a paginated drawer', async () => {
485
+ const Wrapper = () => {
486
+ const pages = ['a', 'b'] as const;
487
+ const pagination = usePagination<typeof pages>('a');
488
+ return (
489
+ <Drawer isOpen={true} title="Test" onClose={vi.fn()} pagination={pagination}>
490
+ <DrawerPage id="a">Page A Content</DrawerPage>
491
+ <DrawerPage id="b">Page B Content</DrawerPage>
492
+ </Drawer>
493
+ );
494
+ };
495
+
496
+ render(<Wrapper />);
497
+
498
+ await waitFor(() => {
499
+ expect(screen.getByText('Page A Content')).toBeInTheDocument();
500
+ });
501
+ });
502
+
503
+ it('does not render lazy page until it becomes active', async () => {
504
+ const LazyChild = () => <span data-testid="lazy-content">Lazy Loaded</span>;
505
+
506
+ const Wrapper = () => {
507
+ const pages = ['a', 'b'] as const;
508
+ const pagination = usePagination<typeof pages>('a');
509
+ return (
510
+ <Drawer isOpen={true} title="Test" onClose={vi.fn()} pagination={pagination}>
511
+ <DrawerPage id="a">
512
+ <button type="button" onClick={() => pagination.open('b')}>
513
+ Go to B
514
+ </button>
515
+ </DrawerPage>
516
+ <DrawerPage id="b" lazy>
517
+ <LazyChild />
518
+ </DrawerPage>
519
+ </Drawer>
520
+ );
521
+ };
522
+
523
+ const { user } = render(<Wrapper />);
524
+
525
+ await waitFor(() => {
526
+ expect(screen.getByText('Go to B')).toBeInTheDocument();
527
+ });
528
+
529
+ expect(screen.queryByTestId('lazy-content')).not.toBeInTheDocument();
530
+
531
+ await user.click(screen.getByText('Go to B'));
532
+
533
+ await waitFor(() => {
534
+ expect(screen.getByTestId('lazy-content')).toBeInTheDocument();
535
+ });
536
+ });
537
+ });
538
+
539
+ describe('onAfterClose', () => {
540
+ it('calls onAfterClose after the drawer close animation completes', async () => {
541
+ const onAfterClose = vi.fn();
542
+ const onClose = vi.fn();
543
+
544
+ const { rerender } = render(
545
+ <Drawer isOpen={true} title="Test" onClose={onClose} onAfterClose={onAfterClose}>
546
+ Content
547
+ </Drawer>,
548
+ );
549
+
550
+ await waitFor(() => {
551
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
552
+ });
553
+
554
+ rerender(
555
+ <Drawer isOpen={false} title="Test" onClose={onClose} onAfterClose={onAfterClose}>
556
+ Content
557
+ </Drawer>,
558
+ );
559
+
560
+ await waitFor(() => {
561
+ expect(onAfterClose).toHaveBeenCalledTimes(1);
562
+ });
563
+ });
564
+
565
+ it('does not call onAfterClose on initial render when closed', () => {
566
+ const onAfterClose = vi.fn();
567
+
568
+ render(
569
+ <Drawer isOpen={false} title="Test" onClose={vi.fn()} onAfterClose={onAfterClose}>
570
+ Content
571
+ </Drawer>,
572
+ );
573
+
574
+ expect(onAfterClose).not.toHaveBeenCalled();
575
+ });
576
+ });
577
+
578
+ describe('DrawerBottomPanel', () => {
579
+ it('renders bottom panel content via slot component', async () => {
580
+ render(
581
+ <Drawer isOpen={true} title="Test" onClose={vi.fn()}>
582
+ <DrawerBottomPanel>
583
+ <button type="button">Slot Panel</button>
584
+ </DrawerBottomPanel>
585
+ Content
586
+ </Drawer>,
587
+ );
588
+
589
+ await waitFor(() => {
590
+ expect(screen.getByText('Slot Panel')).toBeInTheDocument();
591
+ });
592
+ });
593
+
594
+ it('renders multiple append-mode bottom panels', async () => {
595
+ render(
596
+ <Drawer isOpen={true} title="Test" onClose={vi.fn()}>
597
+ <DrawerBottomPanel mode="append">
598
+ <span>First Panel</span>
599
+ </DrawerBottomPanel>
600
+ <DrawerBottomPanel mode="append">
601
+ <span>Second Panel</span>
602
+ </DrawerBottomPanel>
603
+ Content
604
+ </Drawer>,
605
+ );
606
+
607
+ await waitFor(() => {
608
+ expect(screen.getByText('First Panel')).toBeInTheDocument();
609
+ expect(screen.getByText('Second Panel')).toBeInTheDocument();
610
+ });
611
+ });
612
+
613
+ it('orders multiple append slots by priority', async () => {
614
+ render(
615
+ <Drawer isOpen={true} title="Test" onClose={vi.fn()}>
616
+ <DrawerBottomPanel mode="append" priority={20}>
617
+ <span data-testid="p20">Priority 20</span>
618
+ </DrawerBottomPanel>
619
+ <DrawerBottomPanel mode="append" priority={10}>
620
+ <span data-testid="p10">Priority 10</span>
621
+ </DrawerBottomPanel>
622
+ Content
623
+ </Drawer>,
624
+ );
625
+
626
+ await waitFor(() => {
627
+ const p10 = screen.getByTestId('p10');
628
+ const p20 = screen.getByTestId('p20');
629
+ // CSS order controls visual ordering — lower priority = visually higher
630
+ const p10Order = Number((p10.parentElement as HTMLElement).style.order);
631
+ const p20Order = Number((p20.parentElement as HTMLElement).style.order);
632
+ expect(p10Order).toBeLessThan(p20Order);
633
+ });
634
+ });
635
+
636
+ it('only shows active page bottom panel in paginated drawer', async () => {
637
+ const Wrapper = () => {
638
+ const pages = ['a', 'b'] as const;
639
+ const pagination = usePagination<typeof pages>('a');
640
+ return (
641
+ <Drawer isOpen={true} title="Test" onClose={vi.fn()} pagination={pagination}>
642
+ <DrawerPage id="a">
643
+ <DrawerBottomPanel>
644
+ <span>Panel A</span>
645
+ </DrawerBottomPanel>
646
+ Page A
647
+ </DrawerPage>
648
+ <DrawerPage id="b">
649
+ <DrawerBottomPanel>
650
+ <span>Panel B</span>
651
+ </DrawerBottomPanel>
652
+ Page B
653
+ </DrawerPage>
654
+ </Drawer>
655
+ );
656
+ };
657
+
658
+ render(<Wrapper />);
659
+
660
+ await waitFor(() => {
661
+ expect(screen.getByText('Panel A')).toBeInTheDocument();
662
+ });
663
+
664
+ expect(screen.queryByText('Panel B')).not.toBeInTheDocument();
665
+ });
666
+ });
667
+
668
+ describe('DrawerProgressBar', () => {
669
+ it('renders progress bar with auto-calculated value from pagination', async () => {
670
+ const Wrapper = () => {
671
+ const pages = ['a', 'b', 'c'] as const;
672
+ const pagination = usePagination<typeof pages>('b');
673
+ return (
674
+ <Drawer isOpen={true} title="Test" onClose={vi.fn()} pagination={pagination} progressBar>
675
+ <DrawerPage id="a">Page A</DrawerPage>
676
+ <DrawerPage id="b">Page B</DrawerPage>
677
+ <DrawerPage id="c">Page C</DrawerPage>
678
+ </Drawer>
679
+ );
680
+ };
681
+
682
+ render(<Wrapper />);
683
+
684
+ await waitFor(() => {
685
+ const bar = screen.getByRole('progressbar');
686
+ expect(bar).toBeInTheDocument();
687
+ // Page 'b' is index 1, so (1+1)/3 * 100 ≈ 67
688
+ expect(bar).toHaveAttribute('aria-valuenow', '67');
689
+ });
690
+ });
691
+
692
+ it('renders progress bar with explicit value override', async () => {
693
+ const Wrapper = () => {
694
+ const pages = ['a', 'b'] as const;
695
+ const pagination = usePagination<typeof pages>('a');
696
+ return (
697
+ <Drawer isOpen={true} title="Test" onClose={vi.fn()} pagination={pagination}>
698
+ <DrawerPage id="a">Page A</DrawerPage>
699
+ <DrawerPage id="b">Page B</DrawerPage>
700
+ <DrawerProgressBar value={42} />
701
+ </Drawer>
702
+ );
703
+ };
704
+
705
+ render(<Wrapper />);
706
+
707
+ await waitFor(() => {
708
+ const bar = screen.getByRole('progressbar');
709
+ expect(bar).toBeInTheDocument();
710
+ expect(bar).toHaveAttribute('aria-valuenow', '42');
711
+ });
712
+ });
713
+
714
+ it('clamps explicit value to 0–100 range', async () => {
715
+ const Wrapper = () => {
716
+ const pages = ['a'] as const;
717
+ const pagination = usePagination<typeof pages>('a');
718
+ return (
719
+ <Drawer isOpen={true} title="Test" onClose={vi.fn()} pagination={pagination}>
720
+ <DrawerPage id="a">Page A</DrawerPage>
721
+ <DrawerProgressBar value={150} />
722
+ </Drawer>
723
+ );
724
+ };
725
+
726
+ render(<Wrapper />);
727
+
728
+ await waitFor(() => {
729
+ const bar = screen.getByRole('progressbar');
730
+ expect(bar).toHaveAttribute('aria-valuenow', '100');
731
+ });
732
+ });
733
+ });
259
734
  });