@alaarab/ogrid-vue 2.0.11 → 2.0.12

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.
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * Uses SVG rects positioned via cell data-attribute measurements.
8
8
  */
9
- import { defineComponent, ref, computed, watch, onMounted, onUnmounted, h } from 'vue';
9
+ import { defineComponent, ref, computed, watch, onMounted, onUnmounted, h, toValue } from 'vue';
10
10
  import { Z_INDEX, measureRange, injectGlobalStyles } from '@alaarab/ogrid-core';
11
11
  export const MarchingAntsOverlay = defineComponent({
12
12
  name: 'MarchingAntsOverlay',
@@ -37,7 +37,9 @@ export const MarchingAntsOverlay = defineComponent({
37
37
  let ro;
38
38
  const clipRange = computed(() => props.copyRange ?? props.cutRange);
39
39
  const measureAll = () => {
40
- const container = props.containerRef.value;
40
+ // Use toValue to handle both Ref<HTMLElement> and raw HTMLElement
41
+ // (Vue templates may auto-unwrap refs passed as props)
42
+ const container = toValue(props.containerRef);
41
43
  if (!container) {
42
44
  selRect.value = null;
43
45
  clipRect.value = null;
@@ -51,7 +53,7 @@ export const MarchingAntsOverlay = defineComponent({
51
53
  injectGlobalStyles('ogrid-marching-ants-keyframes', '@keyframes ogrid-marching-ants{to{stroke-dashoffset:-8}}');
52
54
  });
53
55
  // Measure when any range changes; re-measure on resize, column changes, data changes
54
- watch([() => props.selectionRange, clipRange, () => props.containerRef.value, () => props.items, () => props.visibleColumns, () => props.columnSizingOverrides, () => props.columnOrder], () => {
56
+ watch([() => props.selectionRange, clipRange, () => toValue(props.containerRef), () => props.items, () => props.visibleColumns, () => props.columnSizingOverrides, () => props.columnOrder], () => {
55
57
  if (!props.selectionRange && !clipRange.value) {
56
58
  selRect.value = null;
57
59
  clipRect.value = null;
@@ -59,7 +61,7 @@ export const MarchingAntsOverlay = defineComponent({
59
61
  }
60
62
  // Delay one frame so cells are rendered
61
63
  rafId = requestAnimationFrame(measureAll);
62
- const container = props.containerRef.value;
64
+ const container = toValue(props.containerRef);
63
65
  if (container) {
64
66
  ro?.disconnect();
65
67
  ro = new ResizeObserver(measureAll);
@@ -23,8 +23,8 @@ export const StatusBar = defineComponent({
23
23
  style: {
24
24
  marginTop: 'auto',
25
25
  padding: '6px 12px',
26
- borderTop: '1px solid rgba(0,0,0,0.12)',
27
- backgroundColor: 'rgba(0,0,0,0.04)',
26
+ borderTop: '1px solid var(--ogrid-border, rgba(0,0,0,0.12))',
27
+ backgroundColor: 'var(--ogrid-header-bg, rgba(0,0,0,0.04))',
28
28
  display: 'flex',
29
29
  alignItems: 'center',
30
30
  gap: '16px',
@@ -37,11 +37,11 @@ export const StatusBar = defineComponent({
37
37
  alignItems: 'center',
38
38
  gap: '4px',
39
39
  ...(i < parts.length - 1
40
- ? { marginRight: '16px', borderRight: '1px solid rgba(0,0,0,0.12)', paddingRight: '16px' }
40
+ ? { marginRight: '16px', borderRight: '1px solid var(--ogrid-border, rgba(0,0,0,0.12))', paddingRight: '16px' }
41
41
  : {}),
42
42
  },
43
43
  }, [
44
- h('span', { style: { color: 'rgba(0,0,0,0.6)' } }, p.label),
44
+ h('span', { style: { color: 'var(--ogrid-fg-secondary, rgba(0,0,0,0.6))' } }, p.label),
45
45
  h('span', { style: { fontWeight: '600' } }, p.value.toLocaleString()),
46
46
  ])));
47
47
  };
@@ -0,0 +1,481 @@
1
+ /**
2
+ * Shared DataGridTable factory for Vue UI packages.
3
+ *
4
+ * Both vue-vuetify and vue-primevue DataGridTable components are 97% identical —
5
+ * they only differ in which checkbox and spinner components they render.
6
+ * This factory extracts all shared logic into one place.
7
+ */
8
+ import { defineComponent, computed, h, Teleport } from 'vue';
9
+ import { useDataGridTableSetup, } from '../composables';
10
+ import { getHeaderFilterConfig, getCellRenderDescriptor, resolveCellDisplayContent, resolveCellStyle, buildInlineEditorProps, buildPopoverEditorProps, getCellInteractionProps, } from '../utils';
11
+ import { buildHeaderRows, CHECKBOX_COLUMN_WIDTH, ROW_NUMBER_COLUMN_WIDTH, DEFAULT_MIN_COLUMN_WIDTH } from '@alaarab/ogrid-core';
12
+ import { StatusBar } from './StatusBar';
13
+ import { MarchingAntsOverlay } from './MarchingAntsOverlay';
14
+ const NOOP = () => { };
15
+ /**
16
+ * Creates a DataGridTable component with framework-specific UI bindings.
17
+ * All grid logic, layout, and interaction handling is shared.
18
+ */
19
+ export function createDataGridTable(ui) {
20
+ return defineComponent({
21
+ name: 'DataGridTable',
22
+ props: {
23
+ gridProps: { type: Object, required: true },
24
+ },
25
+ setup(props) {
26
+ const propsRef = computed(() => props.gridProps);
27
+ const { wrapperRef, tableContainerRef, tableRef, lastMouseShift, state, columnReorder: { isDragging: isReorderDragging, dropIndicatorX, handleHeaderMouseDown: handleReorderMouseDown }, virtualScroll: { containerRef: vsContainerRef, visibleRange, totalHeight: _totalHeight, scrollToRow: _scrollToRow }, virtualScrollEnabled, columnResize: { handleResizeStart, getColumnWidth }, } = useDataGridTableSetup({ props: propsRef });
28
+ // Stable handlers — avoid creating new closures per render
29
+ const onWrapperMousedown = (e) => { lastMouseShift.value = e.shiftKey; };
30
+ const onContextmenu = (e) => e.preventDefault();
31
+ const stopPropagation = (e) => e.stopPropagation();
32
+ return () => {
33
+ const p = props.gridProps;
34
+ const layout = state.layout.value;
35
+ const rowSel = state.rowSelection.value;
36
+ const editing = state.editing.value;
37
+ const interaction = state.interaction.value;
38
+ const ctxMenu = state.contextMenu.value;
39
+ const viewModels = state.viewModels.value;
40
+ const pinning = state.pinning.value;
41
+ const { headerMenu } = pinning;
42
+ const { visibleCols, hasCheckboxCol, hasRowNumbersCol, colOffset: _colOffset, containerWidth, minTableWidth, desiredTableWidth, } = layout;
43
+ const currentPage = p.currentPage ?? 1;
44
+ const pageSize = p.pageSize ?? 25;
45
+ const rowNumberOffset = hasRowNumbersCol ? (currentPage - 1) * pageSize : 0;
46
+ const { selectedRowIds, handleRowCheckboxChange, handleSelectAll, allSelected, someSelected } = rowSel;
47
+ const { editingCell: _editingCell, setEditingCell, pendingEditorValue, setPendingEditorValue, commitCellEdit, cancelPopoverEdit, popoverAnchorEl, setPopoverAnchorEl } = editing;
48
+ const { setActiveCell, handleCellMouseDown, handleSelectAllCells, selectionRange, hasCellSelection, handleGridKeyDown, handleFillHandleMouseDown, handleCopy, handleCut, handlePaste, cutRange: _cutRange, copyRange: _copyRange, canUndo, canRedo, onUndo, onRedo, isDragging: _isDragging, } = interaction;
49
+ const { menuPosition, handleCellContextMenu, closeContextMenu } = ctxMenu;
50
+ const { headerFilterInput, cellDescriptorInput, statusBarConfig, showEmptyInGrid, onCellError: _onCellError } = viewModels;
51
+ const items = p.items;
52
+ const getRowId = p.getRowId;
53
+ const layoutMode = p.layoutMode ?? 'fill';
54
+ const rowSelection = p.rowSelection ?? 'none';
55
+ const freezeRows = p.freezeRows;
56
+ const freezeCols = p.freezeCols;
57
+ const suppressHorizontalScroll = p.suppressHorizontalScroll;
58
+ const isLoading = p.isLoading ?? false;
59
+ const loadingMessage = p.loadingMessage ?? 'Loading\u2026';
60
+ const ariaLabel = p['aria-label'];
61
+ const ariaLabelledBy = p['aria-labelledby'];
62
+ const fitToContent = layoutMode === 'content';
63
+ const allowOverflowX = !suppressHorizontalScroll && containerWidth > 0 && (minTableWidth > containerWidth || desiredTableWidth > containerWidth);
64
+ const headerRows = buildHeaderRows(p.columns, p.visibleColumns);
65
+ const editCallbacks = { commitCellEdit, setEditingCell, setPendingEditorValue, cancelPopoverEdit };
66
+ const interactionHandlers = { handleCellMouseDown, setActiveCell, setEditingCell, handleCellContextMenu };
67
+ const handleSingleRowClick = (e) => {
68
+ if (rowSelection !== 'single')
69
+ return;
70
+ const tr = e.currentTarget;
71
+ const rowId = tr.dataset.rowId;
72
+ if (!rowId)
73
+ return;
74
+ rowSel.updateSelection(selectedRowIds.has(rowId) ? new Set() : new Set([rowId]));
75
+ };
76
+ // Render a cell's content
77
+ const renderCellContent = (item, col, rowIndex, colIdx) => {
78
+ const descriptor = getCellRenderDescriptor(item, col, rowIndex, colIdx, cellDescriptorInput);
79
+ if (descriptor.mode === 'editing-inline') {
80
+ const editorProps = buildInlineEditorProps(item, col, descriptor, editCallbacks);
81
+ return h(ui.InlineCellEditor, {
82
+ value: editorProps.value,
83
+ item: editorProps.item,
84
+ column: editorProps.column,
85
+ rowIndex: editorProps.rowIndex,
86
+ editorType: editorProps.editorType,
87
+ onCommit: editorProps.onCommit,
88
+ onCancel: editorProps.onCancel,
89
+ });
90
+ }
91
+ if (descriptor.mode === 'editing-popover' && typeof col.cellEditor === 'function') {
92
+ const editorProps = buildPopoverEditorProps(item, col, descriptor, pendingEditorValue, editCallbacks);
93
+ const CustomEditor = col.cellEditor;
94
+ return h('div', [
95
+ h('div', {
96
+ ref: (el) => { if (el)
97
+ setPopoverAnchorEl(el); },
98
+ class: 'ogrid-popover-anchor',
99
+ 'aria-hidden': 'true',
100
+ }),
101
+ popoverAnchorEl
102
+ ? h(CustomEditor, editorProps)
103
+ : null,
104
+ ]);
105
+ }
106
+ // Display mode
107
+ const content = resolveCellDisplayContent(col, item, descriptor.displayValue);
108
+ const cellStyle = resolveCellStyle(col, item);
109
+ const interactionProps2 = getCellInteractionProps(descriptor, col.columnId, interactionHandlers);
110
+ const cellClasses = ['ogrid-cell-content'];
111
+ if (col.type === 'numeric')
112
+ cellClasses.push('ogrid-cell-content--numeric');
113
+ else if (col.type === 'boolean')
114
+ cellClasses.push('ogrid-cell-content--boolean');
115
+ if (descriptor.canEditAny)
116
+ cellClasses.push('ogrid-cell-content--editable');
117
+ if (descriptor.isActive && !descriptor.isInRange)
118
+ cellClasses.push('ogrid-cell-content--active');
119
+ if (descriptor.isInRange)
120
+ cellClasses.push('ogrid-cell-in-range');
121
+ if (descriptor.isInCutRange)
122
+ cellClasses.push('ogrid-cell-cut');
123
+ const styledContent = cellStyle
124
+ ? h('span', { style: cellStyle }, content)
125
+ : content;
126
+ return h('div', {
127
+ ...interactionProps2,
128
+ class: cellClasses.join(' '),
129
+ }, [
130
+ styledContent,
131
+ ...(descriptor.canEditAny && descriptor.isSelectionEndCell ? [
132
+ h('div', {
133
+ onMousedown: handleFillHandleMouseDown,
134
+ 'aria-label': 'Fill handle',
135
+ class: 'ogrid-fill-handle',
136
+ }),
137
+ ] : []),
138
+ ]);
139
+ };
140
+ // Pre-computed pinning offsets
141
+ const leftOffsets = pinning.leftOffsets;
142
+ const rightOffsets = pinning.rightOffsets;
143
+ // Build column layouts
144
+ const columnLayouts = visibleCols.map((col, colIdx) => {
145
+ const isFreezeCol = freezeCols != null && freezeCols >= 1 && colIdx < freezeCols;
146
+ const isPinnedLeft = col.pinned === 'left';
147
+ const isPinnedRight = col.pinned === 'right';
148
+ const columnWidth = getColumnWidth(col);
149
+ const tdClasses = ['ogrid-data-cell'];
150
+ const tdDynamicStyle = {
151
+ minWidth: `${col.minWidth ?? DEFAULT_MIN_COLUMN_WIDTH}px`,
152
+ width: `${columnWidth}px`,
153
+ maxWidth: `${columnWidth}px`,
154
+ };
155
+ if (isPinnedLeft || (isFreezeCol && colIdx === 0)) {
156
+ tdClasses.push('ogrid-data-cell--pinned-left');
157
+ tdDynamicStyle.left = `${leftOffsets[col.columnId] ?? 0}px`;
158
+ }
159
+ else if (isPinnedRight) {
160
+ tdClasses.push('ogrid-data-cell--pinned-right');
161
+ tdDynamicStyle.right = `${rightOffsets[col.columnId] ?? 0}px`;
162
+ }
163
+ return { col, tdClasses: tdClasses.join(' '), tdDynamicStyle };
164
+ });
165
+ // Build header cell classes + dynamic styles
166
+ const getHeaderClassAndStyle = (col, colIdx) => {
167
+ const isFreezeCol = freezeCols != null && freezeCols >= 1 && colIdx < freezeCols;
168
+ const isPinnedLeft = col.pinned === 'left';
169
+ const isPinnedRight = col.pinned === 'right';
170
+ const columnWidth = getColumnWidth(col);
171
+ const classes = ['ogrid-header-cell'];
172
+ const style = {
173
+ cursor: isReorderDragging.value ? 'grabbing' : 'grab',
174
+ minWidth: `${col.minWidth ?? DEFAULT_MIN_COLUMN_WIDTH}px`,
175
+ width: `${columnWidth}px`,
176
+ maxWidth: `${columnWidth}px`,
177
+ };
178
+ if (isPinnedLeft || (isFreezeCol && colIdx === 0)) {
179
+ classes.push('ogrid-header-cell--pinned-left');
180
+ style.left = `${leftOffsets[col.columnId] ?? 0}px`;
181
+ }
182
+ else if (isPinnedRight) {
183
+ classes.push('ogrid-header-cell--pinned-right');
184
+ style.right = `${rightOffsets[col.columnId] ?? 0}px`;
185
+ }
186
+ return { classes: classes.join(' '), style };
187
+ };
188
+ // Dynamic wrapper style
189
+ const wrapperStyle = {
190
+ position: 'relative',
191
+ flex: '1',
192
+ minHeight: '0',
193
+ width: fitToContent ? 'fit-content' : '100%',
194
+ maxWidth: '100%',
195
+ overflowX: suppressHorizontalScroll ? 'hidden' : allowOverflowX ? 'auto' : 'hidden',
196
+ overflowY: 'auto',
197
+ backgroundColor: '#fff',
198
+ willChange: 'scroll-position',
199
+ };
200
+ return h('div', { class: 'ogrid-outer-container' }, [
201
+ // Scrollable wrapper
202
+ h('div', {
203
+ ref: (el) => { wrapperRef.value = el; vsContainerRef.value = el; },
204
+ tabindex: 0,
205
+ role: 'region',
206
+ 'aria-label': ariaLabel ?? (ariaLabelledBy ? undefined : 'Data grid'),
207
+ 'aria-labelledby': ariaLabelledBy,
208
+ onMousedown: onWrapperMousedown,
209
+ onKeydown: handleGridKeyDown,
210
+ onContextmenu,
211
+ 'data-overflow-x': allowOverflowX ? 'true' : 'false',
212
+ style: wrapperStyle,
213
+ }, [
214
+ h('div', { class: 'ogrid-scroll-wrapper' }, [
215
+ h('div', { style: { minWidth: allowOverflowX ? `${minTableWidth}px` : undefined } }, [
216
+ h('div', {
217
+ ref: (el) => { tableContainerRef.value = el; },
218
+ class: ['ogrid-table-container', isLoading && items.length > 0 ? 'ogrid-table-container--loading' : ''],
219
+ }, [
220
+ // Drop indicator for column reorder
221
+ ...(isReorderDragging.value && dropIndicatorX.value !== null ? [
222
+ h('div', {
223
+ class: 'ogrid-drop-indicator',
224
+ style: { left: `${dropIndicatorX.value}px` },
225
+ }),
226
+ ] : []),
227
+ // Table
228
+ h('table', {
229
+ ref: (el) => { tableRef.value = el; },
230
+ class: 'ogrid-table',
231
+ style: { minWidth: `${minTableWidth}px` },
232
+ 'data-freeze-rows': freezeRows != null && freezeRows >= 1 ? freezeRows : undefined,
233
+ 'data-freeze-cols': freezeCols != null && freezeCols >= 1 ? freezeCols : undefined,
234
+ }, [
235
+ // Header
236
+ h('thead', { class: 'ogrid-thead' }, headerRows.map((row, rowIdx) => h('tr', { key: rowIdx, class: 'ogrid-header-row' }, [
237
+ // Checkbox header cell
238
+ ...(rowIdx === headerRows.length - 1 && hasCheckboxCol ? [
239
+ h('th', {
240
+ class: 'ogrid-checkbox-header',
241
+ style: {
242
+ width: `${CHECKBOX_COLUMN_WIDTH}px`,
243
+ minWidth: `${CHECKBOX_COLUMN_WIDTH}px`,
244
+ maxWidth: `${CHECKBOX_COLUMN_WIDTH}px`,
245
+ },
246
+ }, ui.renderCheckbox({
247
+ modelValue: allSelected,
248
+ indeterminate: someSelected,
249
+ ariaLabel: 'Select all rows',
250
+ onChange: (c) => handleSelectAll(!!c),
251
+ })),
252
+ ] : []),
253
+ // Checkbox spacer in group header row
254
+ ...(rowIdx === 0 && rowIdx < headerRows.length - 1 && hasCheckboxCol ? [
255
+ h('th', {
256
+ rowSpan: headerRows.length - 1,
257
+ class: 'ogrid-checkbox-spacer',
258
+ style: { width: `${CHECKBOX_COLUMN_WIDTH}px`, minWidth: `${CHECKBOX_COLUMN_WIDTH}px` },
259
+ }),
260
+ ] : []),
261
+ // Row numbers header
262
+ ...(rowIdx === headerRows.length - 1 && hasRowNumbersCol ? [
263
+ h('th', {
264
+ class: 'ogrid-row-number-header',
265
+ style: {
266
+ width: `${ROW_NUMBER_COLUMN_WIDTH}px`,
267
+ minWidth: `${ROW_NUMBER_COLUMN_WIDTH}px`,
268
+ maxWidth: `${ROW_NUMBER_COLUMN_WIDTH}px`,
269
+ position: 'sticky',
270
+ left: hasCheckboxCol ? `${CHECKBOX_COLUMN_WIDTH}px` : '0',
271
+ zIndex: 3,
272
+ },
273
+ }, '#'),
274
+ ] : []),
275
+ // Row numbers spacer
276
+ ...(rowIdx === 0 && rowIdx < headerRows.length - 1 && hasRowNumbersCol ? [
277
+ h('th', {
278
+ rowSpan: headerRows.length - 1,
279
+ class: 'ogrid-row-number-spacer',
280
+ style: {
281
+ width: `${ROW_NUMBER_COLUMN_WIDTH}px`,
282
+ position: 'sticky',
283
+ left: hasCheckboxCol ? `${CHECKBOX_COLUMN_WIDTH}px` : '0',
284
+ zIndex: 3,
285
+ },
286
+ }),
287
+ ] : []),
288
+ // Header cells
289
+ ...row.map((cell, cellIdx) => {
290
+ if (cell.isGroup) {
291
+ return h('th', {
292
+ key: cellIdx,
293
+ colSpan: cell.colSpan,
294
+ scope: 'colgroup',
295
+ class: 'ogrid-column-group-header',
296
+ }, cell.label);
297
+ }
298
+ const col = cell.columnDef;
299
+ const colIdx = visibleCols.indexOf(col);
300
+ const { classes: headerClasses, style: headerStyle } = getHeaderClassAndStyle(col, colIdx);
301
+ return h('th', {
302
+ key: col.columnId,
303
+ scope: 'col',
304
+ 'data-column-id': col.columnId,
305
+ rowSpan: headerRows.length > 1 ? headerRows.length - rowIdx : undefined,
306
+ class: headerClasses,
307
+ style: headerStyle,
308
+ onMousedown: (e) => handleReorderMouseDown(col.columnId, e),
309
+ }, [
310
+ h('div', { class: 'ogrid-header-content' }, [
311
+ h(ui.ColumnHeaderFilter, getHeaderFilterConfig(col, headerFilterInput)),
312
+ h('button', {
313
+ onClick: (e) => {
314
+ e.stopPropagation();
315
+ headerMenu.open(col.columnId, e.currentTarget);
316
+ },
317
+ 'aria-label': 'Column options',
318
+ title: 'Column options',
319
+ class: 'ogrid-column-menu-btn',
320
+ }, '\u22EE'),
321
+ ]),
322
+ h('div', {
323
+ onMousedown: (e) => { e.stopPropagation(); handleResizeStart(e, col); },
324
+ class: 'ogrid-resize-handle',
325
+ }),
326
+ ]);
327
+ }),
328
+ ]))),
329
+ // Body
330
+ ...(!showEmptyInGrid ? [
331
+ h('tbody', {}, (() => {
332
+ const vsEnabled = virtualScrollEnabled.value;
333
+ const vr = visibleRange.value;
334
+ const startIdx = vsEnabled ? vr.startIndex : 0;
335
+ const endIdx = vsEnabled ? Math.min(vr.endIndex, items.length - 1) : items.length - 1;
336
+ const rows = [];
337
+ if (vsEnabled && vr.offsetTop > 0) {
338
+ rows.push(h('tr', { key: '__vs-top', style: { height: `${vr.offsetTop}px` } }));
339
+ }
340
+ for (let rowIndex = startIdx; rowIndex <= endIdx; rowIndex++) {
341
+ const item = items[rowIndex];
342
+ if (!item)
343
+ continue;
344
+ const rowIdStr = getRowId(item);
345
+ const isSelected = selectedRowIds.has(rowIdStr);
346
+ rows.push(h('tr', {
347
+ key: rowIdStr,
348
+ 'data-row-id': rowIdStr,
349
+ onClick: handleSingleRowClick,
350
+ style: { cursor: rowSelection === 'single' ? 'pointer' : undefined },
351
+ }, [
352
+ // Checkbox cell
353
+ ...(hasCheckboxCol ? [
354
+ h('td', {
355
+ class: 'ogrid-checkbox-cell',
356
+ style: {
357
+ width: `${CHECKBOX_COLUMN_WIDTH}px`,
358
+ minWidth: `${CHECKBOX_COLUMN_WIDTH}px`,
359
+ maxWidth: `${CHECKBOX_COLUMN_WIDTH}px`,
360
+ },
361
+ }, h('div', {
362
+ 'data-row-index': rowIndex,
363
+ 'data-col-index': 0,
364
+ onClick: stopPropagation,
365
+ class: 'ogrid-checkbox-wrapper',
366
+ }, ui.renderCheckbox({
367
+ modelValue: isSelected,
368
+ ariaLabel: `Select row ${rowIndex + 1}`,
369
+ onChange: (checked) => handleRowCheckboxChange(rowIdStr, checked, rowIndex, lastMouseShift.value),
370
+ }))),
371
+ ] : []),
372
+ // Row numbers cell
373
+ ...(hasRowNumbersCol ? [
374
+ h('td', {
375
+ class: 'ogrid-row-number-cell',
376
+ style: {
377
+ width: `${ROW_NUMBER_COLUMN_WIDTH}px`,
378
+ minWidth: `${ROW_NUMBER_COLUMN_WIDTH}px`,
379
+ maxWidth: `${ROW_NUMBER_COLUMN_WIDTH}px`,
380
+ padding: '6px',
381
+ position: 'sticky',
382
+ left: hasCheckboxCol ? `${CHECKBOX_COLUMN_WIDTH}px` : '0',
383
+ zIndex: 2,
384
+ },
385
+ }, String(rowNumberOffset + rowIndex + 1)),
386
+ ] : []),
387
+ // Data cells
388
+ ...columnLayouts.map((cl, colIdx) => h('td', {
389
+ key: cl.col.columnId,
390
+ 'data-column-id': cl.col.columnId,
391
+ class: cl.tdClasses,
392
+ style: cl.tdDynamicStyle,
393
+ }, [renderCellContent(item, cl.col, rowIndex, colIdx)])),
394
+ ]));
395
+ }
396
+ if (vsEnabled && vr.offsetBottom > 0) {
397
+ rows.push(h('tr', { key: '__vs-bottom', style: { height: `${vr.offsetBottom}px` } }));
398
+ }
399
+ return rows;
400
+ })()),
401
+ ] : []),
402
+ ]),
403
+ // Empty state
404
+ ...(showEmptyInGrid && p.emptyState ? [
405
+ ui.renderEmptyState(p.emptyState),
406
+ ] : []),
407
+ ]),
408
+ ]),
409
+ ]),
410
+ ]),
411
+ // Context menu (teleported to body)
412
+ ...(menuPosition ? [
413
+ h(Teleport, { to: 'body' }, h(ui.GridContextMenu, {
414
+ x: menuPosition.x,
415
+ y: menuPosition.y,
416
+ hasSelection: hasCellSelection,
417
+ canUndo,
418
+ canRedo,
419
+ onUndo: onUndo ?? NOOP,
420
+ onRedo: onRedo ?? NOOP,
421
+ onCopy: handleCopy,
422
+ onCut: handleCut,
423
+ onPaste: () => { void handlePaste(); },
424
+ onSelectAll: handleSelectAllCells,
425
+ onClose: closeContextMenu,
426
+ })),
427
+ ] : []),
428
+ // Marching ants overlay
429
+ h(MarchingAntsOverlay, {
430
+ containerRef: tableContainerRef,
431
+ selectionRange,
432
+ copyRange: _copyRange,
433
+ cutRange: _cutRange,
434
+ colOffset: _colOffset,
435
+ items,
436
+ visibleColumns: p.visibleColumns instanceof Set ? Array.from(p.visibleColumns) : p.visibleColumns,
437
+ columnSizingOverrides: layout.columnSizingOverrides,
438
+ columnOrder: p.columnOrder,
439
+ }),
440
+ // Column header menu
441
+ h(ui.ColumnHeaderMenu, {
442
+ isOpen: headerMenu.isOpen,
443
+ anchorElement: headerMenu.anchorElement,
444
+ onClose: headerMenu.close,
445
+ onPinLeft: headerMenu.handlePinLeft,
446
+ onPinRight: headerMenu.handlePinRight,
447
+ onUnpin: headerMenu.handleUnpin,
448
+ onSortAsc: headerMenu.handleSortAsc,
449
+ onSortDesc: headerMenu.handleSortDesc,
450
+ onClearSort: headerMenu.handleClearSort,
451
+ onAutosizeThis: headerMenu.handleAutosizeThis,
452
+ onAutosizeAll: headerMenu.handleAutosizeAll,
453
+ canPinLeft: headerMenu.canPinLeft,
454
+ canPinRight: headerMenu.canPinRight,
455
+ canUnpin: headerMenu.canUnpin,
456
+ currentSort: headerMenu.currentSort,
457
+ isSortable: headerMenu.isSortable,
458
+ isResizable: headerMenu.isResizable,
459
+ }),
460
+ // Status bar
461
+ ...(statusBarConfig ? [
462
+ h(StatusBar, {
463
+ totalCount: statusBarConfig.totalCount,
464
+ filteredCount: statusBarConfig.filteredCount,
465
+ selectedCount: statusBarConfig.selectedCount ?? selectedRowIds.size,
466
+ selectedCellCount: selectionRange
467
+ ? (Math.abs(selectionRange.endRow - selectionRange.startRow) + 1) * (Math.abs(selectionRange.endCol - selectionRange.startCol) + 1)
468
+ : undefined,
469
+ aggregation: statusBarConfig.aggregation,
470
+ suppressRowCount: statusBarConfig.suppressRowCount,
471
+ }),
472
+ ] : []),
473
+ // Loading overlay
474
+ ...(isLoading ? [
475
+ h('div', { class: 'ogrid-loading-overlay' }, ui.renderSpinner(loadingMessage)),
476
+ ] : []),
477
+ ]);
478
+ };
479
+ },
480
+ });
481
+ }
@@ -1,10 +1,11 @@
1
1
  import { ref, computed } from 'vue';
2
+ import { measureColumnContentWidth } from '@alaarab/ogrid-core';
2
3
  /**
3
4
  * Manages state for the column header menu (pin/unpin, sort, autosize actions).
4
5
  * Tracks which column's menu is open, anchor element, and action handlers.
5
6
  */
6
7
  export function useColumnHeaderMenuState(params) {
7
- const { columns, pinnedColumns, onPinColumn, onUnpinColumn, onSort, onAutosizeColumn, onAutosizeAllColumns, sortBy, sortDirection, } = params;
8
+ const { columns, pinnedColumns, onPinColumn, onUnpinColumn, onSort, onColumnResized, onAutosizeColumn, sortBy, sortDirection, } = params;
8
9
  const isOpen = ref(false);
9
10
  const openForColumn = ref(null);
10
11
  const anchorElement = ref(null);
@@ -74,16 +75,21 @@ export function useColumnHeaderMenuState(params) {
74
75
  }
75
76
  };
76
77
  const handleAutosizeThis = () => {
77
- if (openForColumn.value && onAutosizeColumn) {
78
- onAutosizeColumn(openForColumn.value);
79
- close();
80
- }
78
+ const resizer = onAutosizeColumn ?? onColumnResized;
79
+ if (!openForColumn.value || !resizer || !isResizable.value)
80
+ return;
81
+ const col = currentColumn.value;
82
+ resizer(openForColumn.value, measureColumnContentWidth(openForColumn.value, col?.minWidth));
83
+ close();
81
84
  };
82
85
  const handleAutosizeAll = () => {
83
- if (onAutosizeAllColumns) {
84
- onAutosizeAllColumns();
85
- close();
86
- }
86
+ const resizer = onAutosizeColumn ?? onColumnResized;
87
+ if (!resizer)
88
+ return;
89
+ columns.value.forEach((col) => {
90
+ resizer(col.columnId, measureColumnContentWidth(col.columnId, col.minWidth));
91
+ });
92
+ close();
87
93
  };
88
94
  return {
89
95
  isOpen,
@@ -1,5 +1,5 @@
1
1
  import { ref, computed } from 'vue';
2
- import { flattenColumns, getDataGridStatusBarConfig, parseValue, computeAggregations } from '@alaarab/ogrid-core';
2
+ import { flattenColumns, getDataGridStatusBarConfig, parseValue, computeAggregations, CHECKBOX_COLUMN_WIDTH, DEFAULT_MIN_COLUMN_WIDTH } from '@alaarab/ogrid-core';
3
3
  import { useRowSelection } from './useRowSelection';
4
4
  import { useCellEditing } from './useCellEditing';
5
5
  import { useActiveCell } from './useActiveCell';
@@ -175,17 +175,35 @@ export function useDataGridState(params) {
175
175
  pinnedColumns: pinnedColumnsProp,
176
176
  onColumnPinned: onColumnPinnedProp.value,
177
177
  });
178
+ // Autosize callback — updates internal column sizing state + notifies external listener
179
+ const handleAutosizeColumn = (columnId, width) => {
180
+ setColumnSizingOverrides({ ...columnSizingOverrides.value, [columnId]: { widthPx: width } });
181
+ onColumnResizedProp.value?.(columnId, width);
182
+ };
178
183
  const headerMenuResult = useColumnHeaderMenuState({
179
184
  columns: flatColumns,
180
185
  pinnedColumns: pinningResult.pinnedColumns,
181
186
  onPinColumn: pinningResult.pinColumn,
182
187
  onUnpinColumn: pinningResult.unpinColumn,
183
188
  onSort: props.value.onColumnSort,
184
- onAutosizeColumn: props.value.onAutosizeColumn,
185
- onAutosizeAllColumns: props.value.onAutosizeAllColumns,
189
+ onColumnResized: onColumnResizedProp.value,
190
+ onAutosizeColumn: handleAutosizeColumn,
186
191
  sortBy: computed(() => props.value.sortBy),
187
192
  sortDirection: computed(() => props.value.sortDirection),
188
193
  });
194
+ // Build column width map for pinning offset computation
195
+ const columnWidthMap = computed(() => {
196
+ const map = {};
197
+ for (const col of visibleCols.value) {
198
+ const override = columnSizingOverrides.value[col.columnId];
199
+ map[col.columnId] = override
200
+ ? override.widthPx
201
+ : (col.idealWidth ?? col.defaultWidth ?? col.minWidth ?? DEFAULT_MIN_COLUMN_WIDTH);
202
+ }
203
+ return map;
204
+ });
205
+ const leftOffsets = computed(() => pinningResult.computeLeftOffsets(visibleCols.value, columnWidthMap.value, DEFAULT_MIN_COLUMN_WIDTH, hasCheckboxCol.value, CHECKBOX_COLUMN_WIDTH));
206
+ const rightOffsets = computed(() => pinningResult.computeRightOffsets(visibleCols.value, columnWidthMap.value, DEFAULT_MIN_COLUMN_WIDTH));
189
207
  const aggregation = computed(() => computeAggregations(items.value, visibleCols.value, cellSelection.value ? selectionRange.value : null));
190
208
  const statusBarConfig = computed(() => {
191
209
  const base = getDataGridStatusBarConfig(statusBarProp.value, items.value.length, rowSelectionResult.selectedRowIds.value.size);
@@ -330,8 +348,8 @@ export function useDataGridState(params) {
330
348
  pinColumn: pinningResult.pinColumn,
331
349
  unpinColumn: pinningResult.unpinColumn,
332
350
  isPinned: pinningResult.isPinned,
333
- computeLeftOffsets: pinningResult.computeLeftOffsets,
334
- computeRightOffsets: pinningResult.computeRightOffsets,
351
+ leftOffsets: leftOffsets.value,
352
+ rightOffsets: rightOffsets.value,
335
353
  headerMenu: {
336
354
  isOpen: headerMenuResult.isOpen.value,
337
355
  openForColumn: headerMenuResult.openForColumn.value,
@@ -1,5 +1,5 @@
1
1
  import { ref, computed, watch } from 'vue';
2
- import { mergeFilter, deriveFilterOptionsFromData, getMultiSelectFilterFields, flattenColumns, processClientSideData, } from '@alaarab/ogrid-core';
2
+ import { mergeFilter, deriveFilterOptionsFromData, getMultiSelectFilterFields, flattenColumns, processClientSideData, computeNextSortState, } from '@alaarab/ogrid-core';
3
3
  import { useFilterOptions } from './useFilterOptions';
4
4
  import { useSideBarState } from './useSideBarState';
5
5
  const DEFAULT_PAGE_SIZE = 25;
@@ -126,22 +126,8 @@ export function useOGrid(props) {
126
126
  internalVisibleColumns.value = cols;
127
127
  callbacks.value.onVisibleColumnsChange?.(cols);
128
128
  };
129
- const handleSort = (columnKey) => {
130
- const currentSort = sort.value;
131
- if (currentSort.field === columnKey) {
132
- // Cycle: asc → desc → clear
133
- if (currentSort.direction === 'asc') {
134
- setSort({ field: columnKey, direction: 'desc' });
135
- }
136
- else {
137
- // Clear sort (empty field means no column is sorted)
138
- setSort({ field: '', direction: 'asc' });
139
- }
140
- }
141
- else {
142
- // Start new sort
143
- setSort({ field: columnKey, direction: 'asc' });
144
- }
129
+ const handleSort = (columnKey, direction) => {
130
+ setSort(computeNextSortState(sort.value, columnKey, direction));
145
131
  };
146
132
  const handleFilterChange = (key, value) => {
147
133
  setFilters(mergeFilter(filters.value, key, value));
@@ -340,6 +326,9 @@ export function useOGrid(props) {
340
326
  rowSelection: p.rowSelection ?? 'none',
341
327
  selectedRows: effectiveSelectedRows.value,
342
328
  onSelectionChange: handleSelectionChange,
329
+ showRowNumbers: p.showRowNumbers,
330
+ currentPage: page.value,
331
+ pageSize: pageSize.value,
343
332
  statusBar: statusBarConfig.value,
344
333
  isLoading: isLoadingResolved.value,
345
334
  filters: filters.value,
@@ -350,7 +339,9 @@ export function useOGrid(props) {
350
339
  getUserByEmail: ds?.getUserByEmail,
351
340
  layoutMode: p.layoutMode,
352
341
  suppressHorizontalScroll: p.suppressHorizontalScroll,
342
+ columnReorder: p.columnReorder,
353
343
  virtualScroll: p.virtualScroll,
344
+ density: p.density ?? 'normal',
354
345
  'aria-label': p['aria-label'],
355
346
  'aria-labelledby': p['aria-labelledby'],
356
347
  emptyState: {
package/dist/esm/index.js CHANGED
@@ -8,3 +8,5 @@ export { StatusBar } from './components/StatusBar';
8
8
  export { useOGrid, useDataGridState, useActiveCell, useCellEditing, useCellSelection, useClipboard, useRowSelection, useKeyboardNavigation, useFillHandle, useUndoRedo, useContextMenu, useColumnResize, useColumnReorder, useVirtualScroll, useFilterOptions, useDebounce, useDebouncedCallback, useTableLayout, useColumnHeaderFilterState, useTextFilterState, useMultiSelectFilterState, usePeopleFilterState, useDateFilterState, useColumnChooserState, useInlineCellEditorState, useRichSelectState, useSideBarState, useColumnPinning, useColumnHeaderMenuState, useDataGridTableSetup, } from './composables';
9
9
  // View model utilities (for UI packages)
10
10
  export { getHeaderFilterConfig, getCellRenderDescriptor, resolveCellDisplayContent, resolveCellStyle, buildInlineEditorProps, buildPopoverEditorProps, getCellInteractionProps, } from './utils';
11
+ // DataGridTable factory (for UI packages)
12
+ export { createDataGridTable } from './components/createDataGridTable';
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Shared DataGridTable factory for Vue UI packages.
3
+ *
4
+ * Both vue-vuetify and vue-primevue DataGridTable components are 97% identical —
5
+ * they only differ in which checkbox and spinner components they render.
6
+ * This factory extracts all shared logic into one place.
7
+ */
8
+ import { type PropType, type VNode, type Component } from 'vue';
9
+ import type { IOGridDataGridProps } from '../types';
10
+ /** Framework-specific component bindings passed by each UI package */
11
+ export interface IDataGridTableUIBindings {
12
+ /** Render a checkbox (Vuetify VCheckbox vs PrimeVue Checkbox) */
13
+ renderCheckbox: (props: {
14
+ modelValue: boolean;
15
+ indeterminate?: boolean;
16
+ ariaLabel: string;
17
+ onChange: (checked: boolean) => void;
18
+ }) => VNode;
19
+ /** Render a loading spinner with message */
20
+ renderSpinner: (message: string) => VNode;
21
+ /** Package-local ColumnHeaderFilter component */
22
+ ColumnHeaderFilter: Component;
23
+ /** Package-local ColumnHeaderMenu component */
24
+ ColumnHeaderMenu: Component;
25
+ /** Package-local InlineCellEditor component */
26
+ InlineCellEditor: Component;
27
+ /** Package-local GridContextMenu component */
28
+ GridContextMenu: Component;
29
+ /** Package-local renderEmptyState function */
30
+ renderEmptyState: (emptyState: {
31
+ render?: () => unknown;
32
+ message?: string | null;
33
+ hasActiveFilters?: boolean;
34
+ onClearAll?: () => void;
35
+ }) => VNode;
36
+ }
37
+ /**
38
+ * Creates a DataGridTable component with framework-specific UI bindings.
39
+ * All grid logic, layout, and interaction handling is shared.
40
+ */
41
+ export declare function createDataGridTable(ui: IDataGridTableUIBindings): import("vue").DefineComponent<import("vue").ExtractPropTypes<{
42
+ gridProps: {
43
+ type: PropType<IOGridDataGridProps<unknown>>;
44
+ required: true;
45
+ };
46
+ }>, () => VNode<import("vue").RendererNode, import("vue").RendererElement, {
47
+ [key: string]: any;
48
+ }>, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
49
+ gridProps: {
50
+ type: PropType<IOGridDataGridProps<unknown>>;
51
+ required: true;
52
+ };
53
+ }>> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
@@ -6,10 +6,12 @@ export interface UseColumnHeaderMenuStateParams<T = unknown> {
6
6
  onPinColumn: (columnId: string, side: 'left' | 'right') => void;
7
7
  onUnpinColumn: (columnId: string) => void;
8
8
  onSort?: (columnId: string, direction: 'asc' | 'desc' | null) => void;
9
- onAutosizeColumn?: (columnId: string) => void;
10
- onAutosizeAllColumns?: () => void;
9
+ onColumnResized?: (columnId: string, width: number) => void;
10
+ onAutosizeColumn?: (columnId: string, width: number) => void;
11
11
  sortBy?: Ref<string | undefined>;
12
12
  sortDirection?: Ref<'asc' | 'desc' | undefined>;
13
+ data?: Ref<unknown[]>;
14
+ getRowId?: (item: unknown) => string | number;
13
15
  }
14
16
  export interface UseColumnHeaderMenuStateResult {
15
17
  isOpen: Ref<boolean>;
@@ -121,12 +121,8 @@ export interface DataGridPinningState {
121
121
  pinColumn: (columnId: string, side: 'left' | 'right') => void;
122
122
  unpinColumn: (columnId: string) => void;
123
123
  isPinned: (columnId: string) => 'left' | 'right' | undefined;
124
- computeLeftOffsets: (visibleCols: {
125
- columnId: string;
126
- }[], columnWidths: Record<string, number>, defaultWidth: number, hasCheckboxColumn: boolean, checkboxColumnWidth: number) => Record<string, number>;
127
- computeRightOffsets: (visibleCols: {
128
- columnId: string;
129
- }[], columnWidths: Record<string, number>, defaultWidth: number) => Record<string, number>;
124
+ leftOffsets: Record<string, number>;
125
+ rightOffsets: Record<string, number>;
130
126
  headerMenu: {
131
127
  isOpen: boolean;
132
128
  openForColumn: string | null;
@@ -8,4 +8,5 @@ export { useOGrid, useDataGridState, useActiveCell, useCellEditing, useCellSelec
8
8
  export type { UseOGridResult, UseOGridPagination, UseOGridColumnChooser, UseOGridLayout, UseOGridFilters, ColumnChooserPlacement, UseDataGridStateParams, UseDataGridStateResult, DataGridLayoutState, DataGridRowSelectionState, DataGridEditingState, DataGridCellInteractionState, DataGridContextMenuState, DataGridViewModelState, DataGridPinningState, UseActiveCellResult, EditingCell, UseCellEditingParams, UseCellEditingResult, UseCellSelectionParams, UseCellSelectionResult, UseClipboardParams, UseClipboardResult, UseRowSelectionParams, UseRowSelectionResult, UseKeyboardNavigationParams, UseKeyboardNavigationResult, UseFillHandleParams, UseFillHandleResult, UseUndoRedoParams, UseUndoRedoResult, ContextMenuPosition, UseContextMenuResult, UseColumnResizeParams, UseColumnResizeResult, UseColumnReorderParams, UseColumnReorderResult, UseVirtualScrollParams, UseVirtualScrollResult, UseFilterOptionsResult, UseTableLayoutParams, UseTableLayoutResult, UseColumnHeaderFilterStateParams, UseColumnHeaderFilterStateResult, UseTextFilterStateParams, UseTextFilterStateResult, UseMultiSelectFilterStateParams, UseMultiSelectFilterStateResult, UsePeopleFilterStateParams, UsePeopleFilterStateResult, UseDateFilterStateParams, UseDateFilterStateResult, UseColumnChooserStateParams, UseColumnChooserStateResult, InlineCellEditorType, UseInlineCellEditorStateParams, UseInlineCellEditorStateResult, UseRichSelectStateParams, UseRichSelectStateResult, UseSideBarStateParams, UseSideBarStateResult, DebouncedFn, UseColumnPinningParams, UseColumnPinningResult, UseColumnHeaderMenuStateParams, UseColumnHeaderMenuStateResult, UseDataGridTableSetupParams, UseDataGridTableSetupResult, } from './composables';
9
9
  export { getHeaderFilterConfig, getCellRenderDescriptor, resolveCellDisplayContent, resolveCellStyle, buildInlineEditorProps, buildPopoverEditorProps, getCellInteractionProps, } from './utils';
10
10
  export type { HeaderFilterConfigInput, HeaderFilterConfig, CellRenderDescriptorInput, CellRenderDescriptor, CellRenderMode, CellInteractionHandlers, CellInteractionProps, } from './utils';
11
+ export { createDataGridTable, type IDataGridTableUIBindings } from './components/createDataGridTable';
11
12
  export type { SideBarProps, SideBarFilterColumn } from './components/SideBar';
@@ -42,6 +42,8 @@ interface IOGridBaseProps<T> {
42
42
  rowSelection?: RowSelectionMode;
43
43
  selectedRows?: Set<RowId>;
44
44
  onSelectionChange?: (event: IRowSelectionChangeEvent<T>) => void;
45
+ /** Show Excel-style row numbers column at the start of the grid (1, 2, 3...). Default: false. */
46
+ showRowNumbers?: boolean;
45
47
  statusBar?: boolean | IStatusBarProps;
46
48
  defaultPageSize?: number;
47
49
  defaultSortBy?: string;
@@ -102,17 +104,15 @@ export interface IOGridDataGridProps<T> {
102
104
  getRowId: (item: T) => RowId;
103
105
  sortBy?: string;
104
106
  sortDirection: 'asc' | 'desc';
105
- onColumnSort: (columnKey: string) => void;
107
+ onColumnSort: (columnKey: string, direction?: 'asc' | 'desc' | null) => void;
106
108
  visibleColumns: Set<string>;
107
109
  /** Optional column display order (column ids). When set, visible columns are ordered by this array. */
108
110
  columnOrder?: string[];
109
111
  onColumnOrderChange?: (order: string[]) => void;
110
112
  /** Called when a column is resized by the user. */
111
113
  onColumnResized?: (columnId: string, width: number) => void;
112
- /** Called when user requests autosize for a single column. */
113
- onAutosizeColumn?: (columnId: string) => void;
114
- /** Called when user requests autosize for all columns. */
115
- onAutosizeAllColumns?: () => void;
114
+ /** Called when user requests autosize for a single column (with measured width). */
115
+ onAutosizeColumn?: (columnId: string, width: number) => void;
116
116
  /** Called when a column is pinned or unpinned. */
117
117
  onColumnPinned?: (columnId: string, pinned: 'left' | 'right' | null) => void;
118
118
  /** Runtime pin overrides (from restored state or programmatic changes). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alaarab/ogrid-vue",
3
- "version": "2.0.11",
3
+ "version": "2.0.12",
4
4
  "description": "OGrid Vue – Vue 3 composables, headless components, and utilities for OGrid data grids.",
5
5
  "main": "dist/esm/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -35,7 +35,7 @@
35
35
  "node": ">=18"
36
36
  },
37
37
  "dependencies": {
38
- "@alaarab/ogrid-core": "2.0.11"
38
+ "@alaarab/ogrid-core": "2.0.12"
39
39
  },
40
40
  "peerDependencies": {
41
41
  "vue": "^3.3.0"