@ornery/ui-grid-react 0.1.4

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 ADDED
@@ -0,0 +1,440 @@
1
+ import React from 'react';
2
+ import type {
3
+ GridOptions,
4
+ GridCellTemplateContext,
5
+ GridExpandableTemplateContext,
6
+ UiGridApi,
7
+ GridColumnDef,
8
+ GridRow,
9
+ } from '@ornery/ui-grid';
10
+ import type { DisplayItem, RowItem } from '@ornery/ui-grid';
11
+ import { useGridState } from './useGridState';
12
+ import { useVirtualScroll } from './useVirtualScroll';
13
+
14
+ export interface UiGridProps {
15
+ options: GridOptions;
16
+ onRegisterApi?: (api: UiGridApi) => void;
17
+ cellRenderer?: (context: GridCellTemplateContext) => React.ReactNode;
18
+ expandableRenderer?: (context: GridExpandableTemplateContext) => React.ReactNode;
19
+ className?: string;
20
+ }
21
+
22
+ export function UiGrid({ options, onRegisterApi, cellRenderer, expandableRenderer, className }: UiGridProps) {
23
+ const state = useGridState(options, onRegisterApi);
24
+
25
+ const {
26
+ pipeline,
27
+ visibleColumns,
28
+ labels,
29
+ gridTemplateColumns,
30
+ gridContainerRef,
31
+ displayItems,
32
+ virtualizationEnabled,
33
+ pipelineMs,
34
+ visibleRowCount,
35
+ totalRows,
36
+ benchmarkResult,
37
+ rowSize,
38
+ viewportHeightPx,
39
+ editingValue,
40
+ sortingFeature,
41
+ filteringFeature,
42
+ groupingFeature,
43
+ paginationFeature,
44
+ cellEditFeature,
45
+ expandableFeature,
46
+ treeViewFeature,
47
+ csvExportFeature,
48
+ paginationCurrentPage,
49
+ paginationTotalPages,
50
+ paginationSelectedPageSize,
51
+ } = state;
52
+
53
+ const virtualScroll = useVirtualScroll({
54
+ itemCount: displayItems.length,
55
+ itemSize: rowSize,
56
+ viewportHeight: options.viewportHeight ?? 560,
57
+ overscan: 3,
58
+ });
59
+
60
+ const itemsToRender = virtualizationEnabled
61
+ ? displayItems.slice(virtualScroll.visibleRange.start, virtualScroll.visibleRange.end)
62
+ : displayItems;
63
+
64
+ const onViewportScroll = (event: React.UIEvent<HTMLDivElement>) => {
65
+ virtualScroll.onScroll(event);
66
+ const startIndex = Math.floor(event.currentTarget.scrollTop / rowSize);
67
+ state.onViewportScroll(startIndex);
68
+ };
69
+
70
+ function renderDisplayItem(item: DisplayItem) {
71
+ if (groupingFeature && state.isGroupItem(item)) {
72
+ return (
73
+ <button
74
+ key={item.id}
75
+ type="button"
76
+ className="group-row ui-grid-row ui-grid-group-row"
77
+ data-part="group-row"
78
+ role="row"
79
+ aria-expanded={!item.collapsed}
80
+ style={{ gridColumn: '1 / -1', paddingInlineStart: `${item.depth * 1.25 + 1}rem` }}
81
+ onClick={() => state.toggleGroup(item)}
82
+ >
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}>
86
+ <path d={item.collapsed ? 'M10 7l5 5-5 5z' : 'M7 10l5 5 5-5z'} />
87
+ </svg>
88
+ <span className="sr-only ui-grid-sr-only">{state.groupDisclosureLabel(item)}</span>
89
+ </button>
90
+ );
91
+ }
92
+
93
+ if (expandableFeature && state.isExpandableItem(item)) {
94
+ const ctx = state.expandedContext(item.row);
95
+ return (
96
+ <div
97
+ key={item.id}
98
+ className="expandable-row ui-grid-row ui-grid-expandable-row"
99
+ data-part="expandable-row"
100
+ style={{ gridColumn: '1 / -1', minHeight: `${item.row.expandedRowHeight}px` }}
101
+ >
102
+ {expandableRenderer?.(ctx)}
103
+ </div>
104
+ );
105
+ }
106
+
107
+ if (item.kind !== 'row') return null;
108
+ const rowItem = item as RowItem;
109
+
110
+ return visibleColumns.map((column) => (
111
+ <div
112
+ key={`${rowItem.row.id}-${column.name}`}
113
+ className={cellClassName(rowItem, column)}
114
+ data-part="body-cell"
115
+ role="gridcell"
116
+ tabIndex={0}
117
+ data-row-id={rowItem.row.id}
118
+ data-col-name={column.name}
119
+ onFocus={() => state.focusCell(rowItem.row, column)}
120
+ onClick={() => state.focusCell(rowItem.row, column)}
121
+ onDoubleClick={(e) => state.handleCellDoubleClick(rowItem.row, column, e)}
122
+ onKeyDown={(e) => state.handleCellKeyDown(rowItem.row, column, e)}
123
+ >
124
+ <div className="cell-shell" style={{ paddingInlineStart: state.cellIndent(rowItem.row, column) }}>
125
+ {treeViewFeature && state.showTreeToggle(rowItem.row, column) && (
126
+ <button
127
+ type="button"
128
+ className="row-toggle row-toggle-tree"
129
+ data-part="tree-toggle"
130
+ aria-label={state.treeToggleLabel(rowItem.row)}
131
+ aria-expanded={state.isTreeRowExpanded(rowItem.row)}
132
+ onClick={(e) => state.toggleTreeRow(rowItem.row, e)}
133
+ >
134
+ <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'} />
136
+ </svg>
137
+ </button>
138
+ )}
139
+ {expandableFeature && state.showExpandToggle(rowItem.row, column) && (
140
+ <button
141
+ type="button"
142
+ className="row-toggle row-toggle-expand"
143
+ data-part="expand-toggle"
144
+ aria-label={state.expandToggleLabel(rowItem.row)}
145
+ aria-expanded={rowItem.row.expanded}
146
+ onClick={(e) => state.toggleRowExpansion(rowItem.row, e)}
147
+ >
148
+ <svg className="toggle-icon" viewBox="0 0 24 24" aria-hidden="true" focusable={false}>
149
+ <path d={rowItem.row.expanded ? 'M7 10l5 5 5-5z' : 'M10 7l5 5-5 5z'} />
150
+ </svg>
151
+ </button>
152
+ )}
153
+ <span className="cell-value">
154
+ {cellEditFeature && state.isEditingCell(rowItem.row, column) ? (
155
+ <input
156
+ className="cell-editor"
157
+ data-row-id={rowItem.row.id}
158
+ data-col-name={column.name}
159
+ aria-label={state.headerLabel(column)}
160
+ type={state.editorInputType(column)}
161
+ defaultValue={editingValue}
162
+ onChange={(e) => state.updateEditingValue(e.target.value)}
163
+ onKeyDown={(e) => state.handleEditorKeyDown(e)}
164
+ onBlur={(e) => state.handleEditorBlur(e)}
165
+ />
166
+ ) : cellRenderer ? (
167
+ cellRenderer(state.cellContext(rowItem.row, column)) ?? state.displayValue(rowItem.row, column)
168
+ ) : (
169
+ state.displayValue(rowItem.row, column)
170
+ )}
171
+ </span>
172
+ </div>
173
+ </div>
174
+ ));
175
+ }
176
+
177
+ function cellClassName(item: RowItem, column: GridColumnDef): string {
178
+ const classes = ['body-cell', 'ui-grid-cell'];
179
+ if (state.isOddStripedRow(item)) classes.push('body-cell-odd');
180
+ if (column.align === 'center') classes.push('align-center');
181
+ if (column.align === 'end') classes.push('align-end');
182
+ if (state.isFocusedCell(item.row, column)) classes.push('cell-focused');
183
+ if (cellEditFeature && state.isEditingCell(item.row, column)) classes.push('cell-editing');
184
+ return classes.join(' ');
185
+ }
186
+
187
+ function renderSortIcon(column: GridColumnDef) {
188
+ const direction = state.sortDirection(column);
189
+ switch (direction) {
190
+ case 'asc':
191
+ return (
192
+ <svg viewBox="0 0 24 24" aria-hidden="true" focusable={false}>
193
+ <path d="M12 5l-6 6h4v8h4v-8h4z" />
194
+ </svg>
195
+ );
196
+ case 'desc':
197
+ return (
198
+ <svg viewBox="0 0 24 24" aria-hidden="true" focusable={false}>
199
+ <path d="M12 19l6-6h-4V5h-4v8H6z" />
200
+ </svg>
201
+ );
202
+ default:
203
+ return (
204
+ <svg viewBox="0 0 24 24" aria-hidden="true" focusable={false}>
205
+ <path d="M7 6h10v2H7V6Zm0 5h7v2H7v-2Zm0 5h4v2H7v-2Z" />
206
+ </svg>
207
+ );
208
+ }
209
+ }
210
+
211
+ return (
212
+ <div className={`ui-grid-host ${className ?? ''}`} ref={gridContainerRef}>
213
+ <section className="grid-shell ui-grid-shell" data-part="shell">
214
+ <header className="grid-hero ui-grid-toolbar-shell" data-part="hero">
215
+ <div>
216
+ <p className="eyebrow">React wrapper for @ornery/ui-grid</p>
217
+ <h1>{options.title ?? 'UI Grid'}</h1>
218
+ <p className="deck">
219
+ Familiar `gridOptions` and `onRegisterApi`, built with React hooks, virtualization,
220
+ grouping, sorting, filtering, and column ordering.
221
+ </p>
222
+ </div>
223
+
224
+ <div className="hero-actions">
225
+ <button type="button" className="action action-secondary" data-part="action benchmark-action" onClick={() => state.runBenchmark()}>
226
+ Benchmark
227
+ </button>
228
+ {csvExportFeature && (
229
+ <button type="button" className="action action-secondary" data-part="action export-action" onClick={() => state.exportCsv()}>
230
+ Export CSV
231
+ </button>
232
+ )}
233
+ <div className="stats-card" data-part="stats-card">
234
+ <span>{visibleRowCount}</span>
235
+ <small>{labels.statsVisibleRows}</small>
236
+ </div>
237
+ </div>
238
+ </header>
239
+
240
+ <section className="metrics-strip" data-part="metrics" aria-label="Grid performance metrics">
241
+ <article data-part="metric-card">
242
+ <strong>{pipelineMs.toFixed(2)} ms</strong>
243
+ <span>pipeline</span>
244
+ </article>
245
+ <article data-part="metric-card">
246
+ <strong>{virtualizationEnabled ? 'On' : 'Off'}</strong>
247
+ <span>virtualization</span>
248
+ </article>
249
+ <article data-part="metric-card">
250
+ <strong>{state.groupByColumns.length}</strong>
251
+ <span>group columns</span>
252
+ </article>
253
+ <article data-part="metric-card">
254
+ <strong>{benchmarkResult?.averageMs?.toFixed(2) || '—'}</strong>
255
+ <span>benchmark avg</span>
256
+ </article>
257
+ </section>
258
+
259
+ <section className="grid-frame ui-grid" data-part="grid-frame" role="grid" aria-label={options.title ?? 'Data grid'}>
260
+ <div className="grid-toolbar" data-part="grid-toolbar">
261
+ <div>
262
+ <strong>{visibleRowCount}</strong>
263
+ <span>{labels.toolbarOf} {totalRows} {labels.toolbarRows}</span>
264
+ </div>
265
+ <p>
266
+ `gridOptions` compatibility layer: sorting, filtering, grouping, column moving, templating,
267
+ and virtualized rendering.
268
+ </p>
269
+ </div>
270
+
271
+ <div className="grid-table ui-grid-contents-wrapper" data-part="grid-table">
272
+ {/* Header row */}
273
+ <div
274
+ className="header-grid ui-grid-header ui-grid-header-canvas"
275
+ data-part="header"
276
+ role="row"
277
+ style={{ gridTemplateColumns }}
278
+ >
279
+ {visibleColumns.map((column) => (
280
+ <div
281
+ key={column.name}
282
+ className={`header-cell ui-grid-header-cell${sortingFeature && state.sortDirection(column) !== 'none' ? ' is-active' : ''}`}
283
+ data-part="header-cell"
284
+ role="columnheader"
285
+ aria-sort={sortingFeature ? state.sortAriaSort(column) as any : undefined}
286
+ >
287
+ <span className="header-label">{state.headerLabel(column)}</span>
288
+
289
+ <div className="header-actions">
290
+ {sortingFeature && (
291
+ <button
292
+ type="button"
293
+ className={`header-action${!state.isColumnSortable(column) ? ' header-action-disabled' : ''}`}
294
+ disabled={!state.isColumnSortable(column)}
295
+ aria-label={state.sortButtonLabel(column)}
296
+ title={state.sortButtonLabel(column)}
297
+ onClick={() => state.toggleSort(column)}
298
+ >
299
+ {renderSortIcon(column)}
300
+ <span className="sr-only ui-grid-sr-only">{state.sortButtonLabel(column)}</span>
301
+ </button>
302
+ )}
303
+
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
+ )}
319
+ </div>
320
+ </div>
321
+ ))}
322
+ </div>
323
+
324
+ {/* Filter row */}
325
+ {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>
330
+ <input
331
+ className="ui-grid-filter-input"
332
+ type="text"
333
+ defaultValue={state.filterValue(column.name)}
334
+ placeholder={state.filterPlaceholder(column)}
335
+ disabled={state.isFilterInputDisabled(column)}
336
+ onChange={(e) => state.updateFilter(column.name, e.target.value)}
337
+ />
338
+ </label>
339
+ ))}
340
+ </div>
341
+ )}
342
+
343
+ {/* Body */}
344
+ {displayItems.length > 0 ? (
345
+ 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>
369
+ </div>
370
+ </div>
371
+ ) : (
372
+ <div
373
+ className="body-grid ui-grid-canvas"
374
+ data-part="body"
375
+ role="rowgroup"
376
+ style={{ gridTemplateColumns }}
377
+ >
378
+ {displayItems.map(renderDisplayItem)}
379
+ </div>
380
+ )
381
+ ) : (
382
+ <div className="empty-state ui-grid-no-row-overlay" data-part="empty-state">
383
+ <strong>{options.emptyMessage ?? labels.emptyHeading}</strong>
384
+ <p>{labels.emptyDescription}</p>
385
+ </div>
386
+ )}
387
+
388
+ {/* Pagination footer */}
389
+ {paginationFeature && state.showPaginationControls() && (
390
+ <footer className="pagination-bar ui-grid-pagination" data-part="pagination" role="navigation" aria-label={labels.paginationPage}>
391
+ <p>{state.paginationSummary()}</p>
392
+ <div className="pagination-controls">
393
+ <button
394
+ type="button"
395
+ className="action action-secondary pagination-button"
396
+ aria-label={labels.paginationPrevious}
397
+ disabled={paginationCurrentPage <= 1}
398
+ onClick={() => state.previousPage()}
399
+ >
400
+ <svg className="pagination-icon" viewBox="0 0 24 24" aria-hidden="true" focusable={false}>
401
+ <path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z" />
402
+ </svg>
403
+ <span className="sr-only">{labels.paginationPrevious}</span>
404
+ </button>
405
+ <span>{labels.paginationPage} {paginationCurrentPage} {labels.paginationOf} {paginationTotalPages}</span>
406
+ <button
407
+ type="button"
408
+ className="action action-secondary pagination-button"
409
+ aria-label={labels.paginationNext}
410
+ disabled={paginationCurrentPage >= paginationTotalPages}
411
+ onClick={() => state.nextPage()}
412
+ >
413
+ <svg className="pagination-icon" viewBox="0 0 24 24" aria-hidden="true" focusable={false}>
414
+ <path d="M8.59 16.59L10 18l6-6-6-6-1.41 1.41L13.17 12z" />
415
+ </svg>
416
+ <span className="sr-only">{labels.paginationNext}</span>
417
+ </button>
418
+ {state.pageSizeOptions().length > 0 && (
419
+ <label className="pagination-size">
420
+ <span className="sr-only">{labels.paginationRows}</span>
421
+ <select
422
+ aria-label={labels.paginationRows}
423
+ value={paginationSelectedPageSize}
424
+ onChange={(e) => state.onPageSizeChange(e.target.value)}
425
+ >
426
+ {state.pageSizeOptions().map((size) => (
427
+ <option key={size} value={size}>{size}</option>
428
+ ))}
429
+ </select>
430
+ </label>
431
+ )}
432
+ </div>
433
+ </footer>
434
+ )}
435
+ </div>
436
+ </section>
437
+ </section>
438
+ </div>
439
+ );
440
+ }
package/src/index.ts ADDED
@@ -0,0 +1,23 @@
1
+ export { UiGrid } from './UiGrid';
2
+ export type { UiGridProps } from './UiGrid';
3
+ export { useGridState } from './useGridState';
4
+ export type { UseGridStateResult } from './useGridState';
5
+ export { useVirtualScroll } from './useVirtualScroll';
6
+ export type { UseVirtualScrollOptions, UseVirtualScrollResult } from './useVirtualScroll';
7
+
8
+ export type {
9
+ GridOptions,
10
+ GridColumnDef,
11
+ GridRow,
12
+ GridRecord,
13
+ GridLabels,
14
+ GridCellTemplateContext,
15
+ GridExpandableTemplateContext,
16
+ GridCellEditableContext,
17
+ GridBenchmarkResult,
18
+ GridSavedState,
19
+ SortState,
20
+ } from '@ornery/ui-grid';
21
+
22
+ export type { UiGridApi } from '@ornery/ui-grid';
23
+ export { DEFAULT_GRID_LABELS } from '@ornery/ui-grid';