@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
@@ -6,12 +6,14 @@ import React, {
6
6
  useCallback,
7
7
  useEffect,
8
8
  useImperativeHandle,
9
+ useMemo,
9
10
  useRef,
10
11
  useState,
11
12
  } from 'react';
12
13
  import type { ActionData } from '..';
13
14
  import { noop } from '../../helpers/utils';
14
15
  import type { Data } from '../../types/data';
16
+ import { ActionBar, type ActionBarData } from '../ActionBar';
15
17
  import { ButtonContext } from '../Buttons/Button.model';
16
18
  import { TextButton } from '../Buttons/TextButton/TextButton';
17
19
  import {
@@ -27,7 +29,7 @@ import { ListHeader } from './ListHeader/ListHeader';
27
29
  import type { ListRowProps } from './ListRow/ListRow';
28
30
  import { ListRowLoader } from './ListRow/ListRowLoader';
29
31
  import { ListRowRenderer } from './ListRowRenderer/ListRowRenderer';
30
- import { getActionButtonVisibility, isTrigger, useSort } from './helpers';
32
+ import { getActionButtonVisibility, getTriggerIndex, useSort } from './helpers';
31
33
  import { useColumnsSize } from './useColumnsSize';
32
34
 
33
35
  export interface ListProps<T extends Data> {
@@ -44,7 +46,7 @@ export interface ListProps<T extends Data> {
44
46
  isLoading?: boolean;
45
47
  /** Defines whether there was an error with the data. If true a button will be shown that will trigger `onRetry` when hit. */
46
48
  isError?: boolean;
47
- /** Optional error message to display if an error occurs. (default: 'There was an error.) */
49
+ /** Optional error message to display if an error occurs. (default: 'There was an error.') */
48
50
  errorMsg?: string;
49
51
  /** Determines whether the component should render the error message and the 'onRetry' button. Set this to false if error handling will be done outside this component. (default: true) */
50
52
  handleRetry?: boolean;
@@ -90,13 +92,16 @@ export interface ListProps<T extends Data> {
90
92
  /**
91
93
  * When set, this function is used to generate the link that the user should be navigated to for each item.
92
94
  *
93
- * This property takes precedence over `onItemClicked`, which will not be executed in case `generateItemLink` is set!
95
+ * This property takes precedence over `onItemClicked`, which is only executed
96
+ * while the link is suppressed, i.e. when the list is in a selection mode or
97
+ * the row is disabled.
94
98
  */
95
99
  generateItemLink?: (data: T) => LocationDescriptor<unknown>;
96
100
  /**
97
101
  * Raised when an data item is clicked.
98
102
  *
99
- * This is not being used, if generateItemLink is set!
103
+ * If `generateItemLink` is set, this is only raised while the list is in a
104
+ * selection mode or the row is disabled.
100
105
  */
101
106
  onItemClicked?: (data: T) => void | Promise<void>;
102
107
  /** Raised when item selection has changed */
@@ -109,6 +114,16 @@ export interface ListProps<T extends Data> {
109
114
  onRetry?: () => void;
110
115
  /** CSS Class name for additional styles */
111
116
  className?: string;
117
+ /**
118
+ * Content of the bar that sits on top of the column header, holding the
119
+ * actions of whichever context is currently active.
120
+ *
121
+ * Consumers that offer several sets of actions, e.g. bulk actions and quick
122
+ * edit, resolve which of them is active and pass that one - only a single set
123
+ * can be on display at a time. The bar collapses while it has neither a label
124
+ * nor any actions to show.
125
+ */
126
+ actionBar?: ActionBarData;
112
127
  /** Provide inline actions which are available through '...' context menu */
113
128
  inlineMenuActions?: (data: T) => ActionData[];
114
129
  /** Function to determine if a specific row should be disabled */
@@ -117,6 +132,31 @@ export interface ListProps<T extends Data> {
117
132
  customRowRenderer?: (props: ListRowProps<T>) => ReactElement | null;
118
133
  }
119
134
 
135
+ interface Selection {
136
+ /** Keys of the rows that are selected individually. */
137
+ keys: ReadonlySet<string>;
138
+ /**
139
+ * Set while every row counts as selected without being tracked individually:
140
+ * the header 'select all' checkbox is checked, or the user has checked every
141
+ * row while `enableSelectAllDeselect` is on. Rows that are loaded later are
142
+ * selected too, so this cannot be derived from `keys` and `data`.
143
+ */
144
+ isAll: boolean;
145
+ }
146
+
147
+ /** Shared empty selection, so that clearing twice does not re-render. */
148
+ const EMPTY_SELECTION: Selection = { keys: new Set<string>(), isAll: false };
149
+
150
+ /**
151
+ * Property used to identify a row when no `keyProperty` is given. `Data` allows
152
+ * any string index, so this holds for every `T` even though TypeScript cannot
153
+ * prove that 'id' is one of `keyof T`.
154
+ */
155
+ const DEFAULT_KEY_PROPERTY = 'id';
156
+
157
+ /** Prefix for the positional fallback key, to keep it out of real key values. */
158
+ const INDEX_KEY_PREFIX = '__list_index_';
159
+
120
160
  const ListRenderer = <T extends Data>(
121
161
  {
122
162
  columns,
@@ -134,7 +174,7 @@ const ListRenderer = <T extends Data>(
134
174
  headerRowActionSize = '28px',
135
175
  horizontalTextAlign = 'left',
136
176
  verticalTextAlign = 'center',
137
- keyProperty = 'id' as keyof T,
177
+ keyProperty,
138
178
  showActionButton = true,
139
179
  loadingTriggerOffset = 10,
140
180
  defaultSortOrder,
@@ -149,158 +189,212 @@ const ListRenderer = <T extends Data>(
149
189
  onRetry = noop,
150
190
  generateItemLink,
151
191
  className = '',
192
+ actionBar,
152
193
  inlineMenuActions,
153
194
  isRowDisabled,
154
195
  customRowRenderer,
155
196
  }: PropsWithChildren<ListProps<T>>,
156
197
  ref?: React.ForwardedRef<ListElement>,
157
198
  ): JSX.Element => {
158
- const [listItems, setListItems] = useState<ListItem<T>[]>([]);
159
- const isAllItemsChecked = useRef<boolean>(false);
199
+ const [selection, setSelection] = useState<Selection>(EMPTY_SELECTION);
200
+ /**
201
+ * Mirrors `selection` so that handlers can read-modify-write it without
202
+ * waiting for a re-render, which keeps several toggles within one batch from
203
+ * overwriting each other. Never read while rendering - `selection` is what
204
+ * drives the output.
205
+ */
206
+ const selectionRef = useRef<Selection>(selection);
207
+
208
+ const applySelection = useCallback((next: Selection): void => {
209
+ selectionRef.current = next;
210
+ setSelection(next);
211
+ }, []);
212
+
213
+ const rowKeyProperty = (keyProperty ?? DEFAULT_KEY_PROPERTY) as keyof T;
214
+
215
+ /**
216
+ * Resolves the stable identity of a row. Falls back to the row position if
217
+ * the data carries no value for `keyProperty`, in which case the selection
218
+ * cannot survive a re-ordering of `data` (see the warning below).
219
+ */
220
+ const getRowKey = useCallback(
221
+ (row: T, index: number): string => {
222
+ const key = row[rowKeyProperty];
223
+ return key == null ? `${INDEX_KEY_PREFIX}${index}` : String(key);
224
+ },
225
+ [rowKeyProperty],
226
+ );
227
+
228
+ const listItems = useMemo<ListItem<T>[]>(
229
+ () =>
230
+ data.map((row, index) => ({
231
+ selected: selection.isAll || selection.keys.has(getRowKey(row, index)),
232
+ data: row,
233
+ })),
234
+ [data, getRowKey, selection],
235
+ );
236
+
237
+ const hasWarnedAboutKeyProperty = useRef<boolean>(false);
160
238
 
161
239
  useEffect(() => {
162
- setListItems((prevState: ListItem<T>[]) => {
163
- const newListItems: ListItem<T>[] = data.map((i, index) => ({
164
- selected: isAllItemsChecked.current || prevState[index]?.selected,
165
- data: i,
166
- }));
167
- return newListItems;
168
- });
169
- }, [data]);
170
-
171
- const { columnSizes, resetColumnSizes, setColumnSizes, hasActionColumn } =
172
- useColumnsSize(
173
- columns,
174
- Boolean(showActionButton),
175
- selectionMode,
176
- Boolean(inlineMenuActions),
240
+ if (
241
+ hasWarnedAboutKeyProperty.current ||
242
+ selectionMode === ListSelectMode.None ||
243
+ data.length === 0 ||
244
+ data[0][rowKeyProperty] != null
245
+ ) {
246
+ return;
247
+ }
248
+
249
+ hasWarnedAboutKeyProperty.current = true;
250
+ // eslint-disable-next-line no-console
251
+ console.warn(
252
+ `List: 'selectionMode' is enabled but the data has no '${String(
253
+ rowKeyProperty,
254
+ )}' property. The selection is tracked by row position and will apply to the wrong rows once the data is sorted or an item is removed. Set 'keyProperty' to a property that identifies a row.`,
177
255
  );
256
+ }, [data, rowKeyProperty, selectionMode]);
178
257
 
179
- const customStyles = {
180
- gridRowGap: rowGap,
181
- minWidth: minimumWidth,
182
- width: '100%',
183
- };
258
+ const {
259
+ columnSizes,
260
+ resetColumnSizes,
261
+ setColumnSizes,
262
+ hasActionColumn,
263
+ actionSlotSize,
264
+ } = useColumnsSize(
265
+ columns,
266
+ Boolean(showActionButton),
267
+ selectionMode,
268
+ Boolean(inlineMenuActions),
269
+ );
184
270
 
185
- const itemSelectionHandler = useCallback(
186
- (items: ListItem<T>[]) => {
187
- const selectedData: T[] = items.map(({ data }) => data);
188
- if (enableSelectAllDeselect) {
189
- if (items.length === listItems.length) {
190
- isAllItemsChecked.current = true;
191
- } else {
192
- isAllItemsChecked.current = false;
193
- }
194
- }
195
- onItemSelected &&
196
- onItemSelected({
197
- mode: 'SINGLE_ITEMS',
198
- items: selectedData,
199
- });
200
- },
201
- [enableSelectAllDeselect, listItems.length, onItemSelected],
271
+ // `minimumWidth` sizes the columns, so it belongs to the container that holds
272
+ // them rather than to the frame around it.
273
+ const listStyles = useMemo(
274
+ () => ({ gridRowGap: rowGap, minWidth: minimumWidth }),
275
+ [minimumWidth, rowGap],
202
276
  );
203
277
 
278
+ const clearSelection = useCallback((): void => {
279
+ applySelection(EMPTY_SELECTION);
280
+
281
+ onItemSelected({
282
+ mode: 'SINGLE_ITEMS',
283
+ items: [],
284
+ });
285
+ }, [applySelection, onItemSelected]);
286
+
204
287
  const headerCheckboxHandler = useCallback(
205
288
  (checked: boolean): void => {
206
- setListItems((prevState) =>
207
- prevState.map((i) => ({
208
- selected: checked,
209
- data: i.data,
210
- })),
289
+ applySelection({ keys: EMPTY_SELECTION.keys, isAll: checked });
290
+
291
+ onItemSelected(
292
+ checked ? { mode: 'SELECT_ALL' } : { mode: 'SINGLE_ITEMS', items: [] },
211
293
  );
294
+ },
295
+ [applySelection, onItemSelected],
296
+ );
212
297
 
213
- isAllItemsChecked.current = checked;
298
+ const itemSelectedHandler = useCallback(
299
+ (selected: boolean, key: string): void => {
300
+ const current = selectionRef.current;
301
+ // Toggling a row while 'select all' is active turns the implicit
302
+ // selection into an explicit one, so that the other rows stay selected.
303
+ const nextKeys = new Set(
304
+ current.isAll ? data.map(getRowKey) : current.keys,
305
+ );
214
306
 
215
- if (checked) {
216
- onItemSelected({
217
- mode: 'SELECT_ALL',
218
- });
307
+ if (selected) {
308
+ nextKeys.add(key);
219
309
  } else {
220
- onItemSelected({
221
- mode: 'SINGLE_ITEMS',
222
- items: [],
223
- });
310
+ nextKeys.delete(key);
224
311
  }
225
- },
226
- [onItemSelected],
227
- );
228
312
 
229
- const itemSelectedHandler = useCallback(
230
- (selected: boolean, index: number): void => {
231
- const items = [...listItems];
232
- items[index].selected = selected;
233
- setListItems(items);
234
- itemSelectionHandler(items.filter((item) => item.selected === true));
313
+ applySelection({
314
+ keys: nextKeys,
315
+ isAll:
316
+ enableSelectAllDeselect &&
317
+ data.length > 0 &&
318
+ nextKeys.size === data.length,
319
+ });
320
+
321
+ onItemSelected({
322
+ mode: 'SINGLE_ITEMS',
323
+ items: data.filter((row, index) => nextKeys.has(getRowKey(row, index))),
324
+ });
235
325
  },
236
- [itemSelectionHandler, listItems],
326
+ [applySelection, data, enableSelectAllDeselect, getRowKey, onItemSelected],
237
327
  );
238
328
 
239
- const onTriggeredHandler = (): void => {
329
+ const onTriggeredHandler = useCallback((): void => {
240
330
  if (!isLoading && !isError) {
241
331
  onRequestMoreData();
242
332
  }
243
- };
333
+ }, [isError, isLoading, onRequestMoreData]);
244
334
 
245
- const itemClickedHandler = async (data: T, index: number): Promise<void> => {
335
+ const itemClickedHandler = async (row: T, index: number): Promise<void> => {
246
336
  try {
247
- await onItemClicked(data);
337
+ await onItemClicked(row);
248
338
 
249
339
  if (selectionMode === ListSelectMode.Single) {
250
- setListItems((prevState) =>
251
- prevState.map((item, i) => ({
252
- selected: i === index,
253
- data: item.data,
254
- })),
255
- );
340
+ applySelection({
341
+ keys: new Set([getRowKey(row, index)]),
342
+ isAll: false,
343
+ });
256
344
  }
257
- } catch (error) {
345
+ } catch {
346
+ // The consumer rejected the click, so the selection is left untouched.
258
347
  return;
259
348
  }
260
349
  };
261
350
 
262
351
  const { sort, sortChangedHandler } = useSort(defaultSortOrder, onSortChanged);
263
352
 
353
+ // Intentionally without a dependency array: the handle reads `listItems` to
354
+ // resolve the index, so it has to be rebuilt whenever the rows change.
264
355
  useImperativeHandle(ref, () => ({
265
- resetSelection: () => {
266
- setListItems((prevState) =>
267
- prevState.map((i) => ({
268
- selected: false,
269
- data: i.data,
270
- })),
271
- );
356
+ resetSelection: clearSelection,
357
+ selectIndex: (index: number) => {
358
+ const item = listItems[index];
272
359
 
273
- isAllItemsChecked.current = false;
360
+ // Nothing at that position, or the list renders no selection at all - in
361
+ // which case selecting would raise `onItemSelected` for something the
362
+ // user can neither see nor undo.
363
+ if (!item || selectionMode === ListSelectMode.None) {
364
+ return;
365
+ }
274
366
 
275
- onItemSelected({
276
- mode: 'SINGLE_ITEMS',
277
- items: [],
278
- });
279
- },
280
- selectIndex: (index: number) => {
281
367
  if (selectionMode === ListSelectMode.Single) {
282
- itemClickedHandler(listItems[index].data, index);
368
+ // Single selection only follows clicks, so it raises no event of its
369
+ // own, and raising `onItemClicked` would report a click that never
370
+ // happened. The row is just marked.
371
+ applySelection({
372
+ keys: new Set([getRowKey(item.data, index)]),
373
+ isAll: false,
374
+ });
283
375
  } else {
284
- itemSelectedHandler(true, index);
376
+ itemSelectedHandler(true, getRowKey(item.data, index));
285
377
  }
286
378
  },
287
379
  }));
288
380
 
381
+ const previousSelectionMode = useRef<ListSelectMode>(selectionMode);
382
+
289
383
  useEffect(() => {
290
- if (selectionMode !== ListSelectMode.Multi) {
291
- setListItems((prevState) =>
292
- prevState.map((i) => ({
293
- selected: false,
294
- data: i.data,
295
- })),
296
- );
384
+ const modeChanged = previousSelectionMode.current !== selectionMode;
385
+ previousSelectionMode.current = selectionMode;
386
+
387
+ // Leaving multi select drops the selection, so that the consumer is not
388
+ // left holding a selection that is no longer visible in the list.
389
+ if (modeChanged && selectionMode !== ListSelectMode.Multi) {
390
+ clearSelection();
297
391
  }
298
- }, [selectionMode]);
392
+ }, [clearSelection, selectionMode]);
299
393
 
300
394
  const getRowDisabledState = useCallback(
301
395
  (item: ListItem<T>, index: number): boolean => {
302
396
  const isDisabledDueToSelectAll =
303
- isAllItemsChecked.current &&
397
+ selection.isAll &&
304
398
  !enableSelectAllDeselect &&
305
399
  selectionMode === ListSelectMode.Multi;
306
400
 
@@ -309,95 +403,126 @@ const ListRenderer = <T extends Data>(
309
403
 
310
404
  return isDisabledDueToSelectAll || isDisabledByCustomLogic;
311
405
  },
312
- [enableSelectAllDeselect, selectionMode, isRowDisabled],
406
+ [enableSelectAllDeselect, isRowDisabled, selection.isAll, selectionMode],
313
407
  );
314
408
 
409
+ const triggerIndex = getTriggerIndex(listItems.length, loadingTriggerOffset);
410
+
411
+ // Without a label and without actions there is nothing for the bar to show,
412
+ // so it gives its space back to the list.
413
+ const showActionBar =
414
+ actionBar !== undefined &&
415
+ (!!actionBar.label || actionBar.actions.length > 0);
416
+
315
417
  return (
316
418
  <div
317
419
  className={clsx(classes.wrapper, 'list-wrapper', className)}
318
- style={customStyles}
319
420
  data-test-id="list"
320
421
  >
321
- {/* Header */}
322
- <ListHeader
323
- columns={columns}
324
- itemSelected={isAllItemsChecked.current}
325
- sortData={sort}
326
- columnSizes={columnSizes}
327
- columnGap={columnGap}
328
- rowHeight={headerRowHeight}
329
- actionSize={headerRowActionSize}
330
- horizontalTextAlign={horizontalTextAlign}
331
- verticalTextAlign={verticalTextAlign}
332
- showItemCheckbox={
333
- enableSelectAll &&
334
- selectionMode === ListSelectMode.Multi &&
335
- listItems.length > 0
336
- }
337
- isCheckboxDisabled={listItems.length === 0}
338
- onCheckboxToggled={headerCheckboxHandler}
339
- onSortChanged={sortChangedHandler}
340
- onResetColumnSizes={resetColumnSizes}
341
- onColumnSizesChanged={setColumnSizes}
342
- hasActionColumn={hasActionColumn}
343
- />
344
-
345
- {/* Rows */}
346
- {listItems.map((item: ListItem<T>, index) => (
347
- <ListRowRenderer<T>
348
- key={String(item.data[keyProperty] || index)}
349
- customRowRenderer={customRowRenderer}
350
- isTrigger={isTrigger<T>(index, listItems, loadingTriggerOffset)}
351
- onTriggered={onTriggeredHandler}
352
- listRowProps={{
353
- columns,
354
- data: item.data,
355
- itemSelected: item.selected,
356
- isRowDisabled: getRowDisabledState(item, index),
357
- columnSizes,
358
- columnGap,
359
- rowHeight: listRowHeight,
360
- actionSize: listRowActionSize,
361
- horizontalTextAlign,
362
- verticalTextAlign,
363
- textWrap,
364
- selectionMode,
365
- showActionButton:
366
- selectionMode === ListSelectMode.None &&
367
- getActionButtonVisibility(item.data, showActionButton),
368
- showCheckMark: selectionMode === ListSelectMode.Single,
369
- showItemCheckbox: selectionMode === ListSelectMode.Multi,
370
- onItemClicked: generateItemLink
371
- ? generateItemLink(item.data)
372
- : (data: T) => itemClickedHandler(data, index),
373
- onItemSelected: (checked: boolean) =>
374
- itemSelectedHandler(checked, index),
375
- inlineMenuActions,
376
- }}
422
+ {/* Actions of the active context, on top of the column header */}
423
+ {showActionBar && actionBar !== undefined && (
424
+ <ActionBar
425
+ {...actionBar}
426
+ height={headerRowHeight}
427
+ className={classes.actionBar}
377
428
  />
378
- ))}
429
+ )}
379
430
 
380
- {isLoading && (
381
- <ListRowLoader
431
+ <div className={clsx(classes.list, 'list-container')} style={listStyles}>
432
+ {/* Header */}
433
+ <ListHeader
434
+ columns={columns}
435
+ itemSelected={selection.isAll}
436
+ sortData={sort}
382
437
  columnSizes={columnSizes}
383
438
  columnGap={columnGap}
384
- rowHeight={listRowHeight}
385
- columns={columns}
439
+ rowHeight={headerRowHeight}
440
+ actionSize={headerRowActionSize}
441
+ actionSlotSize={actionSlotSize}
442
+ // The bar sticks to the top of the list first, so the header comes to
443
+ // rest below it. Its bottom border adds to the height of its row.
444
+ stickyOffset={
445
+ showActionBar ? `calc(${headerRowHeight} + 1px)` : undefined
446
+ }
447
+ horizontalTextAlign={horizontalTextAlign}
448
+ verticalTextAlign={verticalTextAlign}
449
+ showItemCheckbox={
450
+ enableSelectAll &&
451
+ selectionMode === ListSelectMode.Multi &&
452
+ listItems.length > 0
453
+ }
454
+ isCheckboxDisabled={listItems.length === 0}
455
+ onCheckboxToggled={headerCheckboxHandler}
456
+ onSortChanged={sortChangedHandler}
457
+ onResetColumnSizes={resetColumnSizes}
458
+ onColumnSizesChanged={setColumnSizes}
459
+ hasActionColumn={hasActionColumn}
386
460
  />
387
- )}
388
- {noItemsMessage(listItems.length, isLoading, isError)}
389
- {isError && handleRetry && (
390
- <div className={clsx(classes.error)}>
391
- <p>{errorMsg}</p>
392
- <TextButton
393
- text={'Retry'}
394
- height={listRowActionSize}
395
- width={'100px'}
396
- buttonContext={ButtonContext.Context}
397
- onButtonClicked={() => onRetry()}
461
+
462
+ {/* Rows */}
463
+ {listItems.map((item: ListItem<T>, index) => {
464
+ const rowKey = getRowKey(item.data, index);
465
+
466
+ return (
467
+ <ListRowRenderer<T>
468
+ key={rowKey}
469
+ customRowRenderer={customRowRenderer}
470
+ isTrigger={index === triggerIndex}
471
+ onTriggered={onTriggeredHandler}
472
+ listRowProps={{
473
+ columns,
474
+ data: item.data,
475
+ itemSelected: item.selected,
476
+ isRowDisabled: getRowDisabledState(item, index),
477
+ columnSizes,
478
+ columnGap,
479
+ rowHeight: listRowHeight,
480
+ actionSize: listRowActionSize,
481
+ horizontalTextAlign,
482
+ verticalTextAlign,
483
+ textWrap,
484
+ selectionMode,
485
+ showActionButton:
486
+ selectionMode === ListSelectMode.None &&
487
+ getActionButtonVisibility(item.data, showActionButton),
488
+ showCheckMark: selectionMode === ListSelectMode.Single,
489
+ showItemCheckbox: selectionMode === ListSelectMode.Multi,
490
+ linkTo: generateItemLink?.(item.data),
491
+ onItemClicked: (row: T) => itemClickedHandler(row, index),
492
+ onItemSelected: (checked: boolean) =>
493
+ itemSelectedHandler(checked, rowKey),
494
+ inlineMenuActions,
495
+ }}
496
+ />
497
+ );
498
+ })}
499
+
500
+ {isLoading && (
501
+ <ListRowLoader
502
+ columnSizes={columnSizes}
503
+ columnGap={columnGap}
504
+ rowHeight={listRowHeight}
505
+ columns={columns}
398
506
  />
399
- </div>
400
- )}
507
+ )}
508
+ {!isLoading && !isError && listItems.length === 0 && (
509
+ <div className={classes.noData} data-test-id="list-empty">
510
+ <p>No items found</p>
511
+ </div>
512
+ )}
513
+ {isError && handleRetry && (
514
+ <div className={classes.error}>
515
+ <p>{errorMsg}</p>
516
+ <TextButton
517
+ text={'Retry'}
518
+ height={listRowActionSize}
519
+ width={'100px'}
520
+ buttonContext={ButtonContext.Context}
521
+ onButtonClicked={() => onRetry()}
522
+ />
523
+ </div>
524
+ )}
525
+ </div>
401
526
  </div>
402
527
  );
403
528
  };
@@ -412,17 +537,3 @@ const ListRenderer = <T extends Data>(
412
537
  * />
413
538
  */
414
539
  export const List = React.forwardRef(ListRenderer);
415
-
416
- const noItemsMessage = (
417
- itemsCount: number,
418
- isLoading: boolean,
419
- isError: boolean,
420
- ): ReactElement | undefined => {
421
- if (!isLoading && !isError && itemsCount === 0) {
422
- return (
423
- <div className={clsx(classes.NoData)} data-test-id="list-empty">
424
- <p>No items found</p>
425
- </div>
426
- );
427
- }
428
- };
@@ -15,6 +15,20 @@
15
15
  border-bottom: solid 1px
16
16
  var(--list-header-border-color, $list-header-border-color);
17
17
 
18
+ // The select all checkbox lines up with the checkboxes of the rows, which
19
+ // occupy the last slot of the actions column - any slot before it belongs to
20
+ // the inline menu or the row action button.
21
+ .actionColumn {
22
+ justify-items: end;
23
+
24
+ .selectAllSlot {
25
+ height: 100%;
26
+
27
+ display: grid;
28
+ place-items: center;
29
+ }
30
+ }
31
+
18
32
  .columnLabel {
19
33
  box-sizing: border-box;
20
34
  width: 100%;
@@ -37,6 +37,7 @@ const mockProps: ListHeaderProps<TestListHeaderData> = {
37
37
  columnGap: '5px',
38
38
  rowHeight: '44px',
39
39
  actionSize: '50px',
40
+ actionSlotSize: '50px',
40
41
  horizontalTextAlign: 'left',
41
42
  verticalTextAlign: 'center',
42
43
  hasActionColumn: true,