@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/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
- GridExpandableTemplateContext,
6
- GridHeaderTemplateContext,
6
+ GridRecord,
7
7
  UiGridApi,
8
- GridColumnDef,
9
- GridRow,
10
8
  } from '@ornery/ui-grid-core';
11
- import type { DisplayItem, RowItem } from '@ornery/ui-grid-core';
12
- import { useGridState } from './useGridState';
13
- import { useVirtualScroll } from './useVirtualScroll';
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
- cellRenderer?: (context: GridCellTemplateContext) => React.ReactNode;
19
- headerRenderer?: (context: GridHeaderTemplateContext) => React.ReactNode;
20
- expandableRenderer?: (context: GridExpandableTemplateContext) => React.ReactNode;
23
+ cellRenderers?: UiGridCellRenderers;
21
24
  className?: string;
22
25
  }
23
26
 
24
- export function UiGrid({
25
- options,
26
- onRegisterApi,
27
- cellRenderer,
28
- headerRenderer,
29
- expandableRenderer,
30
- className,
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
- if (column.headerRenderer) {
92
- return column.headerRenderer(context);
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
- return value;
96
- }
55
+ let el: UiGridStandaloneElement | null = null;
56
+ let disposed = false;
97
57
 
98
- const eventPathIncludesClass = React.useCallback((event: Event, className: string): boolean => {
99
- const eventPath =
100
- typeof event.composedPath === 'function'
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
- const classList = (target as { classList?: DOMTokenList }).classList;
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
- const onPinTrigger = React.useCallback(
127
- (column: GridColumnDef, event?: React.MouseEvent) => {
128
- event?.stopPropagation();
129
- if (state.isPinned(column)) {
130
- setOpenPinMenuColumn(null);
131
- state.gridApi.pinning.pinColumn(column.name, 'none');
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
- setOpenPinMenuColumn((current) => (current === column.name ? null : column.name));
136
- },
137
- [state],
138
- );
139
-
140
- const choosePinDirection = React.useCallback(
141
- (column: GridColumnDef, direction: 'left' | 'right', event?: React.MouseEvent) => {
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
- const handleHeaderDragStart = React.useCallback(
150
- (column: GridColumnDef, event: React.DragEvent<HTMLDivElement>) => {
151
- if (!columnMovingFeature) {
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
- setDraggedColumnName(column.name);
157
- setDropTargetColumnName(null);
158
- event.dataTransfer.effectAllowed = 'move';
159
- event.dataTransfer.setData('text/plain', column.name);
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
- const handleHeaderDragOver = React.useCallback(
165
- (column: GridColumnDef, event: React.DragEvent<HTMLDivElement>) => {
166
- if (!columnMovingFeature || !draggedColumnName || draggedColumnName === column.name) {
167
- return;
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
- event.preventDefault();
171
- event.dataTransfer.dropEffect = 'move';
172
- setDropTargetColumnName(column.name);
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
- const sourceColumnName = draggedColumnName ?? event.dataTransfer.getData('text/plain');
186
- setDraggedColumnName(null);
187
- setDropTargetColumnName(null);
130
+ function applyOptions(el: UiGridStandaloneElement, opts: GridOptions) {
131
+ const renderers = cellRenderersRef.current;
132
+ const cellSlotColumns: string[] = [];
188
133
 
189
- if (!sourceColumnName || sourceColumnName === column.name) {
190
- return;
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 observer = new ResizeObserver(() => {
216
- setHeaderStickyHeight(headerGridRef.current?.offsetHeight ?? 0);
217
- setFilterStickyHeight(filterGridRef.current?.offsetHeight ?? 0);
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
- if (headerElement) {
221
- observer.observe(headerElement);
222
- }
223
- if (filterElement) {
224
- observer.observe(filterElement);
225
- }
150
+ el.options = wrappedOptions;
226
151
 
227
- return () => observer.disconnect();
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
- React.useEffect(() => {
231
- if (!openPinMenuColumn) {
232
- return;
157
+ if (columnsChanged) {
158
+ currentSlotColumnsRef.current = cellSlotColumns;
159
+ el.setFrameworkRenderedSlots({ cells: cellSlotColumns });
233
160
  }
161
+ }
234
162
 
235
- const handleDocumentClick = (event: MouseEvent) => {
236
- if (eventPathIncludesClass(event, 'pin-control')) {
237
- return;
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
- setOpenPinMenuColumn(null);
241
- };
168
+ setSlots((prev) => {
169
+ const next = new Map(prev);
242
170
 
243
- const handleDocumentEscape = (event: KeyboardEvent) => {
244
- if (event.key === 'Escape') {
245
- setOpenPinMenuColumn(null);
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
- if (expandableFeature && state.isExpandableItem(item)) {
302
- const ctx = state.expandedContext(item.row);
303
- return (
304
- <div
305
- key={item.id}
306
- className="expandable-row ui-grid-row ui-grid-expandable-row"
307
- data-part="expandable-row"
308
- style={{ gridColumn: '1 / -1', minHeight: `${item.row.expandedRowHeight}px` }}
309
- >
310
- {expandableRenderer?.(ctx)}
311
- </div>
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
- if (item.kind !== 'row') return null;
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
- function cellClassName(item: RowItem, column: GridColumnDef): string {
412
- const classes = ['body-cell', 'ui-grid-cell'];
413
- if (state.isOddStripedRow(item)) classes.push('body-cell-odd');
414
- if (column.align === 'center') classes.push('align-center');
415
- if (column.align === 'end') classes.push('align-end');
416
- if (state.isFocusedCell(item.row, column)) classes.push('cell-focused');
417
- if (cellEditFeature && state.isEditingCell(item.row, column)) classes.push('cell-editing');
418
- return classes.join(' ');
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 className={`ui-grid-host ${className ?? ''}`} ref={gridContainerRef}>
447
- <section
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
+ }