@ornery/ui-grid-react 0.1.5 → 0.1.7

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.
Files changed (41) hide show
  1. package/demo/main.tsx +1 -1
  2. package/demo/vite.config.ts +1 -1
  3. package/dist/UiGrid.d.ts +11 -0
  4. package/dist/UiGrid.d.ts.map +1 -0
  5. package/dist/gridStateMath.d.ts +8 -0
  6. package/dist/gridStateMath.d.ts.map +1 -0
  7. package/dist/index.d.ts +14 -155
  8. package/dist/index.d.ts.map +1 -0
  9. package/dist/index.js +699 -1063
  10. package/dist/index.mjs +594 -930
  11. package/dist/mountUiGrid.d.ts +4 -0
  12. package/dist/mountUiGrid.d.ts.map +1 -0
  13. package/dist/rustWasmGridEngine.d.ts +8 -0
  14. package/dist/rustWasmGridEngine.d.ts.map +1 -0
  15. package/dist/{index.d.mts → useGridState.d.ts} +6 -50
  16. package/dist/useGridState.d.ts.map +1 -0
  17. package/dist/useVirtualScroll.d.ts +20 -0
  18. package/dist/useVirtualScroll.d.ts.map +1 -0
  19. package/dist/vanillaAdapter.d.ts +14 -0
  20. package/dist/vanillaAdapter.d.ts.map +1 -0
  21. package/dist/virtualScrollMath.d.ts +17 -0
  22. package/dist/virtualScrollMath.d.ts.map +1 -0
  23. package/package.json +11 -11
  24. package/src/UiGrid.test.tsx +63 -25
  25. package/src/UiGrid.tsx +438 -187
  26. package/src/gridStateMath.test.ts +10 -8
  27. package/src/gridStateMath.ts +20 -7
  28. package/src/index.ts +21 -5
  29. package/src/mountUiGrid.tsx +10 -0
  30. package/src/rustWasmGridEngine.test.ts +4 -4
  31. package/src/rustWasmGridEngine.ts +7 -5
  32. package/src/ui-grid.css +200 -1
  33. package/src/useGridState.ts +56 -30
  34. package/src/useVirtualScroll.ts +2 -0
  35. package/src/vanillaAdapter.test.ts +33 -0
  36. package/src/vanillaAdapter.ts +36 -0
  37. package/tsconfig.build.json +6 -0
  38. package/tsconfig.dts.json +15 -0
  39. package/tsconfig.json +1 -1
  40. package/vitest.config.ts +1 -1
  41. package/CLAUDE.md +0 -283
package/src/UiGrid.tsx CHANGED
@@ -6,8 +6,8 @@ import type {
6
6
  UiGridApi,
7
7
  GridColumnDef,
8
8
  GridRow,
9
- } from '@ornery/ui-grid';
10
- import type { DisplayItem, RowItem } from '@ornery/ui-grid';
9
+ } from '@ornery/ui-grid-core';
10
+ import type { DisplayItem, RowItem } from '@ornery/ui-grid-core';
11
11
  import { useGridState } from './useGridState';
12
12
  import { useVirtualScroll } from './useVirtualScroll';
13
13
 
