@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
@@ -18,6 +18,13 @@ export interface ListHeaderProps<T extends Data> {
18
18
  rowHeight: string;
19
19
  /** Size of the checkbox */
20
20
  actionSize: string;
21
+ /** Width of a single action slot of the trailing actions column */
22
+ actionSlotSize: string;
23
+ /**
24
+ * Distance the header keeps from the top of the list while it is stuck, to
25
+ * leave room for whatever sticks above it. (default: '0')
26
+ */
27
+ stickyOffset?: string;
21
28
  /** Horizontal alignment of text */
22
29
  horizontalTextAlign: 'left' | 'right' | 'center';
23
30
  /** Vertical alignment of text */
@@ -54,6 +61,8 @@ export interface ListHeaderProps<T extends Data> {
54
61
  * columnSizes={'1fr'}
55
62
  * columnGap={'5px'}
56
63
  * rowHeight={'44px'}
64
+ * actionSize={'28px'}
65
+ * actionSlotSize={'50px'}
57
66
  * horizontalTextAlign={'left'}
58
67
  * verticalTextAlign={'center'}
59
68
  * />
@@ -64,6 +73,8 @@ export const ListHeader = <T extends Data>({
64
73
  columnGap,
65
74
  rowHeight,
66
75
  actionSize,
76
+ actionSlotSize,
77
+ stickyOffset,
67
78
  horizontalTextAlign,
68
79
  verticalTextAlign,
69
80
  itemSelected = false,
@@ -83,6 +94,7 @@ export const ListHeader = <T extends Data>({
83
94
  gridColumnGap: columnGap,
84
95
  justifyItems: horizontalTextAlign,
85
96
  alignItems: verticalTextAlign,
97
+ top: stickyOffset,
86
98
  } as React.CSSProperties;
87
99
 
88
100
  const { cols, mouseDown, ResizeIndicator } = useResize(
@@ -131,16 +143,21 @@ export const ListHeader = <T extends Data>({
131
143
  {hasActionColumn && (
132
144
  <div
133
145
  ref={cols[cols.length - 1].ref}
134
- className={clsx(classes.columnLabel)}
146
+ className={clsx(classes.columnLabel, classes.actionColumn)}
135
147
  >
136
148
  {showItemCheckbox && (
137
- <ListCheckBox
138
- height={actionSize}
139
- width={actionSize}
140
- isChecked={itemSelected}
141
- isDisabled={isCheckboxDisabled}
142
- onCheckBoxToggled={onCheckboxToggled}
143
- />
149
+ <div
150
+ className={classes.selectAllSlot}
151
+ style={{ width: actionSlotSize }}
152
+ >
153
+ <ListCheckBox
154
+ height={actionSize}
155
+ width={actionSize}
156
+ isChecked={itemSelected}
157
+ isDisabled={isCheckboxDisabled}
158
+ onCheckBoxToggled={onCheckboxToggled}
159
+ />
160
+ </div>
144
161
  )}
145
162
  </div>
146
163
  )}
@@ -1,7 +1,7 @@
1
1
  @import '../../../styles/common.scss';
2
2
 
3
3
  .columnsRoot {
4
- padding: 1px 0px 1px 5px;
4
+ padding: 1px 0;
5
5
  display: grid;
6
6
  justify-items: left;
7
7
  align-items: center;
@@ -1,7 +1,7 @@
1
1
  /* eslint-disable @typescript-eslint/no-non-null-assertion */
2
2
  import { fireEvent, render } from '@testing-library/react';
3
3
  import React from 'react';
4
- import { BrowserRouter } from 'react-router-dom';
4
+ import { BrowserRouter, MemoryRouter, useLocation } from 'react-router-dom';
5
5
  import { describe, expect, it, vi } from 'vitest';
6
6
  import { type Column, ListSelectMode } from '../List.model';
7
7
  import { ListRow, type ListRowProps, setLocale } from './ListRow';
@@ -401,50 +401,137 @@ describe('ListRow', () => {
401
401
  });
402
402
 
403
403
  describe('linking', () => {
404
- it('does render a link if not in select mode and got a string as "onItemClicked"', () => {
404
+ /** Renders the path the router currently points at, to tell a click that
405
+ * navigated apart from one that was suppressed. `Link` calls
406
+ * `preventDefault` itself whenever it navigates, so the event is no help. */
407
+ const LocationProbe: React.FC = () => {
408
+ const { pathname } = useLocation();
409
+ return <span data-test-id="location">{pathname}</span>;
410
+ };
411
+
412
+ const renderRow = (
413
+ props: Partial<ListRowProps<TestExplorerData>>,
414
+ ): { link: HTMLAnchorElement; path: () => string | null } => {
415
+ const { container } = render(
416
+ <MemoryRouter initialEntries={['/start']}>
417
+ <ListRow {...mockProps} {...props} />
418
+ <LocationProbe />
419
+ </MemoryRouter>,
420
+ );
421
+
422
+ return {
423
+ link: container.querySelector('a') as HTMLAnchorElement,
424
+ path: () =>
425
+ container.querySelector('[data-test-id="location"]')?.textContent ??
426
+ null,
427
+ };
428
+ };
429
+
430
+ it(`does render a link if it got a 'linkTo'`, () => {
405
431
  const { container } = render(
406
432
  <BrowserRouter>
407
433
  <ListRow
408
434
  {...mockProps}
409
- onItemClicked="http://this.is.a.test.url"
435
+ linkTo="http://this.is.a.test.url"
410
436
  selectionMode={ListSelectMode.None}
411
437
  />
412
438
  </BrowserRouter>,
413
439
  );
414
440
 
415
- const link = container.querySelector('a');
416
-
417
- expect(link).toBeInTheDocument();
441
+ expect(container.querySelector('a')).toBeInTheDocument();
418
442
  });
419
443
 
420
- it('does not render a link if in select mode and got a string as "onItemClicked"', () => {
444
+ it(`does not render a link if it got no 'linkTo'`, () => {
421
445
  const { container } = render(
422
446
  <ListRow
423
447
  {...mockProps}
424
- onItemClicked="http://this.is.a.test.url"
448
+ onItemClicked={() => {
449
+ // empty
450
+ }}
425
451
  selectionMode={ListSelectMode.Single}
426
452
  />,
427
453
  );
428
454
 
429
- const link = container.querySelector('.link');
455
+ expect(container.querySelector('.link')).not.toBeInTheDocument();
456
+ });
430
457
 
431
- expect(link).not.toBeInTheDocument();
458
+ it('navigates when there is no selection mode', () => {
459
+ const onItemClickedSpy = vi.fn();
460
+ const { link, path } = renderRow({
461
+ linkTo: '/somewhere',
462
+ selectionMode: ListSelectMode.None,
463
+ onItemClicked: onItemClickedSpy,
464
+ });
465
+
466
+ fireEvent.click(link);
467
+
468
+ expect(path()).toBe('/somewhere');
469
+ // The link takes precedence, so the click handler stays out of the way
470
+ expect(onItemClickedSpy).not.toHaveBeenCalled();
432
471
  });
433
472
 
434
- it('does not render a link if it got a function as "onItemClicked"', () => {
435
- const { container } = render(
436
- <ListRow
437
- {...mockProps}
438
- onItemClicked={() => {
439
- // empty
440
- }}
441
- selectionMode={ListSelectMode.Single}
442
- />,
443
- );
473
+ it('keeps the link in the tree in single select mode, but does not navigate', () => {
474
+ const onItemClickedSpy = vi.fn();
475
+ const { link, path } = renderRow({
476
+ linkTo: '/somewhere',
477
+ selectionMode: ListSelectMode.Single,
478
+ onItemClicked: onItemClickedSpy,
479
+ });
480
+
481
+ // The anchor stays, so that switching selection mode does not remount the cells
482
+ expect(link).toBeInTheDocument();
483
+
484
+ fireEvent.click(link);
485
+
486
+ expect(path()).toBe('/start');
487
+ expect(onItemClickedSpy).toHaveBeenCalledTimes(1);
488
+ });
489
+
490
+ it('selects instead of navigating in multi select mode', () => {
491
+ const onItemSelectedSpy = vi.fn();
492
+ const { link, path } = renderRow({
493
+ linkTo: '/somewhere',
494
+ selectionMode: ListSelectMode.Multi,
495
+ itemSelected: false,
496
+ onItemSelected: onItemSelectedSpy,
497
+ });
498
+
499
+ fireEvent.click(link);
444
500
 
445
- const link = container.querySelector('.link');
501
+ expect(path()).toBe('/start');
502
+ expect(onItemSelectedSpy).toHaveBeenCalledWith(true);
503
+ });
504
+
505
+ it('does not navigate if the row is disabled', () => {
506
+ const onItemClickedSpy = vi.fn();
507
+ const { link, path } = renderRow({
508
+ linkTo: '/somewhere',
509
+ selectionMode: ListSelectMode.None,
510
+ isRowDisabled: true,
511
+ onItemClicked: onItemClickedSpy,
512
+ });
513
+
514
+ fireEvent.click(link);
515
+
516
+ expect(path()).toBe('/start');
517
+ expect(onItemClickedSpy).not.toHaveBeenCalled();
518
+ });
519
+
520
+ it('does not navigate on a middle click while the link is suppressed', () => {
521
+ const { link } = renderRow({
522
+ linkTo: '/somewhere',
523
+ selectionMode: ListSelectMode.Multi,
524
+ });
525
+
526
+ // `Link` ignores auxclick entirely, so a prevented default here is ours
527
+ const event = new MouseEvent('auxclick', {
528
+ bubbles: true,
529
+ cancelable: true,
530
+ button: 1,
531
+ });
532
+ fireEvent(link, event);
446
533
 
447
- expect(link).not.toBeInTheDocument();
534
+ expect(event.defaultPrevented).toBe(true);
448
535
  });
449
536
  });
450
537
 
@@ -44,11 +44,22 @@ export interface ListRowProps<T extends Data> {
44
44
  showItemCheckbox?: boolean;
45
45
  /** Defines whether a row is disabled (default: false) */
46
46
  isRowDisabled?: boolean;
47
- /** Used for when a row is clicked. This can be either a callback function that will be executed
48
- * or a URL where the user should be navigated to.
49
- * When the list should navigate the user to e.g. a details page of a entry it is recommended to
50
- * directly pass the URL here instead of a callback function that does the routing in JavaScript. */
51
- onItemClicked?: LocationDescriptor<unknown> | ((data: T) => void);
47
+ /**
48
+ * Location the user should be navigated to when the row is clicked. A row with
49
+ * a link renders its content as an anchor, which is preferred over routing in
50
+ * JavaScript when the row leads to e.g. a details page.
51
+ *
52
+ * Whether this is set decides the element the row content is wrapped in, so it
53
+ * must not come and go over the lifetime of a row: doing so remounts every
54
+ * cell and resets their state. To stop a row from navigating, put the list in
55
+ * a selection mode or disable the row instead of removing the link.
56
+ */
57
+ linkTo?: LocationDescriptor<unknown>;
58
+ /**
59
+ * Called when the row is clicked. A row that navigates through `linkTo` only
60
+ * raises this while its navigation is suppressed.
61
+ */
62
+ onItemClicked?: (data: T) => void;
52
63
  /**
53
64
  * A function that is getting called, when the selection state of the row changes.
54
65
  */
@@ -105,6 +116,7 @@ const ListRowComponent = <T extends Data>(
105
116
  showActionButton = true,
106
117
  showCheckMark = false,
107
118
  showItemCheckbox = false,
119
+ linkTo,
108
120
  onItemClicked = noop,
109
121
  onItemSelected = noop,
110
122
  isRowDisabled = false,
@@ -131,16 +143,41 @@ const ListRowComponent = <T extends Data>(
131
143
  if (selectionMode === ListSelectMode.Multi) {
132
144
  onItemSelected(!itemSelected);
133
145
  // Emit onItemClicked for all other modes
134
- } else if (typeof onItemClicked === 'function') {
146
+ } else {
135
147
  onItemClicked(data);
136
148
  }
137
149
  },
138
150
  [isRowDisabled, itemSelected, onItemClicked, onItemSelected, selectionMode],
139
151
  );
140
152
 
141
- const isLinkable: boolean =
142
- selectionMode === ListSelectMode.None &&
143
- typeof onItemClicked !== 'function';
153
+ // A row that is disabled, or that is part of a selection, selects instead of
154
+ // navigating. The anchor stays in the tree either way - swapping it for
155
+ // another element would remount every cell and reset their state.
156
+ const suppressNavigation =
157
+ isRowDisabled || selectionMode !== ListSelectMode.None;
158
+
159
+ const onLinkClicked = useCallback(
160
+ (event: React.MouseEvent): void => {
161
+ if (!suppressNavigation) {
162
+ return;
163
+ }
164
+
165
+ event.preventDefault();
166
+ onItemClickedHandler(data);
167
+ },
168
+ [data, onItemClickedHandler, suppressNavigation],
169
+ );
170
+
171
+ // A middle click raises `auxclick` instead of `click`, and would otherwise
172
+ // open the suppressed link in a new tab.
173
+ const onLinkAuxClicked = useCallback(
174
+ (event: React.MouseEvent): void => {
175
+ if (suppressNavigation) {
176
+ event.preventDefault();
177
+ }
178
+ },
179
+ [suppressNavigation],
180
+ );
144
181
 
145
182
  const generateCells = useMemo(() => {
146
183
  return columns.map((column: Column<T>) => {
@@ -174,10 +211,12 @@ const ListRowComponent = <T extends Data>(
174
211
  data-test-id="list-entry"
175
212
  >
176
213
  {/* Items */}
177
- {isLinkable ? (
214
+ {linkTo !== undefined ? (
178
215
  <Link
179
- to={onItemClicked as string}
216
+ to={linkTo}
180
217
  className={clsx(classes.content, classes.link)}
218
+ onClick={onLinkClicked}
219
+ onAuxClick={onLinkAuxClicked}
181
220
  >
182
221
  {generateCells}
183
222
  </Link>
@@ -206,12 +245,12 @@ const ListRowComponent = <T extends Data>(
206
245
  {showActionButton && selectionMode === ListSelectMode.None && (
207
246
  <>
208
247
  {showActionButton &&
209
- (typeof onItemClicked !== 'function' ? (
248
+ (linkTo !== undefined ? (
210
249
  <Button
211
250
  icon={IconName.ChevronRight}
212
251
  height={actionSize}
213
252
  width={actionSize}
214
- path={onItemClicked}
253
+ path={linkTo}
215
254
  dataTestId="list-entry-action"
216
255
  />
217
256
  ) : (
@@ -1,4 +1,4 @@
1
- import React, { useCallback, useRef } from 'react';
1
+ import React, { useCallback, useEffect, useRef } from 'react';
2
2
  import type { Data } from '../../../types/data';
3
3
  import { ListRow, type ListRowProps } from '../ListRow/ListRow';
4
4
 
@@ -30,31 +30,37 @@ export const ListRowRenderer = <T extends Data>({
30
30
  > | null => {
31
31
  // Pagination trigger logic
32
32
  // Trigger based on: https://www.youtube.com/watch?v=NZKUirTtxcg
33
- const onTriggeredHandler = useCallback(() => {
34
- onTriggered && onTriggered();
33
+ const observer = useRef<IntersectionObserver>();
34
+
35
+ // `onTriggered` is read through a ref so that `elementRef` below can stay
36
+ // stable. React only re-runs a callback ref when its identity or the node
37
+ // changes, and every re-run observes the node again - which reports the
38
+ // current intersection state right away and would raise `onTriggered` anew
39
+ // for a row that never moved.
40
+ const onTriggeredRef = useRef(onTriggered);
41
+
42
+ useEffect(() => {
43
+ onTriggeredRef.current = onTriggered;
35
44
  }, [onTriggered]);
36
45
 
37
- const observer = useRef<IntersectionObserver>();
38
- const elementRef = useCallback(
39
- (node: HTMLDivElement) => {
40
- if (isTrigger === false) {
41
- return;
42
- }
43
- if (observer.current) {
44
- observer.current.disconnect();
45
- }
46
- observer.current = new IntersectionObserver(([entry]) => {
47
- if (entry.isIntersecting) {
48
- onTriggeredHandler();
49
- }
50
- });
46
+ const elementRef = useCallback((node: HTMLDivElement | null): void => {
47
+ observer.current?.disconnect();
48
+ observer.current = undefined;
49
+
50
+ // React passes null when the node is detached, either on unmount or
51
+ // because the row stopped being the trigger.
52
+ if (node === null) {
53
+ return;
54
+ }
51
55
 
52
- if (node) {
53
- observer.current.observe(node);
56
+ observer.current = new IntersectionObserver(([entry]) => {
57
+ if (entry.isIntersecting) {
58
+ onTriggeredRef.current?.();
54
59
  }
55
- },
56
- [isTrigger, onTriggeredHandler],
57
- );
60
+ });
61
+
62
+ observer.current.observe(node);
63
+ }, []);
58
64
 
59
65
  if (customRowRenderer) {
60
66
  const customElement = customRowRenderer(listRowProps);
@@ -0,0 +1,29 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getTriggerIndex } from './helpers';
3
+
4
+ describe('getTriggerIndex', () => {
5
+ it('sits loadingTriggerOffset rows before the end', () => {
6
+ // 100 rows, 10 rows left after the trigger row (indices 90-99)
7
+ expect(getTriggerIndex(100, 10)).toBe(89);
8
+ expect(getTriggerIndex(30, 10)).toBe(19);
9
+ expect(getTriggerIndex(11, 10)).toBe(0);
10
+ });
11
+
12
+ it('clamps to the first row when the page is shorter than the offset', () => {
13
+ // Such a page cannot fill the viewport, so no scrolling could ever bring a
14
+ // later row into view - the list has to ask for more data straight away.
15
+ expect(getTriggerIndex(10, 10)).toBe(0);
16
+ expect(getTriggerIndex(5, 10)).toBe(0);
17
+ expect(getTriggerIndex(1, 10)).toBe(0);
18
+ });
19
+
20
+ it('has no trigger row if there are no rows', () => {
21
+ expect(getTriggerIndex(0, 10)).toBe(-1);
22
+ expect(getTriggerIndex(0, 0)).toBe(-1);
23
+ });
24
+
25
+ it('triggers on the last row if the offset is zero or negative', () => {
26
+ expect(getTriggerIndex(30, 0)).toBe(29);
27
+ expect(getTriggerIndex(30, -5)).toBe(29);
28
+ });
29
+ });
@@ -1,14 +1,22 @@
1
1
  import { useState } from 'react';
2
- import type { Data } from '../../types';
3
- import type { ListItem, SortData } from './List.model';
2
+ import type { SortData } from './List.model';
4
3
 
5
- export const isTrigger = function <T extends Data>(
6
- index: number,
7
- listItems: ListItem<T>[],
4
+ /**
5
+ * Index of the row that requests the next page once it comes into view. It sits
6
+ * `loadingTriggerOffset` rows before the end, clamped to the first row: a page
7
+ * that is shorter than the offset does not fill the viewport, so there is no
8
+ * scrolling that could ever bring a later row into view.
9
+ * @param itemCount number of rows currently rendered
10
+ * @param loadingTriggerOffset number of rows left before a load is triggered
11
+ * @returns the index of the trigger row, or -1 if there are no rows
12
+ */
13
+ export const getTriggerIndex = (
14
+ itemCount: number,
8
15
  loadingTriggerOffset: number,
9
- ): boolean {
10
- return listItems.length - loadingTriggerOffset === index + 1 ? true : false;
11
- };
16
+ ): number =>
17
+ itemCount === 0
18
+ ? -1
19
+ : Math.max(0, itemCount - Math.max(0, loadingTriggerOffset) - 1);
12
20
 
13
21
  export const useSort = <T>(
14
22
  defaultSortOrder: SortData<T> | undefined,
@@ -21,7 +29,7 @@ export const useSort = <T>(
21
29
 
22
30
  const sortChangedHandler = (sort: SortData<T>): void => {
23
31
  setSort(sort);
24
- onSortChanged && onSortChanged(sort);
32
+ onSortChanged(sort);
25
33
  };
26
34
 
27
35
  return { sort, sortChangedHandler } as const;
@@ -30,24 +30,31 @@ const getColumnsSizeDefinition = function <T extends Data>(
30
30
  return columnSizeDefinition.join(' ');
31
31
  };
32
32
 
33
+ /** Width of a single action within the trailing actions column. */
34
+ const ACTION_SLOT_SIZE_PX = 50;
35
+
36
+ /**
37
+ * Spacing between two actions of the trailing actions column. Has to match the
38
+ * `grid-gap` of the actions of ListRow.
39
+ */
40
+ const ACTION_SLOT_GAP_PX = 8;
41
+
33
42
  const getActionsColumnSizePx = (...enableActions: boolean[]): string => {
34
43
  const enabledActionsCount = enableActions.filter(
35
44
  (actionEnabled) => actionEnabled,
36
45
  ).length;
37
46
 
38
- const defaultActionSizePx = 50;
39
- const defaultActionsRawGapPx = 8;
40
47
  const calculateMultiActionsColumnSizePx = (actionsCount: number): number => {
41
48
  return (
42
- defaultActionSizePx * actionsCount +
43
- defaultActionsRawGapPx * (actionsCount - 1)
49
+ ACTION_SLOT_SIZE_PX * actionsCount +
50
+ ACTION_SLOT_GAP_PX * (actionsCount - 1)
44
51
  );
45
52
  };
46
53
 
47
54
  const sizePx =
48
55
  enabledActionsCount > 1
49
56
  ? calculateMultiActionsColumnSizePx(enabledActionsCount)
50
- : defaultActionSizePx;
57
+ : ACTION_SLOT_SIZE_PX;
51
58
 
52
59
  return `${sizePx}px`;
53
60
  };
@@ -59,8 +66,9 @@ const getActionsColumnSizePx = (...enableActions: boolean[]): string => {
59
66
  * @param selectMode current select mode
60
67
  * @param showInlineMenu whether the inline menu is shown
61
68
  * @returns an object containing the columnSizes (CSS grid-template-columns),
62
- * a reset function (to reset to the initial column sizes)
63
- * and a setter function (to update the column sizes)
69
+ * a reset function (to reset to the initial column sizes),
70
+ * a setter function (to update the column sizes)
71
+ * and the size of a single action slot of the trailing actions column
64
72
  */
65
73
  export const useColumnsSize = <T extends Data>(
66
74
  columns: Column<T>[],
@@ -72,6 +80,7 @@ export const useColumnsSize = <T extends Data>(
72
80
  resetColumnSizes: () => void;
73
81
  setColumnSizes: (newColumnSizes: string) => void;
74
82
  hasActionColumn: boolean;
83
+ actionSlotSize: string;
75
84
  } => {
76
85
  const [isResized, setIsResized] = useState<boolean>(false);
77
86
 
@@ -124,6 +133,7 @@ export const useColumnsSize = <T extends Data>(
124
133
  showActionButton,
125
134
  showInlineMenu,
126
135
  ),
136
+ actionSlotSize: `${ACTION_SLOT_SIZE_PX}px`,
127
137
  };
128
138
  };
129
139
  function hasActionColumn(
@@ -1,5 +1,5 @@
1
+ import type { IconName } from '../Icons';
1
2
  import type { PageHeaderActionProps } from './PageHeaderAction/PageHeaderAction.model';
2
- import type { PageHeaderActionsGroupProps } from './PageHeaderActionsGroup/PageHeaderActionsGroup';
3
3
 
4
4
  export interface PageHeaderProps {
5
5
  /** Title shown in page header */
@@ -17,6 +17,43 @@ export interface PageHeaderProps {
17
17
  // eslint-disable-next-line @typescript-eslint/no-empty-interface
18
18
  export interface PageHeaderSpacerProps {}
19
19
 
20
+ /**
21
+ * A group of actions. The group renders a single action on the page header,
22
+ * which opens an action bar holding the actions of the group below the page
23
+ * header. Only one group can be open at a time, and groups are told apart by
24
+ * their label.
25
+ */
26
+ export interface PageHeaderActionsGroupProps {
27
+ /** Array of Actions to be rendered on the action bar */
28
+ actions: PageHeaderActionProps[];
29
+ /** Whether group is disabled (default: false) */
30
+ disabled?: boolean;
31
+ /** Whether actions in the group are disabled or not (default: false)*/
32
+ groupActionsDisabled?: boolean;
33
+ /** CSS Class name for additional styles of the action that opens the group */
34
+ className?: string;
35
+ /** The label of the action. */
36
+ label: string;
37
+ /** Optional built in icon. This prop also accepts an img src. */
38
+ icon?: IconName | string;
39
+ /**
40
+ * Optional text rendered at the start of the action bar while the group is
41
+ * open, e.g. 'Selected 3'. (default: undefined)
42
+ */
43
+ actionBarLabel?: string;
44
+ /**
45
+ * Whether group actions are shown by default. Only one group can be open, so
46
+ * if several groups set this, the first of them opens. (default: false)
47
+ */
48
+ openActionsGroupOnStart?: boolean;
49
+ /**
50
+ * Callback to emit when Group Actions is toggled
51
+ * The expanded state is supplied as an argument
52
+ * A rejected promise aborts the toggle.
53
+ */
54
+ onActionsGroupToggled?: (expanded: boolean) => Promise<void>;
55
+ }
56
+
20
57
  export type PageHeaderActionItemProps =
21
58
  | (PageHeaderSpacerProps & { kind: 'spacer' })
22
59
  | (PageHeaderActionProps & { kind: 'action' })