@axinom/mosaic-ui 0.75.0-rc.5 → 0.77.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 (183) hide show
  1. package/dist/THIRD-PARTY-LICENSES.txt +1296 -0
  2. package/dist/cjs/components/ActionBar/ActionBar.d.ts +26 -0
  3. package/dist/cjs/components/ActionBar/ActionBar.d.ts.map +1 -0
  4. package/dist/cjs/components/ActionBar/ActionBar.model.d.ts +49 -0
  5. package/dist/cjs/components/ActionBar/ActionBar.model.d.ts.map +1 -0
  6. package/dist/cjs/components/ActionBar/ActionBarButton/ActionBarButton.d.ts +22 -0
  7. package/dist/cjs/components/ActionBar/ActionBarButton/ActionBarButton.d.ts.map +1 -0
  8. package/dist/cjs/components/ActionBar/helpers.d.ts +22 -0
  9. package/dist/cjs/components/ActionBar/helpers.d.ts.map +1 -0
  10. package/dist/cjs/components/ActionBar/index.d.ts +3 -0
  11. package/dist/cjs/components/ActionBar/index.d.ts.map +1 -0
  12. package/dist/cjs/components/Actions/Action/Action.d.ts.map +1 -1
  13. package/dist/cjs/components/ConfirmDialog/index.d.ts +1 -0
  14. package/dist/cjs/components/ConfirmDialog/index.d.ts.map +1 -1
  15. package/dist/cjs/components/ConfirmDialog/useConfirmableAction.d.ts +55 -0
  16. package/dist/cjs/components/ConfirmDialog/useConfirmableAction.d.ts.map +1 -0
  17. package/dist/cjs/components/Explorer/BulkEdit/useBulkEdit.d.ts.map +1 -1
  18. package/dist/cjs/components/Explorer/Explorer.d.ts.map +1 -1
  19. package/dist/cjs/components/Explorer/QuickEdit/useQuickEdit.d.ts +14 -3
  20. package/dist/cjs/components/Explorer/QuickEdit/useQuickEdit.d.ts.map +1 -1
  21. package/dist/cjs/components/Explorer/helpers/useActions.d.ts +15 -4
  22. package/dist/cjs/components/Explorer/helpers/useActions.d.ts.map +1 -1
  23. package/dist/cjs/components/Explorer/helpers/useSubtitle.d.ts.map +1 -1
  24. package/dist/cjs/components/List/List.d.ts +17 -3
  25. package/dist/cjs/components/List/List.d.ts.map +1 -1
  26. package/dist/cjs/components/List/List.model.d.ts +5 -1
  27. package/dist/cjs/components/List/List.model.d.ts.map +1 -1
  28. package/dist/cjs/components/List/ListHeader/ListHeader.d.ts +10 -1
  29. package/dist/cjs/components/List/ListHeader/ListHeader.d.ts.map +1 -1
  30. package/dist/cjs/components/List/ListRow/ListRow.d.ts +16 -5
  31. package/dist/cjs/components/List/ListRow/ListRow.d.ts.map +1 -1
  32. package/dist/cjs/components/List/ListRowRenderer/ListRowRenderer.d.ts.map +1 -1
  33. package/dist/cjs/components/List/helpers.d.ts +11 -3
  34. package/dist/cjs/components/List/helpers.d.ts.map +1 -1
  35. package/dist/cjs/components/List/useColumnsSize.d.ts +4 -2
  36. package/dist/cjs/components/List/useColumnsSize.d.ts.map +1 -1
  37. package/dist/cjs/components/PageHeader/PageHeader.d.ts +4 -1
  38. package/dist/cjs/components/PageHeader/PageHeader.d.ts.map +1 -1
  39. package/dist/cjs/components/PageHeader/PageHeader.model.d.ts +37 -1
  40. package/dist/cjs/components/PageHeader/PageHeader.model.d.ts.map +1 -1
  41. package/dist/cjs/components/PageHeader/PageHeaderAction/PageHeaderAction.d.ts +3 -0
  42. package/dist/cjs/components/PageHeader/PageHeaderAction/PageHeaderAction.d.ts.map +1 -1
  43. package/dist/cjs/components/PageHeader/PageHeaderAction/PageHeaderAction.model.d.ts +8 -0
  44. package/dist/cjs/components/PageHeader/PageHeaderAction/PageHeaderAction.model.d.ts.map +1 -1
  45. package/dist/cjs/components/PageHeader/helpers.d.ts +9 -0
  46. package/dist/cjs/components/PageHeader/helpers.d.ts.map +1 -0
  47. package/dist/cjs/components/index.d.ts +1 -0
  48. package/dist/cjs/components/index.d.ts.map +1 -1
  49. package/dist/cjs/hooks/index.d.ts +1 -0
  50. package/dist/cjs/hooks/index.d.ts.map +1 -1
  51. package/dist/cjs/hooks/useElementWidthObserver/useElementWidthObserver.d.ts.map +1 -0
  52. package/dist/cjs/index.js +4 -4
  53. package/dist/cjs/index.js.map +1 -1
  54. package/dist/esm/components/ActionBar/ActionBar.d.ts +26 -0
  55. package/dist/esm/components/ActionBar/ActionBar.d.ts.map +1 -0
  56. package/dist/esm/components/ActionBar/ActionBar.model.d.ts +49 -0
  57. package/dist/esm/components/ActionBar/ActionBar.model.d.ts.map +1 -0
  58. package/dist/esm/components/ActionBar/ActionBarButton/ActionBarButton.d.ts +22 -0
  59. package/dist/esm/components/ActionBar/ActionBarButton/ActionBarButton.d.ts.map +1 -0
  60. package/dist/esm/components/ActionBar/helpers.d.ts +22 -0
  61. package/dist/esm/components/ActionBar/helpers.d.ts.map +1 -0
  62. package/dist/esm/components/ActionBar/index.d.ts +3 -0
  63. package/dist/esm/components/ActionBar/index.d.ts.map +1 -0
  64. package/dist/esm/components/Actions/Action/Action.d.ts.map +1 -1
  65. package/dist/esm/components/ConfirmDialog/index.d.ts +1 -0
  66. package/dist/esm/components/ConfirmDialog/index.d.ts.map +1 -1
  67. package/dist/esm/components/ConfirmDialog/useConfirmableAction.d.ts +55 -0
  68. package/dist/esm/components/ConfirmDialog/useConfirmableAction.d.ts.map +1 -0
  69. package/dist/esm/components/Explorer/BulkEdit/useBulkEdit.d.ts.map +1 -1
  70. package/dist/esm/components/Explorer/Explorer.d.ts.map +1 -1
  71. package/dist/esm/components/Explorer/QuickEdit/useQuickEdit.d.ts +14 -3
  72. package/dist/esm/components/Explorer/QuickEdit/useQuickEdit.d.ts.map +1 -1
  73. package/dist/esm/components/Explorer/helpers/useActions.d.ts +15 -4
  74. package/dist/esm/components/Explorer/helpers/useActions.d.ts.map +1 -1
  75. package/dist/esm/components/Explorer/helpers/useSubtitle.d.ts.map +1 -1
  76. package/dist/esm/components/List/List.d.ts +17 -3
  77. package/dist/esm/components/List/List.d.ts.map +1 -1
  78. package/dist/esm/components/List/List.model.d.ts +5 -1
  79. package/dist/esm/components/List/List.model.d.ts.map +1 -1
  80. package/dist/esm/components/List/ListHeader/ListHeader.d.ts +10 -1
  81. package/dist/esm/components/List/ListHeader/ListHeader.d.ts.map +1 -1
  82. package/dist/esm/components/List/ListRow/ListRow.d.ts +16 -5
  83. package/dist/esm/components/List/ListRow/ListRow.d.ts.map +1 -1
  84. package/dist/esm/components/List/ListRowRenderer/ListRowRenderer.d.ts.map +1 -1
  85. package/dist/esm/components/List/helpers.d.ts +11 -3
  86. package/dist/esm/components/List/helpers.d.ts.map +1 -1
  87. package/dist/esm/components/List/useColumnsSize.d.ts +4 -2
  88. package/dist/esm/components/List/useColumnsSize.d.ts.map +1 -1
  89. package/dist/esm/components/PageHeader/PageHeader.d.ts +4 -1
  90. package/dist/esm/components/PageHeader/PageHeader.d.ts.map +1 -1
  91. package/dist/esm/components/PageHeader/PageHeader.model.d.ts +37 -1
  92. package/dist/esm/components/PageHeader/PageHeader.model.d.ts.map +1 -1
  93. package/dist/esm/components/PageHeader/PageHeaderAction/PageHeaderAction.d.ts +3 -0
  94. package/dist/esm/components/PageHeader/PageHeaderAction/PageHeaderAction.d.ts.map +1 -1
  95. package/dist/esm/components/PageHeader/PageHeaderAction/PageHeaderAction.model.d.ts +8 -0
  96. package/dist/esm/components/PageHeader/PageHeaderAction/PageHeaderAction.model.d.ts.map +1 -1
  97. package/dist/esm/components/PageHeader/helpers.d.ts +9 -0
  98. package/dist/esm/components/PageHeader/helpers.d.ts.map +1 -0
  99. package/dist/esm/components/index.d.ts +1 -0
  100. package/dist/esm/components/index.d.ts.map +1 -1
  101. package/dist/esm/hooks/index.d.ts +1 -0
  102. package/dist/esm/hooks/index.d.ts.map +1 -1
  103. package/dist/esm/hooks/useElementWidthObserver/useElementWidthObserver.d.ts.map +1 -0
  104. package/dist/esm/index.js +4 -4
  105. package/dist/esm/index.js.map +1 -1
  106. package/package.json +7 -3
  107. package/src/components/ActionBar/ActionBar.model.ts +61 -0
  108. package/src/components/ActionBar/ActionBar.module.scss +64 -0
  109. package/src/components/ActionBar/ActionBar.spec.tsx +398 -0
  110. package/src/components/ActionBar/ActionBar.stories.tsx +205 -0
  111. package/src/components/ActionBar/ActionBar.tsx +170 -0
  112. package/src/components/ActionBar/ActionBarButton/ActionBarButton.module.scss +140 -0
  113. package/src/components/ActionBar/ActionBarButton/ActionBarButton.tsx +193 -0
  114. package/src/components/ActionBar/helpers.spec.ts +41 -0
  115. package/src/components/ActionBar/helpers.ts +54 -0
  116. package/src/components/ActionBar/index.ts +2 -0
  117. package/src/components/Actions/Action/Action.spec.tsx +64 -2
  118. package/src/components/Actions/Action/Action.tsx +6 -1
  119. package/src/components/ConfirmDialog/index.ts +1 -0
  120. package/src/components/ConfirmDialog/useConfirmableAction.tsx +159 -0
  121. package/src/components/EmptyStation/EmptyStation.stories.tsx +97 -0
  122. package/src/components/Explorer/BulkEdit/useBulkEdit.tsx +2 -0
  123. package/src/components/Explorer/Explorer.spec.tsx +97 -8
  124. package/src/components/Explorer/Explorer.tsx +26 -13
  125. package/src/components/Explorer/NavigationExplorer/NavigationExplorer.stories.tsx +124 -1
  126. package/src/components/Explorer/QuickEdit/useQuickEdit.spec.tsx +94 -50
  127. package/src/components/Explorer/QuickEdit/useQuickEdit.tsx +101 -42
  128. package/src/components/Explorer/SelectionExplorer/SelectionExplorer.spec.tsx +46 -13
  129. package/src/components/Explorer/helpers/useActions.ts +125 -73
  130. package/src/components/Explorer/helpers/useSubtitle.spec.tsx +33 -23
  131. package/src/components/Explorer/helpers/useSubtitle.tsx +18 -26
  132. package/src/components/List/List.model.ts +5 -1
  133. package/src/components/List/List.module.scss +25 -6
  134. package/src/components/List/List.spec.tsx +498 -1
  135. package/src/components/List/List.stories.tsx +137 -8
  136. package/src/components/List/List.tsx +305 -194
  137. package/src/components/List/ListHeader/ListHeader.module.scss +14 -0
  138. package/src/components/List/ListHeader/ListHeader.spec.tsx +1 -0
  139. package/src/components/List/ListHeader/ListHeader.tsx +25 -8
  140. package/src/components/List/ListRow/ListRow.module.scss +1 -1
  141. package/src/components/List/ListRow/ListRow.spec.tsx +109 -22
  142. package/src/components/List/ListRow/ListRow.tsx +52 -13
  143. package/src/components/List/ListRowRenderer/ListRowRenderer.tsx +28 -22
  144. package/src/components/List/helpers.spec.ts +29 -0
  145. package/src/components/List/helpers.ts +17 -9
  146. package/src/components/List/useColumnsSize.ts +17 -7
  147. package/src/components/PageHeader/PageHeader.model.ts +38 -1
  148. package/src/components/PageHeader/PageHeader.module.scss +39 -18
  149. package/src/components/PageHeader/PageHeader.spec.tsx +407 -18
  150. package/src/components/PageHeader/PageHeader.stories.tsx +95 -12
  151. package/src/components/PageHeader/PageHeader.tsx +89 -38
  152. package/src/components/PageHeader/PageHeaderAction/PageHeaderAction.model.ts +8 -0
  153. package/src/components/PageHeader/PageHeaderAction/PageHeaderAction.module.scss +50 -42
  154. package/src/components/PageHeader/PageHeaderAction/PageHeaderAction.spec.tsx +193 -1
  155. package/src/components/PageHeader/PageHeaderAction/PageHeaderAction.stories.tsx +36 -0
  156. package/src/components/PageHeader/PageHeaderAction/PageHeaderAction.tsx +81 -130
  157. package/src/components/PageHeader/helpers.spec.ts +60 -0
  158. package/src/components/PageHeader/helpers.ts +36 -0
  159. package/src/components/index.ts +1 -0
  160. package/src/hooks/index.ts +1 -0
  161. package/src/styles/variables.scss +41 -7
  162. package/dist/cjs/components/PageHeader/PageHeaderActionsGroup/PageHeaderActionsGroup.d.ts +0 -35
  163. package/dist/cjs/components/PageHeader/PageHeaderActionsGroup/PageHeaderActionsGroup.d.ts.map +0 -1
  164. package/dist/cjs/components/PageHeader/PageHeaderActionsGroup/PageHeaderActionsGroupsContext.d.ts +0 -6
  165. package/dist/cjs/components/PageHeader/PageHeaderActionsGroup/PageHeaderActionsGroupsContext.d.ts.map +0 -1
  166. package/dist/cjs/components/PageHeader/PageHeaderActionsGroup/PageHeaderActionsGroupsContextProvider.d.ts +0 -3
  167. package/dist/cjs/components/PageHeader/PageHeaderActionsGroup/PageHeaderActionsGroupsContextProvider.d.ts.map +0 -1
  168. package/dist/cjs/components/PageHeader/helpers/useElementWidthObserver.d.ts.map +0 -1
  169. package/dist/esm/components/PageHeader/PageHeaderActionsGroup/PageHeaderActionsGroup.d.ts +0 -35
  170. package/dist/esm/components/PageHeader/PageHeaderActionsGroup/PageHeaderActionsGroup.d.ts.map +0 -1
  171. package/dist/esm/components/PageHeader/PageHeaderActionsGroup/PageHeaderActionsGroupsContext.d.ts +0 -6
  172. package/dist/esm/components/PageHeader/PageHeaderActionsGroup/PageHeaderActionsGroupsContext.d.ts.map +0 -1
  173. package/dist/esm/components/PageHeader/PageHeaderActionsGroup/PageHeaderActionsGroupsContextProvider.d.ts +0 -3
  174. package/dist/esm/components/PageHeader/PageHeaderActionsGroup/PageHeaderActionsGroupsContextProvider.d.ts.map +0 -1
  175. package/dist/esm/components/PageHeader/helpers/useElementWidthObserver.d.ts.map +0 -1
  176. package/src/components/PageHeader/PageHeaderActionsGroup/PageHeaderActionsGroup.module.scss +0 -77
  177. package/src/components/PageHeader/PageHeaderActionsGroup/PageHeaderActionsGroup.spec.tsx +0 -122
  178. package/src/components/PageHeader/PageHeaderActionsGroup/PageHeaderActionsGroup.tsx +0 -235
  179. package/src/components/PageHeader/PageHeaderActionsGroup/PageHeaderActionsGroupsContext.ts +0 -15
  180. package/src/components/PageHeader/PageHeaderActionsGroup/PageHeaderActionsGroupsContextProvider.tsx +0 -37
  181. /package/dist/cjs/{components/PageHeader/helpers → hooks/useElementWidthObserver}/useElementWidthObserver.d.ts +0 -0
  182. /package/dist/esm/{components/PageHeader/helpers → hooks/useElementWidthObserver}/useElementWidthObserver.d.ts +0 -0
  183. /package/src/{components/PageHeader/helpers → hooks/useElementWidthObserver}/useElementWidthObserver.tsx +0 -0
