@centreon/ui 26.10.4 → 26.10.6
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/package.json +1 -1
- package/src/components/SortableEntriesList/Row.tsx +117 -0
- package/src/components/SortableEntriesList/SortableEntriesList.cypress.spec.tsx +792 -0
- package/src/components/SortableEntriesList/SortableEntriesList.stories.tsx +325 -0
- package/src/components/SortableEntriesList/SortableEntriesList.tsx +199 -0
- package/src/components/SortableEntriesList/index.ts +3 -0
- package/src/components/SortableEntriesList/models.ts +20 -0
- package/src/components/SortableEntriesList/translatedLabels.ts +12 -0
- package/src/components/SortableEntriesList/useMaxVisibleRows.ts +77 -0
- package/src/components/SortableEntriesList/useSortableEntries.ts +145 -0
- package/src/components/index.ts +1 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined';
|
|
2
|
+
import EditIcon from '@mui/icons-material/Edit';
|
|
3
|
+
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
|
4
|
+
import VisibilityIcon from '@mui/icons-material/Visibility';
|
|
5
|
+
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
|
|
6
|
+
|
|
7
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
8
|
+
import { useState } from 'react';
|
|
9
|
+
|
|
10
|
+
import buildListingEndpoint from '../../api/buildListingEndpoint';
|
|
11
|
+
import type { Listing } from '../../api/models';
|
|
12
|
+
import type { SelectEntry } from '../../InputField/Select';
|
|
13
|
+
import type { GetEndpointParams } from '../../InputField/Select/Autocomplete/Connected';
|
|
14
|
+
import SingleConnectedAutocompleteField from '../../InputField/Select/Autocomplete/Connected/Single';
|
|
15
|
+
import TextField from '../../InputField/Text';
|
|
16
|
+
import type { RenderRowParams } from './models';
|
|
17
|
+
import {
|
|
18
|
+
SortableEntriesList,
|
|
19
|
+
type Props as SortableEntriesListProps
|
|
20
|
+
} from './SortableEntriesList';
|
|
21
|
+
|
|
22
|
+
const meta: Meta<typeof SortableEntriesList> = {
|
|
23
|
+
component: SortableEntriesList
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export default meta;
|
|
27
|
+
type Story = StoryObj<typeof SortableEntriesList>;
|
|
28
|
+
|
|
29
|
+
const ValuesPreview = ({ values }: { values: Array<unknown> }): JSX.Element => (
|
|
30
|
+
<pre className="mt-4 rounded bg-background-default p-2 text-text-primary text-xs">
|
|
31
|
+
{JSON.stringify(values, null, 2)}
|
|
32
|
+
</pre>
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
// Templates: one connected autocomplete per row, order matters (inheritance)
|
|
36
|
+
|
|
37
|
+
type TemplateRow = SelectEntry | null;
|
|
38
|
+
|
|
39
|
+
const hostTemplateOptions: Array<SelectEntry> = [
|
|
40
|
+
{ id: 1, name: 'generic-active-host' },
|
|
41
|
+
{ id: 2, name: 'generic-passive-host' },
|
|
42
|
+
{ id: 3, name: 'generic-host-custom' },
|
|
43
|
+
{ id: 4, name: 'linux-server-standard' },
|
|
44
|
+
{ id: 5, name: 'windows-server-standard' }
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
const templatesEndpoint = '/configuration/hosts/templates';
|
|
48
|
+
|
|
49
|
+
const getTemplatesEndpoint = (parameters: GetEndpointParams): string =>
|
|
50
|
+
buildListingEndpoint({ baseEndpoint: templatesEndpoint, parameters });
|
|
51
|
+
|
|
52
|
+
const templatesMockData = [
|
|
53
|
+
{
|
|
54
|
+
delay: 300,
|
|
55
|
+
method: 'GET',
|
|
56
|
+
response: (): Listing<SelectEntry> => ({
|
|
57
|
+
meta: { limit: 10, page: 1, total: hostTemplateOptions.length },
|
|
58
|
+
result: hostTemplateOptions
|
|
59
|
+
}),
|
|
60
|
+
status: 200,
|
|
61
|
+
url: `${templatesEndpoint}?page=`
|
|
62
|
+
}
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
const createTemplateRow = (): TemplateRow => null;
|
|
66
|
+
|
|
67
|
+
const renderTemplateRow = ({
|
|
68
|
+
setValue,
|
|
69
|
+
value,
|
|
70
|
+
values
|
|
71
|
+
}: RenderRowParams<TemplateRow>): JSX.Element => {
|
|
72
|
+
// A template already chosen in another row cannot be chosen twice.
|
|
73
|
+
const selectedElsewhere = values
|
|
74
|
+
.map((template) => template?.id)
|
|
75
|
+
.filter((id) => id !== value?.id);
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<SingleConnectedAutocompleteField
|
|
79
|
+
baseEndpoint=""
|
|
80
|
+
field="name"
|
|
81
|
+
fullWidth
|
|
82
|
+
getEndpoint={getTemplatesEndpoint}
|
|
83
|
+
getOptionDisabled={(option: SelectEntry): boolean =>
|
|
84
|
+
selectedElsewhere.includes(option.id)
|
|
85
|
+
}
|
|
86
|
+
label="Select a template"
|
|
87
|
+
onChange={(_event, template): void => setValue(template as TemplateRow)}
|
|
88
|
+
value={value}
|
|
89
|
+
/>
|
|
90
|
+
);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
type TemplatesListArgs = Partial<
|
|
94
|
+
Pick<
|
|
95
|
+
SortableEntriesListProps<TemplateRow>,
|
|
96
|
+
'addLabel' | 'draggable' | 'maxVisibleRows'
|
|
97
|
+
>
|
|
98
|
+
>;
|
|
99
|
+
|
|
100
|
+
const TemplatesList = (args: TemplatesListArgs): JSX.Element => {
|
|
101
|
+
const [templates, setTemplates] = useState<Array<TemplateRow>>([
|
|
102
|
+
hostTemplateOptions[0],
|
|
103
|
+
hostTemplateOptions[3]
|
|
104
|
+
]);
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<div className="w-[640px]">
|
|
108
|
+
<SortableEntriesList<TemplateRow>
|
|
109
|
+
actions={({ value }) => [
|
|
110
|
+
{
|
|
111
|
+
icon: <EditIcon />,
|
|
112
|
+
id: 'edit-template',
|
|
113
|
+
isDisabled: !value,
|
|
114
|
+
label: 'Edit template',
|
|
115
|
+
onClick: () => undefined
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
icon: <InfoOutlinedIcon />,
|
|
119
|
+
id: 'template-info',
|
|
120
|
+
isDisabled: !value,
|
|
121
|
+
label: 'Template information',
|
|
122
|
+
onClick: () => undefined
|
|
123
|
+
}
|
|
124
|
+
]}
|
|
125
|
+
addLabel="Add new entry"
|
|
126
|
+
createValue={createTemplateRow}
|
|
127
|
+
label="Templates"
|
|
128
|
+
onChange={setTemplates}
|
|
129
|
+
renderRow={renderTemplateRow}
|
|
130
|
+
values={templates}
|
|
131
|
+
{...args}
|
|
132
|
+
/>
|
|
133
|
+
<ValuesPreview values={templates} />
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
export const Templates: Story = {
|
|
139
|
+
args: { draggable: true, maxVisibleRows: 10 },
|
|
140
|
+
argTypes: {
|
|
141
|
+
addLabel: { control: 'text' },
|
|
142
|
+
draggable: { control: 'boolean' },
|
|
143
|
+
maxVisibleRows: { control: 'number' }
|
|
144
|
+
},
|
|
145
|
+
parameters: { mockData: templatesMockData },
|
|
146
|
+
render: (args) => <TemplatesList {...(args as TemplatesListArgs)} />
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// Custom macros: several inputs per row, row styled from its own value
|
|
150
|
+
|
|
151
|
+
interface MacroRow {
|
|
152
|
+
isPassword: boolean;
|
|
153
|
+
name: string;
|
|
154
|
+
origin: 'direct' | 'fromCommand' | 'fromTpl';
|
|
155
|
+
value: string;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const createMacroRow = (): MacroRow => ({
|
|
159
|
+
isPassword: false,
|
|
160
|
+
name: '',
|
|
161
|
+
origin: 'direct',
|
|
162
|
+
value: ''
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const macroRowClassName: Record<MacroRow['origin'], string> = {
|
|
166
|
+
direct: 'rounded p-1',
|
|
167
|
+
fromCommand: 'rounded bg-teal-50 p-1 dark:bg-teal-950',
|
|
168
|
+
fromTpl: 'rounded bg-purple-50 p-1 dark:bg-purple-950'
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const renderMacroRow = ({
|
|
172
|
+
setField,
|
|
173
|
+
value: macro
|
|
174
|
+
}: RenderRowParams<MacroRow>): JSX.Element => (
|
|
175
|
+
<div className="grid grid-cols-2 gap-2 @max-[600px]:grid-cols-1">
|
|
176
|
+
<TextField
|
|
177
|
+
dataTestId="macro-name"
|
|
178
|
+
fullWidth
|
|
179
|
+
label="Name"
|
|
180
|
+
onChange={(event): void => setField('name', event.target.value)}
|
|
181
|
+
value={macro.name}
|
|
182
|
+
/>
|
|
183
|
+
<TextField
|
|
184
|
+
dataTestId="macro-value"
|
|
185
|
+
fullWidth
|
|
186
|
+
label="Value"
|
|
187
|
+
onChange={(event): void => setField('value', event.target.value)}
|
|
188
|
+
type={macro.isPassword ? 'password' : 'text'}
|
|
189
|
+
value={macro.value}
|
|
190
|
+
/>
|
|
191
|
+
</div>
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
const initialMacros: Array<MacroRow> = [
|
|
195
|
+
{ isPassword: false, name: 'HOST_IP', origin: 'direct', value: '10.0.0.1' },
|
|
196
|
+
{
|
|
197
|
+
isPassword: true,
|
|
198
|
+
name: 'API_TOKEN',
|
|
199
|
+
origin: 'fromTpl',
|
|
200
|
+
value: 'secret-token'
|
|
201
|
+
},
|
|
202
|
+
{ isPassword: false, name: 'PORT', origin: 'fromCommand', value: '443' }
|
|
203
|
+
];
|
|
204
|
+
|
|
205
|
+
const buildMacros = (count: number): Array<MacroRow> =>
|
|
206
|
+
Array.from({ length: count }, (_, index) => ({
|
|
207
|
+
isPassword: false,
|
|
208
|
+
name: `MACRO_${index + 1}`,
|
|
209
|
+
origin: 'direct',
|
|
210
|
+
value: `value ${index + 1}`
|
|
211
|
+
}));
|
|
212
|
+
|
|
213
|
+
const MacrosList = ({
|
|
214
|
+
draggable,
|
|
215
|
+
initialValues = initialMacros,
|
|
216
|
+
maxVisibleRows
|
|
217
|
+
}: {
|
|
218
|
+
draggable: boolean;
|
|
219
|
+
initialValues?: Array<MacroRow>;
|
|
220
|
+
maxVisibleRows?: number;
|
|
221
|
+
}): JSX.Element => {
|
|
222
|
+
const [macros, setMacros] = useState(initialValues);
|
|
223
|
+
|
|
224
|
+
const toggleIsPassword = (index: number): void =>
|
|
225
|
+
setMacros((current) =>
|
|
226
|
+
current.map((macro, macroIndex) =>
|
|
227
|
+
macroIndex === index
|
|
228
|
+
? { ...macro, isPassword: !macro.isPassword }
|
|
229
|
+
: macro
|
|
230
|
+
)
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
return (
|
|
234
|
+
<>
|
|
235
|
+
<SortableEntriesList<MacroRow>
|
|
236
|
+
actions={({ index, value }) => [
|
|
237
|
+
{
|
|
238
|
+
icon: value.isPassword ? <VisibilityOffIcon /> : <VisibilityIcon />,
|
|
239
|
+
id: 'toggle-password',
|
|
240
|
+
label: value.isPassword ? 'Show value' : 'Hide value',
|
|
241
|
+
onClick: () => toggleIsPassword(index)
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
icon: <DescriptionOutlinedIcon />,
|
|
245
|
+
id: 'macro-description',
|
|
246
|
+
label: 'Description',
|
|
247
|
+
onClick: () => undefined
|
|
248
|
+
}
|
|
249
|
+
]}
|
|
250
|
+
createValue={createMacroRow}
|
|
251
|
+
draggable={draggable}
|
|
252
|
+
getRowClassName={({ value }) => macroRowClassName[value.origin]}
|
|
253
|
+
label="Custom macros"
|
|
254
|
+
maxVisibleRows={maxVisibleRows}
|
|
255
|
+
onChange={setMacros}
|
|
256
|
+
renderRow={renderMacroRow}
|
|
257
|
+
values={macros}
|
|
258
|
+
/>
|
|
259
|
+
<ValuesPreview values={macros} />
|
|
260
|
+
</>
|
|
261
|
+
);
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
export const CustomMacros: Story = {
|
|
265
|
+
render: () => (
|
|
266
|
+
<div className="w-[640px]">
|
|
267
|
+
<MacrosList draggable />
|
|
268
|
+
</div>
|
|
269
|
+
)
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
export const NonDraggable: Story = {
|
|
273
|
+
render: () => (
|
|
274
|
+
<div className="w-[640px]">
|
|
275
|
+
<MacrosList draggable={false} />
|
|
276
|
+
</div>
|
|
277
|
+
)
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
export const Empty: Story = {
|
|
281
|
+
render: () => (
|
|
282
|
+
<div className="w-[640px]">
|
|
283
|
+
<MacrosList draggable initialValues={[]} />
|
|
284
|
+
</div>
|
|
285
|
+
)
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
// Capped list in a resizable container: drag the bottom-right corner or use the
|
|
289
|
+
// presets to check the row layout against the list width, not the viewport.
|
|
290
|
+
const panelWidths = [1400, 900, 720, 560];
|
|
291
|
+
|
|
292
|
+
const ScrollableMacrosList = (): JSX.Element => {
|
|
293
|
+
const [width, setWidth] = useState(panelWidths[0]);
|
|
294
|
+
|
|
295
|
+
return (
|
|
296
|
+
<div className="flex flex-col gap-2">
|
|
297
|
+
<div className="flex gap-2">
|
|
298
|
+
{panelWidths.map((panelWidth) => (
|
|
299
|
+
<button
|
|
300
|
+
className="rounded border px-2 py-1"
|
|
301
|
+
key={panelWidth}
|
|
302
|
+
onClick={(): void => setWidth(panelWidth)}
|
|
303
|
+
type="button"
|
|
304
|
+
>
|
|
305
|
+
{panelWidth}px
|
|
306
|
+
</button>
|
|
307
|
+
))}
|
|
308
|
+
</div>
|
|
309
|
+
<div
|
|
310
|
+
className="max-w-full resize-x overflow-auto border border-dashed p-2"
|
|
311
|
+
style={{ width }}
|
|
312
|
+
>
|
|
313
|
+
<MacrosList
|
|
314
|
+
draggable
|
|
315
|
+
initialValues={buildMacros(15)}
|
|
316
|
+
maxVisibleRows={10}
|
|
317
|
+
/>
|
|
318
|
+
</div>
|
|
319
|
+
</div>
|
|
320
|
+
);
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
export const ScrollableAndResizable: Story = {
|
|
324
|
+
render: () => <ScrollableMacrosList />
|
|
325
|
+
};
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Announcements,
|
|
3
|
+
closestCenter,
|
|
4
|
+
DndContext,
|
|
5
|
+
type DragEndEvent,
|
|
6
|
+
KeyboardSensor,
|
|
7
|
+
type Modifier,
|
|
8
|
+
PointerSensor,
|
|
9
|
+
type UniqueIdentifier,
|
|
10
|
+
useSensor,
|
|
11
|
+
useSensors
|
|
12
|
+
} from '@dnd-kit/core';
|
|
13
|
+
import {
|
|
14
|
+
SortableContext,
|
|
15
|
+
sortableKeyboardCoordinates,
|
|
16
|
+
verticalListSortingStrategy
|
|
17
|
+
} from '@dnd-kit/sortable';
|
|
18
|
+
import type { ReactNode } from 'react';
|
|
19
|
+
import { useTranslation } from 'react-i18next';
|
|
20
|
+
|
|
21
|
+
import { ItemComposition } from '../ItemComposition';
|
|
22
|
+
import type { RenderRowParams, RowParams, SortableRowAction } from './models';
|
|
23
|
+
import { Row } from './Row';
|
|
24
|
+
import {
|
|
25
|
+
labelAdd,
|
|
26
|
+
labelRowDragCancelled,
|
|
27
|
+
labelRowDragInstructions,
|
|
28
|
+
labelRowDropped,
|
|
29
|
+
labelRowMovedOver,
|
|
30
|
+
labelRowPickedUp
|
|
31
|
+
} from './translatedLabels';
|
|
32
|
+
import { useMaxVisibleRows } from './useMaxVisibleRows';
|
|
33
|
+
import { useSortableEntries } from './useSortableEntries';
|
|
34
|
+
|
|
35
|
+
export interface Props<T> {
|
|
36
|
+
/** Extra per-row buttons, rendered before the delete button. */
|
|
37
|
+
actions?: (params: RowParams<T>) => Array<SortableRowAction>;
|
|
38
|
+
addLabel?: string;
|
|
39
|
+
/** Initial value of a row added with the add button. */
|
|
40
|
+
createValue: () => T;
|
|
41
|
+
/** Shows the drag handle (last in the row) and enables reordering. */
|
|
42
|
+
draggable?: boolean;
|
|
43
|
+
getRowClassName?: (params: RowParams<T>) => string | undefined;
|
|
44
|
+
/** Accessible name of the list. */
|
|
45
|
+
label?: string;
|
|
46
|
+
/**
|
|
47
|
+
* Caps the list to this many rows, the next one half visible, and scrolls
|
|
48
|
+
* the rest. The add button stays outside the scroll area.
|
|
49
|
+
*/
|
|
50
|
+
maxVisibleRows?: number;
|
|
51
|
+
onChange: (values: Array<T>) => void;
|
|
52
|
+
/**
|
|
53
|
+
* Renders the inputs of one row. `values` gives access to sibling rows. The
|
|
54
|
+
* list is a CSS container, so the row layout can adapt to the list width
|
|
55
|
+
* with container query classes (e.g. `@max-[600px]:grid-cols-1`).
|
|
56
|
+
*/
|
|
57
|
+
renderRow: (params: RenderRowParams<T>) => ReactNode;
|
|
58
|
+
values: Array<T>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const restrictToVerticalAxis: Modifier = ({ transform }) => ({
|
|
62
|
+
...transform,
|
|
63
|
+
x: 0
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
export const SortableEntriesList = <T,>({
|
|
67
|
+
actions,
|
|
68
|
+
addLabel,
|
|
69
|
+
createValue,
|
|
70
|
+
draggable = true,
|
|
71
|
+
getRowClassName,
|
|
72
|
+
label,
|
|
73
|
+
maxVisibleRows,
|
|
74
|
+
onChange,
|
|
75
|
+
renderRow,
|
|
76
|
+
values
|
|
77
|
+
}: Props<T>): JSX.Element => {
|
|
78
|
+
const { t } = useTranslation();
|
|
79
|
+
const translatedAddLabel = addLabel || t(labelAdd);
|
|
80
|
+
|
|
81
|
+
const {
|
|
82
|
+
addEntry,
|
|
83
|
+
containerRef,
|
|
84
|
+
entries,
|
|
85
|
+
removeEntry,
|
|
86
|
+
reorderEntries,
|
|
87
|
+
updateEntry
|
|
88
|
+
} = useSortableEntries({
|
|
89
|
+
addLabel: translatedAddLabel,
|
|
90
|
+
createValue,
|
|
91
|
+
onChange,
|
|
92
|
+
values
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const {
|
|
96
|
+
hasHiddenRowsBelow,
|
|
97
|
+
listRef,
|
|
98
|
+
maxHeight,
|
|
99
|
+
scrollRef,
|
|
100
|
+
updateHiddenRowsBelow
|
|
101
|
+
} = useMaxVisibleRows(maxVisibleRows);
|
|
102
|
+
|
|
103
|
+
const sensors = useSensors(
|
|
104
|
+
useSensor(PointerSensor),
|
|
105
|
+
useSensor(KeyboardSensor, {
|
|
106
|
+
coordinateGetter: sortableKeyboardCoordinates,
|
|
107
|
+
// Instant scroll keeps each key press based on up-to-date positions in a
|
|
108
|
+
// capped list.
|
|
109
|
+
scrollBehavior: 'auto'
|
|
110
|
+
})
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const entryIds = entries.map(({ id }) => id);
|
|
114
|
+
|
|
115
|
+
const getPosition = (id: UniqueIdentifier): number =>
|
|
116
|
+
entryIds.indexOf(id as string) + 1;
|
|
117
|
+
|
|
118
|
+
const announceMove =
|
|
119
|
+
(moveLabel: string) =>
|
|
120
|
+
({ active, over }: Pick<DragEndEvent, 'active' | 'over'>) =>
|
|
121
|
+
over
|
|
122
|
+
? t(moveLabel, {
|
|
123
|
+
newPosition: getPosition(over.id),
|
|
124
|
+
position: getPosition(active.id)
|
|
125
|
+
})
|
|
126
|
+
: undefined;
|
|
127
|
+
|
|
128
|
+
const announcements: Announcements = {
|
|
129
|
+
onDragCancel: ({ active }) =>
|
|
130
|
+
t(labelRowDragCancelled, { position: getPosition(active.id) }),
|
|
131
|
+
onDragEnd: announceMove(labelRowDropped),
|
|
132
|
+
onDragOver: announceMove(labelRowMovedOver),
|
|
133
|
+
onDragStart: ({ active }) =>
|
|
134
|
+
t(labelRowPickedUp, { position: getPosition(active.id) })
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<div className="@container w-full" ref={containerRef}>
|
|
139
|
+
<ItemComposition labelAdd={translatedAddLabel} onAddItem={addEntry}>
|
|
140
|
+
{[
|
|
141
|
+
<DndContext
|
|
142
|
+
accessibility={{
|
|
143
|
+
announcements,
|
|
144
|
+
screenReaderInstructions: {
|
|
145
|
+
draggable: t(labelRowDragInstructions)
|
|
146
|
+
}
|
|
147
|
+
}}
|
|
148
|
+
collisionDetection={closestCenter}
|
|
149
|
+
key="sortable-entries"
|
|
150
|
+
modifiers={[restrictToVerticalAxis]}
|
|
151
|
+
onDragEnd={reorderEntries}
|
|
152
|
+
sensors={sensors}
|
|
153
|
+
>
|
|
154
|
+
<SortableContext
|
|
155
|
+
items={entryIds}
|
|
156
|
+
strategy={verticalListSortingStrategy}
|
|
157
|
+
>
|
|
158
|
+
<div
|
|
159
|
+
className={
|
|
160
|
+
maxVisibleRows
|
|
161
|
+
? `relative w-full overflow-y-auto pt-2 [scrollbar-gutter:stable] [scrollbar-width:thin] ${hasHiddenRowsBelow ? '[mask-image:linear-gradient(to_bottom,#000_calc(100%-2rem),transparent)]' : ''}`
|
|
162
|
+
: 'w-full'
|
|
163
|
+
}
|
|
164
|
+
data-testid="sortable-entries-scroll"
|
|
165
|
+
onScroll={updateHiddenRowsBelow}
|
|
166
|
+
ref={scrollRef}
|
|
167
|
+
style={{ maxHeight }}
|
|
168
|
+
>
|
|
169
|
+
<ul
|
|
170
|
+
aria-label={label}
|
|
171
|
+
className="m-0 flex w-full list-none flex-col gap-2 p-0"
|
|
172
|
+
ref={listRef}
|
|
173
|
+
>
|
|
174
|
+
{entries.map(({ id, value }, index) => {
|
|
175
|
+
const rowParams = { index, value, values };
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<Row<T>
|
|
179
|
+
actions={actions?.(rowParams) ?? []}
|
|
180
|
+
className={getRowClassName?.(rowParams)}
|
|
181
|
+
draggable={draggable}
|
|
182
|
+
id={id}
|
|
183
|
+
key={id}
|
|
184
|
+
onDelete={removeEntry}
|
|
185
|
+
onUpdate={updateEntry}
|
|
186
|
+
renderRow={renderRow}
|
|
187
|
+
rowParams={rowParams}
|
|
188
|
+
/>
|
|
189
|
+
);
|
|
190
|
+
})}
|
|
191
|
+
</ul>
|
|
192
|
+
</div>
|
|
193
|
+
</SortableContext>
|
|
194
|
+
</DndContext>
|
|
195
|
+
]}
|
|
196
|
+
</ItemComposition>
|
|
197
|
+
</div>
|
|
198
|
+
);
|
|
199
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ReactElement } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface SortableRowAction {
|
|
4
|
+
icon: ReactElement;
|
|
5
|
+
id: string;
|
|
6
|
+
isDisabled?: boolean;
|
|
7
|
+
label: string;
|
|
8
|
+
onClick: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface RowParams<T> {
|
|
12
|
+
index: number;
|
|
13
|
+
value: T;
|
|
14
|
+
values: ReadonlyArray<T>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface RenderRowParams<T> extends RowParams<T> {
|
|
18
|
+
setField: <K extends keyof T>(field: K, fieldValue: T[K]) => void;
|
|
19
|
+
setValue: (value: T) => void;
|
|
20
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const labelAdd = 'Add';
|
|
2
|
+
export const labelDelete = 'Delete';
|
|
3
|
+
export const labelDragToReorder = 'Drag to reorder';
|
|
4
|
+
export const labelRowDragInstructions =
|
|
5
|
+
'To pick up a row, press space or enter. Use the arrow keys to move it, then press space or enter to drop it, or escape to cancel.';
|
|
6
|
+
export const labelRowPickedUp = 'Picked up row {{position}}';
|
|
7
|
+
export const labelRowMovedOver =
|
|
8
|
+
'Row {{position}} moved to position {{newPosition}}';
|
|
9
|
+
export const labelRowDropped =
|
|
10
|
+
'Row {{position}} dropped at position {{newPosition}}';
|
|
11
|
+
export const labelRowDragCancelled =
|
|
12
|
+
'Reordering cancelled, row {{position}} returned to its original position';
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { type RefObject, useLayoutEffect, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
interface Result {
|
|
4
|
+
hasHiddenRowsBelow: boolean;
|
|
5
|
+
listRef: RefObject<HTMLUListElement | null>;
|
|
6
|
+
maxHeight?: number;
|
|
7
|
+
scrollRef: RefObject<HTMLDivElement | null>;
|
|
8
|
+
updateHiddenRowsBelow: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Caps the list height to `maxVisibleRows` rows, measured from the rendered
|
|
12
|
+
// rows so it stays right whatever the row height (content, container width).
|
|
13
|
+
export const useMaxVisibleRows = (maxVisibleRows?: number): Result => {
|
|
14
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
15
|
+
const listRef = useRef<HTMLUListElement>(null);
|
|
16
|
+
const [maxHeight, setMaxHeight] = useState<number>();
|
|
17
|
+
const [hasHiddenRowsBelow, setHasHiddenRowsBelow] = useState(false);
|
|
18
|
+
|
|
19
|
+
const updateHiddenRowsBelow = (): void => {
|
|
20
|
+
const container = scrollRef.current;
|
|
21
|
+
|
|
22
|
+
setHasHiddenRowsBelow(
|
|
23
|
+
!!container &&
|
|
24
|
+
container.scrollHeight - container.scrollTop - container.clientHeight >
|
|
25
|
+
1
|
|
26
|
+
);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
useLayoutEffect(() => {
|
|
30
|
+
const list = listRef.current;
|
|
31
|
+
|
|
32
|
+
if (!maxVisibleRows || !list) {
|
|
33
|
+
setMaxHeight(undefined);
|
|
34
|
+
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const observer = new ResizeObserver(() => {
|
|
39
|
+
const cutOffRow = list.children[maxVisibleRows] as
|
|
40
|
+
| HTMLElement
|
|
41
|
+
| undefined;
|
|
42
|
+
|
|
43
|
+
// Half of the first row past the cap stays visible, so the cut reads as
|
|
44
|
+
// a deliberate hint that the list scrolls.
|
|
45
|
+
setMaxHeight(
|
|
46
|
+
cutOffRow
|
|
47
|
+
? Math.round(cutOffRow.offsetTop + cutOffRow.offsetHeight / 2)
|
|
48
|
+
: undefined
|
|
49
|
+
);
|
|
50
|
+
updateHiddenRowsBelow();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
observer.observe(list);
|
|
54
|
+
|
|
55
|
+
return () => observer.disconnect();
|
|
56
|
+
}, [maxVisibleRows]);
|
|
57
|
+
|
|
58
|
+
useLayoutEffect(() => {
|
|
59
|
+
// The cap can apply after a row was added and focused (e.g. the 11th row
|
|
60
|
+
// of a list capped to 10): bring the focused row back into view.
|
|
61
|
+
const focusedRow = document.activeElement?.closest('li');
|
|
62
|
+
|
|
63
|
+
if (focusedRow && scrollRef.current?.contains(focusedRow)) {
|
|
64
|
+
focusedRow.scrollIntoView({ block: 'nearest' });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
updateHiddenRowsBelow();
|
|
68
|
+
}, [maxHeight]);
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
hasHiddenRowsBelow,
|
|
72
|
+
listRef,
|
|
73
|
+
maxHeight,
|
|
74
|
+
scrollRef,
|
|
75
|
+
updateHiddenRowsBelow
|
|
76
|
+
};
|
|
77
|
+
};
|