@drivenets/design-system 0.18.0 → 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 +26 -0
- package/dist/index.cjs +15850 -10821
- package/dist/index.d.cts +785 -239
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +785 -239
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15835 -10822
- package/dist/index.js.map +1 -1
- package/dist/index.min.css +1 -1
- package/package.json +13 -13
- 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-tag-filter/ds-tag-filter.module.scss +7 -35
- package/src/components/ds-tag-filter/ds-tag-filter.tsx +34 -57
- package/src/components/ds-tag-filter/ds-tag-filter.types.ts +2 -1
- package/src/components/ds-tag-filter/hooks/use-tag-overflow-calculation.ts +13 -18
- 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']) {
|