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