@@ -201,7 +201,7 @@ describe('List', () => {
201
201
  const { container } = render(<List columns={mockListColumns} data={[]} />);
202
202
 
203
203
  const listRow = container.querySelector('[data-test-id="list-entry"]');
204
- const noData = container.querySelector('.NoData');
204
+ const noData = container.querySelector('.noData');
205
205
 
206
206
  expect(listRow).not.toBeInTheDocument();
207
207
  expect(noData).toBeInTheDocument();
@@ -376,6 +376,183 @@ describe('List', () => {
376
376
  });
377
377
  });
378
378
 
379
+ it('keeps the other rows selected when one is deselected after select all', () => {
380
+ const spy = vi.fn();
381
+ const { container } = render(
382
+ <List
383
+ columns={mockListColumns}
384
+ data={mockListData}
385
+ selectionMode={ListSelectMode.Multi}
386
+ enableSelectAll={true}
387
+ enableSelectAllDeselect={true}
388
+ onItemSelected={spy}
389
+ />,
390
+ );
391
+
392
+ const header = container.querySelector('[data-test-id="list-header"]');
393
+ const headerCheckbox = header?.querySelector(
394
+ '[data-test-id="list-entry-checkbox"]',
395
+ ) as HTMLButtonElement;
396
+
397
+ act(() => {
398
+ fireEvent.click(headerCheckbox);
399
+ });
400
+
401
+ expect(spy).toHaveBeenLastCalledWith({ mode: 'SELECT_ALL' });
402
+
403
+ const rowCheckboxes = Array.from(
404
+ container.querySelectorAll('[data-test-id="list-entry"]'),
405
+ ).map(
406
+ (row) =>
407
+ row.querySelector(
408
+ '[data-test-id="list-entry-checkbox"]',
409
+ ) as HTMLButtonElement,
410
+ );
411
+
412
+ // Deselecting turns the implicit 'select all' into an explicit selection
413
+ act(() => {
414
+ fireEvent.click(rowCheckboxes[1]);
415
+ });
416
+
417
+ expect(spy).toHaveBeenLastCalledWith({
418
+ mode: 'SINGLE_ITEMS',
419
+ items: [mockListData[0], mockListData[2]],
420
+ });
421
+
422
+ expect(rowCheckboxes[0].className).toContain('active');
423
+ expect(rowCheckboxes[1].className).not.toContain('active');
424
+ expect(rowCheckboxes[2].className).toContain('active');
425
+ expect(headerCheckbox.className).not.toContain('active');
426
+ });
427
+
428
+ it('keeps the selection on the same item when the data is re-ordered', () => {
429
+ const spy = vi.fn();
430
+ const props = {
431
+ columns: mockListColumns,
432
+ selectionMode: ListSelectMode.Multi,
433
+ onItemSelected: spy,
434
+ };
435
+
436
+ const { container, rerender } = render(
437
+ <List {...props} data={mockListData} />,
438
+ );
439
+
440
+ const checkboxOf = (title: string): HTMLButtonElement => {
441
+ const row = Array.from(
442
+ container.querySelectorAll('[data-test-id="list-entry"]'),
443
+ ).find((entry) => entry.textContent?.includes(title));
444
+
445
+ return row?.querySelector(
446
+ '[data-test-id="list-entry-checkbox"]',
447
+ ) as HTMLButtonElement;
448
+ };
449
+
450
+ act(() => {
451
+ fireEvent.click(checkboxOf('Item 2'));
452
+ });
453
+
454
+ expect(spy).toHaveBeenLastCalledWith({
455
+ mode: 'SINGLE_ITEMS',
456
+ items: [mockListData[1]],
457
+ });
458
+
459
+ // The consumer sorts and hands back the same items in a different order
460
+ rerender(
461
+ <List
462
+ {...props}
463
+ data={[mockListData[1], mockListData[2], mockListData[0]]}
464
+ />,
465
+ );
466
+
467
+ expect(checkboxOf('Item 2').className).toContain('active');
468
+ expect(checkboxOf('Item 1').className).not.toContain('active');
469
+ expect(checkboxOf('Item 3').className).not.toContain('active');
470
+ });
471
+
472
+ it('drops the selection of items that are no longer in the data', () => {
473
+ const spy = vi.fn();
474
+ const props = {
475
+ columns: mockListColumns,
476
+ selectionMode: ListSelectMode.Multi,
477
+ onItemSelected: spy,
478
+ };
479
+
480
+ const { container, rerender } = render(
481
+ <List {...props} data={mockListData} />,
482
+ );
483
+
484
+ const checkboxOf = (title: string): HTMLButtonElement => {
485
+ const row = Array.from(
486
+ container.querySelectorAll('[data-test-id="list-entry"]'),
487
+ ).find((entry) => entry.textContent?.includes(title));
488
+
489
+ return row?.querySelector(
490
+ '[data-test-id="list-entry-checkbox"]',
491
+ ) as HTMLButtonElement;
492
+ };
493
+
494
+ act(() => {
495
+ fireEvent.click(checkboxOf('Item 1'));
496
+ });
497
+ act(() => {
498
+ fireEvent.click(checkboxOf('Item 3'));
499
+ });
500
+
501
+ // 'Item 1' got removed from the data
502
+ rerender(<List {...props} data={[mockListData[1], mockListData[2]]} />);
503
+
504
+ expect(checkboxOf('Item 3').className).toContain('active');
505
+ expect(checkboxOf('Item 2').className).not.toContain('active');
506
+
507
+ act(() => {
508
+ fireEvent.click(checkboxOf('Item 2'));
509
+ });
510
+
511
+ // The removed item is not reported back to the consumer
512
+ expect(spy).toHaveBeenLastCalledWith({
513
+ mode: 'SINGLE_ITEMS',
514
+ items: [mockListData[1], mockListData[2]],
515
+ });
516
+ });
517
+
518
+ it('warns when the data carries no value for the key property', () => {
519
+ const warn = vi
520
+ .spyOn(console, 'warn')
521
+ .mockImplementation(() => undefined);
522
+
523
+ render(
524
+ <List
525
+ columns={mockListColumns}
526
+ data={[{ title: 'Item 1', desc: 'Description 1' }] as ListTestData[]}
527
+ selectionMode={ListSelectMode.Multi}
528
+ />,
529
+ );
530
+
531
+ expect(warn).toHaveBeenCalledWith(
532
+ expect.stringContaining(`the data has no 'id' property`),
533
+ );
534
+
535
+ warn.mockRestore();
536
+ });
537
+
538
+ it('does not warn about the key property if no selection is possible', () => {
539
+ const warn = vi
540
+ .spyOn(console, 'warn')
541
+ .mockImplementation(() => undefined);
542
+
543
+ render(
544
+ <List
545
+ columns={mockListColumns}
546
+ data={[{ title: 'Item 1', desc: 'Description 1' }] as ListTestData[]}
547
+ selectionMode={ListSelectMode.None}
548
+ />,
549
+ );
550
+
551
+ expect(warn).not.toHaveBeenCalled();
552
+
553
+ warn.mockRestore();
554
+ });
555
+
379
556
  it('header checkbox container is not rendered when there are no items', () => {
380
557
  const { container } = render(
381
558
  <List
@@ -541,6 +718,99 @@ describe('List', () => {
541
718
  expect(headerCheckbox.className).not.toContain('active');
542
719
  });
543
720
 
721
+ describe('selectIndex', () => {
722
+ it('selects the row at the given index', () => {
723
+ const ref = React.createRef<ListElement>();
724
+ const spy = vi.fn();
725
+
726
+ render(
727
+ <List
728
+ ref={ref}
729
+ columns={mockListColumns}
730
+ data={mockListData}
731
+ selectionMode={ListSelectMode.Multi}
732
+ onItemSelected={spy}
733
+ />,
734
+ );
735
+
736
+ act(() => {
737
+ ref.current?.selectIndex(1);
738
+ });
739
+
740
+ expect(spy).toHaveBeenCalledWith({
741
+ mode: 'SINGLE_ITEMS',
742
+ items: [mockListData[1]],
743
+ });
744
+ });
745
+
746
+ it('marks the row in single selection, without raising onItemClicked', () => {
747
+ const ref = React.createRef<ListElement>();
748
+ const onItemClicked = vi.fn();
749
+
750
+ const { container } = render(
751
+ <List
752
+ ref={ref}
753
+ columns={mockListColumns}
754
+ data={mockListData}
755
+ selectionMode={ListSelectMode.Single}
756
+ onItemClicked={onItemClicked}
757
+ />,
758
+ );
759
+
760
+ act(() => {
761
+ ref.current?.selectIndex(1);
762
+ });
763
+
764
+ const rows = container.querySelectorAll('[data-test-id="list-entry"]');
765
+
766
+ expect(rows[1]).toHaveClass('selected');
767
+ expect(rows[0]).not.toHaveClass('selected');
768
+ expect(onItemClicked).not.toHaveBeenCalled();
769
+ });
770
+
771
+ it('does nothing if there is no row at that index', () => {
772
+ const ref = React.createRef<ListElement>();
773
+ const spy = vi.fn();
774
+
775
+ render(
776
+ <List
777
+ ref={ref}
778
+ columns={mockListColumns}
779
+ data={[]}
780
+ selectionMode={ListSelectMode.Multi}
781
+ onItemSelected={spy}
782
+ />,
783
+ );
784
+
785
+ act(() => {
786
+ ref.current?.selectIndex(0);
787
+ });
788
+
789
+ expect(spy).not.toHaveBeenCalled();
790
+ });
791
+
792
+ it('does nothing if the list renders no selection', () => {
793
+ const ref = React.createRef<ListElement>();
794
+ const spy = vi.fn();
795
+
796
+ render(
797
+ <List
798
+ ref={ref}
799
+ columns={mockListColumns}
800
+ data={mockListData}
801
+ selectionMode={ListSelectMode.None}
802
+ onItemSelected={spy}
803
+ />,
804
+ );
805
+
806
+ act(() => {
807
+ ref.current?.selectIndex(1);
808
+ });
809
+
810
+ expect(spy).not.toHaveBeenCalled();
811
+ });
812
+ });
813
+
544
814
  it('does not use onItemSelected if provided with a generateItemLink', () => {
545
815
  const spy = vi.fn();
546
816
  const { container } = render(
@@ -560,6 +830,95 @@ describe('List', () => {
560
830
  expect(link.getAttribute('href')).toBe('/test');
561
831
  });
562
832
 
833
+ describe('cell subtree stability', () => {
834
+ /** Stands in for a cell that holds state, such as an ImageLoader that
835
+ * restarts its request whenever it is mounted. */
836
+ const mountCounter = (): {
837
+ column: Column<ListTestData>;
838
+ count: () => number;
839
+ } => {
840
+ let mounts = 0;
841
+ const Cell: React.FC = () => {
842
+ React.useEffect(() => {
843
+ mounts++;
844
+ }, []);
845
+ return <img alt="" src="/thumb.png" />;
846
+ };
847
+
848
+ return {
849
+ column: {
850
+ propertyName: 'title',
851
+ label: 'Thumb',
852
+ render: () => <Cell />,
853
+ },
854
+ count: () => mounts,
855
+ };
856
+ };
857
+
858
+ const renderList = (
859
+ props: Record<string, unknown>,
860
+ column: Column<ListTestData>,
861
+ ): JSX.Element => (
862
+ <MemoryRouter>
863
+ <List
864
+ columns={[column]}
865
+ data={mockListData}
866
+ generateItemLink={(data) => `/item/${data.id}`}
867
+ {...props}
868
+ />
869
+ </MemoryRouter>
870
+ );
871
+
872
+ it('does not remount cells when the list enters multi select mode', () => {
873
+ const { column, count } = mountCounter();
874
+ const { rerender } = render(
875
+ renderList({ selectionMode: ListSelectMode.None }, column),
876
+ );
877
+
878
+ expect(count()).toBe(mockListData.length);
879
+
880
+ // Opening bulk actions switches the list to Multi
881
+ rerender(renderList({ selectionMode: ListSelectMode.Multi }, column));
882
+
883
+ expect(count()).toBe(mockListData.length);
884
+ });
885
+
886
+ it('does not remount cells when the list enters single select mode', () => {
887
+ const { column, count } = mountCounter();
888
+ const { rerender } = render(
889
+ renderList({ selectionMode: ListSelectMode.None }, column),
890
+ );
891
+
892
+ expect(count()).toBe(mockListData.length);
893
+
894
+ // Opening quick edit switches the list to Single
895
+ rerender(renderList({ selectionMode: ListSelectMode.Single }, column));
896
+
897
+ expect(count()).toBe(mockListData.length);
898
+ });
899
+
900
+ it('does not remount cells when the selection changes', () => {
901
+ const { column, count } = mountCounter();
902
+ const { container } = render(
903
+ renderList({ selectionMode: ListSelectMode.Multi }, column),
904
+ );
905
+
906
+ expect(count()).toBe(mockListData.length);
907
+
908
+ const checkbox = container
909
+ .querySelectorAll('[data-test-id="list-entry"]')[0]
910
+ .querySelector(
911
+ '[data-test-id="list-entry-checkbox"]',
912
+ ) as HTMLButtonElement;
913
+
914
+ act(() => {
915
+ fireEvent.click(checkbox);
916
+ });
917
+
918
+ expect(count()).toBe(mockListData.length);
919
+ });
920
+ });
921
+
563
922
  it('raises onRequestMoreData', () => {
564
923
  const spy = vi.fn();
565
924
  const { container } = render(
@@ -844,6 +1203,144 @@ describe('List', () => {
844
1203
  });
845
1204
  });
846
1205
 
1206
+ describe('action bar', () => {
1207
+ const mockActionBar = {
1208
+ label: 'Showing all of 3 Elements',
1209
+ actions: [
1210
+ { label: 'Publish', onClick: vi.fn() },
1211
+ { label: 'Delete', onClick: vi.fn() },
1212
+ ],
1213
+ };
1214
+
1215
+ it('renders on top of the column header, which stays in place', () => {
1216
+ const { container } = render(
1217
+ <List
1218
+ columns={mockListColumns}
1219
+ data={mockListData}
1220
+ actionBar={mockActionBar}
1221
+ />,
1222
+ );
1223
+
1224
+ expect(
1225
+ container.querySelector('[data-test-id="action-bar"]'),
1226
+ ).toBeInTheDocument();
1227
+ expect(
1228
+ container.querySelector('[data-test-id="list-header"]'),
1229
+ ).toBeInTheDocument();
1230
+ // Sorting and resizing stay reachable while a context is active
1231
+ expect(
1232
+ container.querySelector('[data-test-id="list-label:desc"]'),
1233
+ ).toBeInTheDocument();
1234
+ expect(container.textContent).toContain('Showing all of 3 Elements');
1235
+ });
1236
+
1237
+ it('offsets the header so that both stick below each other', () => {
1238
+ const { container } = render(
1239
+ <List
1240
+ columns={mockListColumns}
1241
+ data={mockListData}
1242
+ headerRowHeight="44px"
1243
+ actionBar={mockActionBar}
1244
+ />,
1245
+ );
1246
+
1247
+ expect(
1248
+ container.querySelector('[data-test-id="list-header"]'),
1249
+ ).toHaveStyle({ top: 'calc(44px + 1px)' });
1250
+ });
1251
+
1252
+ it('leaves the header at the top of the list while no bar is rendered', () => {
1253
+ const { container } = render(
1254
+ <List columns={mockListColumns} data={mockListData} />,
1255
+ );
1256
+
1257
+ expect(container.querySelector('[data-test-id="action-bar"]')).toBeNull();
1258
+ expect(
1259
+ container.querySelector('[data-test-id="list-header"]'),
1260
+ ).not.toHaveStyle({ top: 'calc(44px + 1px)' });
1261
+ });
1262
+
1263
+ it('collapses when there is neither a label nor an action to show', () => {
1264
+ const { container } = render(
1265
+ <List
1266
+ columns={mockListColumns}
1267
+ data={mockListData}
1268
+ actionBar={{ actions: [] }}
1269
+ />,
1270
+ );
1271
+
1272
+ expect(container.querySelector('[data-test-id="action-bar"]')).toBeNull();
1273
+ });
1274
+
1275
+ it('renders with a label alone, without any actions', () => {
1276
+ const { container } = render(
1277
+ <List
1278
+ columns={mockListColumns}
1279
+ data={mockListData}
1280
+ actionBar={{ label: 'Showing all of 3 Elements', actions: [] }}
1281
+ />,
1282
+ );
1283
+
1284
+ expect(
1285
+ container.querySelector('[data-test-id="action-bar"]'),
1286
+ ).toBeInTheDocument();
1287
+ });
1288
+
1289
+ it('keeps the rows of the list', () => {
1290
+ const { container } = render(
1291
+ <List
1292
+ columns={mockListColumns}
1293
+ data={mockListData}
1294
+ actionBar={mockActionBar}
1295
+ />,
1296
+ );
1297
+
1298
+ expect(
1299
+ container.querySelectorAll('[data-test-id="list-entry"]'),
1300
+ ).toHaveLength(mockListData.length);
1301
+ });
1302
+
1303
+ it('keeps the select all checkbox on the header, not on the bar', () => {
1304
+ const { container } = render(
1305
+ <List
1306
+ columns={mockListColumns}
1307
+ data={mockListData}
1308
+ selectionMode={ListSelectMode.Multi}
1309
+ actionBar={mockActionBar}
1310
+ />,
1311
+ );
1312
+
1313
+ const bar = container.querySelector('[data-test-id="action-bar"]');
1314
+ const header = container.querySelector('[data-test-id="list-header"]');
1315
+
1316
+ expect(
1317
+ bar!.querySelector('[data-test-id="list-entry-checkbox"]'),
1318
+ ).toBeNull();
1319
+ expect(
1320
+ header!.querySelector('[data-test-id="list-entry-checkbox"]'),
1321
+ ).toBeInTheDocument();
1322
+ });
1323
+
1324
+ it('runs the action that is clicked', () => {
1325
+ const onClick = vi.fn();
1326
+ const { container } = render(
1327
+ <List
1328
+ columns={mockListColumns}
1329
+ data={mockListData}
1330
+ actionBar={{ actions: [{ label: 'Publish', onClick }] }}
1331
+ />,
1332
+ );
1333
+
1334
+ const bar = container.querySelector('[data-test-id="action-bar"]');
1335
+
1336
+ act(() => {
1337
+ fireEvent.click(bar!.querySelector('[data-test-id="action"]')!);
1338
+ });
1339
+
1340
+ expect(onClick).toHaveBeenCalled();
1341
+ });
1342
+ });
1343
+
847
1344
  describe('customRowRenderer', () => {
848
1345
  const createSimpleCustomRenderer = (testId = 'custom-row') =>
849
1346
  vi.fn(() => <div data-test-id={testId}>Custom Row Content</div>);
@@ -2,11 +2,14 @@ import { faker } from '@faker-js/faker';
2
2
  import type { Meta, StoryObj } from '@storybook/react';
3
3
  import React, { useMemo, useState } from 'react';
4
4
  import { MemoryRouter, useLocation } from 'react-router-dom';
5
+ import { action } from 'storybook/actions';
5
6
  import {
6
7
  createGroups,
7
8
  enumToObj,
8
9
  generateItemArray,
9
10
  } from '../../helpers/storybook';
11
+ import type { ActionBarAction, ActionBarData } from '../ActionBar';
12
+ import { IconName } from '../Icons';
10
13
  import { Message } from '../Message';
11
14
  import { List, type ListProps } from './List';
12
15
  import {
@@ -110,6 +113,7 @@ const groups = createGroups({
110
113
  'loadingTriggerOffset',
111
114
  'isRowDisabled',
112
115
  'customRowRenderer',
116
+ 'actionBar',
113
117
  ],
114
118
  Styling: [
115
119
  'horizontalTextAlign',
@@ -351,6 +355,139 @@ export const NoneSelectionMode: StoryObj<StoryListType> = {
351
355
  render: Default.render,
352
356
  };
353
357
 
358
+ export const WithActionBar: StoryObj<StoryListType> = {
359
+ parameters: {
360
+ docs: {
361
+ description: {
362
+ story: `An action bar sits on top of the column header, which stays in
363
+ place with it. It shows a label of its own, e.g. the result counts, and
364
+ the actions of whichever context is currently active - consumers that
365
+ offer several sets of actions resolve which one that is. Actions that do
366
+ not fit the available width move into an overflow menu, and the bar
367
+ collapses while it has neither a label nor any actions to show.`,
368
+ },
369
+ },
370
+ },
371
+ args: {
372
+ selectionMode: ListSelectMode.Multi,
373
+ // With an inline menu the actions column holds two slots, which shows that
374
+ // the select all checkbox of the header lines up with the row checkboxes.
375
+ inlineMenuActions: () => [
376
+ {
377
+ label: 'Duplicate',
378
+ icon: IconName.Copy,
379
+ onActionSelected: action('onDuplicateSelected'),
380
+ },
381
+ {
382
+ label: 'Delete',
383
+ icon: IconName.Delete,
384
+ onActionSelected: action('onDeleteSelected'),
385
+ },
386
+ ],
387
+ },
388
+ render: (args) =>
389
+ React.createElement(() => {
390
+ const data = useMemo(() => generateData(args.rowCount), []);
391
+ const { listData, sortChangedHandler } =
392
+ useLocalSort<ListStoryData>(data);
393
+ const [activeSet, setActiveSet] = useState<'bulk' | 'quickEdit'>();
394
+ const [showLabel, setShowLabel] = useState<boolean>(true);
395
+ const [selectedCount, setSelectedCount] = useState<number>(0);
396
+ const [activeStation, setActiveStation] = useState<string>('Details');
397
+
398
+ const bulkActions: ActionBarAction[] = [
399
+ {
400
+ label: 'Edit',
401
+ icon: IconName.BulkEdit,
402
+ onClick: () => alert('Edit'),
403
+ },
404
+ {
405
+ label: 'Create Snapshot',
406
+ icon: IconName.Snapshot,
407
+ onClick: () => alert('Create Snapshot'),
408
+ },
409
+ {
410
+ label: 'Publish',
411
+ icon: IconName.Publish,
412
+ onClick: () => alert('Publish'),
413
+ },
414
+ {
415
+ label: 'Unpublish',
416
+ icon: IconName.Unpublish,
417
+ confirmationMode: 'Simple',
418
+ onClick: () => alert('Unpublish'),
419
+ },
420
+ {
421
+ label: 'Delete',
422
+ icon: IconName.Delete,
423
+ confirmationMode: 'Advanced',
424
+ confirmationConfig: {
425
+ title: 'Delete the selected items?',
426
+ body: 'The action is irreversible.',
427
+ },
428
+ disabled: selectedCount === 0,
429
+ onClick: () => alert('Delete'),
430
+ },
431
+ ];
432
+
433
+ const quickEditActions: ActionBarAction[] = [
434
+ 'Details',
435
+ 'Focal Point',
436
+ ].map((station) => ({
437
+ label: station,
438
+ icon: IconName.QuickEditStation,
439
+ toggled: activeStation === station,
440
+ onClick: () => setActiveStation(station),
441
+ }));
442
+
443
+ // The label stays the same whichever context is active, apart from the
444
+ // selection, which only the bulk actions apply to.
445
+ const label = showLabel
446
+ ? `Showing all of ${listData.length} Elements${
447
+ activeSet === 'bulk' ? `, Selected: ${selectedCount}` : ''
448
+ }`
449
+ : undefined;
450
+
451
+ const actionBar: ActionBarData = {
452
+ label,
453
+ actions:
454
+ activeSet === 'bulk'
455
+ ? bulkActions
456
+ : activeSet === 'quickEdit'
457
+ ? quickEditActions
458
+ : [],
459
+ };
460
+
461
+ return (
462
+ <>
463
+ <div style={{ display: 'flex', gap: '8px', padding: '8px' }}>
464
+ <button onClick={() => setActiveSet(undefined)}>No Context</button>
465
+ <button onClick={() => setActiveSet('bulk')}>Bulk Actions</button>
466
+ <button onClick={() => setActiveSet('quickEdit')}>
467
+ Quick Edit
468
+ </button>
469
+ <button onClick={() => setShowLabel((shown) => !shown)}>
470
+ Toggle Label
471
+ </button>
472
+ </div>
473
+ <List<ListStoryData>
474
+ {...args}
475
+ data={listData}
476
+ actionBar={actionBar}
477
+ onItemSelected={(event) =>
478
+ setSelectedCount(
479
+ event.mode === 'SELECT_ALL'
480
+ ? listData.length
481
+ : event.items?.length ?? 0,
482
+ )
483
+ }
484
+ onSortChanged={sortChangedHandler}
485
+ />
486
+ </>
487
+ );
488
+ }),
489
+ };
490
+
354
491
  export const WithoutActionButton: StoryObj<StoryListType> = {
355
492
  args: {
356
493
  showActionButton: false,
@@ -493,14 +630,6 @@ export const PagedData: StoryObj<StoryListType> = {
493
630
  exclude: /.*/g,
494
631
  },
495
632
  },
496
- decorators: [
497
- // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
498
- (Story) => (
499
- <div style={{ height: '500px', overflow: 'auto' }}>
500
- <Story />
501
- </div>
502
- ),
503
- ],
504
633
  render: (_args) =>
505
634
  React.createElement(() => {
506
635
  const [data, setData] = useState<ListStoryData[]>(generateData(30));