@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/src/UiGrid.tsx CHANGED
@@ -19,7 +19,13 @@ export interface UiGridProps {
19
19
  className?: string;
20
20
  }
21
21
 
22
- export function UiGrid({ options, onRegisterApi, cellRenderer, expandableRenderer, className }: UiGridProps) {
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 onViewportScroll = (event: React.UIEvent<HTMLDivElement>) => {
65
- virtualScroll.onScroll(event);
66
- const startIndex = Math.floor(event.currentTarget.scrollTop / rowSize);
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>{item.field}: {item.label}</strong>
84
- <span>{item.count} {labels.groupRowsSuffix}</span>
85
- <svg className="toggle-icon group-disclosure-icon" viewBox="0 0 24 24" aria-hidden="true" focusable={false}>
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 className="cell-shell" style={{ paddingInlineStart: state.cellIndent(rowItem.row, column) }}>
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 d={state.isTreeRowExpanded(rowItem.row) ? 'M7 10l5 5 5-5z' : 'M10 7l5 5-5 5z'} />
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)) ?? state.displayValue(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 type="button" className="action action-secondary" data-part="action benchmark-action" onClick={() => state.runBenchmark()}>
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 type="button" className="action action-secondary" data-part="action export-action" onClick={() => state.exportCsv()}>
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 className="metrics-strip" data-part="metrics" aria-label="Grid performance metrics">
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 className="grid-frame ui-grid" data-part="grid-frame" role="grid" aria-label={options.title ?? 'Data grid'}>
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>{labels.toolbarOf} {totalRows} {labels.toolbarRows}</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, templating,
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 className="grid-table ui-grid-contents-wrapper" data-part="grid-table">
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">{state.sortButtonLabel(column)}</span>
458
+ <span className="sr-only ui-grid-sr-only">
459
+ {state.sortButtonLabel(column)}
460
+ </span>
301
461
  </button>
302
462
  )}
303
463
 
304
- {groupingFeature && state.isGroupingEnabled() && column.enableGrouping !== false && (
305
- <button
306
- type="button"
307
- className={`chip-action${state.isGrouped(column) ? ' chip-action-active' : ''}`}
308
- data-part="group-toggle"
309
- aria-label={state.groupingButtonLabel(column)}
310
- title={state.groupingButtonLabel(column)}
311
- onClick={(e) => state.toggleGrouping(column, e)}
312
- >
313
- <svg viewBox="0 0 24 24" aria-hidden="true" focusable={false}>
314
- <path d="M4 6h8v4H4V6Zm0 8h8v4H4v-4Zm10-8h6v4h-6V6Zm0 8h6v4h-6v-4Z" />
315
- </svg>
316
- <span className="sr-only ui-grid-sr-only">{state.groupingButtonLabel(column)}</span>
317
- </button>
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 className="filter-grid ui-grid-header" data-part="filters" style={{ gridTemplateColumns }}>
327
- {visibleColumns.map((column) => (
328
- <label key={column.name} className="filter-cell ui-grid-filter-container" data-part="filter-cell">
329
- <span className="sr-only ui-grid-sr-only">{labels.filterColumn} {state.headerLabel(column)}</span>
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
- className="grid-viewport ui-grid-viewport"
348
- data-part="viewport"
349
- ref={virtualScroll.viewportRef}
350
- style={{ height: viewportHeightPx, overflow: 'auto', position: 'relative' }}
351
- onScroll={onViewportScroll}
352
- >
353
- <div style={{ height: `${virtualScroll.totalHeight}px`, position: 'relative' }}>
354
- <div
355
- className="body-grid ui-grid-canvas"
356
- data-part="body"
357
- role="rowgroup"
358
- style={{
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 className="pagination-bar ui-grid-pagination" data-part="pagination" role="navigation" aria-label={labels.paginationPage}>
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 className="pagination-icon" viewBox="0 0 24 24" aria-hidden="true" focusable={false}>
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>{labels.paginationPage} {paginationCurrentPage} {labels.paginationOf} {paginationTotalPages}</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 className="pagination-icon" viewBox="0 0 24 24" aria-hidden="true" focusable={false}>
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}>{size}</option>
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,