@drivenets/design-system 0.18.1 → 0.19.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.
- package/CHANGELOG.md +20 -0
- package/dist/index.cjs +17138 -12075
- package/dist/index.d.cts +783 -238
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +783 -238
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17123 -12076
- package/dist/index.js.map +1 -1
- package/dist/index.min.css +1 -1
- package/package.json +15 -15
- package/src/components/ds-bot-button/ds-bot-button.figma.ts +38 -0
- package/src/components/ds-bot-button/ds-bot-button.module.scss +41 -0
- package/src/components/ds-bot-button/ds-bot-button.tsx +48 -0
- package/src/components/ds-bot-button/ds-bot-button.types.ts +33 -0
- package/src/components/ds-bot-button/index.ts +2 -0
- package/src/components/ds-bot-button/net-gen-logo.tsx +46 -0
- package/src/components/ds-bulk-actions/components/action-button.tsx +42 -0
- package/src/components/ds-bulk-actions/components/menu-entries.tsx +76 -0
- package/src/components/ds-bulk-actions/components/overflow.tsx +75 -0
- package/src/components/ds-bulk-actions/ds-bulk-actions.figma.ts +28 -0
- package/src/components/ds-bulk-actions/ds-bulk-actions.module.scss +199 -0
- package/src/components/ds-bulk-actions/ds-bulk-actions.tsx +220 -0
- package/src/components/ds-bulk-actions/ds-bulk-actions.types.ts +170 -0
- package/src/components/ds-bulk-actions/ds-bulk-actions.unit.test.ts +195 -0
- package/src/components/ds-bulk-actions/ds-bulk-actions.utils.ts +91 -0
- package/src/components/ds-bulk-actions/ds-bulk-actions.utils.unit.test.ts +134 -0
- package/src/components/ds-bulk-actions/hooks/compute-bulk-actions-overflow.ts +85 -0
- package/src/components/ds-bulk-actions/hooks/use-bulk-actions-overflow.ts +153 -0
- package/src/components/ds-bulk-actions/index.ts +18 -0
- package/src/components/ds-button-v3/ds-button-v3.module.scss +2 -2
- package/src/components/ds-catalog-layout/ds-catalog-layout.figma.ts +1 -1
- package/src/components/ds-dropdown-menu/ds-dropdown-menu.tsx +2 -0
- package/src/components/ds-empty-state/ds-empty-state.module.scss +16 -0
- package/src/components/ds-empty-state/ds-empty-state.tsx +45 -0
- package/src/components/ds-empty-state/ds-empty-state.types.ts +38 -0
- package/src/components/ds-empty-state/index.ts +3 -0
- package/src/components/ds-file-upload/ds-file-upload.tsx +4 -1
- package/src/components/ds-form-control/ds-form-control.tsx +18 -10
- package/src/components/ds-illustration/ds-illustration.figma.ts +34 -0
- package/src/components/ds-illustration/ds-illustration.module.scss +6 -0
- package/src/components/ds-illustration/ds-illustration.tsx +29 -0
- package/src/components/ds-illustration/ds-illustration.types.ts +45 -0
- package/src/components/ds-illustration/illustrations/configuration.tsx +51 -0
- package/src/components/ds-illustration/illustrations/device.tsx +133 -0
- package/src/components/ds-illustration/illustrations/done.tsx +107 -0
- package/src/components/{ds-catalog-layout/catalog-layout-empty-illustration.tsx → ds-illustration/illustrations/empty-inbox.tsx} +10 -14
- package/src/components/ds-illustration/illustrations/error.tsx +86 -0
- package/src/components/ds-illustration/illustrations/go-filter.tsx +51 -0
- package/src/components/ds-illustration/illustrations/illustration-clouds.tsx +10 -0
- package/src/components/ds-illustration/illustrations/index.ts +39 -0
- package/src/components/ds-illustration/illustrations/no-connection.tsx +54 -0
- package/src/components/ds-illustration/illustrations/no-credit-card.tsx +48 -0
- package/src/components/ds-illustration/illustrations/no-device.tsx +61 -0
- package/src/components/ds-illustration/illustrations/no-documents.tsx +52 -0
- package/src/components/ds-illustration/illustrations/no-gps.tsx +57 -0
- package/src/components/ds-illustration/illustrations/no-images.tsx +82 -0
- package/src/components/ds-illustration/illustrations/no-items-cart.tsx +86 -0
- package/src/components/ds-illustration/illustrations/no-messages.tsx +51 -0
- package/src/components/ds-illustration/illustrations/no-search-result.tsx +76 -0
- package/src/components/ds-illustration/illustrations/no-tasks.tsx +61 -0
- package/src/components/ds-illustration/illustrations/site.tsx +80 -0
- package/src/components/ds-illustration/index.ts +3 -0
- package/src/components/ds-main-menu/components/expanded-item/expanded-item.tsx +108 -0
- package/src/components/ds-main-menu/components/expanded-item/expanded-item.types.ts +8 -0
- package/src/components/ds-main-menu/components/expanded-item/index.ts +1 -0
- package/src/components/ds-main-menu/components/menu-graphic/index.ts +1 -0
- package/src/components/ds-main-menu/components/menu-graphic/menu-graphic.tsx +16 -0
- package/src/components/ds-main-menu/components/menu-graphic/menu-graphic.types.ts +7 -0
- package/src/components/ds-main-menu/components/tile-link/index.ts +1 -0
- package/src/components/ds-main-menu/components/tile-link/tile-link.tsx +85 -0
- package/src/components/ds-main-menu/components/tile-link/tile-link.types.ts +8 -0
- package/src/components/ds-main-menu/components/utility-link/index.ts +1 -0
- package/src/components/ds-main-menu/components/utility-link/utility-link.tsx +37 -0
- package/src/components/ds-main-menu/components/utility-link/utility-link.types.ts +7 -0
- package/src/components/ds-main-menu/ds-main-menu.figma.ts +17 -0
- package/src/components/ds-main-menu/ds-main-menu.module.scss +349 -0
- package/src/components/ds-main-menu/ds-main-menu.tsx +102 -0
- package/src/components/ds-main-menu/ds-main-menu.types.ts +86 -0
- package/src/components/ds-main-menu/ds-main-menu.utils.ts +40 -0
- package/src/components/ds-main-menu/index.ts +9 -0
- package/src/components/ds-modal/figma/ds-modal-footer.figma.ts +1 -1
- package/src/components/ds-popover/ds-popover.tsx +2 -1
- package/src/components/ds-popover/ds-popover.types.ts +7 -0
- package/src/components/ds-site-node/ds-site-node.tsx +2 -2
- package/src/components/ds-table/components/ds-table-body-virtualized/ds-table-body-virtualized.module.scss +12 -2
- package/src/components/ds-table/components/ds-table-body-virtualized/ds-table-body-virtualized.tsx +2 -3
- package/src/components/ds-table/components/ds-table-group-header-cell/ds-table-group-header-cell.module.scss +1 -6
- package/src/components/ds-table/components/ds-table-group-header-cell/ds-table-group-header-cell.tsx +6 -3
- package/src/components/ds-table/components/ds-table-header/ds-table-header.module.scss +18 -7
- package/src/components/ds-table/components/ds-table-header/ds-table-header.tsx +27 -21
- package/src/components/ds-table/components/ds-table-header-label.module.scss +12 -0
- package/src/components/ds-table/components/ds-table-header-label.tsx +28 -0
- package/src/components/ds-table/components/ds-table-row/ds-table-row.module.scss +0 -1
- package/src/components/ds-table/context/ds-table-context.tsx +8 -0
- package/src/components/ds-table/ds-table.module.scss +26 -16
- package/src/components/ds-table/ds-table.tsx +46 -14
- package/src/components/ds-table/ds-table.types.ts +33 -3
- package/src/components/ds-table/hooks/use-column-resize.ts +114 -11
- package/src/components/ds-table/hooks/use-scrollbar-spacer.ts +108 -0
- package/src/components/ds-table/hooks/use-scrollbar-spacer.unit.test.ts +26 -0
- package/src/components/ds-table/styles/_variables.scss +0 -2
- package/src/components/ds-table/utils/column-size.ts +274 -1
- package/src/components/ds-table/utils/column-size.unit.test.ts +157 -0
- package/src/components/ds-table/utils/constants.ts +13 -5
- package/src/components/ds-table/utils/table-columns.tsx +27 -13
- package/src/components/ds-table/utils/table-columns.unit.test.ts +44 -0
- package/src/components/ds-toggle/ds-toggle.module.scss +18 -0
- package/src/components/ds-toggle/ds-toggle.tsx +15 -13
- package/src/components/ds-tooltip/ds-tooltip.tsx +6 -2
- package/src/components/ds-tooltip/ds-tooltip.types.ts +17 -0
- package/src/components/ds-top-bar-navigation/ds-top-bar-navigation.figma.ts +35 -0
- package/src/components/ds-top-bar-navigation/ds-top-bar-navigation.module.scss +150 -0
- package/src/components/ds-top-bar-navigation/ds-top-bar-navigation.tsx +161 -0
- package/src/components/ds-top-bar-navigation/ds-top-bar-navigation.types.ts +117 -0
- package/src/components/ds-top-bar-navigation/index.ts +2 -0
- package/src/index.ts +6 -0
- package/src/utils/use-is-horizontally-overflowing.ts +35 -0
- package/src/components/ds-site-node/hooks/use-is-overflowing.ts +0 -31
- package/src/components/ds-table/components/ds-table-bulk-actions/ds-table-bulk-actions.module.scss +0 -112
- package/src/components/ds-table/components/ds-table-bulk-actions/ds-table-bulk-actions.tsx +0 -67
- package/src/components/ds-table/components/ds-table-bulk-actions/ds-table-bulk-actions.types.ts +0 -41
- package/src/components/ds-table/components/ds-table-bulk-actions/index.ts +0 -2
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/* eslint-disable vitest/expect-expect */
|
|
2
|
+
import { describe, expect, expectTypeOf, it } from 'vitest';
|
|
3
|
+
import type {
|
|
4
|
+
DsBulkActionsItemProps,
|
|
5
|
+
DsBulkActionsMenuEntry,
|
|
6
|
+
DsBulkActionsProps,
|
|
7
|
+
} from './ds-bulk-actions.types';
|
|
8
|
+
import { computeBulkActionsOverflow } from './hooks/compute-bulk-actions-overflow';
|
|
9
|
+
|
|
10
|
+
type ClickHandler = () => void;
|
|
11
|
+
|
|
12
|
+
const chrome = {
|
|
13
|
+
badgeWidth: 60,
|
|
14
|
+
dismissWidth: 40,
|
|
15
|
+
labelWidth: 120,
|
|
16
|
+
gap: 4,
|
|
17
|
+
moreWidth: 70,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
describe('DsBulkActionsItemProps discriminated union', () => {
|
|
21
|
+
it('allows click items with icon, label, and onClick', () => {
|
|
22
|
+
expectTypeOf<{ icon: 'edit'; label: string; onClick: ClickHandler }>().toExtend<DsBulkActionsItemProps>();
|
|
23
|
+
expectTypeOf<{
|
|
24
|
+
icon: 'edit';
|
|
25
|
+
label: string;
|
|
26
|
+
onClick: ClickHandler;
|
|
27
|
+
disabled: true;
|
|
28
|
+
width: 120;
|
|
29
|
+
}>().toExtend<DsBulkActionsItemProps>();
|
|
30
|
+
expectTypeOf<{
|
|
31
|
+
icon: 'edit';
|
|
32
|
+
label: string;
|
|
33
|
+
onClick: ClickHandler;
|
|
34
|
+
width: 'fixed';
|
|
35
|
+
}>().toExtend<DsBulkActionsItemProps>();
|
|
36
|
+
expectTypeOf<{
|
|
37
|
+
icon: 'edit';
|
|
38
|
+
label: string;
|
|
39
|
+
onClick: ClickHandler;
|
|
40
|
+
width: 'fit-content';
|
|
41
|
+
}>().toExtend<DsBulkActionsItemProps>();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('allows menu items with icon, label, and menu', () => {
|
|
45
|
+
expectTypeOf<{
|
|
46
|
+
icon: 'share';
|
|
47
|
+
label: string;
|
|
48
|
+
menu: DsBulkActionsMenuEntry[];
|
|
49
|
+
}>().toExtend<DsBulkActionsItemProps>();
|
|
50
|
+
expectTypeOf<{
|
|
51
|
+
icon: 'share';
|
|
52
|
+
label: string;
|
|
53
|
+
disabled: true;
|
|
54
|
+
menu: Array<
|
|
55
|
+
| {
|
|
56
|
+
value: string;
|
|
57
|
+
label: string;
|
|
58
|
+
icon: 'mail';
|
|
59
|
+
onSelect: ClickHandler;
|
|
60
|
+
disabled: false;
|
|
61
|
+
variant: 'error';
|
|
62
|
+
}
|
|
63
|
+
| {
|
|
64
|
+
value: string;
|
|
65
|
+
label: string;
|
|
66
|
+
menu: Array<{ value: string; label: string; onSelect: ClickHandler }>;
|
|
67
|
+
}
|
|
68
|
+
>;
|
|
69
|
+
}>().toExtend<DsBulkActionsItemProps>();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('rejects mixing onClick with menu', () => {
|
|
73
|
+
expectTypeOf<{
|
|
74
|
+
icon: 'edit';
|
|
75
|
+
label: string;
|
|
76
|
+
onClick: ClickHandler;
|
|
77
|
+
menu: DsBulkActionsMenuEntry[];
|
|
78
|
+
}>().not.toExtend<DsBulkActionsItemProps>();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('rejects children composition', () => {
|
|
82
|
+
expectTypeOf<{ children: string }>().not.toExtend<DsBulkActionsItemProps>();
|
|
83
|
+
expectTypeOf<{
|
|
84
|
+
icon: 'edit';
|
|
85
|
+
label: string;
|
|
86
|
+
onClick: ClickHandler;
|
|
87
|
+
children: string;
|
|
88
|
+
}>().not.toExtend<DsBulkActionsItemProps>();
|
|
89
|
+
expectTypeOf<{
|
|
90
|
+
icon: 'share';
|
|
91
|
+
label: string;
|
|
92
|
+
menu: DsBulkActionsMenuEntry[];
|
|
93
|
+
children: string;
|
|
94
|
+
}>().not.toExtend<DsBulkActionsItemProps>();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('requires icon, label, and either onClick or menu', () => {
|
|
98
|
+
expectTypeOf<{ icon: 'edit'; label: string }>().not.toExtend<DsBulkActionsItemProps>();
|
|
99
|
+
expectTypeOf<{ icon: 'edit'; onClick: ClickHandler }>().not.toExtend<DsBulkActionsItemProps>();
|
|
100
|
+
expectTypeOf<{ label: string; onClick: ClickHandler }>().not.toExtend<DsBulkActionsItemProps>();
|
|
101
|
+
expectTypeOf<{ icon: 'share'; menu: DsBulkActionsMenuEntry[] }>().not.toExtend<DsBulkActionsItemProps>();
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('DsBulkActionsMenuEntry', () => {
|
|
106
|
+
it('allows a leaf with onSelect or a branch with nested menu, not both', () => {
|
|
107
|
+
expectTypeOf<{
|
|
108
|
+
value: string;
|
|
109
|
+
label: string;
|
|
110
|
+
icon: 'mail';
|
|
111
|
+
onSelect: ClickHandler;
|
|
112
|
+
disabled: true;
|
|
113
|
+
variant: 'error';
|
|
114
|
+
}>().toExtend<DsBulkActionsMenuEntry>();
|
|
115
|
+
expectTypeOf<{
|
|
116
|
+
value: string;
|
|
117
|
+
label: string;
|
|
118
|
+
menu: Array<{ value: string; label: string; onSelect: ClickHandler }>;
|
|
119
|
+
}>().toExtend<DsBulkActionsMenuEntry>();
|
|
120
|
+
expectTypeOf<{ value: string; label: string }>().toExtend<DsBulkActionsMenuEntry>();
|
|
121
|
+
expectTypeOf<{
|
|
122
|
+
value: string;
|
|
123
|
+
label: string;
|
|
124
|
+
onSelect: ClickHandler;
|
|
125
|
+
menu: DsBulkActionsMenuEntry[];
|
|
126
|
+
}>().not.toExtend<DsBulkActionsMenuEntry>();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('DsBulkActionsProps', () => {
|
|
131
|
+
it('accepts children action slots, menuPlacement, and locale.moreActionsLabel', () => {
|
|
132
|
+
expectTypeOf<{
|
|
133
|
+
selectedCount: number;
|
|
134
|
+
onClearSelection: ClickHandler;
|
|
135
|
+
menuPlacement: 'top';
|
|
136
|
+
locale: { moreActionsLabel: string };
|
|
137
|
+
}>().toExtend<DsBulkActionsProps>();
|
|
138
|
+
|
|
139
|
+
expectTypeOf<DsBulkActionsProps>().toHaveProperty('children');
|
|
140
|
+
expectTypeOf<DsBulkActionsProps>().toHaveProperty('menuPlacement');
|
|
141
|
+
expectTypeOf<DsBulkActionsProps>().not.toHaveProperty('actions');
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('computeBulkActionsOverflow', () => {
|
|
146
|
+
it('keeps every item visible when the bar is wide enough', () => {
|
|
147
|
+
expect(
|
|
148
|
+
computeBulkActionsOverflow({
|
|
149
|
+
...chrome,
|
|
150
|
+
containerWidth: 600,
|
|
151
|
+
itemWidths: [70, 70, 70, 70],
|
|
152
|
+
}),
|
|
153
|
+
).toEqual({ visibleCount: 4, showMore: false, summaryLabelHidden: false });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('collapses rightmost items into More and keeps the summary label', () => {
|
|
157
|
+
expect(
|
|
158
|
+
computeBulkActionsOverflow({
|
|
159
|
+
...chrome,
|
|
160
|
+
containerWidth: 450,
|
|
161
|
+
itemWidths: [70, 70, 70, 70, 70],
|
|
162
|
+
}),
|
|
163
|
+
).toEqual({ visibleCount: 2, showMore: true, summaryLabelHidden: false });
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('hides the summary label only after collapsing to one visible action', () => {
|
|
167
|
+
expect(
|
|
168
|
+
computeBulkActionsOverflow({
|
|
169
|
+
...chrome,
|
|
170
|
+
containerWidth: 250,
|
|
171
|
+
itemWidths: [70, 70, 70, 70, 70],
|
|
172
|
+
}),
|
|
173
|
+
).toEqual({ visibleCount: 1, showMore: true, summaryLabelHidden: true });
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('does not render More when a single item remains', () => {
|
|
177
|
+
expect(
|
|
178
|
+
computeBulkActionsOverflow({
|
|
179
|
+
...chrome,
|
|
180
|
+
containerWidth: 200,
|
|
181
|
+
itemWidths: [70],
|
|
182
|
+
}),
|
|
183
|
+
).toEqual({ visibleCount: 1, showMore: false, summaryLabelHidden: true });
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('returns no actions when the bar has none', () => {
|
|
187
|
+
expect(
|
|
188
|
+
computeBulkActionsOverflow({
|
|
189
|
+
...chrome,
|
|
190
|
+
containerWidth: 400,
|
|
191
|
+
itemWidths: [],
|
|
192
|
+
}),
|
|
193
|
+
).toEqual({ visibleCount: 0, showMore: false, summaryLabelHidden: false });
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Children,
|
|
3
|
+
Fragment,
|
|
4
|
+
isValidElement,
|
|
5
|
+
type CSSProperties,
|
|
6
|
+
type ReactElement,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
} from 'react';
|
|
9
|
+
import type {
|
|
10
|
+
BulkActionsItemWidthMode,
|
|
11
|
+
DsBulkActionsItemProps,
|
|
12
|
+
DsBulkActionsItemWidth,
|
|
13
|
+
DsBulkActionsMenuEntry,
|
|
14
|
+
} from './ds-bulk-actions.types';
|
|
15
|
+
|
|
16
|
+
const ITEM_WIDTH_CUSTOM_VAR = '--ds-bulk-actions-item-width';
|
|
17
|
+
|
|
18
|
+
export interface ItemWidthLayout {
|
|
19
|
+
mode: BulkActionsItemWidthMode | 'custom';
|
|
20
|
+
buttonStyle?: CSSProperties;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const resolveItemWidth = (width?: DsBulkActionsItemWidth): ItemWidthLayout => {
|
|
24
|
+
if (width === undefined || width === 'fixed') {
|
|
25
|
+
return { mode: 'fixed' };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (width === 'fit-content') {
|
|
29
|
+
return { mode: 'fit-content' };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
mode: 'custom',
|
|
34
|
+
buttonStyle: { [ITEM_WIDTH_CUSTOM_VAR]: `${String(width)}px` } as CSSProperties,
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const isMenuItemProps = (
|
|
39
|
+
props: DsBulkActionsItemProps,
|
|
40
|
+
): props is Extract<DsBulkActionsItemProps, { menu: DsBulkActionsMenuEntry[] }> =>
|
|
41
|
+
'menu' in props && props.menu !== undefined;
|
|
42
|
+
|
|
43
|
+
export const isSubmenuEntry = (
|
|
44
|
+
entry: DsBulkActionsMenuEntry,
|
|
45
|
+
): entry is Extract<DsBulkActionsMenuEntry, { menu: DsBulkActionsMenuEntry[] }> =>
|
|
46
|
+
'menu' in entry && entry.menu !== undefined;
|
|
47
|
+
|
|
48
|
+
export const isItemElement = (child: ReactNode): child is ReactElement<DsBulkActionsItemProps> =>
|
|
49
|
+
isValidElement(child) &&
|
|
50
|
+
typeof child.type === 'function' &&
|
|
51
|
+
'displayName' in child.type &&
|
|
52
|
+
child.type.displayName === 'DsBulkActions.Item';
|
|
53
|
+
|
|
54
|
+
export const getBulkActionItems = (children: ReactNode): ReactElement<DsBulkActionsItemProps>[] => {
|
|
55
|
+
const items: ReactElement<DsBulkActionsItemProps>[] = [];
|
|
56
|
+
|
|
57
|
+
Children.forEach(children, (child) => {
|
|
58
|
+
if (isItemElement(child)) {
|
|
59
|
+
items.push(child);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (isValidElement(child) && child.type === Fragment) {
|
|
64
|
+
items.push(...getBulkActionItems((child.props as { children?: ReactNode }).children));
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
return items;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const itemToOverflowEntry = (item: DsBulkActionsItemProps, index: number): DsBulkActionsMenuEntry => {
|
|
72
|
+
const { icon, label, disabled } = item;
|
|
73
|
+
|
|
74
|
+
if (isMenuItemProps(item)) {
|
|
75
|
+
return {
|
|
76
|
+
value: `overflow-${String(index)}`,
|
|
77
|
+
label,
|
|
78
|
+
icon,
|
|
79
|
+
disabled,
|
|
80
|
+
menu: item.menu,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
value: `overflow-${String(index)}`,
|
|
86
|
+
label,
|
|
87
|
+
icon,
|
|
88
|
+
disabled,
|
|
89
|
+
onSelect: item.onClick,
|
|
90
|
+
};
|
|
91
|
+
};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { createElement, Fragment, type FunctionComponent } from 'react';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import type { DsBulkActionsItemProps } from './ds-bulk-actions.types';
|
|
4
|
+
import {
|
|
5
|
+
getBulkActionItems,
|
|
6
|
+
isItemElement,
|
|
7
|
+
isSubmenuEntry,
|
|
8
|
+
itemToOverflowEntry,
|
|
9
|
+
resolveItemWidth,
|
|
10
|
+
} from './ds-bulk-actions.utils';
|
|
11
|
+
|
|
12
|
+
const StubItem: FunctionComponent<DsBulkActionsItemProps> = () => null;
|
|
13
|
+
StubItem.displayName = 'DsBulkActions.Item';
|
|
14
|
+
|
|
15
|
+
const createItem = (props: DsBulkActionsItemProps, key?: string) =>
|
|
16
|
+
createElement(StubItem, { key, ...props });
|
|
17
|
+
|
|
18
|
+
const clickItem: DsBulkActionsItemProps = { icon: 'edit', label: 'Edit', onClick: () => {} };
|
|
19
|
+
|
|
20
|
+
describe('resolveItemWidth', () => {
|
|
21
|
+
it('uses the fixed column when width is omitted', () => {
|
|
22
|
+
expect(resolveItemWidth()).toEqual({ mode: 'fixed' });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('uses the fixed column when width is fixed', () => {
|
|
26
|
+
expect(resolveItemWidth('fixed')).toEqual({ mode: 'fixed' });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('hugs the label when width is fit-content', () => {
|
|
30
|
+
expect(resolveItemWidth('fit-content')).toEqual({ mode: 'fit-content' });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('sets a custom pixel width via CSS variable', () => {
|
|
34
|
+
expect(resolveItemWidth(120)).toEqual({
|
|
35
|
+
mode: 'custom',
|
|
36
|
+
buttonStyle: { '--ds-bulk-actions-item-width': '120px' },
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('itemToOverflowEntry', () => {
|
|
42
|
+
it('maps a click item to a leaf entry that keeps onSelect', () => {
|
|
43
|
+
const onClick = vi.fn();
|
|
44
|
+
const item: DsBulkActionsItemProps = {
|
|
45
|
+
icon: 'edit',
|
|
46
|
+
label: 'Edit',
|
|
47
|
+
disabled: true,
|
|
48
|
+
onClick,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
expect(itemToOverflowEntry(item, 2)).toEqual({
|
|
52
|
+
value: 'overflow-2',
|
|
53
|
+
label: 'Edit',
|
|
54
|
+
icon: 'edit',
|
|
55
|
+
disabled: true,
|
|
56
|
+
onSelect: onClick,
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('maps a menu item to a branch entry that keeps the nested menu', () => {
|
|
61
|
+
const menu = [{ value: 'mail', label: 'Mail', icon: 'mail' as const }];
|
|
62
|
+
const item: DsBulkActionsItemProps = {
|
|
63
|
+
icon: 'share',
|
|
64
|
+
label: 'Share',
|
|
65
|
+
menu,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
expect(itemToOverflowEntry(item, 0)).toEqual({
|
|
69
|
+
value: 'overflow-0',
|
|
70
|
+
label: 'Share',
|
|
71
|
+
icon: 'share',
|
|
72
|
+
disabled: undefined,
|
|
73
|
+
menu,
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('isItemElement', () => {
|
|
79
|
+
it('matches an element whose type carries the Item displayName', () => {
|
|
80
|
+
expect(isItemElement(createItem(clickItem))).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('rejects other elements, strings, and nullish children', () => {
|
|
84
|
+
expect(isItemElement(createElement('div'))).toBe(false);
|
|
85
|
+
expect(isItemElement('Notify')).toBe(false);
|
|
86
|
+
expect(isItemElement(null)).toBe(false);
|
|
87
|
+
expect(isItemElement(undefined)).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('getBulkActionItems', () => {
|
|
92
|
+
it('collects only Item elements and drops other children', () => {
|
|
93
|
+
const first = createItem(clickItem, 'a');
|
|
94
|
+
const second = createItem({ icon: 'delete_outline', label: 'Delete', onClick: () => {} }, 'b');
|
|
95
|
+
|
|
96
|
+
const items = getBulkActionItems([first, 'text', createElement('span'), second, null]);
|
|
97
|
+
|
|
98
|
+
expect(items).toEqual([first, second]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('flattens Item elements nested inside fragments', () => {
|
|
102
|
+
const first = createItem(clickItem, 'a');
|
|
103
|
+
const second = createItem({ icon: 'share', label: 'Share', onClick: () => {} }, 'b');
|
|
104
|
+
const fragment = createElement(Fragment, null, second);
|
|
105
|
+
|
|
106
|
+
expect(getBulkActionItems([first, fragment])).toEqual([first, second]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('returns an empty array when there are no Item children', () => {
|
|
110
|
+
expect(getBulkActionItems(createElement('div'))).toEqual([]);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('isSubmenuEntry', () => {
|
|
115
|
+
it('identifies a branch entry with a nested menu', () => {
|
|
116
|
+
expect(
|
|
117
|
+
isSubmenuEntry({
|
|
118
|
+
value: 'share',
|
|
119
|
+
label: 'Share',
|
|
120
|
+
menu: [{ value: 'mail', label: 'Mail' }],
|
|
121
|
+
}),
|
|
122
|
+
).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('rejects a leaf entry with onSelect', () => {
|
|
126
|
+
expect(
|
|
127
|
+
isSubmenuEntry({
|
|
128
|
+
value: 'edit',
|
|
129
|
+
label: 'Edit',
|
|
130
|
+
onSelect: () => undefined,
|
|
131
|
+
}),
|
|
132
|
+
).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
const FIT_EPSILON_PX = 0.5;
|
|
2
|
+
const MIN_VISIBLE_ACTIONS = 1;
|
|
3
|
+
const STACK_GAP_COUNT_WITH_LABEL = 3;
|
|
4
|
+
const STACK_GAP_COUNT_WITHOUT_LABEL = 2;
|
|
5
|
+
|
|
6
|
+
export interface BulkActionsOverflowMetrics {
|
|
7
|
+
containerWidth: number;
|
|
8
|
+
badgeWidth: number;
|
|
9
|
+
dismissWidth: number;
|
|
10
|
+
labelWidth: number;
|
|
11
|
+
itemWidths: number[];
|
|
12
|
+
moreWidth: number;
|
|
13
|
+
gap: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface BulkActionsOverflowState {
|
|
17
|
+
visibleCount: number;
|
|
18
|
+
showMore: boolean;
|
|
19
|
+
summaryLabelHidden: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const sum = (values: number[]): number => values.reduce((total, value) => total + value, 0);
|
|
23
|
+
|
|
24
|
+
const chromeWidth = ({
|
|
25
|
+
badgeWidth,
|
|
26
|
+
dismissWidth,
|
|
27
|
+
labelWidth,
|
|
28
|
+
gap,
|
|
29
|
+
hideLabel,
|
|
30
|
+
}: Pick<BulkActionsOverflowMetrics, 'badgeWidth' | 'dismissWidth' | 'labelWidth' | 'gap'> & {
|
|
31
|
+
hideLabel: boolean;
|
|
32
|
+
}): number => {
|
|
33
|
+
const gapCount = hideLabel ? STACK_GAP_COUNT_WITHOUT_LABEL : STACK_GAP_COUNT_WITH_LABEL;
|
|
34
|
+
const labelPart = hideLabel ? 0 : labelWidth;
|
|
35
|
+
|
|
36
|
+
return badgeWidth + dismissWidth + labelPart + gapCount * gap;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const fits = (usedWidth: number, containerWidth: number): boolean =>
|
|
40
|
+
usedWidth <= containerWidth + FIT_EPSILON_PX;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* First-N visible / rightmost overflow, then hide the summary label as a last resort.
|
|
44
|
+
* Dismiss and the count badge are never collapsed.
|
|
45
|
+
*/
|
|
46
|
+
export const computeBulkActionsOverflow = ({
|
|
47
|
+
containerWidth,
|
|
48
|
+
badgeWidth,
|
|
49
|
+
dismissWidth,
|
|
50
|
+
labelWidth,
|
|
51
|
+
itemWidths,
|
|
52
|
+
moreWidth,
|
|
53
|
+
gap,
|
|
54
|
+
}: BulkActionsOverflowMetrics): BulkActionsOverflowState => {
|
|
55
|
+
const itemCount = itemWidths.length;
|
|
56
|
+
|
|
57
|
+
if (itemCount === 0) {
|
|
58
|
+
return { visibleCount: 0, showMore: false, summaryLabelHidden: false };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const chrome = {
|
|
62
|
+
badgeWidth,
|
|
63
|
+
dismissWidth,
|
|
64
|
+
labelWidth,
|
|
65
|
+
gap,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
if (fits(chromeWidth({ ...chrome, hideLabel: false }) + sum(itemWidths), containerWidth)) {
|
|
69
|
+
return { visibleCount: itemCount, showMore: false, summaryLabelHidden: false };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (let visibleCount = itemCount - 1; visibleCount >= MIN_VISIBLE_ACTIONS; visibleCount--) {
|
|
73
|
+
const actionsWidth = sum(itemWidths.slice(0, visibleCount)) + moreWidth;
|
|
74
|
+
|
|
75
|
+
if (fits(chromeWidth({ ...chrome, hideLabel: false }) + actionsWidth, containerWidth)) {
|
|
76
|
+
return { visibleCount, showMore: true, summaryLabelHidden: false };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
visibleCount: MIN_VISIBLE_ACTIONS,
|
|
82
|
+
showMore: itemCount > MIN_VISIBLE_ACTIONS,
|
|
83
|
+
summaryLabelHidden: true,
|
|
84
|
+
};
|
|
85
|
+
};
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { type CSSProperties, type RefObject, useLayoutEffect, useState } from 'react';
|
|
2
|
+
import { computeBulkActionsOverflow, type BulkActionsOverflowState } from './compute-bulk-actions-overflow';
|
|
3
|
+
|
|
4
|
+
const allVisible = (itemCount: number): BulkActionsOverflowState => ({
|
|
5
|
+
visibleCount: itemCount,
|
|
6
|
+
showMore: false,
|
|
7
|
+
summaryLabelHidden: false,
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
const sameOverflowState = (prev: BulkActionsOverflowState, next: BulkActionsOverflowState): boolean =>
|
|
11
|
+
prev.visibleCount === next.visibleCount &&
|
|
12
|
+
prev.showMore === next.showMore &&
|
|
13
|
+
prev.summaryLabelHidden === next.summaryLabelHidden;
|
|
14
|
+
|
|
15
|
+
const isConstrainedCssLength = (value: CSSProperties['width']): boolean => {
|
|
16
|
+
if (value === undefined) {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
value !== 'auto' &&
|
|
22
|
+
value !== 'fit-content' &&
|
|
23
|
+
value !== 'max-content' &&
|
|
24
|
+
value !== 'min-content' &&
|
|
25
|
+
value !== 'none'
|
|
26
|
+
);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// A bare `width` set via className cannot be detected here: the default
|
|
30
|
+
// `width: fit-content` also resolves to a pixel value, so computed `width` is
|
|
31
|
+
// ambiguous. Consumers must bound the toolbar via `style.width`, `style.maxWidth`,
|
|
32
|
+
// or a CSS `max-width` (detected below) to enable overflow.
|
|
33
|
+
const hasExplicitWidthConstraint = (element: HTMLElement, style?: CSSProperties): boolean => {
|
|
34
|
+
if (isConstrainedCssLength(style?.width) || isConstrainedCssLength(style?.maxWidth)) {
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return getComputedStyle(element).maxWidth !== 'none';
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
interface UseBulkActionsOverflowOptions {
|
|
42
|
+
active: boolean;
|
|
43
|
+
itemCount: number;
|
|
44
|
+
style?: CSSProperties;
|
|
45
|
+
containerRef: RefObject<HTMLElement | null>;
|
|
46
|
+
stackRef: RefObject<HTMLElement | null>;
|
|
47
|
+
badgeRef: RefObject<HTMLElement | null>;
|
|
48
|
+
dismissRef: RefObject<HTMLElement | null>;
|
|
49
|
+
measureRef: RefObject<HTMLElement | null>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const useBulkActionsOverflow = ({
|
|
53
|
+
active,
|
|
54
|
+
itemCount,
|
|
55
|
+
style,
|
|
56
|
+
containerRef,
|
|
57
|
+
stackRef,
|
|
58
|
+
badgeRef,
|
|
59
|
+
dismissRef,
|
|
60
|
+
measureRef,
|
|
61
|
+
}: UseBulkActionsOverflowOptions): BulkActionsOverflowState => {
|
|
62
|
+
const [state, setState] = useState<BulkActionsOverflowState>(() => allVisible(itemCount));
|
|
63
|
+
const styleWidth = style?.width;
|
|
64
|
+
const styleMaxWidth = style?.maxWidth;
|
|
65
|
+
|
|
66
|
+
useLayoutEffect(() => {
|
|
67
|
+
if (!active) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const container = containerRef.current;
|
|
72
|
+
|
|
73
|
+
if (!container) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const constraintStyle = { width: styleWidth, maxWidth: styleMaxWidth };
|
|
78
|
+
let observerRafId = 0;
|
|
79
|
+
|
|
80
|
+
const measureLayout = () => {
|
|
81
|
+
if (!hasExplicitWidthConstraint(container, constraintStyle) || itemCount === 0) {
|
|
82
|
+
setState((prev) => {
|
|
83
|
+
const next = allVisible(itemCount);
|
|
84
|
+
return sameOverflowState(prev, next) ? prev : next;
|
|
85
|
+
});
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const stack = stackRef.current;
|
|
90
|
+
const badge = badgeRef.current;
|
|
91
|
+
const dismiss = dismissRef.current;
|
|
92
|
+
const measure = measureRef.current;
|
|
93
|
+
|
|
94
|
+
if (!stack || !badge || !dismiss || !measure) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const itemWidths = Array.from(
|
|
99
|
+
measure.querySelectorAll<HTMLElement>('[data-overflow-measure="item"]'),
|
|
100
|
+
).map((el) => el.getBoundingClientRect().width);
|
|
101
|
+
const moreEl = measure.querySelector<HTMLElement>('[data-overflow-measure="more"]');
|
|
102
|
+
const labelEl = measure.querySelector<HTMLElement>('[data-overflow-measure="label"]');
|
|
103
|
+
|
|
104
|
+
if (!moreEl || !labelEl || itemWidths.length !== itemCount) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const gap = parseFloat(getComputedStyle(stack).columnGap || getComputedStyle(stack).gap) || 0;
|
|
109
|
+
|
|
110
|
+
const next = computeBulkActionsOverflow({
|
|
111
|
+
containerWidth: container.getBoundingClientRect().width,
|
|
112
|
+
badgeWidth: badge.getBoundingClientRect().width,
|
|
113
|
+
dismissWidth: dismiss.getBoundingClientRect().width,
|
|
114
|
+
labelWidth: labelEl.getBoundingClientRect().width,
|
|
115
|
+
itemWidths,
|
|
116
|
+
moreWidth: moreEl.getBoundingClientRect().width,
|
|
117
|
+
gap,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
setState((prev) => (sameOverflowState(prev, next) ? prev : next));
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
measureLayout();
|
|
124
|
+
|
|
125
|
+
const observer = new ResizeObserver(() => {
|
|
126
|
+
cancelAnimationFrame(observerRafId);
|
|
127
|
+
observerRafId = requestAnimationFrame(measureLayout);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
observer.observe(container);
|
|
131
|
+
|
|
132
|
+
if (measureRef.current) {
|
|
133
|
+
observer.observe(measureRef.current);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return () => {
|
|
137
|
+
cancelAnimationFrame(observerRafId);
|
|
138
|
+
observer.disconnect();
|
|
139
|
+
};
|
|
140
|
+
}, [
|
|
141
|
+
active,
|
|
142
|
+
badgeRef,
|
|
143
|
+
containerRef,
|
|
144
|
+
dismissRef,
|
|
145
|
+
itemCount,
|
|
146
|
+
measureRef,
|
|
147
|
+
stackRef,
|
|
148
|
+
styleMaxWidth,
|
|
149
|
+
styleWidth,
|
|
150
|
+
]);
|
|
151
|
+
|
|
152
|
+
return state;
|
|
153
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export { default as DsBulkActions } from './ds-bulk-actions';
|
|
2
|
+
export type {
|
|
3
|
+
DsBulkActionsItemProps,
|
|
4
|
+
DsBulkActionsItemWidth,
|
|
5
|
+
DsBulkActionsLocale,
|
|
6
|
+
DsBulkActionsMenuEntry,
|
|
7
|
+
DsBulkActionsProps,
|
|
8
|
+
BulkActionsItemWidthMode,
|
|
9
|
+
BulkActionsMenuItemVariant,
|
|
10
|
+
BulkActionsMenuPlacement,
|
|
11
|
+
BulkActionsPlacement,
|
|
12
|
+
} from './ds-bulk-actions.types';
|
|
13
|
+
export {
|
|
14
|
+
bulkActionsItemWidthModes,
|
|
15
|
+
bulkActionsMenuItemVariants,
|
|
16
|
+
bulkActionsMenuPlacements,
|
|
17
|
+
bulkActionsPlacements,
|
|
18
|
+
} from './ds-bulk-actions.types';
|
|
@@ -164,7 +164,7 @@ $focus-ring-width: 2px;
|
|
|
164
164
|
|
|
165
165
|
.root[data-color='default'][data-variant='secondary'] {
|
|
166
166
|
background-color: var(--background-action-secondary);
|
|
167
|
-
color: var(--font-
|
|
167
|
+
color: var(--font-action);
|
|
168
168
|
border-color: var(--border);
|
|
169
169
|
|
|
170
170
|
&:hover:not(:disabled):not(:active):not([data-selected='true']) {
|
|
@@ -202,7 +202,7 @@ $focus-ring-width: 2px;
|
|
|
202
202
|
|
|
203
203
|
.root[data-color='default'][data-variant='tertiary'] {
|
|
204
204
|
background-color: transparent;
|
|
205
|
-
color: var(--font-
|
|
205
|
+
color: var(--font-action);
|
|
206
206
|
border-color: transparent;
|
|
207
207
|
|
|
208
208
|
&:hover:not(:disabled):not(:active):not([data-selected='true']) {
|