@@ -51,25 +51,202 @@ export function UiGrid({
51
51
  expandableFeature,
52
52
  treeViewFeature,
53
53
  csvExportFeature,
54
+ columnMovingFeature,
54
55
  paginationCurrentPage,
55
56
  paginationTotalPages,
56
57
  paginationSelectedPageSize,
57
58
  } = state;
58
59
 
60
+ const headerGridRef = React.useRef<HTMLDivElement | null>(null);
61
+ const filterGridRef = React.useRef<HTMLDivElement | null>(null);
62
+ const [headerStickyHeight, setHeaderStickyHeight] = React.useState(0);
63
+ const [filterStickyHeight, setFilterStickyHeight] = React.useState(0);
64
+ const stickyChromeHeight = headerStickyHeight + filterStickyHeight;
65
+ const bodyViewportHeight = Math.max(
66
+ rowSize,
67
+ (options.viewportHeight ?? 560) - stickyChromeHeight,
68
+ );
69
+
59
70
  const virtualScroll = useVirtualScroll({
60
71
  itemCount: displayItems.length,
61
72
  itemSize: rowSize,
62
- viewportHeight: options.viewportHeight ?? 560,
73
+ viewportHeight: bodyViewportHeight,
63
74
  overscan: 3,
64
75
  });
65
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 = `${options.viewportHeight ?? 560}px`;
81
+
82
+ const eventPathIncludesClass = React.useCallback((event: Event, className: string): boolean => {
83
+ const eventPath =
84
+ typeof event.composedPath === 'function'
85
+ ? event.composedPath()
86
+ : event.target
87
+ ? [event.target]
88
+ : [];
89
+
90
+ return eventPath.some((target) => {
91
+ if (!target || typeof target !== 'object' || !('classList' in target)) {
92
+ return false;
93
+ }
94
+
95
+ const classList = (target as { classList?: DOMTokenList }).classList;
96
+ return classList?.contains(className) ?? false;
97
+ });
98
+ }, []);
99
+
100
+ const isPinMenuOpen = React.useCallback(
101
+ (column: GridColumnDef) => openPinMenuColumn === column.name,
102
+ [openPinMenuColumn],
103
+ );
104
+
105
+ const pinButtonLabel = React.useCallback(
106
+ (column: GridColumnDef) => (state.isPinned(column) ? labels.unpin : labels.pinColumn),
107
+ [labels, state],
108
+ );
109
+
110
+ const onPinTrigger = React.useCallback(
111
+ (column: GridColumnDef, event?: React.MouseEvent) => {
112
+ event?.stopPropagation();
113
+ if (state.isPinned(column)) {
114
+ setOpenPinMenuColumn(null);
115
+ state.gridApi.pinning.pinColumn(column.name, 'none');
116
+ return;
117
+ }
118
+
119
+ setOpenPinMenuColumn((current) => (current === column.name ? null : column.name));
120
+ },
121
+ [state],
122
+ );
123
+
124
+ const choosePinDirection = React.useCallback(
125
+ (column: GridColumnDef, direction: 'left' | 'right', event?: React.MouseEvent) => {
126
+ event?.stopPropagation();
127
+ setOpenPinMenuColumn(null);
128
+ state.gridApi.pinning.pinColumn(column.name, direction);
129
+ },
130
+ [state],
131
+ );
132
+
133
+ const handleHeaderDragStart = React.useCallback(
134
+ (column: GridColumnDef, event: React.DragEvent<HTMLDivElement>) => {
135
+ if (!columnMovingFeature) {
136
+ event.preventDefault();
137
+ return;
138
+ }
139
+
140
+ setDraggedColumnName(column.name);
141
+ setDropTargetColumnName(null);
142
+ event.dataTransfer.effectAllowed = 'move';
143
+ event.dataTransfer.setData('text/plain', column.name);
144
+ },
145
+ [columnMovingFeature],
146
+ );
147
+
148
+ const handleHeaderDragOver = React.useCallback(
149
+ (column: GridColumnDef, event: React.DragEvent<HTMLDivElement>) => {
150
+ if (!columnMovingFeature || !draggedColumnName || draggedColumnName === column.name) {
151
+ return;
152
+ }
153
+
154
+ event.preventDefault();
155
+ event.dataTransfer.dropEffect = 'move';
156
+ setDropTargetColumnName(column.name);
157
+ },
158
+ [columnMovingFeature, draggedColumnName],
159
+ );
160
+
161
+ const handleHeaderDrop = React.useCallback(
162
+ (column: GridColumnDef, event: React.DragEvent<HTMLDivElement>) => {
163
+ event.preventDefault();
164
+
165
+ if (!columnMovingFeature) {
166
+ return;
167
+ }
168
+
169
+ const sourceColumnName = draggedColumnName ?? event.dataTransfer.getData('text/plain');
170
+ setDraggedColumnName(null);
171
+ setDropTargetColumnName(null);
172
+
173
+ if (!sourceColumnName || sourceColumnName === column.name) {
174
+ return;
175
+ }
176
+
177
+ state.moveVisibleColumn(sourceColumnName, column.name);
178
+ },
179
+ [columnMovingFeature, draggedColumnName, state],
180
+ );
181
+
182
+ const handleHeaderDragEnd = React.useCallback(() => {
183
+ setDraggedColumnName(null);
184
+ setDropTargetColumnName(null);
185
+ }, []);
186
+
187
+ React.useLayoutEffect(() => {
188
+ setHeaderStickyHeight(headerGridRef.current?.offsetHeight ?? 0);
189
+ setFilterStickyHeight(filterGridRef.current?.offsetHeight ?? 0);
190
+ }, [visibleColumns, filteringFeature, options.enableFiltering]);
191
+
192
+ React.useLayoutEffect(() => {
193
+ const headerElement = headerGridRef.current;
194
+ const filterElement = filterGridRef.current;
195
+ if (typeof ResizeObserver === 'undefined' || (!headerElement && !filterElement)) {
196
+ return;
197
+ }
198
+
199
+ const observer = new ResizeObserver(() => {
200
+ setHeaderStickyHeight(headerGridRef.current?.offsetHeight ?? 0);
201
+ setFilterStickyHeight(filterGridRef.current?.offsetHeight ?? 0);
202
+ });
203
+
204
+ if (headerElement) {
205
+ observer.observe(headerElement);
206
+ }
207
+ if (filterElement) {
208
+ observer.observe(filterElement);
209
+ }
210
+
211
+ return () => observer.disconnect();
212
+ }, []);
213
+
214
+ React.useEffect(() => {
215
+ if (!openPinMenuColumn) {
216
+ return;
217
+ }
218
+
219
+ const handleDocumentClick = (event: MouseEvent) => {
220
+ if (eventPathIncludesClass(event, 'pin-control')) {
221
+ return;
222
+ }
223
+
224
+ setOpenPinMenuColumn(null);
225
+ };
226
+
227
+ const handleDocumentEscape = (event: KeyboardEvent) => {
228
+ if (event.key === 'Escape') {
229
+ setOpenPinMenuColumn(null);
230
+ }
231
+ };
232
+
233
+ document.addEventListener('click', handleDocumentClick);
234
+ document.addEventListener('keydown', handleDocumentEscape);
235
+
236
+ return () => {
237
+ document.removeEventListener('click', handleDocumentClick);
238
+ document.removeEventListener('keydown', handleDocumentEscape);
239
+ };
240
+ }, [eventPathIncludesClass, openPinMenuColumn]);
241
+
66
242
  const itemsToRender = virtualizationEnabled
67
243
  ? displayItems.slice(virtualScroll.visibleRange.start, virtualScroll.visibleRange.end)
68
244
  : displayItems;
69
245
 
70
- const onViewportScroll = (event: React.UIEvent<HTMLDivElement>) => {
71
- virtualScroll.onScroll(event);
72
- const startIndex = Math.floor(event.currentTarget.scrollTop / rowSize);
246
+ const onGridTableScroll = (event: React.UIEvent<HTMLDivElement>) => {
247
+ const bodyScrollTop = Math.max(0, event.currentTarget.scrollTop - stickyChromeHeight);
248
+ virtualScroll.setScrollTop(bodyScrollTop);
249
+ const startIndex = Math.floor(bodyScrollTop / rowSize);
73
250
  state.onViewportScroll(startIndex);
74
251
  };
75
252
 
@@ -126,81 +303,91 @@ export function UiGrid({
126
303
  const pinned = state.isPinned(column);
127
304
  const pinOffset = pinned ? state.pinnedOffset(column) : null;
128
305
  return (
129
- <div
130
- key={`${rowItem.row.id}-${column.name}`}
131
- className={`${cellClassName(rowItem, column)}${pinned ? ' is-pinned' : ''}`}
132
- data-part="body-cell"
133
- role="gridcell"
134
- tabIndex={0}
135
- data-row-id={rowItem.row.id}
136
- data-col-name={column.name}
137
- onFocus={() => state.focusCell(rowItem.row, column)}
138
- onClick={() => state.focusCell(rowItem.row, column)}
139
- onDoubleClick={(e) => state.handleCellDoubleClick(rowItem.row, column, e)}
140
- onKeyDown={(e) => state.handleCellKeyDown(rowItem.row, column, e)}
141
- style={{
142
- position: pinned ? 'sticky' : undefined,
143
- left: pinOffset?.side === 'left' ? pinOffset.offset : undefined,
144
- right: pinOffset?.side === 'right' ? pinOffset.offset : undefined,
145
- zIndex: pinned ? 2 : undefined,
146
- }}
147
- >
148
306
  <div
149
- className="cell-shell"
150
- style={{ paddingInlineStart: state.cellIndent(rowItem.row, column) }}
307
+ key={`${rowItem.row.id}-${column.name}`}
308
+ className={`${cellClassName(rowItem, column)}${pinned ? ' is-pinned' : ''}`}
309
+ data-part="body-cell"
310
+ role="gridcell"
311
+ tabIndex={0}
312
+ data-row-id={rowItem.row.id}
313
+ data-col-name={column.name}
314
+ onFocus={() => state.focusCell(rowItem.row, column)}
315
+ onClick={() => state.focusCell(rowItem.row, column)}
316
+ onDoubleClick={(e) => state.handleCellDoubleClick(rowItem.row, column, e)}
317
+ onKeyDown={(e) => state.handleCellKeyDown(rowItem.row, column, e)}
318
+ style={{
319
+ position: pinned ? 'sticky' : undefined,
320
+ left: pinOffset?.side === 'left' ? pinOffset.offset : undefined,
321
+ right: pinOffset?.side === 'right' ? pinOffset.offset : undefined,
322
+ zIndex: pinned ? 2 : undefined,
323
+ }}
151
324
  >
152
- {treeViewFeature && state.showTreeToggle(rowItem.row, column) && (
153
- <button
154
- type="button"
155
- className="row-toggle row-toggle-tree"
156
- data-part="tree-toggle"
157
- aria-label={state.treeToggleLabel(rowItem.row)}
158
- aria-expanded={state.isTreeRowExpanded(rowItem.row)}
159
- onClick={(e) => state.toggleTreeRow(rowItem.row, e)}
160
- >
161
- <svg className="toggle-icon" viewBox="0 0 24 24" aria-hidden="true" focusable={false}>
162
- <path
163
- d={state.isTreeRowExpanded(rowItem.row) ? 'M7 10l5 5 5-5z' : 'M10 7l5 5-5 5z'}
164
- />
165
- </svg>
166
- </button>
167
- )}
168
- {expandableFeature && state.showExpandToggle(rowItem.row, column) && (
169
- <button
170
- type="button"
171
- className="row-toggle row-toggle-expand"
172
- data-part="expand-toggle"
173
- aria-label={state.expandToggleLabel(rowItem.row)}
174
- aria-expanded={rowItem.row.expanded}
175
- onClick={(e) => state.toggleRowExpansion(rowItem.row, e)}
176
- >
177
- <svg className="toggle-icon" viewBox="0 0 24 24" aria-hidden="true" focusable={false}>
178
- <path d={rowItem.row.expanded ? 'M7 10l5 5 5-5z' : 'M10 7l5 5-5 5z'} />
179
- </svg>
180
- </button>
181
- )}
182
- <span className="cell-value">
183
- {cellEditFeature && state.isEditingCell(rowItem.row, column) ? (
184
- <input
185
- className="cell-editor"
186
- data-row-id={rowItem.row.id}
187
- data-col-name={column.name}
188
- aria-label={state.headerLabel(column)}
189
- type={state.editorInputType(column)}
190
- defaultValue={editingValue}
191
- onChange={(e) => state.updateEditingValue(e.target.value)}
192
- onKeyDown={(e) => state.handleEditorKeyDown(e)}
193
- onBlur={(e) => state.handleEditorBlur(e)}
194
- />
195
- ) : cellRenderer ? (
196
- (cellRenderer(state.cellContext(rowItem.row, column)) ??
197
- state.displayValue(rowItem.row, column))
198
- ) : (
199
- state.displayValue(rowItem.row, column)
325
+ <div
326
+ className="cell-shell"
327
+ style={{ paddingInlineStart: state.cellIndent(rowItem.row, column) }}
328
+ >
329
+ {treeViewFeature && state.showTreeToggle(rowItem.row, column) && (
330
+ <button
331
+ type="button"
332
+ className="row-toggle row-toggle-tree"
333
+ data-part="tree-toggle"
334
+ aria-label={state.treeToggleLabel(rowItem.row)}
335
+ aria-expanded={state.isTreeRowExpanded(rowItem.row)}
336
+ onClick={(e) => state.toggleTreeRow(rowItem.row, e)}
337
+ >
338
+ <svg
339
+ className="toggle-icon"
340
+ viewBox="0 0 24 24"
341
+ aria-hidden="true"
342
+ focusable={false}
343
+ >
344
+ <path
345
+ d={state.isTreeRowExpanded(rowItem.row) ? 'M7 10l5 5 5-5z' : 'M10 7l5 5-5 5z'}
346
+ />
347
+ </svg>
348
+ </button>
200
349
  )}
201
- </span>
350
+ {expandableFeature && state.showExpandToggle(rowItem.row, column) && (
351
+ <button
352
+ type="button"
353
+ className="row-toggle row-toggle-expand"
354
+ data-part="expand-toggle"
355
+ aria-label={state.expandToggleLabel(rowItem.row)}
356
+ aria-expanded={rowItem.row.expanded}
357
+ onClick={(e) => state.toggleRowExpansion(rowItem.row, e)}
358
+ >
359
+ <svg
360
+ className="toggle-icon"
361
+ viewBox="0 0 24 24"
362
+ aria-hidden="true"
363
+ focusable={false}
364
+ >
365
+ <path d={rowItem.row.expanded ? 'M7 10l5 5 5-5z' : 'M10 7l5 5-5 5z'} />
366
+ </svg>
367
+ </button>
368
+ )}
369
+ <span className="cell-value">
370
+ {cellEditFeature && state.isEditingCell(rowItem.row, column) ? (
371
+ <input
372
+ className="cell-editor"
373
+ data-row-id={rowItem.row.id}
374
+ data-col-name={column.name}
375
+ aria-label={state.headerLabel(column)}
376
+ type={state.editorInputType(column)}
377
+ defaultValue={editingValue}
378
+ onChange={(e) => state.updateEditingValue(e.target.value)}
379
+ onKeyDown={(e) => state.handleEditorKeyDown(e)}
380
+ onBlur={(e) => state.handleEditorBlur(e)}
381
+ />
382
+ ) : cellRenderer ? (
383
+ (cellRenderer(state.cellContext(rowItem.row, column)) ??
384
+ state.displayValue(rowItem.row, column))
385
+ ) : (
386
+ state.displayValue(rowItem.row, column)
387
+ )}
388
+ </span>
389
+ </div>
202
390
  </div>
203
- </div>
204
391
  );
205
392
  });
206
393
  }
@@ -320,90 +507,157 @@ export function UiGrid({
320
507
  </p>
321
508
  </div>
322
509
 
323
- <div className="grid-table ui-grid-contents-wrapper" data-part="grid-table">
510
+ <div
511
+ className="grid-table ui-grid-contents-wrapper"
512
+ data-part="grid-table"
513
+ style={
514
+ virtualizationEnabled
515
+ ? { height: scrollContainerHeight, overflowY: 'auto' }
516
+ : undefined
517
+ }
518
+ onScroll={virtualizationEnabled ? onGridTableScroll : undefined}
519
+ >
324
520
  {/* Header row */}
325
521
  <div
326
522
  className="header-grid ui-grid-header ui-grid-header-canvas"
327
523
  data-part="header"
328
524
  role="row"
525
+ ref={headerGridRef}
329
526
  style={{ gridTemplateColumns }}
330
527
  >
331
528
  {visibleColumns.map((column) => {
332
529
  const pinned = state.isPinned(column);
333
530
  const pinOffset = pinned ? state.pinnedOffset(column) : null;
531
+ const pinMenuOpen = isPinMenuOpen(column);
334
532
  return (
335
- <div
336
- key={column.name}
337
- className={`header-cell ui-grid-header-cell${sortingFeature && state.sortDirection(column) !== 'none' ? ' is-active' : ''}${pinned ? ' is-pinned' : ''}`}
338
- data-part="header-cell"
339
- role="columnheader"
340
- aria-sort={sortingFeature ? (state.sortAriaSort(column) as any) : undefined}
341
- style={{
342
- position: pinned ? 'sticky' : undefined,
343
- left: pinOffset?.side === 'left' ? pinOffset.offset : undefined,
344
- right: pinOffset?.side === 'right' ? pinOffset.offset : undefined,
345
- zIndex: pinned ? 2 : undefined,
346
- }}
347
- >
348
- <span className="header-label">{state.headerLabel(column)}</span>
349
-
350
- <div className="header-actions">
351
- {sortingFeature && (
352
- <button
353
- type="button"
354
- className={`header-action${!state.isColumnSortable(column) ? ' header-action-disabled' : ''}`}
355
- disabled={!state.isColumnSortable(column)}
356
- aria-label={state.sortButtonLabel(column)}
357
- title={state.sortButtonLabel(column)}
358
- onClick={() => state.toggleSort(column)}
359
- >
360
- {renderSortIcon(column)}
361
- <span className="sr-only ui-grid-sr-only">
362
- {state.sortButtonLabel(column)}
363
- </span>
364
- </button>
365
- )}
366
-
367
- {groupingFeature &&
368
- state.isGroupingEnabled() &&
369
- column.enableGrouping !== false && (
370
- <button
371
- type="button"
372
- className={`chip-action${state.isGrouped(column) ? ' chip-action-active' : ''}`}
373
- data-part="group-toggle"
374
- aria-label={state.groupingButtonLabel(column)}
375
- title={state.groupingButtonLabel(column)}
376
- onClick={(e) => state.toggleGrouping(column, e)}
377
- >
378
- <svg viewBox="0 0 24 24" aria-hidden="true" focusable={false}>
379
- <path d="M4 6h8v4H4V6Zm0 8h8v4H4v-4Zm10-8h6v4h-6V6Zm0 8h6v4h-6v-4Z" />
380
- </svg>
381
- <span className="sr-only ui-grid-sr-only">
382
- {state.groupingButtonLabel(column)}
383
- </span>
384
- </button>
385
- )}
386
- {state.pinningFeature &&
387
- state.isPinningEnabled() &&
388
- state.isColumnPinnable(column) && (
533
+ <div
534
+ key={column.name}
535
+ 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' : ''}`}
536
+ data-part="header-cell"
537
+ role="columnheader"
538
+ aria-sort={sortingFeature ? (state.sortAriaSort(column) as any) : undefined}
539
+ draggable={columnMovingFeature}
540
+ onDragStart={(event) => handleHeaderDragStart(column, event)}
541
+ onDragOver={(event) => handleHeaderDragOver(column, event)}
542
+ onDrop={(event) => handleHeaderDrop(column, event)}
543
+ onDragEnd={handleHeaderDragEnd}
544
+ onDragLeave={() => {
545
+ if (dropTargetColumnName === column.name) {
546
+ setDropTargetColumnName(null);
547
+ }
548
+ }}
549
+ style={{
550
+ position: pinned ? 'sticky' : undefined,
551
+ left: pinOffset?.side === 'left' ? pinOffset.offset : undefined,
552
+ right: pinOffset?.side === 'right' ? pinOffset.offset : undefined,
553
+ zIndex: pinMenuOpen ? 8 : pinned ? 2 : undefined,
554
+ }}
555
+ >
556
+ <span className="header-label">{state.headerLabel(column)}</span>
557
+
558
+ <div className="header-actions">
559
+ {sortingFeature && (
389
560
  <button
390
561
  type="button"
391
- className={`chip-action${pinned ? ' chip-action-active' : ''}`}
392
- data-part="pin-toggle"
393
- aria-label={pinned ? labels.unpin : labels.pinLeft}
394
- title={pinned ? labels.unpin : labels.pinLeft}
395
- onClick={() => state.togglePin(column)}
562
+ className={`header-action${!state.isColumnSortable(column) ? ' header-action-disabled' : ''}`}
563
+ disabled={!state.isColumnSortable(column)}
564
+ aria-label={state.sortButtonLabel(column)}
565
+ title={state.sortButtonLabel(column)}
566
+ onClick={() => state.toggleSort(column)}
396
567
  >
397
- <svg viewBox="0 0 24 24" aria-hidden="true" focusable={false}>
398
- <path d="M16 12V4h1V2H7v2h1v8l-2 2v2h5v6l1 1 1-1v-6h5v-2l-2-2z" />
399
- </svg>
568
+ {renderSortIcon(column)}
400
569
  <span className="sr-only ui-grid-sr-only">
401
- {pinned ? labels.unpin : labels.pinLeft}
570
+ {state.sortButtonLabel(column)}
402
571
  </span>
403
572
  </button>
404
573
  )}
574
+
575
+ {groupingFeature &&
576
+ state.isGroupingEnabled() &&
577
+ column.enableGrouping !== false && (
578
+ <button
579
+ type="button"
580
+ className={`chip-action${state.isGrouped(column) ? ' chip-action-active' : ''}`}
581
+ data-part="group-toggle"
582
+ aria-label={state.groupingButtonLabel(column)}
583
+ title={state.groupingButtonLabel(column)}
584
+ onClick={(e) => state.toggleGrouping(column, e)}
585
+ >
586
+ <svg viewBox="0 0 24 24" aria-hidden="true" focusable={false}>
587
+ <path d="M4 6h8v4H4V6Zm0 8h8v4H4v-4Zm10-8h6v4h-6V6Zm0 8h6v4h-6v-4Z" />
588
+ </svg>
589
+ <span className="sr-only ui-grid-sr-only">
590
+ {state.groupingButtonLabel(column)}
591
+ </span>
592
+ </button>
593
+ )}
594
+ {state.pinningFeature &&
595
+ state.isPinningEnabled() &&
596
+ state.isColumnPinnable(column) && (
597
+ <div
598
+ className={`pin-control${pinMenuOpen ? ' pin-control-open' : ''}`}
599
+ onClick={(event) => event.stopPropagation()}
600
+ >
601
+ <button
602
+ type="button"
603
+ className={`chip-action pin-trigger${pinned || pinMenuOpen ? ' chip-action-active' : ''}`}
604
+ data-part="pin-toggle"
605
+ aria-label={pinButtonLabel(column)}
606
+ title={pinButtonLabel(column)}
607
+ aria-haspopup={pinned ? undefined : 'menu'}
608
+ aria-expanded={pinned ? undefined : pinMenuOpen}
609
+ onClick={(event) => onPinTrigger(column, event)}
610
+ >
611
+ <svg viewBox="0 0 24 24" aria-hidden="true" focusable={false}>
612
+ <path d="M16 12V4h1V2H7v2h1v8l-2 2v2h5v6l1 1 1-1v-6h5v-2l-2-2z" />
613
+ </svg>
614
+ <span className="sr-only ui-grid-sr-only">
615
+ {pinButtonLabel(column)}
616
+ </span>
617
+ </button>
618
+
619
+ <div
620
+ className="pin-menu"
621
+ data-part="pin-menu"
622
+ role="menu"
623
+ aria-label="Pin options"
624
+ aria-hidden={!pinMenuOpen}
625
+ >
626
+ <button
627
+ type="button"
628
+ className="pin-menu-action"
629
+ data-part="pin-left-action"
630
+ role="menuitem"
631
+ aria-label={labels.pinLeft}
632
+ title={labels.pinLeft}
633
+ tabIndex={pinMenuOpen ? 0 : -1}
634
+ onClick={(event) => choosePinDirection(column, 'left', event)}
635
+ >
636
+ <svg viewBox="0 0 24 24" aria-hidden="true" focusable={false}>
637
+ <path d="M10 6 4 12l6 6v-4h10v-4H10V6z" />
638
+ </svg>
639
+ <span className="sr-only ui-grid-sr-only">{labels.pinLeft}</span>
640
+ </button>
641
+ <button
642
+ type="button"
643
+ className="pin-menu-action"
644
+ data-part="pin-right-action"
645
+ role="menuitem"
646
+ aria-label={labels.pinRight}
647
+ title={labels.pinRight}
648
+ tabIndex={pinMenuOpen ? 0 : -1}
649
+ onClick={(event) => choosePinDirection(column, 'right', event)}
650
+ >
651
+ <svg viewBox="0 0 24 24" aria-hidden="true" focusable={false}>
652
+ <path d="M14 6v4H4v4h10v4l6-6-6-6z" />
653
+ </svg>
654
+ <span className="sr-only ui-grid-sr-only">{labels.pinRight}</span>
655
+ </button>
656
+ </div>
657
+ </div>
658
+ )}
659
+ </div>
405
660
  </div>
406
- </div>
407
661
  );
408
662
  })}
409
663
  </div>
@@ -413,35 +667,39 @@ export function UiGrid({
413
667
  <div
414
668
  className="filter-grid ui-grid-header"
415
669
  data-part="filters"
416
- style={{ gridTemplateColumns }}
670
+ ref={filterGridRef}
671
+ style={{
672
+ gridTemplateColumns,
673
+ ['--ui-grid-header-sticky-top' as string]: `${headerStickyHeight}px`,
674
+ }}
417
675
  >
418
676
  {visibleColumns.map((column) => {
419
677
  const pinned = state.isPinned(column);
420
678
  const pinOffset = pinned ? state.pinnedOffset(column) : null;
421
679
  return (
422
- <label
423
- key={column.name}
424
- className={`filter-cell ui-grid-filter-container${pinned ? ' is-pinned' : ''}`}
425
- data-part="filter-cell"
426
- style={{
427
- position: pinned ? 'sticky' : undefined,
428
- left: pinOffset?.side === 'left' ? pinOffset.offset : undefined,
429
- right: pinOffset?.side === 'right' ? pinOffset.offset : undefined,
430
- zIndex: pinned ? 2 : undefined,
431
- }}
432
- >
433
- <span className="sr-only ui-grid-sr-only">
434
- {labels.filterColumn} {state.headerLabel(column)}
435
- </span>
436
- <input
437
- className="ui-grid-filter-input"
438
- type="text"
439
- defaultValue={state.filterValue(column.name)}
440
- placeholder={state.filterPlaceholder(column)}
441
- disabled={state.isFilterInputDisabled(column)}
442
- onChange={(e) => state.updateFilter(column.name, e.target.value)}
443
- />
444
- </label>
680
+ <label
681
+ key={column.name}
682
+ className={`filter-cell ui-grid-filter-container${pinned ? ' is-pinned' : ''}`}
683
+ data-part="filter-cell"
684
+ style={{
685
+ position: pinned ? 'sticky' : undefined,
686
+ left: pinOffset?.side === 'left' ? pinOffset.offset : undefined,
687
+ right: pinOffset?.side === 'right' ? pinOffset.offset : undefined,
688
+ zIndex: pinned ? 2 : undefined,
689
+ }}
690
+ >
691
+ <span className="sr-only ui-grid-sr-only">
692
+ {labels.filterColumn} {state.headerLabel(column)}
693
+ </span>
694
+ <input
695
+ className="ui-grid-filter-input"
696
+ type="text"
697
+ defaultValue={state.filterValue(column.name)}
698
+ placeholder={state.filterPlaceholder(column)}
699
+ disabled={state.isFilterInputDisabled(column)}
700
+ onChange={(e) => state.updateFilter(column.name, e.target.value)}
701
+ />
702
+ </label>
445
703
  );
446
704
  })}
447
705
  </div>
@@ -451,28 +709,21 @@ export function UiGrid({
451
709
  {displayItems.length > 0 ? (
452
710
  virtualizationEnabled ? (
453
711
  <div
454
- className="grid-viewport ui-grid-viewport"
455
- data-part="viewport"
456
- ref={virtualScroll.viewportRef}
457
- style={{ height: viewportHeightPx, overflow: 'auto', position: 'relative' }}
458
- onScroll={onViewportScroll}
712
+ className="grid-virtual-spacer"
713
+ style={{ height: `${virtualScroll.totalHeight}px` }}
459
714
  >
460
- <div style={{ height: `${virtualScroll.totalHeight}px`, position: 'relative' }}>
461
- <div
462
- className="body-grid ui-grid-canvas"
463
- data-part="body"
464
- role="rowgroup"
465
- style={{
466
- gridTemplateColumns,
467
- position: 'absolute',
468
- top: 0,
469
- left: 0,
470
- right: 0,
471
- transform: `translateY(${virtualScroll.offsetY}px)`,
472
- }}
473
- >
474
- {itemsToRender.map(renderDisplayItem)}
475
- </div>
715
+ <div
716
+ className="body-grid ui-grid-canvas grid-virtual-body"
717
+ data-part="body"
718
+ role="rowgroup"
719
+ style={{
720
+ gridTemplateColumns,
721
+ position: 'absolute',
722
+ top: `${virtualScroll.offsetY}px`,
723
+ left: 0,
724
+ }}
725
+ >
726
+ {itemsToRender.map(renderDisplayItem)}
476
727
  </div>
477
728
  </div>
478
729
  ) : (