@ornery/ui-grid-react 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/UiGrid.d.ts +11 -0
- package/dist/UiGrid.d.ts.map +1 -0
- package/dist/gridStateMath.d.ts +8 -0
- package/dist/gridStateMath.d.ts.map +1 -0
- package/dist/index.d.ts +13 -133
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1248 -1202
- package/dist/index.mjs +1198 -1131
- package/dist/mountUiGrid.d.ts +4 -0
- package/dist/mountUiGrid.d.ts.map +1 -0
- package/dist/rustWasmGridEngine.d.ts +8 -0
- package/dist/rustWasmGridEngine.d.ts.map +1 -0
- package/dist/{index.d.mts → useGridState.d.ts} +14 -37
- package/dist/useGridState.d.ts.map +1 -0
- package/dist/useVirtualScroll.d.ts +20 -0
- package/dist/useVirtualScroll.d.ts.map +1 -0
- package/dist/virtualScrollMath.d.ts +17 -0
- package/dist/virtualScrollMath.d.ts.map +1 -0
- package/package.json +3 -3
- package/src/UiGrid.test.tsx +2 -1
- package/src/UiGrid.tsx +330 -74
- package/src/gridStateMath.test.ts +49 -0
- package/src/gridStateMath.ts +32 -0
- package/src/index.ts +3 -0
- package/src/mountUiGrid.tsx +10 -0
- package/src/rustWasmGridEngine.test.ts +56 -0
- package/src/rustWasmGridEngine.ts +23 -0
- package/src/ui-grid.css +161 -1
- package/src/useGridState.ts +664 -343
- package/src/useVirtualScroll.ts +13 -10
- package/src/virtualScrollMath.test.ts +44 -0
- package/src/virtualScrollMath.ts +36 -0
- package/tsconfig.build.json +6 -0
- package/tsconfig.dts.json +15 -0
- package/CLAUDE.md +0 -283
package/src/UiGrid.tsx
CHANGED
|
@@ -19,7 +19,13 @@ export interface UiGridProps {
|
|
|
19
19
|
className?: string;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
export function UiGrid({
|
|
22
|
+
export function UiGrid({
|
|
23
|
+
options,
|
|
24
|
+
onRegisterApi,
|
|
25
|
+
cellRenderer,
|
|
26
|
+
expandableRenderer,
|
|
27
|
+
className,
|
|
28
|
+
}: UiGridProps) {
|
|
23
29
|
const state = useGridState(options, onRegisterApi);
|
|
24
30
|
|
|
25
31
|
const {
|
|
@@ -57,13 +63,103 @@ export function UiGrid({ options, onRegisterApi, cellRenderer, expandableRendere
|
|
|
57
63
|
overscan: 3,
|
|
58
64
|
});
|
|
59
65
|
|
|
66
|
+
const headerGridRef = React.useRef<HTMLDivElement | null>(null);
|
|
67
|
+
const filterGridRef = React.useRef<HTMLDivElement | null>(null);
|
|
68
|
+
const [openPinMenuColumn, setOpenPinMenuColumn] = React.useState<string | null>(null);
|
|
69
|
+
const [headerStickyHeight, setHeaderStickyHeight] = React.useState(0);
|
|
70
|
+
const [filterStickyHeight, setFilterStickyHeight] = React.useState(0);
|
|
71
|
+
const stickyChromeHeight = headerStickyHeight + filterStickyHeight;
|
|
72
|
+
const scrollContainerHeight = `${(options.viewportHeight ?? 560) + stickyChromeHeight}px`;
|
|
73
|
+
|
|
74
|
+
const eventPathIncludesClass = React.useCallback((event: Event, className: string): boolean => {
|
|
75
|
+
const eventPath = typeof event.composedPath === 'function'
|
|
76
|
+
? event.composedPath()
|
|
77
|
+
: (event.target ? [event.target] : []);
|
|
78
|
+
|
|
79
|
+
return eventPath.some((target) => {
|
|
80
|
+
if (!target || typeof target !== 'object' || !('classList' in target)) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const classList = (target as { classList?: DOMTokenList }).classList;
|
|
85
|
+
return classList?.contains(className) ?? false;
|
|
86
|
+
});
|
|
87
|
+
}, []);
|
|
88
|
+
|
|
89
|
+
const isPinMenuOpen = React.useCallback(
|
|
90
|
+
(column: GridColumnDef) => openPinMenuColumn === column.name,
|
|
91
|
+
[openPinMenuColumn],
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const pinButtonLabel = React.useCallback(
|
|
95
|
+
(column: GridColumnDef) => (state.isPinned(column) ? labels.unpin : labels.pinColumn),
|
|
96
|
+
[labels, state],
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const onPinTrigger = React.useCallback(
|
|
100
|
+
(column: GridColumnDef, event?: React.MouseEvent) => {
|
|
101
|
+
event?.stopPropagation();
|
|
102
|
+
if (state.isPinned(column)) {
|
|
103
|
+
setOpenPinMenuColumn(null);
|
|
104
|
+
state.gridApi.pinning.pinColumn(column.name, 'none');
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
setOpenPinMenuColumn((current) => (current === column.name ? null : column.name));
|
|
109
|
+
},
|
|
110
|
+
[state],
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const choosePinDirection = React.useCallback(
|
|
114
|
+
(column: GridColumnDef, direction: 'left' | 'right', event?: React.MouseEvent) => {
|
|
115
|
+
event?.stopPropagation();
|
|
116
|
+
setOpenPinMenuColumn(null);
|
|
117
|
+
state.gridApi.pinning.pinColumn(column.name, direction);
|
|
118
|
+
},
|
|
119
|
+
[state],
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
React.useLayoutEffect(() => {
|
|
123
|
+
setHeaderStickyHeight(headerGridRef.current?.offsetHeight ?? 0);
|
|
124
|
+
setFilterStickyHeight(filterGridRef.current?.offsetHeight ?? 0);
|
|
125
|
+
}, [visibleColumns, filteringFeature, options.enableFiltering]);
|
|
126
|
+
|
|
127
|
+
React.useEffect(() => {
|
|
128
|
+
if (!openPinMenuColumn) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const handleDocumentClick = (event: MouseEvent) => {
|
|
133
|
+
if (eventPathIncludesClass(event, 'pin-control')) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
setOpenPinMenuColumn(null);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const handleDocumentEscape = (event: KeyboardEvent) => {
|
|
141
|
+
if (event.key === 'Escape') {
|
|
142
|
+
setOpenPinMenuColumn(null);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
document.addEventListener('click', handleDocumentClick);
|
|
147
|
+
document.addEventListener('keydown', handleDocumentEscape);
|
|
148
|
+
|
|
149
|
+
return () => {
|
|
150
|
+
document.removeEventListener('click', handleDocumentClick);
|
|
151
|
+
document.removeEventListener('keydown', handleDocumentEscape);
|
|
152
|
+
};
|
|
153
|
+
}, [eventPathIncludesClass, openPinMenuColumn]);
|
|
154
|
+
|
|
60
155
|
const itemsToRender = virtualizationEnabled
|
|
61
156
|
? displayItems.slice(virtualScroll.visibleRange.start, virtualScroll.visibleRange.end)
|
|
62
157
|
: displayItems;
|
|
63
158
|
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
159
|
+
const onGridTableScroll = (event: React.UIEvent<HTMLDivElement>) => {
|
|
160
|
+
const bodyScrollTop = Math.max(0, event.currentTarget.scrollTop - stickyChromeHeight);
|
|
161
|
+
virtualScroll.setScrollTop(bodyScrollTop);
|
|
162
|
+
const startIndex = Math.floor(bodyScrollTop / rowSize);
|
|
67
163
|
state.onViewportScroll(startIndex);
|
|
68
164
|
};
|
|
69
165
|
|
|
@@ -80,9 +176,18 @@ export function UiGrid({ options, onRegisterApi, cellRenderer, expandableRendere
|
|
|
80
176
|
style={{ gridColumn: '1 / -1', paddingInlineStart: `${item.depth * 1.25 + 1}rem` }}
|
|
81
177
|
onClick={() => state.toggleGroup(item)}
|
|
82
178
|
>
|
|
83
|
-
<strong>
|
|
84
|
-
|
|
85
|
-
|
|
179
|
+
<strong>
|
|
180
|
+
{item.field}: {item.label}
|
|
181
|
+
</strong>
|
|
182
|
+
<span>
|
|
183
|
+
{item.count} {labels.groupRowsSuffix}
|
|
184
|
+
</span>
|
|
185
|
+
<svg
|
|
186
|
+
className="toggle-icon group-disclosure-icon"
|
|
187
|
+
viewBox="0 0 24 24"
|
|
188
|
+
aria-hidden="true"
|
|
189
|
+
focusable={false}
|
|
190
|
+
>
|
|
86
191
|
<path d={item.collapsed ? 'M10 7l5 5-5 5z' : 'M7 10l5 5 5-5z'} />
|
|
87
192
|
</svg>
|
|
88
193
|
<span className="sr-only ui-grid-sr-only">{state.groupDisclosureLabel(item)}</span>
|
|
@@ -107,10 +212,13 @@ export function UiGrid({ options, onRegisterApi, cellRenderer, expandableRendere
|
|
|
107
212
|
if (item.kind !== 'row') return null;
|
|
108
213
|
const rowItem = item as RowItem;
|
|
109
214
|
|
|
110
|
-
return visibleColumns.map((column) =>
|
|
215
|
+
return visibleColumns.map((column) => {
|
|
216
|
+
const pinned = state.isPinned(column);
|
|
217
|
+
const pinOffset = pinned ? state.pinnedOffset(column) : null;
|
|
218
|
+
return (
|
|
111
219
|
<div
|
|
112
220
|
key={`${rowItem.row.id}-${column.name}`}
|
|
113
|
-
className={cellClassName(rowItem, column)}
|
|
221
|
+
className={`${cellClassName(rowItem, column)}${pinned ? ' is-pinned' : ''}`}
|
|
114
222
|
data-part="body-cell"
|
|
115
223
|
role="gridcell"
|
|
116
224
|
tabIndex={0}
|
|
@@ -120,8 +228,17 @@ export function UiGrid({ options, onRegisterApi, cellRenderer, expandableRendere
|
|
|
120
228
|
onClick={() => state.focusCell(rowItem.row, column)}
|
|
121
229
|
onDoubleClick={(e) => state.handleCellDoubleClick(rowItem.row, column, e)}
|
|
122
230
|
onKeyDown={(e) => state.handleCellKeyDown(rowItem.row, column, e)}
|
|
231
|
+
style={{
|
|
232
|
+
position: pinned ? 'sticky' : undefined,
|
|
233
|
+
left: pinOffset?.side === 'left' ? pinOffset.offset : undefined,
|
|
234
|
+
right: pinOffset?.side === 'right' ? pinOffset.offset : undefined,
|
|
235
|
+
zIndex: pinned ? 2 : undefined,
|
|
236
|
+
}}
|
|
123
237
|
>
|
|
124
|
-
<div
|
|
238
|
+
<div
|
|
239
|
+
className="cell-shell"
|
|
240
|
+
style={{ paddingInlineStart: state.cellIndent(rowItem.row, column) }}
|
|
241
|
+
>
|
|
125
242
|
{treeViewFeature && state.showTreeToggle(rowItem.row, column) && (
|
|
126
243
|
<button
|
|
127
244
|
type="button"
|
|
@@ -132,7 +249,9 @@ export function UiGrid({ options, onRegisterApi, cellRenderer, expandableRendere
|
|
|
132
249
|
onClick={(e) => state.toggleTreeRow(rowItem.row, e)}
|
|
133
250
|
>
|
|
134
251
|
<svg className="toggle-icon" viewBox="0 0 24 24" aria-hidden="true" focusable={false}>
|
|
135
|
-
<path
|
|
252
|
+
<path
|
|
253
|
+
d={state.isTreeRowExpanded(rowItem.row) ? 'M7 10l5 5 5-5z' : 'M10 7l5 5-5 5z'}
|
|
254
|
+
/>
|
|
136
255
|
</svg>
|
|
137
256
|
</button>
|
|
138
257
|
)}
|
|
@@ -164,14 +283,16 @@ export function UiGrid({ options, onRegisterApi, cellRenderer, expandableRendere
|
|
|
164
283
|
onBlur={(e) => state.handleEditorBlur(e)}
|
|
165
284
|
/>
|
|
166
285
|
) : cellRenderer ? (
|
|
167
|
-
cellRenderer(state.cellContext(rowItem.row, column)) ??
|
|
286
|
+
(cellRenderer(state.cellContext(rowItem.row, column)) ??
|
|
287
|
+
state.displayValue(rowItem.row, column))
|
|
168
288
|
) : (
|
|
169
289
|
state.displayValue(rowItem.row, column)
|
|
170
290
|
)}
|
|
171
291
|
</span>
|
|
172
292
|
</div>
|
|
173
293
|
</div>
|
|
174
|
-
|
|
294
|
+
);
|
|
295
|
+
});
|
|
175
296
|
}
|
|
176
297
|
|
|
177
298
|
function cellClassName(item: RowItem, column: GridColumnDef): string {
|
|
@@ -222,11 +343,21 @@ export function UiGrid({ options, onRegisterApi, cellRenderer, expandableRendere
|
|
|
222
343
|
</div>
|
|
223
344
|
|
|
224
345
|
<div className="hero-actions">
|
|
225
|
-
<button
|
|
346
|
+
<button
|
|
347
|
+
type="button"
|
|
348
|
+
className="action action-secondary"
|
|
349
|
+
data-part="action benchmark-action"
|
|
350
|
+
onClick={() => state.runBenchmark()}
|
|
351
|
+
>
|
|
226
352
|
Benchmark
|
|
227
353
|
</button>
|
|
228
354
|
{csvExportFeature && (
|
|
229
|
-
<button
|
|
355
|
+
<button
|
|
356
|
+
type="button"
|
|
357
|
+
className="action action-secondary"
|
|
358
|
+
data-part="action export-action"
|
|
359
|
+
onClick={() => state.exportCsv()}
|
|
360
|
+
>
|
|
230
361
|
Export CSV
|
|
231
362
|
</button>
|
|
232
363
|
)}
|
|
@@ -237,7 +368,11 @@ export function UiGrid({ options, onRegisterApi, cellRenderer, expandableRendere
|
|
|
237
368
|
</div>
|
|
238
369
|
</header>
|
|
239
370
|
|
|
240
|
-
<section
|
|
371
|
+
<section
|
|
372
|
+
className="metrics-strip"
|
|
373
|
+
data-part="metrics"
|
|
374
|
+
aria-label="Grid performance metrics"
|
|
375
|
+
>
|
|
241
376
|
<article data-part="metric-card">
|
|
242
377
|
<strong>{pipelineMs.toFixed(2)} ms</strong>
|
|
243
378
|
<span>pipeline</span>
|
|
@@ -256,33 +391,56 @@ export function UiGrid({ options, onRegisterApi, cellRenderer, expandableRendere
|
|
|
256
391
|
</article>
|
|
257
392
|
</section>
|
|
258
393
|
|
|
259
|
-
<section
|
|
394
|
+
<section
|
|
395
|
+
className="grid-frame ui-grid"
|
|
396
|
+
data-part="grid-frame"
|
|
397
|
+
role="grid"
|
|
398
|
+
aria-label={options.title ?? 'Data grid'}
|
|
399
|
+
>
|
|
260
400
|
<div className="grid-toolbar" data-part="grid-toolbar">
|
|
261
401
|
<div>
|
|
262
402
|
<strong>{visibleRowCount}</strong>
|
|
263
|
-
<span>
|
|
403
|
+
<span>
|
|
404
|
+
{labels.toolbarOf} {totalRows} {labels.toolbarRows}
|
|
405
|
+
</span>
|
|
264
406
|
</div>
|
|
265
407
|
<p>
|
|
266
|
-
`gridOptions` compatibility layer: sorting, filtering, grouping, column moving,
|
|
267
|
-
and virtualized rendering.
|
|
408
|
+
`gridOptions` compatibility layer: sorting, filtering, grouping, column moving,
|
|
409
|
+
templating, and virtualized rendering.
|
|
268
410
|
</p>
|
|
269
411
|
</div>
|
|
270
412
|
|
|
271
|
-
<div
|
|
413
|
+
<div
|
|
414
|
+
className="grid-table ui-grid-contents-wrapper"
|
|
415
|
+
data-part="grid-table"
|
|
416
|
+
style={virtualizationEnabled ? { height: scrollContainerHeight, overflowY: 'auto' } : undefined}
|
|
417
|
+
onScroll={virtualizationEnabled ? onGridTableScroll : undefined}
|
|
418
|
+
>
|
|
272
419
|
{/* Header row */}
|
|
273
420
|
<div
|
|
274
421
|
className="header-grid ui-grid-header ui-grid-header-canvas"
|
|
275
422
|
data-part="header"
|
|
276
423
|
role="row"
|
|
424
|
+
ref={headerGridRef}
|
|
277
425
|
style={{ gridTemplateColumns }}
|
|
278
426
|
>
|
|
279
|
-
{visibleColumns.map((column) =>
|
|
427
|
+
{visibleColumns.map((column) => {
|
|
428
|
+
const pinned = state.isPinned(column);
|
|
429
|
+
const pinOffset = pinned ? state.pinnedOffset(column) : null;
|
|
430
|
+
const pinMenuOpen = isPinMenuOpen(column);
|
|
431
|
+
return (
|
|
280
432
|
<div
|
|
281
433
|
key={column.name}
|
|
282
|
-
className={`header-cell ui-grid-header-cell${sortingFeature && state.sortDirection(column) !== 'none' ? ' is-active' : ''}`}
|
|
434
|
+
className={`header-cell ui-grid-header-cell${sortingFeature && state.sortDirection(column) !== 'none' ? ' is-active' : ''}${pinned ? ' is-pinned' : ''}${pinMenuOpen ? ' is-pin-menu-open' : ''}`}
|
|
283
435
|
data-part="header-cell"
|
|
284
436
|
role="columnheader"
|
|
285
|
-
aria-sort={sortingFeature ? state.sortAriaSort(column) as any : undefined}
|
|
437
|
+
aria-sort={sortingFeature ? (state.sortAriaSort(column) as any) : undefined}
|
|
438
|
+
style={{
|
|
439
|
+
position: pinned ? 'sticky' : undefined,
|
|
440
|
+
left: pinOffset?.side === 'left' ? pinOffset.offset : undefined,
|
|
441
|
+
right: pinOffset?.side === 'right' ? pinOffset.offset : undefined,
|
|
442
|
+
zIndex: pinMenuOpen ? 8 : pinned ? 2 : undefined,
|
|
443
|
+
}}
|
|
286
444
|
>
|
|
287
445
|
<span className="header-label">{state.headerLabel(column)}</span>
|
|
288
446
|
|
|
@@ -297,36 +455,123 @@ export function UiGrid({ options, onRegisterApi, cellRenderer, expandableRendere
|
|
|
297
455
|
onClick={() => state.toggleSort(column)}
|
|
298
456
|
>
|
|
299
457
|
{renderSortIcon(column)}
|
|
300
|
-
<span className="sr-only ui-grid-sr-only">
|
|
458
|
+
<span className="sr-only ui-grid-sr-only">
|
|
459
|
+
{state.sortButtonLabel(column)}
|
|
460
|
+
</span>
|
|
301
461
|
</button>
|
|
302
462
|
)}
|
|
303
463
|
|
|
304
|
-
{groupingFeature &&
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
464
|
+
{groupingFeature &&
|
|
465
|
+
state.isGroupingEnabled() &&
|
|
466
|
+
column.enableGrouping !== false && (
|
|
467
|
+
<button
|
|
468
|
+
type="button"
|
|
469
|
+
className={`chip-action${state.isGrouped(column) ? ' chip-action-active' : ''}`}
|
|
470
|
+
data-part="group-toggle"
|
|
471
|
+
aria-label={state.groupingButtonLabel(column)}
|
|
472
|
+
title={state.groupingButtonLabel(column)}
|
|
473
|
+
onClick={(e) => state.toggleGrouping(column, e)}
|
|
474
|
+
>
|
|
475
|
+
<svg viewBox="0 0 24 24" aria-hidden="true" focusable={false}>
|
|
476
|
+
<path d="M4 6h8v4H4V6Zm0 8h8v4H4v-4Zm10-8h6v4h-6V6Zm0 8h6v4h-6v-4Z" />
|
|
477
|
+
</svg>
|
|
478
|
+
<span className="sr-only ui-grid-sr-only">
|
|
479
|
+
{state.groupingButtonLabel(column)}
|
|
480
|
+
</span>
|
|
481
|
+
</button>
|
|
482
|
+
)}
|
|
483
|
+
{state.pinningFeature &&
|
|
484
|
+
state.isPinningEnabled() &&
|
|
485
|
+
state.isColumnPinnable(column) && (
|
|
486
|
+
<div className={`pin-control${pinMenuOpen ? ' pin-control-open' : ''}`} onClick={(event) => event.stopPropagation()}>
|
|
487
|
+
<button
|
|
488
|
+
type="button"
|
|
489
|
+
className={`chip-action pin-trigger${pinned || pinMenuOpen ? ' chip-action-active' : ''}`}
|
|
490
|
+
data-part="pin-toggle"
|
|
491
|
+
aria-label={pinButtonLabel(column)}
|
|
492
|
+
title={pinButtonLabel(column)}
|
|
493
|
+
aria-haspopup={pinned ? undefined : 'menu'}
|
|
494
|
+
aria-expanded={pinned ? undefined : pinMenuOpen}
|
|
495
|
+
onClick={(event) => onPinTrigger(column, event)}
|
|
496
|
+
>
|
|
497
|
+
<svg viewBox="0 0 24 24" aria-hidden="true" focusable={false}>
|
|
498
|
+
<path d="M16 12V4h1V2H7v2h1v8l-2 2v2h5v6l1 1 1-1v-6h5v-2l-2-2z" />
|
|
499
|
+
</svg>
|
|
500
|
+
<span className="sr-only ui-grid-sr-only">{pinButtonLabel(column)}</span>
|
|
501
|
+
</button>
|
|
502
|
+
|
|
503
|
+
<div
|
|
504
|
+
className="pin-menu"
|
|
505
|
+
data-part="pin-menu"
|
|
506
|
+
role="menu"
|
|
507
|
+
aria-label="Pin options"
|
|
508
|
+
aria-hidden={!pinMenuOpen}
|
|
509
|
+
>
|
|
510
|
+
<button
|
|
511
|
+
type="button"
|
|
512
|
+
className="pin-menu-action"
|
|
513
|
+
data-part="pin-left-action"
|
|
514
|
+
role="menuitem"
|
|
515
|
+
aria-label={labels.pinLeft}
|
|
516
|
+
title={labels.pinLeft}
|
|
517
|
+
tabIndex={pinMenuOpen ? 0 : -1}
|
|
518
|
+
onClick={(event) => choosePinDirection(column, 'left', event)}
|
|
519
|
+
>
|
|
520
|
+
<svg viewBox="0 0 24 24" aria-hidden="true" focusable={false}>
|
|
521
|
+
<path d="M10 6 4 12l6 6v-4h10v-4H10V6z" />
|
|
522
|
+
</svg>
|
|
523
|
+
<span className="sr-only ui-grid-sr-only">{labels.pinLeft}</span>
|
|
524
|
+
</button>
|
|
525
|
+
<button
|
|
526
|
+
type="button"
|
|
527
|
+
className="pin-menu-action"
|
|
528
|
+
data-part="pin-right-action"
|
|
529
|
+
role="menuitem"
|
|
530
|
+
aria-label={labels.pinRight}
|
|
531
|
+
title={labels.pinRight}
|
|
532
|
+
tabIndex={pinMenuOpen ? 0 : -1}
|
|
533
|
+
onClick={(event) => choosePinDirection(column, 'right', event)}
|
|
534
|
+
>
|
|
535
|
+
<svg viewBox="0 0 24 24" aria-hidden="true" focusable={false}>
|
|
536
|
+
<path d="M14 6v4H4v4h10v4l6-6-6-6z" />
|
|
537
|
+
</svg>
|
|
538
|
+
<span className="sr-only ui-grid-sr-only">{labels.pinRight}</span>
|
|
539
|
+
</button>
|
|
540
|
+
</div>
|
|
541
|
+
</div>
|
|
542
|
+
)}
|
|
319
543
|
</div>
|
|
320
544
|
</div>
|
|
321
|
-
|
|
545
|
+
);
|
|
546
|
+
})}
|
|
322
547
|
</div>
|
|
323
548
|
|
|
324
549
|
{/* Filter row */}
|
|
325
550
|
{filteringFeature && state.isFilteringEnabled() && (
|
|
326
|
-
<div
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
551
|
+
<div
|
|
552
|
+
className="filter-grid ui-grid-header"
|
|
553
|
+
data-part="filters"
|
|
554
|
+
ref={filterGridRef}
|
|
555
|
+
style={{ gridTemplateColumns, ['--ui-grid-header-sticky-top' as string]: `${headerStickyHeight}px` }}
|
|
556
|
+
>
|
|
557
|
+
{visibleColumns.map((column) => {
|
|
558
|
+
const pinned = state.isPinned(column);
|
|
559
|
+
const pinOffset = pinned ? state.pinnedOffset(column) : null;
|
|
560
|
+
return (
|
|
561
|
+
<label
|
|
562
|
+
key={column.name}
|
|
563
|
+
className={`filter-cell ui-grid-filter-container${pinned ? ' is-pinned' : ''}`}
|
|
564
|
+
data-part="filter-cell"
|
|
565
|
+
style={{
|
|
566
|
+
position: pinned ? 'sticky' : undefined,
|
|
567
|
+
left: pinOffset?.side === 'left' ? pinOffset.offset : undefined,
|
|
568
|
+
right: pinOffset?.side === 'right' ? pinOffset.offset : undefined,
|
|
569
|
+
zIndex: pinned ? 2 : undefined,
|
|
570
|
+
}}
|
|
571
|
+
>
|
|
572
|
+
<span className="sr-only ui-grid-sr-only">
|
|
573
|
+
{labels.filterColumn} {state.headerLabel(column)}
|
|
574
|
+
</span>
|
|
330
575
|
<input
|
|
331
576
|
className="ui-grid-filter-input"
|
|
332
577
|
type="text"
|
|
@@ -336,36 +581,27 @@ export function UiGrid({ options, onRegisterApi, cellRenderer, expandableRendere
|
|
|
336
581
|
onChange={(e) => state.updateFilter(column.name, e.target.value)}
|
|
337
582
|
/>
|
|
338
583
|
</label>
|
|
339
|
-
|
|
584
|
+
);
|
|
585
|
+
})}
|
|
340
586
|
</div>
|
|
341
587
|
)}
|
|
342
588
|
|
|
343
589
|
{/* Body */}
|
|
344
590
|
{displayItems.length > 0 ? (
|
|
345
591
|
virtualizationEnabled ? (
|
|
346
|
-
<div
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
gridTemplateColumns,
|
|
360
|
-
position: 'absolute',
|
|
361
|
-
top: 0,
|
|
362
|
-
left: 0,
|
|
363
|
-
right: 0,
|
|
364
|
-
transform: `translateY(${virtualScroll.offsetY}px)`,
|
|
365
|
-
}}
|
|
366
|
-
>
|
|
367
|
-
{itemsToRender.map(renderDisplayItem)}
|
|
368
|
-
</div>
|
|
592
|
+
<div className="grid-virtual-spacer" style={{ height: `${virtualScroll.totalHeight}px` }}>
|
|
593
|
+
<div
|
|
594
|
+
className="body-grid ui-grid-canvas grid-virtual-body"
|
|
595
|
+
data-part="body"
|
|
596
|
+
role="rowgroup"
|
|
597
|
+
style={{
|
|
598
|
+
gridTemplateColumns,
|
|
599
|
+
position: 'absolute',
|
|
600
|
+
top: `${virtualScroll.offsetY}px`,
|
|
601
|
+
left: 0,
|
|
602
|
+
}}
|
|
603
|
+
>
|
|
604
|
+
{itemsToRender.map(renderDisplayItem)}
|
|
369
605
|
</div>
|
|
370
606
|
</div>
|
|
371
607
|
) : (
|
|
@@ -387,7 +623,12 @@ export function UiGrid({ options, onRegisterApi, cellRenderer, expandableRendere
|
|
|
387
623
|
|
|
388
624
|
{/* Pagination footer */}
|
|
389
625
|
{paginationFeature && state.showPaginationControls() && (
|
|
390
|
-
<footer
|
|
626
|
+
<footer
|
|
627
|
+
className="pagination-bar ui-grid-pagination"
|
|
628
|
+
data-part="pagination"
|
|
629
|
+
role="navigation"
|
|
630
|
+
aria-label={labels.paginationPage}
|
|
631
|
+
>
|
|
391
632
|
<p>{state.paginationSummary()}</p>
|
|
392
633
|
<div className="pagination-controls">
|
|
393
634
|
<button
|
|
@@ -397,12 +638,20 @@ export function UiGrid({ options, onRegisterApi, cellRenderer, expandableRendere
|
|
|
397
638
|
disabled={paginationCurrentPage <= 1}
|
|
398
639
|
onClick={() => state.previousPage()}
|
|
399
640
|
>
|
|
400
|
-
<svg
|
|
641
|
+
<svg
|
|
642
|
+
className="pagination-icon"
|
|
643
|
+
viewBox="0 0 24 24"
|
|
644
|
+
aria-hidden="true"
|
|
645
|
+
focusable={false}
|
|
646
|
+
>
|
|
401
647
|
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z" />
|
|
402
648
|
</svg>
|
|
403
649
|
<span className="sr-only">{labels.paginationPrevious}</span>
|
|
404
650
|
</button>
|
|
405
|
-
<span>
|
|
651
|
+
<span>
|
|
652
|
+
{labels.paginationPage} {paginationCurrentPage} {labels.paginationOf}{' '}
|
|
653
|
+
{paginationTotalPages}
|
|
654
|
+
</span>
|
|
406
655
|
<button
|
|
407
656
|
type="button"
|
|
408
657
|
className="action action-secondary pagination-button"
|
|
@@ -410,7 +659,12 @@ export function UiGrid({ options, onRegisterApi, cellRenderer, expandableRendere
|
|
|
410
659
|
disabled={paginationCurrentPage >= paginationTotalPages}
|
|
411
660
|
onClick={() => state.nextPage()}
|
|
412
661
|
>
|
|
413
|
-
<svg
|
|
662
|
+
<svg
|
|
663
|
+
className="pagination-icon"
|
|
664
|
+
viewBox="0 0 24 24"
|
|
665
|
+
aria-hidden="true"
|
|
666
|
+
focusable={false}
|
|
667
|
+
>
|
|
414
668
|
<path d="M8.59 16.59L10 18l6-6-6-6-1.41 1.41L13.17 12z" />
|
|
415
669
|
</svg>
|
|
416
670
|
<span className="sr-only">{labels.paginationNext}</span>
|
|
@@ -424,7 +678,9 @@ export function UiGrid({ options, onRegisterApi, cellRenderer, expandableRendere
|
|
|
424
678
|
onChange={(e) => state.onPageSizeChange(e.target.value)}
|
|
425
679
|
>
|
|
426
680
|
{state.pageSizeOptions().map((size) => (
|
|
427
|
-
<option key={size} value={size}>
|
|
681
|
+
<option key={size} value={size}>
|
|
682
|
+
{size}
|
|
683
|
+
</option>
|
|
428
684
|
))}
|
|
429
685
|
</select>
|
|
430
686
|
</label>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { GridColumnDef } from '@ornery/ui-grid';
|
|
3
|
+
import {
|
|
4
|
+
buildGridTemplateColumns,
|
|
5
|
+
computeViewportHeightPx,
|
|
6
|
+
computeViewportRows,
|
|
7
|
+
formatPaginationSummary,
|
|
8
|
+
orderVisibleColumns,
|
|
9
|
+
resolveBenchmarkIterations,
|
|
10
|
+
} from './gridStateMath';
|
|
11
|
+
|
|
12
|
+
describe('gridStateMath', () => {
|
|
13
|
+
const columns: GridColumnDef[] = [
|
|
14
|
+
{ name: 'status', width: '2fr' },
|
|
15
|
+
{ name: 'owner', visible: false },
|
|
16
|
+
{ name: 'revenue', width: '120px' },
|
|
17
|
+
{ name: 'customer' },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
it('orders only visible columns by the provided column order', () => {
|
|
21
|
+
expect(orderVisibleColumns(columns, ['customer', 'revenue', 'status', 'owner']).map((column) => column.name)).toEqual([
|
|
22
|
+
'customer',
|
|
23
|
+
'revenue',
|
|
24
|
+
'status',
|
|
25
|
+
]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('builds grid template columns deterministically', () => {
|
|
29
|
+
expect(buildGridTemplateColumns(orderVisibleColumns(columns, ['status', 'revenue', 'customer']))).toBe('2fr 120px minmax(11rem, max-content)');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('resolves benchmark iterations with a minimum of one', () => {
|
|
33
|
+
expect(resolveBenchmarkIterations(undefined, undefined)).toBe(25);
|
|
34
|
+
expect(resolveBenchmarkIterations(undefined, 7)).toBe(7);
|
|
35
|
+
expect(resolveBenchmarkIterations(0, 7)).toBe(1);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('formats pagination summaries', () => {
|
|
39
|
+
expect(formatPaginationSummary(0, 0, 0)).toBe('0-0 of 0');
|
|
40
|
+
expect(formatPaginationSummary(42, 10, 19)).toBe('11-20 of 42');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('computes viewport height and viewport rows', () => {
|
|
44
|
+
expect(computeViewportHeightPx(undefined, undefined)).toBe('560px');
|
|
45
|
+
expect(computeViewportHeightPx(620, 480)).toBe('620px');
|
|
46
|
+
expect(computeViewportRows(undefined, undefined)).toBe(13);
|
|
47
|
+
expect(computeViewportRows(220, 44)).toBe(5);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { GridColumnDef } from '@ornery/ui-grid';
|
|
2
|
+
import { gridColumnWidth } from '@ornery/ui-grid';
|
|
3
|
+
|
|
4
|
+
export function orderVisibleColumns(columns: readonly GridColumnDef[], order: readonly string[]): GridColumnDef[] {
|
|
5
|
+
return [...columns]
|
|
6
|
+
.filter((column) => column.visible !== false)
|
|
7
|
+
.sort((left, right) => order.indexOf(left.name) - order.indexOf(right.name));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function buildGridTemplateColumns(columns: readonly GridColumnDef[]): string {
|
|
11
|
+
return columns.map((column) => gridColumnWidth(column)).join(' ');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function resolveBenchmarkIterations(iterations?: number, configuredIterations?: number): number {
|
|
15
|
+
return Math.max(1, iterations ?? configuredIterations ?? 25);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function formatPaginationSummary(totalItems: number, firstRowIndex: number, lastRowIndex: number): string {
|
|
19
|
+
if (totalItems === 0) {
|
|
20
|
+
return '0-0 of 0';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return `${firstRowIndex + 1}-${lastRowIndex + 1} of ${totalItems}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function computeViewportHeightPx(viewportHeight?: number, autoViewportHeight?: number | null): string {
|
|
27
|
+
return `${viewportHeight ?? autoViewportHeight ?? 560}px`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function computeViewportRows(viewportHeight?: number, rowHeight?: number): number {
|
|
31
|
+
return Math.max(1, Math.ceil((viewportHeight ?? 560) / (rowHeight ?? 44)));
|
|
32
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
export { UiGrid } from './UiGrid';
|
|
2
2
|
export type { UiGridProps } from './UiGrid';
|
|
3
|
+
export { mountUiGrid } from './mountUiGrid';
|
|
3
4
|
export { useGridState } from './useGridState';
|
|
4
5
|
export type { UseGridStateResult } from './useGridState';
|
|
5
6
|
export { useVirtualScroll } from './useVirtualScroll';
|
|
6
7
|
export type { UseVirtualScrollOptions, UseVirtualScrollResult } from './useVirtualScroll';
|
|
8
|
+
export { orderVisibleColumns, buildGridTemplateColumns, resolveBenchmarkIterations, formatPaginationSummary, computeViewportHeightPx, computeViewportRows } from './gridStateMath';
|
|
9
|
+
export { enableReactUiGridWasmEngine, registerReactUiGridWasmEngineFromModule } from './rustWasmGridEngine';
|
|
7
10
|
|
|
8
11
|
export type {
|
|
9
12
|
GridOptions,
|