@ornery/ui-grid-react 0.1.9 → 1.0.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/demo/main.tsx +4 -2
- package/dist/UiGrid.d.ts +6 -5
- package/dist/UiGrid.d.ts.map +1 -1
- package/dist/gridStateMath.d.ts +2 -2
- package/dist/gridStateMath.d.ts.map +1 -1
- package/dist/index.d.ts +2 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +159 -2020
- package/dist/index.mjs +153 -2106
- package/dist/mountUiGrid.d.ts +2 -0
- package/dist/mountUiGrid.d.ts.map +1 -1
- package/dist/ui-grid.css +94 -15
- package/dist/useGridState.d.ts +5 -0
- package/dist/useGridState.d.ts.map +1 -1
- package/package.json +5 -3
- package/src/UiGrid.test.tsx +64 -235
- package/src/UiGrid.tsx +178 -683
- package/src/gridStateMath.test.ts +3 -3
- package/src/gridStateMath.ts +7 -4
- package/src/index.ts +2 -18
- package/src/mountUiGrid.tsx +26 -0
- package/src/test-setup.ts +13 -0
- package/src/ui-grid.css +94 -15
- package/src/useGridState.ts +173 -24
- package/tsconfig.dts.json +3 -2
- package/tsconfig.json +2 -1
- package/vitest.config.ts +2 -0
package/src/UiGrid.tsx
CHANGED
|
@@ -1,734 +1,229 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
2
3
|
import type {
|
|
3
4
|
GridOptions,
|
|
4
5
|
GridCellTemplateContext,
|
|
5
|
-
|
|
6
|
-
GridHeaderTemplateContext,
|
|
6
|
+
GridRecord,
|
|
7
7
|
UiGridApi,
|
|
8
|
-
GridColumnDef,
|
|
9
|
-
GridRow,
|
|
10
8
|
} from '@ornery/ui-grid-core';
|
|
11
|
-
import type {
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
import type {
|
|
10
|
+
FrameworkCellSlot,
|
|
11
|
+
FrameworkSlotDelta,
|
|
12
|
+
UiGridStandaloneElement,
|
|
13
|
+
} from '@ornery/ui-grid-vanilla';
|
|
14
|
+
import { defineStandaloneUiGridElement } from '@ornery/ui-grid-vanilla';
|
|
15
|
+
|
|
16
|
+
export interface UiGridCellRenderers {
|
|
17
|
+
[columnName: string]: (context: GridCellTemplateContext) => React.ReactNode;
|
|
18
|
+
}
|
|
14
19
|
|
|
15
20
|
export interface UiGridProps {
|
|
16
21
|
options: GridOptions;
|
|
17
22
|
onRegisterApi?: (api: UiGridApi) => void;
|
|
18
|
-
|
|
19
|
-
headerRenderer?: (context: GridHeaderTemplateContext) => React.ReactNode;
|
|
20
|
-
expandableRenderer?: (context: GridExpandableTemplateContext) => React.ReactNode;
|
|
23
|
+
cellRenderers?: UiGridCellRenderers;
|
|
21
24
|
className?: string;
|
|
22
25
|
}
|
|
23
26
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}: UiGridProps) {
|
|
32
|
-
const state = useGridState(options, onRegisterApi);
|
|
33
|
-
|
|
34
|
-
const {
|
|
35
|
-
pipeline,
|
|
36
|
-
visibleColumns,
|
|
37
|
-
labels,
|
|
38
|
-
gridTemplateColumns,
|
|
39
|
-
gridContainerRef,
|
|
40
|
-
displayItems,
|
|
41
|
-
virtualizationEnabled,
|
|
42
|
-
rowSize,
|
|
43
|
-
editingValue,
|
|
44
|
-
sortingFeature,
|
|
45
|
-
filteringFeature,
|
|
46
|
-
groupingFeature,
|
|
47
|
-
paginationFeature,
|
|
48
|
-
cellEditFeature,
|
|
49
|
-
expandableFeature,
|
|
50
|
-
treeViewFeature,
|
|
51
|
-
columnMovingFeature,
|
|
52
|
-
paginationCurrentPage,
|
|
53
|
-
paginationTotalPages,
|
|
54
|
-
paginationSelectedPageSize,
|
|
55
|
-
} = state;
|
|
56
|
-
|
|
57
|
-
const headerGridRef = React.useRef<HTMLDivElement | null>(null);
|
|
58
|
-
const filterGridRef = React.useRef<HTMLDivElement | null>(null);
|
|
59
|
-
const [headerStickyHeight, setHeaderStickyHeight] = React.useState(0);
|
|
60
|
-
const [filterStickyHeight, setFilterStickyHeight] = React.useState(0);
|
|
61
|
-
const stickyChromeHeight = headerStickyHeight + filterStickyHeight;
|
|
62
|
-
const bodyViewportHeight = Math.max(
|
|
63
|
-
rowSize,
|
|
64
|
-
(options.viewportHeight ?? 560) - stickyChromeHeight,
|
|
65
|
-
);
|
|
66
|
-
|
|
67
|
-
const virtualScroll = useVirtualScroll({
|
|
68
|
-
itemCount: displayItems.length,
|
|
69
|
-
itemSize: rowSize,
|
|
70
|
-
viewportHeight: bodyViewportHeight,
|
|
71
|
-
overscan: 3,
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
const [openPinMenuColumn, setOpenPinMenuColumn] = React.useState<string | null>(null);
|
|
75
|
-
const [draggedColumnName, setDraggedColumnName] = React.useState<string | null>(null);
|
|
76
|
-
const [dropTargetColumnName, setDropTargetColumnName] = React.useState<string | null>(null);
|
|
77
|
-
const scrollContainerHeight = `${options.viewportHeight ?? 560}px`;
|
|
78
|
-
|
|
79
|
-
function renderHeaderContent(column: GridColumnDef): React.ReactNode {
|
|
80
|
-
const value = state.headerLabel(column);
|
|
81
|
-
const context: GridHeaderTemplateContext = {
|
|
82
|
-
$implicit: value,
|
|
83
|
-
value,
|
|
84
|
-
column,
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
if (headerRenderer) {
|
|
88
|
-
return headerRenderer(context) ?? value;
|
|
89
|
-
}
|
|
27
|
+
interface SlotEntry {
|
|
28
|
+
slotName: string;
|
|
29
|
+
columnName: string;
|
|
30
|
+
rowId: string;
|
|
31
|
+
context: GridCellTemplateContext;
|
|
32
|
+
wrapper: HTMLSpanElement;
|
|
33
|
+
}
|
|
90
34
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
35
|
+
const TAG_NAME = 'ui-grid-element';
|
|
36
|
+
let definePromise: Promise<void> | null = null;
|
|
37
|
+
|
|
38
|
+
export function UiGrid({ options, onRegisterApi, cellRenderers, className }: UiGridProps) {
|
|
39
|
+
const containerRef = React.useRef<HTMLDivElement>(null);
|
|
40
|
+
const elementRef = React.useRef<UiGridStandaloneElement | null>(null);
|
|
41
|
+
const [slots, setSlots] = React.useState<Map<string, SlotEntry>>(new Map());
|
|
42
|
+
const cellRenderersRef = React.useRef(cellRenderers);
|
|
43
|
+
cellRenderersRef.current = cellRenderers;
|
|
44
|
+
const onRegisterApiRef = React.useRef(onRegisterApi);
|
|
45
|
+
onRegisterApiRef.current = onRegisterApi;
|
|
46
|
+
const optionsRef = React.useRef(options);
|
|
47
|
+
optionsRef.current = options;
|
|
48
|
+
const currentSlotColumnsRef = React.useRef<string[]>([]);
|
|
49
|
+
|
|
50
|
+
// Mount the vanilla element once
|
|
51
|
+
React.useEffect(() => {
|
|
52
|
+
const container = containerRef.current;
|
|
53
|
+
if (!container) return;
|
|
94
54
|
|
|
95
|
-
|
|
96
|
-
|
|
55
|
+
let el: UiGridStandaloneElement | null = null;
|
|
56
|
+
let disposed = false;
|
|
97
57
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
? event.composedPath()
|
|
102
|
-
: event.target
|
|
103
|
-
? [event.target]
|
|
104
|
-
: [];
|
|
105
|
-
|
|
106
|
-
return eventPath.some((target) => {
|
|
107
|
-
if (!target || typeof target !== 'object' || !('classList' in target)) {
|
|
108
|
-
return false;
|
|
58
|
+
const mount = async () => {
|
|
59
|
+
if (!definePromise) {
|
|
60
|
+
definePromise = defineStandaloneUiGridElement(TAG_NAME);
|
|
109
61
|
}
|
|
62
|
+
await definePromise;
|
|
63
|
+
if (disposed) return;
|
|
64
|
+
|
|
65
|
+
el = document.createElement(TAG_NAME) as UiGridStandaloneElement;
|
|
66
|
+
el.style.display = 'block';
|
|
67
|
+
el.style.height = '100%';
|
|
68
|
+
el.style.minHeight = '0';
|
|
69
|
+
elementRef.current = el;
|
|
70
|
+
container.appendChild(el);
|
|
71
|
+
|
|
72
|
+
el.addEventListener('cellSlotsChanged', handleCellSlotsChanged);
|
|
73
|
+
applyOptions(el, optionsRef.current);
|
|
74
|
+
};
|
|
110
75
|
|
|
111
|
-
|
|
112
|
-
return classList?.contains(className) ?? false;
|
|
113
|
-
});
|
|
114
|
-
}, []);
|
|
115
|
-
|
|
116
|
-
const isPinMenuOpen = React.useCallback(
|
|
117
|
-
(column: GridColumnDef) => openPinMenuColumn === column.name,
|
|
118
|
-
[openPinMenuColumn],
|
|
119
|
-
);
|
|
120
|
-
|
|
121
|
-
const pinButtonLabel = React.useCallback(
|
|
122
|
-
(column: GridColumnDef) => (state.isPinned(column) ? labels.unpin : labels.pinColumn),
|
|
123
|
-
[labels, state],
|
|
124
|
-
);
|
|
76
|
+
void mount();
|
|
125
77
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
return;
|
|
78
|
+
return () => {
|
|
79
|
+
disposed = true;
|
|
80
|
+
if (el) {
|
|
81
|
+
el.removeEventListener('cellSlotsChanged', handleCellSlotsChanged);
|
|
82
|
+
el.remove();
|
|
83
|
+
elementRef.current = null;
|
|
133
84
|
}
|
|
85
|
+
setSlots(new Map());
|
|
86
|
+
};
|
|
87
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
88
|
+
}, []);
|
|
134
89
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
event?.stopPropagation();
|
|
143
|
-
setOpenPinMenuColumn(null);
|
|
144
|
-
state.gridApi.pinning.pinColumn(column.name, direction);
|
|
145
|
-
},
|
|
146
|
-
[state],
|
|
147
|
-
);
|
|
90
|
+
// Update options when they change
|
|
91
|
+
React.useEffect(() => {
|
|
92
|
+
const el = elementRef.current;
|
|
93
|
+
if (!el) return;
|
|
94
|
+
applyOptions(el, options);
|
|
95
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
96
|
+
}, [options]);
|
|
148
97
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
event.preventDefault();
|
|
153
|
-
return;
|
|
154
|
-
}
|
|
98
|
+
// Update existing slot contexts when data changes (same rows, new values)
|
|
99
|
+
React.useEffect(() => {
|
|
100
|
+
if (slots.size === 0 || !options.data) return;
|
|
155
101
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
}
|
|
161
|
-
[columnMovingFeature],
|
|
162
|
-
);
|
|
102
|
+
const dataById = new Map<string, GridRecord>();
|
|
103
|
+
for (const row of options.data) {
|
|
104
|
+
const id = String(row['id'] ?? '');
|
|
105
|
+
if (id) dataById.set(id, row);
|
|
106
|
+
}
|
|
163
107
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
108
|
+
let changed = false;
|
|
109
|
+
const nextSlots = new Map(slots);
|
|
110
|
+
for (const [key, entry] of nextSlots) {
|
|
111
|
+
const row = dataById.get(entry.rowId);
|
|
112
|
+
if (!row) continue;
|
|
113
|
+
|
|
114
|
+
const col = options.columnDefs?.find((c) => c.name === entry.columnName);
|
|
115
|
+
const value = col?.field ? getNestedValue(row, col.field) : row[entry.columnName];
|
|
116
|
+
|
|
117
|
+
if (entry.context.value !== value || entry.context.row !== row) {
|
|
118
|
+
nextSlots.set(key, {
|
|
119
|
+
...entry,
|
|
120
|
+
context: { ...entry.context, $implicit: value, value, row },
|
|
121
|
+
});
|
|
122
|
+
changed = true;
|
|
168
123
|
}
|
|
124
|
+
}
|
|
169
125
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
},
|
|
174
|
-
[columnMovingFeature, draggedColumnName],
|
|
175
|
-
);
|
|
176
|
-
|
|
177
|
-
const handleHeaderDrop = React.useCallback(
|
|
178
|
-
(column: GridColumnDef, event: React.DragEvent<HTMLDivElement>) => {
|
|
179
|
-
event.preventDefault();
|
|
180
|
-
|
|
181
|
-
if (!columnMovingFeature) {
|
|
182
|
-
return;
|
|
183
|
-
}
|
|
126
|
+
if (changed) setSlots(nextSlots);
|
|
127
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
128
|
+
}, [options.data]);
|
|
184
129
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
130
|
+
function applyOptions(el: UiGridStandaloneElement, opts: GridOptions) {
|
|
131
|
+
const renderers = cellRenderersRef.current;
|
|
132
|
+
const cellSlotColumns: string[] = [];
|
|
188
133
|
|
|
189
|
-
|
|
190
|
-
|
|
134
|
+
if (renderers && opts.columnDefs) {
|
|
135
|
+
for (const col of opts.columnDefs) {
|
|
136
|
+
if (renderers[col.name]) {
|
|
137
|
+
cellSlotColumns.push(col.name);
|
|
138
|
+
}
|
|
191
139
|
}
|
|
192
|
-
|
|
193
|
-
state.moveVisibleColumn(sourceColumnName, column.name);
|
|
194
|
-
},
|
|
195
|
-
[columnMovingFeature, draggedColumnName, state],
|
|
196
|
-
);
|
|
197
|
-
|
|
198
|
-
const handleHeaderDragEnd = React.useCallback(() => {
|
|
199
|
-
setDraggedColumnName(null);
|
|
200
|
-
setDropTargetColumnName(null);
|
|
201
|
-
}, []);
|
|
202
|
-
|
|
203
|
-
React.useLayoutEffect(() => {
|
|
204
|
-
setHeaderStickyHeight(headerGridRef.current?.offsetHeight ?? 0);
|
|
205
|
-
setFilterStickyHeight(filterGridRef.current?.offsetHeight ?? 0);
|
|
206
|
-
}, [visibleColumns, filteringFeature, options.enableFiltering]);
|
|
207
|
-
|
|
208
|
-
React.useLayoutEffect(() => {
|
|
209
|
-
const headerElement = headerGridRef.current;
|
|
210
|
-
const filterElement = filterGridRef.current;
|
|
211
|
-
if (typeof ResizeObserver === 'undefined' || (!headerElement && !filterElement)) {
|
|
212
|
-
return;
|
|
213
140
|
}
|
|
214
141
|
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
142
|
+
const wrappedOptions: GridOptions = {
|
|
143
|
+
...opts,
|
|
144
|
+
onRegisterApi: (api) => {
|
|
145
|
+
onRegisterApiRef.current?.(api as UiGridApi);
|
|
146
|
+
opts.onRegisterApi?.(api);
|
|
147
|
+
},
|
|
148
|
+
};
|
|
219
149
|
|
|
220
|
-
|
|
221
|
-
observer.observe(headerElement);
|
|
222
|
-
}
|
|
223
|
-
if (filterElement) {
|
|
224
|
-
observer.observe(filterElement);
|
|
225
|
-
}
|
|
150
|
+
el.options = wrappedOptions;
|
|
226
151
|
|
|
227
|
-
|
|
228
|
-
|
|
152
|
+
const prev = currentSlotColumnsRef.current;
|
|
153
|
+
const columnsChanged =
|
|
154
|
+
cellSlotColumns.length !== prev.length ||
|
|
155
|
+
cellSlotColumns.some((name, i) => name !== prev[i]);
|
|
229
156
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
157
|
+
if (columnsChanged) {
|
|
158
|
+
currentSlotColumnsRef.current = cellSlotColumns;
|
|
159
|
+
el.setFrameworkRenderedSlots({ cells: cellSlotColumns });
|
|
233
160
|
}
|
|
161
|
+
}
|
|
234
162
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
163
|
+
function handleCellSlotsChanged(event: Event) {
|
|
164
|
+
const detail = (event as CustomEvent<FrameworkSlotDelta<FrameworkCellSlot>>).detail;
|
|
165
|
+
const el = elementRef.current;
|
|
166
|
+
if (!el) return;
|
|
239
167
|
|
|
240
|
-
|
|
241
|
-
|
|
168
|
+
setSlots((prev) => {
|
|
169
|
+
const next = new Map(prev);
|
|
242
170
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
171
|
+
for (const slot of detail.removed) {
|
|
172
|
+
const entry = next.get(slot.slotName);
|
|
173
|
+
if (entry) {
|
|
174
|
+
entry.wrapper.remove();
|
|
175
|
+
next.delete(slot.slotName);
|
|
176
|
+
}
|
|
246
177
|
}
|
|
247
|
-
};
|
|
248
|
-
|
|
249
|
-
document.addEventListener('click', handleDocumentClick);
|
|
250
|
-
document.addEventListener('keydown', handleDocumentEscape);
|
|
251
|
-
|
|
252
|
-
return () => {
|
|
253
|
-
document.removeEventListener('click', handleDocumentClick);
|
|
254
|
-
document.removeEventListener('keydown', handleDocumentEscape);
|
|
255
|
-
};
|
|
256
|
-
}, [eventPathIncludesClass, openPinMenuColumn]);
|
|
257
|
-
|
|
258
|
-
const itemsToRender = virtualizationEnabled
|
|
259
|
-
? displayItems.slice(virtualScroll.visibleRange.start, virtualScroll.visibleRange.end)
|
|
260
|
-
: displayItems;
|
|
261
|
-
|
|
262
|
-
const onGridTableScroll = (event: React.UIEvent<HTMLDivElement>) => {
|
|
263
|
-
const bodyScrollTop = Math.max(0, event.currentTarget.scrollTop - stickyChromeHeight);
|
|
264
|
-
virtualScroll.setScrollTop(bodyScrollTop);
|
|
265
|
-
const startIndex = Math.floor(bodyScrollTop / rowSize);
|
|
266
|
-
state.onViewportScroll(startIndex);
|
|
267
|
-
};
|
|
268
|
-
|
|
269
|
-
function renderDisplayItem(item: DisplayItem) {
|
|
270
|
-
if (groupingFeature && state.isGroupItem(item)) {
|
|
271
|
-
return (
|
|
272
|
-
<button
|
|
273
|
-
key={item.id}
|
|
274
|
-
type="button"
|
|
275
|
-
className="group-row ui-grid-row ui-grid-group-row"
|
|
276
|
-
data-part="group-row"
|
|
277
|
-
role="row"
|
|
278
|
-
aria-expanded={!item.collapsed}
|
|
279
|
-
style={{ gridColumn: '1 / -1', paddingInlineStart: `${item.depth * 1.25 + 1}rem` }}
|
|
280
|
-
onClick={() => state.toggleGroup(item)}
|
|
281
|
-
>
|
|
282
|
-
<strong>
|
|
283
|
-
{item.field}: {item.label}
|
|
284
|
-
</strong>
|
|
285
|
-
<span>
|
|
286
|
-
{item.count} {labels.groupRowsSuffix}
|
|
287
|
-
</span>
|
|
288
|
-
<svg
|
|
289
|
-
className="toggle-icon group-disclosure-icon"
|
|
290
|
-
viewBox="0 0 24 24"
|
|
291
|
-
aria-hidden="true"
|
|
292
|
-
focusable={false}
|
|
293
|
-
>
|
|
294
|
-
<path d={item.collapsed ? 'M10 7l5 5-5 5z' : 'M7 10l5 5 5-5z'} />
|
|
295
|
-
</svg>
|
|
296
|
-
<span className="sr-only ui-grid-sr-only">{state.groupDisclosureLabel(item)}</span>
|
|
297
|
-
</button>
|
|
298
|
-
);
|
|
299
|
-
}
|
|
300
178
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
179
|
+
for (const slot of detail.added) {
|
|
180
|
+
const existing = next.get(slot.slotName);
|
|
181
|
+
if (existing) {
|
|
182
|
+
existing.wrapper.remove();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const wrapper = document.createElement('span');
|
|
186
|
+
wrapper.setAttribute('slot', slot.slotName);
|
|
187
|
+
el.appendChild(wrapper);
|
|
188
|
+
|
|
189
|
+
next.set(slot.slotName, {
|
|
190
|
+
slotName: slot.slotName,
|
|
191
|
+
columnName: slot.columnName,
|
|
192
|
+
rowId: slot.rowId,
|
|
193
|
+
context: slot.context,
|
|
194
|
+
wrapper,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
314
197
|
|
|
315
|
-
|
|
316
|
-
const rowItem = item as RowItem;
|
|
317
|
-
|
|
318
|
-
return visibleColumns.map((column) => {
|
|
319
|
-
const pinned = state.isPinned(column);
|
|
320
|
-
const pinOffset = pinned ? state.pinnedOffset(column) : null;
|
|
321
|
-
return (
|
|
322
|
-
<div
|
|
323
|
-
key={`${rowItem.row.id}-${column.name}`}
|
|
324
|
-
className={`${cellClassName(rowItem, column)}${pinned ? ' is-pinned' : ''}`}
|
|
325
|
-
data-part="body-cell"
|
|
326
|
-
role="gridcell"
|
|
327
|
-
tabIndex={0}
|
|
328
|
-
data-row-id={rowItem.row.id}
|
|
329
|
-
data-col-name={column.name}
|
|
330
|
-
onFocus={() => state.focusCell(rowItem.row, column)}
|
|
331
|
-
onClick={() => state.focusCell(rowItem.row, column)}
|
|
332
|
-
onDoubleClick={(e) => state.handleCellDoubleClick(rowItem.row, column, e)}
|
|
333
|
-
onKeyDown={(e) => state.handleCellKeyDown(rowItem.row, column, e)}
|
|
334
|
-
style={{
|
|
335
|
-
position: pinned ? 'sticky' : undefined,
|
|
336
|
-
left: pinOffset?.side === 'left' ? pinOffset.offset : undefined,
|
|
337
|
-
right: pinOffset?.side === 'right' ? pinOffset.offset : undefined,
|
|
338
|
-
zIndex: pinned ? 2 : undefined,
|
|
339
|
-
}}
|
|
340
|
-
>
|
|
341
|
-
<div
|
|
342
|
-
className="cell-shell"
|
|
343
|
-
style={{ paddingInlineStart: state.cellIndent(rowItem.row, column) }}
|
|
344
|
-
>
|
|
345
|
-
{treeViewFeature && state.showTreeToggle(rowItem.row, column) && (
|
|
346
|
-
<button
|
|
347
|
-
type="button"
|
|
348
|
-
className="row-toggle row-toggle-tree"
|
|
349
|
-
data-part="tree-toggle"
|
|
350
|
-
aria-label={state.treeToggleLabel(rowItem.row)}
|
|
351
|
-
aria-expanded={state.isTreeRowExpanded(rowItem.row)}
|
|
352
|
-
onClick={(e) => state.toggleTreeRow(rowItem.row, e)}
|
|
353
|
-
>
|
|
354
|
-
<svg
|
|
355
|
-
className="toggle-icon"
|
|
356
|
-
viewBox="0 0 24 24"
|
|
357
|
-
aria-hidden="true"
|
|
358
|
-
focusable={false}
|
|
359
|
-
>
|
|
360
|
-
<path
|
|
361
|
-
d={state.isTreeRowExpanded(rowItem.row) ? 'M7 10l5 5 5-5z' : 'M10 7l5 5-5 5z'}
|
|
362
|
-
/>
|
|
363
|
-
</svg>
|
|
364
|
-
</button>
|
|
365
|
-
)}
|
|
366
|
-
{expandableFeature && state.showExpandToggle(rowItem.row, column) && (
|
|
367
|
-
<button
|
|
368
|
-
type="button"
|
|
369
|
-
className="row-toggle row-toggle-expand"
|
|
370
|
-
data-part="expand-toggle"
|
|
371
|
-
aria-label={state.expandToggleLabel(rowItem.row)}
|
|
372
|
-
aria-expanded={rowItem.row.expanded}
|
|
373
|
-
onClick={(e) => state.toggleRowExpansion(rowItem.row, e)}
|
|
374
|
-
>
|
|
375
|
-
<svg
|
|
376
|
-
className="toggle-icon"
|
|
377
|
-
viewBox="0 0 24 24"
|
|
378
|
-
aria-hidden="true"
|
|
379
|
-
focusable={false}
|
|
380
|
-
>
|
|
381
|
-
<path d={rowItem.row.expanded ? 'M7 10l5 5 5-5z' : 'M10 7l5 5-5 5z'} />
|
|
382
|
-
</svg>
|
|
383
|
-
</button>
|
|
384
|
-
)}
|
|
385
|
-
<span className="cell-value">
|
|
386
|
-
{cellEditFeature && state.isEditingCell(rowItem.row, column) ? (
|
|
387
|
-
<input
|
|
388
|
-
className="cell-editor"
|
|
389
|
-
data-row-id={rowItem.row.id}
|
|
390
|
-
data-col-name={column.name}
|
|
391
|
-
aria-label={state.headerLabel(column)}
|
|
392
|
-
type={state.editorInputType(column)}
|
|
393
|
-
defaultValue={editingValue}
|
|
394
|
-
onChange={(e) => state.updateEditingValue(e.target.value)}
|
|
395
|
-
onKeyDown={(e) => state.handleEditorKeyDown(e)}
|
|
396
|
-
onBlur={(e) => state.handleEditorBlur(e)}
|
|
397
|
-
/>
|
|
398
|
-
) : cellRenderer ? (
|
|
399
|
-
(cellRenderer(state.cellContext(rowItem.row, column)) ??
|
|
400
|
-
state.displayValue(rowItem.row, column))
|
|
401
|
-
) : (
|
|
402
|
-
state.displayValue(rowItem.row, column)
|
|
403
|
-
)}
|
|
404
|
-
</span>
|
|
405
|
-
</div>
|
|
406
|
-
</div>
|
|
407
|
-
);
|
|
198
|
+
return next;
|
|
408
199
|
});
|
|
409
200
|
}
|
|
410
201
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
function renderSortIcon(column: GridColumnDef) {
|
|
422
|
-
const direction = state.sortDirection(column);
|
|
423
|
-
switch (direction) {
|
|
424
|
-
case 'asc':
|
|
425
|
-
return (
|
|
426
|
-
<svg viewBox="0 0 24 24" aria-hidden="true" focusable={false}>
|
|
427
|
-
<path d="M12 5l-6 6h4v8h4v-8h4z" />
|
|
428
|
-
</svg>
|
|
429
|
-
);
|
|
430
|
-
case 'desc':
|
|
431
|
-
return (
|
|
432
|
-
<svg viewBox="0 0 24 24" aria-hidden="true" focusable={false}>
|
|
433
|
-
<path d="M12 19l6-6h-4V5h-4v8H6z" />
|
|
434
|
-
</svg>
|
|
435
|
-
);
|
|
436
|
-
default:
|
|
437
|
-
return (
|
|
438
|
-
<svg viewBox="0 0 24 24" aria-hidden="true" focusable={false}>
|
|
439
|
-
<path d="M7 6h10v2H7V6Zm0 5h7v2H7v-2Zm0 5h4v2H7v-2Z" />
|
|
440
|
-
</svg>
|
|
441
|
-
);
|
|
202
|
+
// Render React portals into the slot wrappers
|
|
203
|
+
const portals: React.ReactNode[] = [];
|
|
204
|
+
const renderers = cellRenderers;
|
|
205
|
+
if (renderers) {
|
|
206
|
+
for (const [, entry] of slots) {
|
|
207
|
+
const renderer = renderers[entry.columnName];
|
|
208
|
+
if (renderer) {
|
|
209
|
+
portals.push(createPortal(renderer(entry.context), entry.wrapper, entry.slotName));
|
|
210
|
+
}
|
|
442
211
|
}
|
|
443
212
|
}
|
|
444
213
|
|
|
445
214
|
return (
|
|
446
|
-
<div
|
|
447
|
-
|
|
448
|
-
className="grid-frame ui-grid"
|
|
449
|
-
data-part="grid-frame"
|
|
450
|
-
role="grid"
|
|
451
|
-
aria-label={options.title ?? 'Data grid'}
|
|
452
|
-
>
|
|
453
|
-
<div
|
|
454
|
-
className="grid-table ui-grid-contents-wrapper"
|
|
455
|
-
data-part="grid-table"
|
|
456
|
-
style={
|
|
457
|
-
virtualizationEnabled ? { height: scrollContainerHeight, overflowY: 'auto' } : undefined
|
|
458
|
-
}
|
|
459
|
-
onScroll={virtualizationEnabled ? onGridTableScroll : undefined}
|
|
460
|
-
>
|
|
461
|
-
<div
|
|
462
|
-
className="header-grid ui-grid-header ui-grid-header-canvas"
|
|
463
|
-
data-part="header"
|
|
464
|
-
role="row"
|
|
465
|
-
ref={headerGridRef}
|
|
466
|
-
style={{ gridTemplateColumns }}
|
|
467
|
-
>
|
|
468
|
-
{visibleColumns.map((column) => {
|
|
469
|
-
const pinned = state.isPinned(column);
|
|
470
|
-
const pinOffset = pinned ? state.pinnedOffset(column) : null;
|
|
471
|
-
const pinMenuOpen = isPinMenuOpen(column);
|
|
472
|
-
return (
|
|
473
|
-
<div
|
|
474
|
-
key={column.name}
|
|
475
|
-
className={`header-cell ui-grid-header-cell${sortingFeature && state.sortDirection(column) !== 'none' ? ' is-active' : ''}${pinned ? ' is-pinned' : ''}${pinMenuOpen ? ' is-pin-menu-open' : ''}${draggedColumnName === column.name ? ' is-dragging' : ''}${dropTargetColumnName === column.name ? ' is-drag-target' : ''}`}
|
|
476
|
-
data-part="header-cell"
|
|
477
|
-
role="columnheader"
|
|
478
|
-
aria-sort={sortingFeature ? (state.sortAriaSort(column) as any) : undefined}
|
|
479
|
-
draggable={columnMovingFeature}
|
|
480
|
-
onDragStart={(event) => handleHeaderDragStart(column, event)}
|
|
481
|
-
onDragOver={(event) => handleHeaderDragOver(column, event)}
|
|
482
|
-
onDrop={(event) => handleHeaderDrop(column, event)}
|
|
483
|
-
onDragEnd={handleHeaderDragEnd}
|
|
484
|
-
onDragLeave={() => {
|
|
485
|
-
if (dropTargetColumnName === column.name) {
|
|
486
|
-
setDropTargetColumnName(null);
|
|
487
|
-
}
|
|
488
|
-
}}
|
|
489
|
-
style={{
|
|
490
|
-
position: pinned ? 'sticky' : undefined,
|
|
491
|
-
left: pinOffset?.side === 'left' ? pinOffset.offset : undefined,
|
|
492
|
-
right: pinOffset?.side === 'right' ? pinOffset.offset : undefined,
|
|
493
|
-
zIndex: pinMenuOpen ? 8 : pinned ? 2 : undefined,
|
|
494
|
-
}}
|
|
495
|
-
>
|
|
496
|
-
<span className="header-label">{renderHeaderContent(column)}</span>
|
|
497
|
-
|
|
498
|
-
<div className="header-actions">
|
|
499
|
-
{sortingFeature && (
|
|
500
|
-
<button
|
|
501
|
-
type="button"
|
|
502
|
-
className={`header-action${!state.isColumnSortable(column) ? ' header-action-disabled' : ''}`}
|
|
503
|
-
disabled={!state.isColumnSortable(column)}
|
|
504
|
-
aria-label={state.sortButtonLabel(column)}
|
|
505
|
-
title={state.sortButtonLabel(column)}
|
|
506
|
-
onClick={() => state.toggleSort(column)}
|
|
507
|
-
>
|
|
508
|
-
{renderSortIcon(column)}
|
|
509
|
-
<span className="sr-only ui-grid-sr-only">
|
|
510
|
-
{state.sortButtonLabel(column)}
|
|
511
|
-
</span>
|
|
512
|
-
</button>
|
|
513
|
-
)}
|
|
514
|
-
|
|
515
|
-
{groupingFeature &&
|
|
516
|
-
state.isGroupingEnabled() &&
|
|
517
|
-
column.enableGrouping !== false && (
|
|
518
|
-
<button
|
|
519
|
-
type="button"
|
|
520
|
-
className={`chip-action${state.isGrouped(column) ? ' chip-action-active' : ''}`}
|
|
521
|
-
data-part="group-toggle"
|
|
522
|
-
aria-label={state.groupingButtonLabel(column)}
|
|
523
|
-
title={state.groupingButtonLabel(column)}
|
|
524
|
-
onClick={(e) => state.toggleGrouping(column, e)}
|
|
525
|
-
>
|
|
526
|
-
<svg viewBox="0 0 24 24" aria-hidden="true" focusable={false}>
|
|
527
|
-
<path d="M4 6h8v4H4V6Zm0 8h8v4H4v-4Zm10-8h6v4h-6V6Zm0 8h6v4h-6v-4Z" />
|
|
528
|
-
</svg>
|
|
529
|
-
<span className="sr-only ui-grid-sr-only">
|
|
530
|
-
{state.groupingButtonLabel(column)}
|
|
531
|
-
</span>
|
|
532
|
-
</button>
|
|
533
|
-
)}
|
|
534
|
-
|
|
535
|
-
{state.pinningFeature &&
|
|
536
|
-
state.isPinningEnabled() &&
|
|
537
|
-
state.isColumnPinnable(column) && (
|
|
538
|
-
<div
|
|
539
|
-
className={`pin-control${pinMenuOpen ? ' pin-control-open' : ''}`}
|
|
540
|
-
onClick={(event) => event.stopPropagation()}
|
|
541
|
-
>
|
|
542
|
-
<button
|
|
543
|
-
type="button"
|
|
544
|
-
className={`chip-action pin-trigger${pinned || pinMenuOpen ? ' chip-action-active' : ''}`}
|
|
545
|
-
data-part="pin-toggle"
|
|
546
|
-
aria-label={pinButtonLabel(column)}
|
|
547
|
-
title={pinButtonLabel(column)}
|
|
548
|
-
aria-haspopup={pinned ? undefined : 'menu'}
|
|
549
|
-
aria-expanded={pinned ? undefined : pinMenuOpen}
|
|
550
|
-
onClick={(event) => onPinTrigger(column, event)}
|
|
551
|
-
>
|
|
552
|
-
<svg viewBox="0 0 24 24" aria-hidden="true" focusable={false}>
|
|
553
|
-
<path d="M16 12V4h1V2H7v2h1v8l-2 2v2h5v6l1 1 1-1v-6h5v-2l-2-2z" />
|
|
554
|
-
</svg>
|
|
555
|
-
<span className="sr-only ui-grid-sr-only">{pinButtonLabel(column)}</span>
|
|
556
|
-
</button>
|
|
557
|
-
|
|
558
|
-
<div
|
|
559
|
-
className="pin-menu"
|
|
560
|
-
data-part="pin-menu"
|
|
561
|
-
role="menu"
|
|
562
|
-
aria-label="Pin options"
|
|
563
|
-
aria-hidden={!pinMenuOpen}
|
|
564
|
-
>
|
|
565
|
-
<button
|
|
566
|
-
type="button"
|
|
567
|
-
className="pin-menu-action"
|
|
568
|
-
data-part="pin-left-action"
|
|
569
|
-
role="menuitem"
|
|
570
|
-
aria-label={labels.pinLeft}
|
|
571
|
-
title={labels.pinLeft}
|
|
572
|
-
tabIndex={pinMenuOpen ? 0 : -1}
|
|
573
|
-
onClick={(event) => choosePinDirection(column, 'left', event)}
|
|
574
|
-
>
|
|
575
|
-
<svg viewBox="0 0 24 24" aria-hidden="true" focusable={false}>
|
|
576
|
-
<path d="M10 6 4 12l6 6v-4h10v-4H10V6z" />
|
|
577
|
-
</svg>
|
|
578
|
-
<span className="sr-only ui-grid-sr-only">{labels.pinLeft}</span>
|
|
579
|
-
</button>
|
|
580
|
-
<button
|
|
581
|
-
type="button"
|
|
582
|
-
className="pin-menu-action"
|
|
583
|
-
data-part="pin-right-action"
|
|
584
|
-
role="menuitem"
|
|
585
|
-
aria-label={labels.pinRight}
|
|
586
|
-
title={labels.pinRight}
|
|
587
|
-
tabIndex={pinMenuOpen ? 0 : -1}
|
|
588
|
-
onClick={(event) => choosePinDirection(column, 'right', event)}
|
|
589
|
-
>
|
|
590
|
-
<svg viewBox="0 0 24 24" aria-hidden="true" focusable={false}>
|
|
591
|
-
<path d="M14 6v4H4v4h10v4l6-6-6-6z" />
|
|
592
|
-
</svg>
|
|
593
|
-
<span className="sr-only ui-grid-sr-only">{labels.pinRight}</span>
|
|
594
|
-
</button>
|
|
595
|
-
</div>
|
|
596
|
-
</div>
|
|
597
|
-
)}
|
|
598
|
-
</div>
|
|
599
|
-
</div>
|
|
600
|
-
);
|
|
601
|
-
})}
|
|
602
|
-
</div>
|
|
603
|
-
|
|
604
|
-
{filteringFeature && state.isFilteringEnabled() && (
|
|
605
|
-
<div
|
|
606
|
-
className="filter-grid ui-grid-header"
|
|
607
|
-
data-part="filters"
|
|
608
|
-
ref={filterGridRef}
|
|
609
|
-
style={{
|
|
610
|
-
gridTemplateColumns,
|
|
611
|
-
['--ui-grid-header-sticky-top' as string]: `${headerStickyHeight}px`,
|
|
612
|
-
}}
|
|
613
|
-
>
|
|
614
|
-
{visibleColumns.map((column) => {
|
|
615
|
-
const pinned = state.isPinned(column);
|
|
616
|
-
const pinOffset = pinned ? state.pinnedOffset(column) : null;
|
|
617
|
-
return (
|
|
618
|
-
<label
|
|
619
|
-
key={column.name}
|
|
620
|
-
className={`filter-cell ui-grid-filter-container${pinned ? ' is-pinned' : ''}`}
|
|
621
|
-
data-part="filter-cell"
|
|
622
|
-
style={{
|
|
623
|
-
position: pinned ? 'sticky' : undefined,
|
|
624
|
-
left: pinOffset?.side === 'left' ? pinOffset.offset : undefined,
|
|
625
|
-
right: pinOffset?.side === 'right' ? pinOffset.offset : undefined,
|
|
626
|
-
zIndex: pinned ? 2 : undefined,
|
|
627
|
-
}}
|
|
628
|
-
>
|
|
629
|
-
<span className="sr-only ui-grid-sr-only">
|
|
630
|
-
{labels.filterColumn} {state.headerLabel(column)}
|
|
631
|
-
</span>
|
|
632
|
-
<input
|
|
633
|
-
className="ui-grid-filter-input"
|
|
634
|
-
type="text"
|
|
635
|
-
defaultValue={state.filterValue(column.name)}
|
|
636
|
-
placeholder={state.filterPlaceholder(column)}
|
|
637
|
-
disabled={state.isFilterInputDisabled(column)}
|
|
638
|
-
onChange={(e) => state.updateFilter(column.name, e.target.value)}
|
|
639
|
-
/>
|
|
640
|
-
</label>
|
|
641
|
-
);
|
|
642
|
-
})}
|
|
643
|
-
</div>
|
|
644
|
-
)}
|
|
645
|
-
|
|
646
|
-
{displayItems.length > 0 ? (
|
|
647
|
-
virtualizationEnabled ? (
|
|
648
|
-
<div className="grid-virtual-spacer" style={{ height: `${virtualScroll.totalHeight}px` }}>
|
|
649
|
-
<div
|
|
650
|
-
className="body-grid ui-grid-canvas grid-virtual-body"
|
|
651
|
-
data-part="body"
|
|
652
|
-
role="rowgroup"
|
|
653
|
-
style={{
|
|
654
|
-
gridTemplateColumns,
|
|
655
|
-
position: 'absolute',
|
|
656
|
-
top: `${virtualScroll.offsetY}px`,
|
|
657
|
-
left: 0,
|
|
658
|
-
}}
|
|
659
|
-
>
|
|
660
|
-
{itemsToRender.map(renderDisplayItem)}
|
|
661
|
-
</div>
|
|
662
|
-
</div>
|
|
663
|
-
) : (
|
|
664
|
-
<div className="body-grid ui-grid-canvas" data-part="body" role="rowgroup" style={{ gridTemplateColumns }}>
|
|
665
|
-
{displayItems.map(renderDisplayItem)}
|
|
666
|
-
</div>
|
|
667
|
-
)
|
|
668
|
-
) : (
|
|
669
|
-
<div className="empty-state ui-grid-no-row-overlay" data-part="empty-state">
|
|
670
|
-
<strong>{options.emptyMessage ?? labels.emptyHeading}</strong>
|
|
671
|
-
<p>{labels.emptyDescription}</p>
|
|
672
|
-
</div>
|
|
673
|
-
)}
|
|
674
|
-
</div>
|
|
675
|
-
|
|
676
|
-
{paginationFeature && state.showPaginationControls() && (
|
|
677
|
-
<footer
|
|
678
|
-
className="pagination-bar ui-grid-pagination"
|
|
679
|
-
data-part="pagination"
|
|
680
|
-
role="navigation"
|
|
681
|
-
aria-label={labels.paginationPage}
|
|
682
|
-
>
|
|
683
|
-
<p>{state.paginationSummary()}</p>
|
|
684
|
-
<div className="pagination-controls">
|
|
685
|
-
<button
|
|
686
|
-
type="button"
|
|
687
|
-
className="action action-secondary pagination-button"
|
|
688
|
-
aria-label={labels.paginationPrevious}
|
|
689
|
-
disabled={paginationCurrentPage <= 1}
|
|
690
|
-
onClick={() => state.previousPage()}
|
|
691
|
-
>
|
|
692
|
-
<svg className="pagination-icon" viewBox="0 0 24 24" aria-hidden="true" focusable={false}>
|
|
693
|
-
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z" />
|
|
694
|
-
</svg>
|
|
695
|
-
<span className="sr-only">{labels.paginationPrevious}</span>
|
|
696
|
-
</button>
|
|
697
|
-
<span>
|
|
698
|
-
{labels.paginationPage} {paginationCurrentPage} {labels.paginationOf} {paginationTotalPages}
|
|
699
|
-
</span>
|
|
700
|
-
<button
|
|
701
|
-
type="button"
|
|
702
|
-
className="action action-secondary pagination-button"
|
|
703
|
-
aria-label={labels.paginationNext}
|
|
704
|
-
disabled={paginationCurrentPage >= paginationTotalPages}
|
|
705
|
-
onClick={() => state.nextPage()}
|
|
706
|
-
>
|
|
707
|
-
<svg className="pagination-icon" viewBox="0 0 24 24" aria-hidden="true" focusable={false}>
|
|
708
|
-
<path d="M8.59 16.59L10 18l6-6-6-6-1.41 1.41L13.17 12z" />
|
|
709
|
-
</svg>
|
|
710
|
-
<span className="sr-only">{labels.paginationNext}</span>
|
|
711
|
-
</button>
|
|
712
|
-
{state.pageSizeOptions().length > 0 && (
|
|
713
|
-
<label className="pagination-size">
|
|
714
|
-
<span className="sr-only">{labels.paginationRows}</span>
|
|
715
|
-
<select
|
|
716
|
-
aria-label={labels.paginationRows}
|
|
717
|
-
value={paginationSelectedPageSize}
|
|
718
|
-
onChange={(e) => state.onPageSizeChange(e.target.value)}
|
|
719
|
-
>
|
|
720
|
-
{state.pageSizeOptions().map((size) => (
|
|
721
|
-
<option key={size} value={size}>
|
|
722
|
-
{size}
|
|
723
|
-
</option>
|
|
724
|
-
))}
|
|
725
|
-
</select>
|
|
726
|
-
</label>
|
|
727
|
-
)}
|
|
728
|
-
</div>
|
|
729
|
-
</footer>
|
|
730
|
-
)}
|
|
731
|
-
</section>
|
|
215
|
+
<div ref={containerRef} className={className} style={{ display: 'block', height: '100%', minHeight: 0 }}>
|
|
216
|
+
{portals}
|
|
732
217
|
</div>
|
|
733
218
|
);
|
|
734
219
|
}
|
|
220
|
+
|
|
221
|
+
function getNestedValue(obj: GridRecord, field: string): unknown {
|
|
222
|
+
const parts = field.split('.');
|
|
223
|
+
let current: unknown = obj;
|
|
224
|
+
for (const part of parts) {
|
|
225
|
+
if (current == null || typeof current !== 'object') return undefined;
|
|
226
|
+
current = (current as Record<string, unknown>)[part];
|
|
227
|
+
}
|
|
228
|
+
return current;
|
|
229
|
+
}
|