@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
@@ -0,0 +1,41 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { getVisibleActionCount, isActionBarNavigationAction } from './helpers';
3
+
4
+ describe('getVisibleActionCount', () => {
5
+ it('keeps every action when they all fit', () => {
6
+ expect(getVisibleActionCount([100, 100, 100], 300, 50)).toBe(3);
7
+ expect(getVisibleActionCount([100, 100, 100], 500, 50)).toBe(3);
8
+ });
9
+
10
+ it('reserves room for the overflow menu once an action does not fit', () => {
11
+ // 299 available, 50 of it goes to the menu, which leaves room for two
12
+ // actions (200) but not for the third (300).
13
+ expect(getVisibleActionCount([100, 100, 100], 299, 50)).toBe(2);
14
+ });
15
+
16
+ it('moves every action into the overflow menu when not even one fits', () => {
17
+ expect(getVisibleActionCount([100, 100], 120, 50)).toBe(0);
18
+ });
19
+
20
+ it('handles an empty set of actions', () => {
21
+ expect(getVisibleActionCount([], 300, 50)).toBe(0);
22
+ });
23
+
24
+ it('keeps a single action that fits exactly', () => {
25
+ expect(getVisibleActionCount([100], 100, 50)).toBe(1);
26
+ });
27
+ });
28
+
29
+ describe('isActionBarNavigationAction', () => {
30
+ it('is true for an action with a path', () => {
31
+ expect(isActionBarNavigationAction({ label: 'Open', path: '/open' })).toBe(
32
+ true,
33
+ );
34
+ });
35
+
36
+ it('is false for an action with a handler', () => {
37
+ expect(
38
+ isActionBarNavigationAction({ label: 'Run', onClick: vi.fn() }),
39
+ ).toBe(false);
40
+ });
41
+ });
@@ -0,0 +1,54 @@
1
+ import type {
2
+ ActionBarAction,
3
+ ActionBarNavigationAction,
4
+ } from './ActionBar.model';
5
+
6
+ /**
7
+ * Determines how many actions of the action bar can be rendered next to each
8
+ * other, given the space that is available to them.
9
+ *
10
+ * Whenever not every action fits, room for the overflow menu is taken off the
11
+ * budget first, so that the menu holding the remaining actions is always
12
+ * reachable. A return value of 0 means that every action has to move into the
13
+ * overflow menu.
14
+ *
15
+ * @param actionWidths the width of each action, in the order they are rendered
16
+ * @param availableWidth the width the actions may occupy
17
+ * @param overflowMenuWidth the width of the overflow menu trigger
18
+ */
19
+ export const getVisibleActionCount = (
20
+ actionWidths: number[],
21
+ availableWidth: number,
22
+ overflowMenuWidth: number,
23
+ ): number => {
24
+ const totalWidth = actionWidths.reduce((total, width) => total + width, 0);
25
+
26
+ if (totalWidth <= availableWidth) {
27
+ return actionWidths.length;
28
+ }
29
+
30
+ const budget = availableWidth - overflowMenuWidth;
31
+
32
+ let usedWidth = 0;
33
+ let count = 0;
34
+
35
+ for (const width of actionWidths) {
36
+ if (usedWidth + width > budget) {
37
+ break;
38
+ }
39
+
40
+ usedWidth += width;
41
+ count++;
42
+ }
43
+
44
+ return count;
45
+ };
46
+
47
+ /**
48
+ * Determines whether an action of the action bar navigates rather than runs a
49
+ * handler.
50
+ * @param action the action to check
51
+ */
52
+ export const isActionBarNavigationAction = (
53
+ action: ActionBarAction,
54
+ ): action is ActionBarNavigationAction => 'path' in action && !!action.path;
@@ -0,0 +1,2 @@
1
+ export { ActionBar, type ActionBarProps } from './ActionBar';
2
+ export type { ActionBarAction, ActionBarData } from './ActionBar.model';
@@ -6,11 +6,11 @@ import {
6
6
  waitFor,
7
7
  } from '@testing-library/react';
8
8
  import React from 'react';
9
- import { BrowserRouter as Router } from 'react-router-dom';
9
+ import { MemoryRouter, Route, BrowserRouter as Router } from 'react-router-dom';
10
10
  import { afterEach, describe, expect, it, test, vi } from 'vitest';
11
11
  import { noop } from '../../../helpers/utils';
12
12
  import type { ConfirmationConfig } from '../../ConfirmDialog';
