@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.
@@ -0,0 +1,145 @@
1
+ import type { DragEndEvent } from '@dnd-kit/core';
2
+ import { arrayMove } from '@dnd-kit/sortable';
3
+ import {
4
+ type RefObject,
5
+ useEffect,
6
+ useLayoutEffect,
7
+ useMemo,
8
+ useRef
9
+ } from 'react';
10
+
11
+ // A value paired with the stable key used for React keys, drag & drop and focus
12
+ // management. Consumers only ever see plain values.
13
+ interface Entry<T> {
14
+ id: string;
15
+ value: T;
16
+ }
17
+
18
+ const generateEntryId = (): string =>
19
+ `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
20
+
21
+ const focusableSelector =
22
+ 'input:not([disabled]):not([type="hidden"]), textarea:not([disabled]), select:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"])';
23
+
24
+ const getRowSelector = (id: string): string =>
25
+ `[data-entry-id="${CSS.escape(id)}"]`;
26
+
27
+ interface Params<T> {
28
+ addLabel: string;
29
+ createValue: () => T;
30
+ onChange: (values: Array<T>) => void;
31
+ values: Array<T>;
32
+ }
33
+
34
+ interface Result<T> {
35
+ addEntry: () => void;
36
+ containerRef: RefObject<HTMLDivElement | null>;
37
+ entries: Array<Entry<T>>;
38
+ removeEntry: (id: string) => void;
39
+ reorderEntries: (event: DragEndEvent) => void;
40
+ updateEntry: (id: string, update: (value: T) => T) => void;
41
+ }
42
+
43
+ export const useSortableEntries = <T>({
44
+ addLabel,
45
+ createValue,
46
+ onChange,
47
+ values
48
+ }: Params<T>): Result<T> => {
49
+ const containerRef = useRef<HTMLDivElement>(null);
50
+ // Latest entries, updated as soon as a change is emitted so that several
51
+ // changes fired before the parent re-renders compose instead of overwriting
52
+ // each other.
53
+ const latestEntriesRef = useRef<Array<Entry<T>>>([]);
54
+ const pendingFocusSelectorRef = useRef<string | null>(null);
55
+
56
+ // Ids are kept by position: after a change emitted here, the latest entries
57
+ // are exactly the emitted ones, so every row keeps its id (and its DOM node)
58
+ // even when the parent stores a copy of the values.
59
+ const entries = useMemo(
60
+ () =>
61
+ values.map((value, index) => ({
62
+ id: latestEntriesRef.current[index]?.id ?? generateEntryId(),
63
+ value
64
+ })),
65
+ [values]
66
+ );
67
+
68
+ useLayoutEffect(() => {
69
+ latestEntriesRef.current = entries;
70
+ }, [entries]);
71
+
72
+ useEffect(() => {
73
+ const selector = pendingFocusSelectorRef.current;
74
+
75
+ if (!selector) {
76
+ return;
77
+ }
78
+
79
+ pendingFocusSelectorRef.current = null;
80
+
81
+ const element = containerRef.current?.querySelector<HTMLElement>(selector);
82
+
83
+ element?.focus();
84
+ // In a capped list, show the whole row, not only the focused element.
85
+ element?.closest('li')?.scrollIntoView({ block: 'nearest' });
86
+ }, [entries]);
87
+
88
+ const commit = (nextEntries: Array<Entry<T>>): void => {
89
+ latestEntriesRef.current = nextEntries;
90
+ onChange(nextEntries.map(({ value }) => value));
91
+ };
92
+
93
+ const updateEntry = (id: string, update: (value: T) => T): void =>
94
+ commit(
95
+ latestEntriesRef.current.map((entry) =>
96
+ entry.id === id ? { ...entry, value: update(entry.value) } : entry
97
+ )
98
+ );
99
+
100
+ const addEntry = (): void => {
101
+ const entry = { id: generateEntryId(), value: createValue() };
102
+
103
+ pendingFocusSelectorRef.current = `${getRowSelector(entry.id)} [data-row-fields] :is(${focusableSelector})`;
104
+ commit([...latestEntriesRef.current, entry]);
105
+ };
106
+
107
+ const removeEntry = (id: string): void => {
108
+ const currentEntries = latestEntriesRef.current;
109
+ const index = currentEntries.findIndex((entry) => entry.id === id);
110
+ // Keep keyboard users in place: focus the row that takes the removed one's
111
+ // position, else the previous row, else the add button.
112
+ const nextFocusedEntry =
113
+ currentEntries[index + 1] ?? currentEntries[index - 1];
114
+
115
+ pendingFocusSelectorRef.current = nextFocusedEntry
116
+ ? `${getRowSelector(nextFocusedEntry.id)} [data-row-fields] :is(${focusableSelector})`
117
+ : `button[aria-label="${CSS.escape(addLabel)}"]`;
118
+ commit(currentEntries.filter((entry) => entry.id !== id));
119
+ };
120
+
121
+ const reorderEntries = ({ active, over }: DragEndEvent): void => {
122
+ if (!over || active.id === over.id) {
123
+ return;
124
+ }
125
+
126
+ const currentEntries = latestEntriesRef.current;
127
+
128
+ commit(
129
+ arrayMove(
130
+ currentEntries,
131
+ currentEntries.findIndex(({ id }) => id === active.id),
132
+ currentEntries.findIndex(({ id }) => id === over.id)
133
+ )
134
+ );
135
+ };
136
+
137
+ return {
138
+ addEntry,
139
+ containerRef,
140
+ entries,
141
+ removeEntry,
142
+ reorderEntries,
143
+ updateEntry
144
+ };
145
+ };
@@ -11,6 +11,7 @@ export * from './Layout';
11
11
  export * from './List';
12
12
  export * from './Menu';
13
13
  export * from './Modal';
14
+ export * from './SortableEntriesList';
14
15
  export * from './Tabs';
15
16
  export * from './Tooltip';
16
17
  export type { ZoomState } from './Zoom/models';