13
- import { IconName } from '../../Icons';
13
+ import { IconName, Icons } from '../../Icons';
14
14
  import {
15
15
  Action,
16
16
  type ActionProps,
@@ -99,6 +99,68 @@ describe('Action', () => {
99
99
  expect(actionContainer?.classList.contains('isDisabled')).toBe(true);
100
100
  });
101
101
 
102
+ it('keeps a disabled link from navigating', () => {
103
+ const { getByTestId, queryByTestId } = render(
104
+ <MemoryRouter initialEntries={['/']}>
105
+ <Action
106
+ action={{ label: 'Export', path: '/export', isDisabled: true }}
107
+ />
108
+ <Route path="/export">
109
+ <span data-test-id="export-page" />
110
+ </Route>
111
+ </MemoryRouter>,
112
+ );
113
+
114
+ const link = getByTestId('action');
115
+
116
+ // Out of reach of the keyboard, and inert if it is activated anyway.
117
+ expect(link).toHaveAttribute('aria-disabled', 'true');
118
+ expect(link).toHaveAttribute('tabindex', '-1');
119
+
120
+ fireEvent.click(link);
121
+
122
+ expect(queryByTestId('export-page')).toBeNull();
123
+ });
124
+
125
+ it('navigates with a link that is not disabled', () => {
126
+ const { getByTestId } = render(
127
+ <MemoryRouter initialEntries={['/']}>
128
+ <Action action={{ label: 'Export', path: '/export' }} />
129
+ <Route path="/export">
130
+ <span data-test-id="export-page" />
131
+ </Route>
132
+ </MemoryRouter>,
133
+ );
134
+
135
+ const link = getByTestId('action');
136
+
137
+ expect(link).not.toHaveAttribute('aria-disabled');
138
+ expect(link).not.toHaveAttribute('tabindex');
139
+
140
+ fireEvent.click(link);
141
+
142
+ expect(getByTestId('export-page')).toBeInTheDocument();
143
+ });
144
+
145
+ it(`uses the icon with the enum value 0 rather than the default icon`, () => {
146
+ const { container } = render(
147
+ <Router>
148
+ <Action
149
+ action={{
150
+ ...defaultNavigationProps.action,
151
+ icon: IconName.Archive,
152
+ }}
153
+ />
154
+ </Router>,
155
+ );
156
+ const { container: archive } = render(<Icons icon={IconName.Archive} />);
157
+
158
+ expect(IconName.Archive).toBe(0);
159
+ expect(container.querySelector('svg')?.innerHTML).toBe(
160
+ archive.querySelector('svg')?.innerHTML,
161
+ );
162
+ });
163
+
102
164
  it(`changes the icon if one provided by props`, () => {
103
165
  const iconName = IconName.External;
104
166
  const props = {
@@ -204,9 +204,14 @@ const NavigationAction: React.FC<{ action: NavigationActionData }> = ({
204
204
  )}
205
205
  data-test-id="action"
206
206
  target={openInNewTab ? '_blank' : undefined}
207
+ // The styles keep the pointer off a disabled link. This keeps it out of
208
+ // reach of the keyboard, and from navigating if it is activated anyway.
209
+ aria-disabled={isDisabled || undefined}
210
+ tabIndex={isDisabled ? -1 : undefined}
211
+ onClick={isDisabled ? (event) => event.preventDefault() : undefined}
207
212
  >
208
213
  <span data-test-id="label">{label}</span>
209
- <Icons icon={icon ? icon : defaultIcon} className={classes.icon} />
214
+ <Icons icon={icon ?? defaultIcon} className={classes.icon} />
210
215
  </Link>
211
216
  );
212
217
  };
@@ -1,2 +1,3 @@
1
1
  export { ConfirmDialog, useConfirmationDelay } from './ConfirmDialog';
2
2
  export * from './ConfirmDialog.models';
3
+ export { useConfirmableAction } from './useConfirmableAction';
@@ -0,0 +1,159 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import { ConfirmDialog, useConfirmationDelay } from './ConfirmDialog';
3
+ import type {
4
+ ConfirmationConfig,
5
+ ConfirmationMode,
6
+ } from './ConfirmDialog.models';
7
+
8
+ interface UseConfirmableActionProps {
9
+ /** How the action confirms before it runs. (default: 'None') */
10
+ confirmationMode?: ConfirmationMode;
11
+ /** Optional text overrides for the confirmation. */
12
+ confirmationConfig?: ConfirmationConfig;
13
+ /** Called once the action is confirmed, or immediately when no confirmation is required. */
14
+ onConfirmed: () => void;
15
+ /** Move the 'Advanced' dialog along the trigger element. (default: 0) */
16
+ offsetSkidding?: number;
17
+ }
18
+
19
+ interface UseConfirmableActionResult<T extends HTMLElement> {
20
+ /** Whether a confirmation is currently pending. */
21
+ isConfirming: boolean;
22
+ /**
23
+ * Whether the trigger should render `simpleConfirmation` in place of its
24
+ * label. Only ever true in 'Simple' mode.
25
+ */
26
+ isSimpleConfirmation: boolean;
27
+ /** Click handler for the trigger element. */
28
+ onTriggerClicked: () => void;
29
+ /** Props that have to be spread on the trigger element. */
30
+ triggerProps: {
31
+ ref: React.LegacyRef<T>;
32
+ onMouseEnter: () => void;
33
+ onMouseLeave: () => void;
34
+ onMouseOver: () => void;
35
+ };
36
+ /** 'Click again to confirm' message, to be rendered instead of the label. */
37
+ simpleConfirmation: JSX.Element | null;
38
+ /** The confirmation dialog of the 'Advanced' mode, anchored to the trigger. */
39
+ advancedConfirmation: JSX.Element | null;
40
+ }
41
+
42
+ /**
43
+ * Covers the concern of confirming an action before it is executed, shared by
44
+ * the components that render actions.
45
+ *
46
+ * In 'Simple' mode the first click arms the confirmation and the trigger swaps
47
+ * its label for `simpleConfirmation`, the second click executes the action. In
48
+ * 'Advanced' mode the click opens `advancedConfirmation`, which executes the
49
+ * action once confirmed.
50
+ *
51
+ * @example
52
+ * const { onTriggerClicked, triggerProps, isSimpleConfirmation, simpleConfirmation, advancedConfirmation } =
53
+ * useConfirmableAction<HTMLButtonElement>({ confirmationMode, confirmationConfig, onConfirmed: onClick });
54
+ *
55
+ * <button {...triggerProps} onClick={onTriggerClicked}>
56
+ * {isSimpleConfirmation ? simpleConfirmation : label}
57
+ * </button>
58
+ * {advancedConfirmation}
59
+ */
60
+ export const useConfirmableAction = <T extends HTMLElement>({
61
+ confirmationMode = 'None',
62
+ confirmationConfig = {},
63
+ onConfirmed,
64
+ offsetSkidding,
65
+ }: UseConfirmableActionProps): UseConfirmableActionResult<T> => {
66
+ const [confirmation, setConfirmation] = useState<boolean>(false);
67
+ // Popper needs the element in state, not in a ref, to know when it changes.
68
+ const [referenceElement, setReferenceElement] = useState<HTMLElement>();
69
+ const { isMouseOver, setIsMouseOver } = useConfirmationDelay({
70
+ confirmationMode,
71
+ confirmation,
72
+ setConfirmation,
73
+ });
74
+
75
+ const { title, body, cancelButtonText, confirmButtonText, onConfirmOpen } =
76
+ confirmationConfig;
77
+
78
+ const onTriggerClicked = (): void => {
79
+ switch (confirmationMode) {
80
+ case 'Simple':
81
+ !confirmation
82
+ ? setConfirmation(true)
83
+ : (onConfirmed(), setConfirmation(false));
84
+ break;
85
+ case 'Advanced':
86
+ setConfirmation(true);
87
+ break;
88
+ default:
89
+ onConfirmed();
90
+ setConfirmation(false);
91
+ break;
92
+ }
93
+ };
94
+
95
+ const isSimpleConfirmation = confirmation && confirmationMode === 'Simple';
96
+
97
+ return {
98
+ isConfirming: confirmation,
99
+ isSimpleConfirmation,
100
+ onTriggerClicked,
101
+ triggerProps: {
102
+ ref: setReferenceElement as React.LegacyRef<T>,
103
+ onMouseEnter: () => setIsMouseOver(true),
104
+ onMouseLeave: () => setIsMouseOver(false),
105
+ onMouseOver: () => {
106
+ // Needed in cases where the mouse enters the component after another
107
+ // confirmation dialog was dismissed
108
+ if (!isMouseOver) {
109
+ setIsMouseOver(true);
110
+ }
111
+ },
112
+ },
113
+ simpleConfirmation: isSimpleConfirmation ? (
114
+ <SimpleConfirmation
115
+ onConfirmOpen={onConfirmOpen}
116
+ data={{ mode: 'Simple' }}
117
+ />
118
+ ) : null,
119
+ advancedConfirmation:
120
+ confirmation && confirmationMode === 'Advanced' ? (
121
+ <ConfirmDialog
122
+ referenceElement={referenceElement}
123
+ title={title}
124
+ cancelButtonText={cancelButtonText}
125
+ confirmButtonText={confirmButtonText}
126
+ offsetSkidding={offsetSkidding}
127
+ onCancel={() => setConfirmation(false)}
128
+ onConfirm={() => {
129
+ onConfirmed();
130
+ setConfirmation(false);
131
+ }}
132
+ onConfirmOpen={(isOpen) =>
133
+ onConfirmOpen?.(isOpen, { mode: 'Advanced' })
134
+ }
135
+ >
136
+ {body}
137
+ </ConfirmDialog>
138
+ ) : null,
139
+ };
140
+ };
141
+
142
+ /**
143
+ * Message shown in place of the label of an action that is awaiting its second
144
+ * click, which also reports the confirmation state while it is mounted.
145
+ */
146
+ const SimpleConfirmation: React.FC<{
147
+ onConfirmOpen: ConfirmationConfig['onConfirmOpen'];
148
+ data: { mode: ConfirmationMode };
149
+ }> = ({ onConfirmOpen, data }) => {
150
+ useEffect(() => {
151
+ onConfirmOpen && onConfirmOpen(true, data);
152
+ return () => {
153
+ onConfirmOpen && onConfirmOpen(false, data);
154
+ };
155
+ // eslint-disable-next-line react-hooks/exhaustive-deps
156
+ }, []);
157
+
158
+ return <span data-test-id="confirm">Click again to confirm</span>;
159
+ };
@@ -4,6 +4,7 @@ import React from 'react';
4
4
  import { action } from 'storybook/actions';
5
5
  import { createGroups } from '../../helpers/storybook';
6
6
  import { IconName } from '../Icons/Icons.models';
7
+ import { PageHeaderActionType } from '../PageHeader';
7
8
  import { EmptyStation } from './EmptyStation';
8
9
 
9
10
  const groups = createGroups({
@@ -90,3 +91,99 @@ export const Default: StoryObj<typeof EmptyStation> = {
90
91
  subtitle: 'My Asset Type',
91
92
  },
92
93
  };
94
+
95
+ export const GroupedActions: StoryObj<typeof EmptyStation> = {
96
+ name: 'Grouped Header Actions',
97
+ parameters: {
98
+ docs: {
99
+ description: {
100
+ story: `Header actions can be grouped. A group renders a single action that opens an action bar with its child actions below the header, pushing the station content down.
101
+ Only one group can be open at a time. Standard actions and groups are separated by a spacer, and actions that do not fit move into the overflow menu of the bar.`,
102
+ },
103
+ },
104
+ },
105
+ argTypes: {
106
+ actions: { ...groups.actions, control: false },
107
+ },
108
+ args: {
109
+ title: 'Title',
110
+ subtitle: 'My Asset Type',
111
+ actions: [
112
+ {
113
+ label: 'Undo',
114
+ icon: IconName.Undo,
115
+ kind: 'action',
116
+ onClick: action('Action.onClick'),
117
+ },
118
+ {
119
+ label: 'Bulk Actions',
120
+ icon: IconName.Bulk,
121
+ kind: 'group',
122
+ actions: [
123
+ {
124
+ label: 'Bulk Edit',
125
+ icon: IconName.BulkEdit,
126
+ actionType: PageHeaderActionType.Context,
127
+ tag: 'BETA',
128
+ onClick: action('BulkAction.onClick'),
129
+ },
130
+ {
131
+ label: 'Bulk Delete',
132
+ icon: IconName.Delete,
133
+ actionType: PageHeaderActionType.Context,
134
+ confirmationMode: 'Advanced',
135
+ onClick: action('BulkAction.onClick'),
136
+ },
137
+ {
138
+ label: 'Bulk Unpublish',
139
+ icon: IconName.Unpublish,
140
+ actionType: PageHeaderActionType.Context,
141
+ confirmationMode: 'Simple',
142
+ onClick: action('BulkAction.onClick'),
143
+ },
144
+ {
145
+ label: 'Disabled Bulk Action',
146
+ icon: IconName.ChevronRight,
147
+ disabled: true,
148
+ onClick: action('BulkAction.onClick'),
149
+ },
150
+ ],
151
+ onActionsGroupToggled: async (expanded: boolean): Promise<void> => {
152
+ action('BulkActions.onActionsGroupToggled')(expanded);
153
+ },
154
+ },
155
+ {
156
+ label: 'Export',
157
+ icon: IconName.File,
158
+ kind: 'group',
159
+ actions: [
160
+ {
161
+ label: 'Export as CSV',
162
+ icon: IconName.ChevronRight,
163
+ onClick: action('ExportAction.onClick'),
164
+ },
165
+ {
166
+ label: 'Export as JSON',
167
+ icon: IconName.ChevronRight,
168
+ onClick: action('ExportAction.onClick'),
169
+ },
170
+ ],
171
+ onActionsGroupToggled: async (expanded: boolean): Promise<void> => {
172
+ action('Export.onActionsGroupToggled')(expanded);
173
+ },
174
+ },
175
+ ],
176
+ },
177
+ render: (args) => (
178
+ <EmptyStation {...args}>
179
+ <div>
180
+ <h1 style={{ textAlign: 'center' }}>Your station content goes here</h1>
181
+ {Array.from({ length: 3 }, (_, i) => i + 1).map((i) => (
182
+ <p key={i}>
183
+ {faker.lorem.paragraph(faker.datatype.number({ min: 10, max: 50 }))}
184
+ </p>
185
+ ))}
186
+ </div>
187
+ </EmptyStation>
188
+ ),
189
+ };
@@ -77,6 +77,8 @@ export const useBulkEdit = <T extends Data>({
77
77
  ? {
78
78
  label: bulkEditRegistration.label ?? 'Edit',
79
79
  icon: bulkEditRegistration.icon ?? IconName.BulkEdit,
80
+ // Stays highlighted while the panel it has opened is on display.
81
+ toggled: isBulkEditMode,
80
82
  onClick: () => {
81
83
  setIsBulkEditMode((prev) => !prev);
82
84
  if (!isBulkEditMode) {
@@ -179,21 +179,22 @@ describe('Explorer', () => {
179
179
  columns={[{ propertyName: 'id' }]}
180
180
  dataProvider={provider}
181
181
  stationKey="mock-key"
182
+ openBulkActionsOnStart={true}
182
183
  bulkActions={[{ label: 'Something', onClick: vi.fn() }]}
183
184
  />,
184
185
  );
185
186
  });
186
187
 
187
- // When no items are selected, bulk action buttons should be disabled
188
+ // When no items are selected, the actions on the bar should be disabled
188
189
  await waitFor(() => {
189
190
  const bulkActionButtons = container.querySelectorAll(
190
- '.page-header-group-action-container .actions button',
191
+ '[data-test-id="action-bar"] button[data-test-id="action"]',
191
192
  );
192
193
  expect(bulkActionButtons.length).toBeGreaterThan(0);
193
194
  });
194
195
 
195
196
  const bulkActionButtons = container.querySelectorAll(
196
- '.page-header-group-action-container .actions button',
197
+ '[data-test-id="action-bar"] button[data-test-id="action"]',
197
198
  );
198
199
  bulkActionButtons.forEach((button) => {
199
200
  expect((button as HTMLButtonElement).disabled).toBe(true);
@@ -1777,7 +1778,7 @@ describe('Explorer', () => {
1777
1778
  });
1778
1779
  });
1779
1780
 
1780
- it('sets the subtitle', async () => {
1781
+ it('reports the result counts on the action bar', async () => {
1781
1782
  const [provider, spy] = getDataProvider();
1782
1783
  const data = [{ id: 1 }, { id: 2 }, { id: 3 }];
1783
1784
 
@@ -1801,15 +1802,17 @@ describe('Explorer', () => {
1801
1802
  );
1802
1803
 
1803
1804
  await waitFor(() => {
1804
- expect(container.textContent).toContain('Showing 4 of 5 Elements');
1805
+ expect(
1806
+ container.querySelector('[data-test-id="action-bar-label"]'),
1807
+ ).toHaveTextContent('Total 5, Filtered 4');
1805
1808
  });
1806
1809
  });
1807
1810
 
1808
- it(`appends 'Selected' count when bulk actions is open`, async () => {
1811
+ it('reports the selection on the action bar once items are selected', async () => {
1809
1812
  const [provider, spy] = getDataProvider();
1810
1813
  const data = [{ id: 1 }, { id: 2 }, { id: 3 }];
1811
1814
 
1812
- spy.mockReturnValueOnce(
1815
+ spy.mockReturnValue(
1813
1816
  Promise.resolve({
1814
1817
  totalCount: 5,
1815
1818
  filteredCount: 4,
@@ -1829,9 +1832,26 @@ describe('Explorer', () => {
1829
1832
  ),
1830
1833
  );
1831
1834
 
1835
+ // Opening the bulk actions alone changes nothing, the counts stay
1832
1836
  await waitFor(() => {
1833
- expect(container.textContent).toContain('Selected');
1837
+ expect(
1838
+ container.querySelector('[data-test-id="action-bar-label"]'),
1839
+ ).toHaveTextContent('Total 5, Filtered 4');
1834
1840
  });
1841
+
1842
+ const firstCheckbox = container
1843
+ .querySelectorAll('[data-test-id="list-entry"]')[0]
1844
+ .querySelector(
1845
+ '[data-test-id="list-entry-checkbox"]',
1846
+ ) as HTMLButtonElement;
1847
+
1848
+ await act(async () => {
1849
+ fireEvent.click(firstCheckbox);
1850
+ });
1851
+
1852
+ expect(
1853
+ container.querySelector('[data-test-id="action-bar-label"]'),
1854
+ ).toHaveTextContent('Selected 1');
1835
1855
  });
1836
1856
 
1837
1857
  describe('global states', () => {
@@ -2041,5 +2061,74 @@ describe('Explorer', () => {
2041
2061
  // Note: Testing callback would require finding and clicking the bulk actions toggle button
2042
2062
  });
2043
2063
 
2064
+ describe('Quick edit', () => {
2065
+ const renderQuickEdit = async (onItemClicked = vi.fn()) => {
2066
+ const [provider, spy] = getDataProvider();
2067
+ const data = [
2068
+ { id: 1, title: 'One', desc: '' },
2069
+ { id: 2, title: 'Two', desc: '' },
2070
+ { id: 3, title: 'Three', desc: '' },
2071
+ ];
2072
+
2073
+ spy.mockReturnValue(
2074
+ Promise.resolve({
2075
+ totalCount: data.length,
2076
+ filteredCount: data.length,
2077
+ data,
2078
+ }),
2079
+ );
2080
+
2081
+ const { container } = await actWithReturn(async () => {
2082
+ return render(
2083
+ <Explorer
2084
+ columns={mockListColumns}
2085
+ dataProvider={provider}
2086
+ stationKey="mock-key"
2087
+ onItemClicked={onItemClicked}
2088
+ quickEditRegistrations={[{ label: 'Details', component: <div /> }]}
2089
+ />,
2090
+ );
2091
+ });
2092
+
2093
+ const rows = () =>
2094
+ container.querySelectorAll('[data-test-id="list-entry"]');
2095
+
2096
+ await waitFor(() => expect(rows()).toHaveLength(data.length));
2097
+
2098
+ const quickViewToggle = container.querySelector(
2099
+ '[data-test-id="page-header-actions"] [data-test-id="action"][title="Quick View"]',
2100
+ ) as HTMLElement;
2101
+
2102
+ await act(async () => {
2103
+ fireEvent.click(quickViewToggle);
2104
+ });
2105
+
2106
+ return { rows };
2107
+ };
2108
+
2109
+ it('marks the item it opens on as selected in the list', async () => {
2110
+ const onItemClicked = vi.fn();
2111
+ const { rows } = await renderQuickEdit(onItemClicked);
2112
+
2113
+ expect(rows()[0]).toHaveClass('selected');
2114
+ expect(rows()[1]).not.toHaveClass('selected');
2115
+ // Marking the item is no click on it.
2116
+ expect(onItemClicked).not.toHaveBeenCalled();
2117
+ });
2118
+
2119
+ it('moves the selection to the item that is clicked', async () => {
2120
+ const { rows } = await renderQuickEdit();
2121
+
2122
+ await act(async () => {
2123
+ fireEvent.click(
2124
+ rows()[2].querySelector('[data-test-id="list-entry-select-button"]')!,
2125
+ );
2126
+ });
2127
+
2128
+ expect(rows()[2]).toHaveClass('selected');
2129
+ expect(rows()[0]).not.toHaveClass('selected');
2130
+ });
2131
+ });
2132
+
2044
2133
  it.todo(`item selection should default to 'SINGLE_ITEMS'`);
2045
2134
  });