@alaarab/ogrid-vue 2.0.23 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/esm/components/createDataGridTable.js +5 -1
  2. package/dist/esm/components/createOGrid.js +10 -7
  3. package/dist/esm/composables/useCellSelection.js +101 -61
  4. package/dist/esm/composables/useClipboard.js +15 -55
  5. package/dist/esm/composables/useColumnChooserState.js +4 -7
  6. package/dist/esm/composables/useColumnHeaderFilterState.js +10 -7
  7. package/dist/esm/composables/useColumnHeaderMenuState.js +2 -4
  8. package/dist/esm/composables/useColumnPinning.js +2 -2
  9. package/dist/esm/composables/useColumnReorder.js +8 -1
  10. package/dist/esm/composables/useDataGridState.js +33 -30
  11. package/dist/esm/composables/useDateFilterState.js +1 -1
  12. package/dist/esm/composables/useFillHandle.js +67 -50
  13. package/dist/esm/composables/useKeyboardNavigation.js +25 -109
  14. package/dist/esm/composables/useLatestRef.js +2 -2
  15. package/dist/esm/composables/useMultiSelectFilterState.js +1 -1
  16. package/dist/esm/composables/useOGrid.js +29 -11
  17. package/dist/esm/composables/usePeopleFilterState.js +2 -2
  18. package/dist/esm/composables/useRowSelection.js +13 -16
  19. package/dist/esm/composables/useTableLayout.js +11 -11
  20. package/dist/esm/composables/useTextFilterState.js +1 -1
  21. package/dist/esm/composables/useVirtualScroll.js +20 -17
  22. package/dist/types/composables/index.d.ts +1 -0
  23. package/dist/types/composables/useCellSelection.d.ts +1 -1
  24. package/dist/types/composables/useClipboard.d.ts +1 -1
  25. package/dist/types/composables/useDateFilterState.d.ts +2 -2
  26. package/dist/types/composables/useFillHandle.d.ts +1 -1
  27. package/dist/types/composables/useKeyboardNavigation.d.ts +4 -6
  28. package/dist/types/composables/useLatestRef.d.ts +3 -1
  29. package/dist/types/composables/useMultiSelectFilterState.d.ts +1 -1
  30. package/dist/types/composables/usePeopleFilterState.d.ts +1 -1
  31. package/dist/types/composables/useTextFilterState.d.ts +2 -2
  32. package/dist/types/index.d.ts +1 -1
  33. package/package.json +10 -3
@@ -24,29 +24,27 @@ const NOOP_CTX = (_e) => { };
24
24
  */
25
25
  export function useDataGridState(params) {
26
26
  const { props, wrapperRef } = params;
27
+ // --- Reactive refs for props consumed by sub-composables ---
28
+ // Only properties that sub-composables need as Ref<...> get their own computed.
29
+ // Everything else is read directly from props.value at the point of use to
30
+ // avoid unnecessary intermediate reactive layers.
27
31
  const items = computed(() => props.value.items);
28
- const columnsProp = computed(() => props.value.columns);
29
32
  const getRowId = props.value.getRowId; // stable function reference, no reactivity needed
30
- const visibleColumnsProp = computed(() => props.value.visibleColumns);
31
- const columnOrderProp = computed(() => props.value.columnOrder);
32
33
  const rowSelectionProp = computed(() => props.value.rowSelection ?? 'none');
33
34
  const controlledSelectedRows = computed(() => props.value.selectedRows);
34
- const onSelectionChangeProp = computed(() => props.value.onSelectionChange);
35
- const statusBarProp = computed(() => props.value.statusBar);
36
- const emptyStateProp = computed(() => props.value.emptyState);
37
35
  const editableProp = computed(() => props.value.editable);
38
- const cellSelectionPropRaw = computed(() => props.value.cellSelection);
39
- const cellSelection = computed(() => cellSelectionPropRaw.value !== false);
40
- const onCellValueChangedProp = computed(() => props.value.onCellValueChanged);
41
- const initialColumnWidths = computed(() => props.value.initialColumnWidths);
42
- const onColumnResizedProp = computed(() => props.value.onColumnResized);
36
+ const cellSelection = computed(() => props.value.cellSelection !== false);
43
37
  const pinnedColumnsProp = computed(() => props.value.pinnedColumns);
44
- const onColumnPinnedProp = computed(() => props.value.onColumnPinned);
45
- const onCellErrorProp = computed(() => props.value.onCellError);
46
38
  // Undo/redo wrapping
47
- const undoRedo = useUndoRedo({ onCellValueChanged: onCellValueChangedProp.value });
39
+ const undoRedo = useUndoRedo({ onCellValueChanged: props.value.onCellValueChanged });
48
40
  const onCellValueChanged = computed(() => undoRedo.onCellValueChanged);
49
- const flatColumnsRaw = computed(() => flattenColumns(columnsProp.value));
41
+ /**
42
+ * Core's flattenColumns returns IColumnDef<unknown>[] because the generic T
43
+ * cannot be propagated through the group-flattening algorithm. At this call
44
+ * site the input is IColumnDef<T>[] (via columnsProp), so the output is
45
+ * guaranteed to be IColumnDef<T>[] — the cast is safe.
46
+ */
47
+ const flatColumnsRaw = computed(() => flattenColumns(props.value.columns));
50
48
  const flatColumns = computed(() => {
51
49
  const pinned = pinnedColumnsProp.value;
52
50
  if (!pinned || Object.keys(pinned).length === 0)
@@ -59,8 +57,8 @@ export function useDataGridState(params) {
59
57
  });
60
58
  });
61
59
  const visibleCols = computed(() => {
62
- const vis = visibleColumnsProp.value;
63
- const order = columnOrderProp.value;
60
+ const vis = props.value.visibleColumns;
61
+ const order = props.value.columnOrder;
64
62
  const filtered = vis ? flatColumns.value.filter((c) => vis.has(c.columnId)) : flatColumns.value;
65
63
  if (!order?.length)
66
64
  return filtered;
@@ -86,7 +84,10 @@ export function useDataGridState(params) {
86
84
  const hasRowNumbersCol = computed(() => !!props.value.showRowNumbers);
87
85
  const specialColsCount = computed(() => (hasCheckboxCol.value ? 1 : 0) + (hasRowNumbersCol.value ? 1 : 0));
88
86
  const totalColCount = computed(() => visibleColumnCount.value + specialColsCount.value);
89
- const colOffset = specialColsCount.value; // snapshot: checkbox/rowNumbers cols are fixed at setup
87
+ const colOffset = specialColsCount; // reactive computed ref instead of snapshot
88
+ // shallowRef + mutate-in-place + triggerRef: the Map is mutated (clear/set)
89
+ // rather than replaced, so Vue's shallow reactivity doesn't detect the change.
90
+ // triggerRef forces dependents to re-evaluate after the in-place mutation.
90
91
  const rowIndexByRowId = shallowRef(new Map());
91
92
  watch(items, (newItems) => {
92
93
  const m = rowIndexByRowId.value;
@@ -99,7 +100,7 @@ export function useDataGridState(params) {
99
100
  getRowId,
100
101
  rowSelection: rowSelectionProp,
101
102
  controlledSelectedRows,
102
- onSelectionChange: onSelectionChangeProp.value,
103
+ onSelectionChange: props.value.onSelectionChange,
103
104
  });
104
105
  const { editingCell, setEditingCell, pendingEditorValue, setPendingEditorValue } = useCellEditing();
105
106
  const { activeCell, setActiveCell } = useActiveCell(wrapperRef, editingCell);
@@ -168,19 +169,19 @@ export function useDataGridState(params) {
168
169
  visibleCols,
169
170
  flatColumns,
170
171
  hasCheckboxCol,
171
- initialColumnWidths: initialColumnWidths.value,
172
- onColumnResized: onColumnResizedProp.value,
172
+ initialColumnWidths: props.value.initialColumnWidths,
173
+ onColumnResized: (columnId, width) => props.value.onColumnResized?.(columnId, width),
173
174
  });
174
175
  // --- Column pinning ---
175
176
  const pinningResult = useColumnPinning({
176
177
  columns: flatColumns,
177
178
  pinnedColumns: pinnedColumnsProp,
178
- onColumnPinned: onColumnPinnedProp.value,
179
+ onColumnPinned: props.value.onColumnPinned,
179
180
  });
180
181
  // Autosize callback — updates internal column sizing state + notifies external listener
181
182
  const handleAutosizeColumn = (columnId, width) => {
182
183
  setColumnSizingOverrides({ ...columnSizingOverrides.value, [columnId]: { widthPx: width } });
183
- onColumnResizedProp.value?.(columnId, width);
184
+ props.value.onColumnResized?.(columnId, width);
184
185
  };
185
186
  const headerMenuResult = useColumnHeaderMenuState({
186
187
  columns: flatColumns,
@@ -188,7 +189,7 @@ export function useDataGridState(params) {
188
189
  onPinColumn: pinningResult.pinColumn,
189
190
  onUnpinColumn: pinningResult.unpinColumn,
190
191
  onSort: props.value.onColumnSort,
191
- onColumnResized: onColumnResizedProp.value,
192
+ onColumnResized: props.value.onColumnResized,
192
193
  onAutosizeColumn: handleAutosizeColumn,
193
194
  sortBy: computed(() => props.value.sortBy),
194
195
  sortDirection: computed(() => props.value.sortDirection),
@@ -196,6 +197,8 @@ export function useDataGridState(params) {
196
197
  // Measure actual column widths from the DOM after layout changes.
197
198
  // Used as a minWidth floor to prevent columns from shrinking when new data
198
199
  // loads (e.g. during server-side pagination transitions).
200
+ // nextTick() defers measurement to after Vue has flushed its DOM updates,
201
+ // ensuring header cells reflect the latest column layout before we read widths.
199
202
  const measuredColumnWidths = ref({});
200
203
  watch([visibleCols, containerWidth, columnSizingOverrides], () => {
201
204
  void nextTick(() => {
@@ -242,12 +245,12 @@ export function useDataGridState(params) {
242
245
  const rightOffsets = computed(() => pinningResult.computeRightOffsets(visibleCols.value, columnWidthMap.value, DEFAULT_MIN_COLUMN_WIDTH));
243
246
  const aggregation = computed(() => computeAggregations(items.value, visibleCols.value, cellSelection.value ? selectionRange.value : null));
244
247
  const statusBarConfig = computed(() => {
245
- const base = getDataGridStatusBarConfig(statusBarProp.value, items.value.length, rowSelectionResult.selectedRowIds.value.size);
248
+ const base = getDataGridStatusBarConfig(props.value.statusBar, items.value.length, rowSelectionResult.selectedRowIds.value.size);
246
249
  if (!base)
247
250
  return null;
248
251
  return { ...base, aggregation: aggregation.value ?? undefined };
249
252
  });
250
- const showEmptyInGrid = computed(() => items.value.length === 0 && !!emptyStateProp.value && !props.value.isLoading);
253
+ const showEmptyInGrid = computed(() => items.value.length === 0 && !!props.value.emptyState && !props.value.isLoading);
251
254
  const hasCellSelection = computed(() => selectionRange.value != null || activeCell.value != null);
252
255
  // --- View-model inputs ---
253
256
  const headerFilterInput = computed(() => ({
@@ -266,7 +269,7 @@ export function useDataGridState(params) {
266
269
  selectionRange: cellSelection.value ? selectionRange.value : null,
267
270
  cutRange: cellSelection.value ? cutRange.value : null,
268
271
  copyRange: cellSelection.value ? copyRange.value : null,
269
- colOffset,
272
+ colOffset: colOffset.value,
270
273
  itemsLength: items.value.length,
271
274
  getRowId,
272
275
  editable: editableProp.value,
@@ -315,7 +318,7 @@ export function useDataGridState(params) {
315
318
  visibleCols: visibleCols.value,
316
319
  visibleColumnCount: visibleColumnCount.value,
317
320
  totalColCount: totalColCount.value,
318
- colOffset,
321
+ colOffset: colOffset.value,
319
322
  hasCheckboxCol: hasCheckboxCol.value,
320
323
  hasRowNumbersCol: hasRowNumbersCol.value,
321
324
  rowIndexByRowId: rowIndexByRowId.value,
@@ -324,7 +327,7 @@ export function useDataGridState(params) {
324
327
  desiredTableWidth: desiredTableWidth.value,
325
328
  columnSizingOverrides: columnSizingOverrides.value,
326
329
  setColumnSizingOverrides,
327
- onColumnResized: onColumnResizedProp.value,
330
+ onColumnResized: props.value.onColumnResized,
328
331
  measuredColumnWidths: measuredColumnWidths.value,
329
332
  }));
330
333
  const rowSelectionState = computed(() => ({
@@ -378,7 +381,7 @@ export function useDataGridState(params) {
378
381
  cellDescriptorInput: cellDescriptorInput.value,
379
382
  statusBarConfig: statusBarConfig.value,
380
383
  showEmptyInGrid: showEmptyInGrid.value,
381
- onCellError: onCellErrorProp.value,
384
+ onCellError: props.value.onCellError,
382
385
  }));
383
386
  const pinningState = computed(() => ({
384
387
  pinnedColumns: pinningResult.pinnedColumns.value,
@@ -4,7 +4,7 @@ export function useDateFilterState(params) {
4
4
  const tempDateFrom = ref(params.dateValue?.from ?? '');
5
5
  const tempDateTo = ref(params.dateValue?.to ?? '');
6
6
  // Sync temp state when popover opens
7
- watch(() => params.isFilterOpen(), (open) => {
7
+ watch(params.isFilterOpen, (open) => {
8
8
  if (open) {
9
9
  tempDateFrom.value = params.dateValue?.from ?? '';
10
10
  tempDateTo.value = params.dateValue?.to ?? '';
@@ -1,11 +1,12 @@
1
- import { shallowRef, watch, onUnmounted } from 'vue';
2
- import { normalizeSelectionRange, getCellValue, parseValue } from '@alaarab/ogrid-core';
1
+ import { shallowRef, watch, isRef, onUnmounted } from 'vue';
2
+ import { normalizeSelectionRange, applyFillValues } from '@alaarab/ogrid-core';
3
3
  const DRAG_ATTR = 'data-drag-range';
4
4
  /**
5
5
  * Manages Excel-style fill handle drag-to-fill for cell ranges.
6
6
  */
7
7
  export function useFillHandle(params) {
8
- const { items, visibleCols, editable, onCellValueChanged, selectionRange, setSelectionRange, setActiveCell, colOffset, wrapperRef, beginBatch, endBatch, visibleRange, } = params;
8
+ const { items, visibleCols, editable, onCellValueChanged, selectionRange, setSelectionRange, setActiveCell, wrapperRef, beginBatch, endBatch, visibleRange, } = params;
9
+ const getColOffset = () => isRef(params.colOffset) ? params.colOffset.value : params.colOffset;
9
10
  const fillDrag = shallowRef(null);
10
11
  let fillDragEnd = { endRow: 0, endCol: 0 };
11
12
  let rafId = 0;
@@ -29,7 +30,7 @@ export function useFillHandle(params) {
29
30
  rafId = 0;
30
31
  }
31
32
  };
32
- watch(fillDrag, (drag) => {
33
+ watch(fillDrag, (drag, _oldDrag, onCleanup) => {
33
34
  // Guard early before setting up any state
34
35
  if (!drag || editable.value === false || !onCellValueChanged.value || !wrapperRef.value) {
35
36
  // Still cleanup if transitioning from active to inactive
@@ -38,6 +39,25 @@ export function useFillHandle(params) {
38
39
  }
39
40
  fillDragEnd = { endRow: drag.startRow, endCol: drag.startCol };
40
41
  liveFillRange = null;
42
+ /** Set of currently drag-marked HTMLElements — avoids O(n) full DOM scan on clear. */
43
+ const markedCells = new Set();
44
+ /** Cell lookup index built on drag start — O(1) lookups per frame. */
45
+ let fillCellIndex = null;
46
+ const buildFillCellIndex = () => {
47
+ const wrapper = wrapperRef.value;
48
+ if (!wrapper)
49
+ return;
50
+ fillCellIndex = new Map();
51
+ const cells = wrapper.querySelectorAll('[data-row-index][data-col-index]');
52
+ for (let i = 0; i < cells.length; i++) {
53
+ const el = cells[i];
54
+ const r = el.getAttribute('data-row-index') ?? '';
55
+ const c = el.getAttribute('data-col-index') ?? '';
56
+ fillCellIndex.set(`${r},${c}`, el);
57
+ }
58
+ };
59
+ // Build the index once at fill drag start
60
+ buildFillCellIndex();
41
61
  const applyDragAttrs = (range) => {
42
62
  const wrapper = wrapperRef.value;
43
63
  if (!wrapper)
@@ -46,29 +66,40 @@ export function useFillHandle(params) {
46
66
  const maxR = Math.max(range.startRow, range.endRow);
47
67
  const minC = Math.min(range.startCol, range.endCol);
48
68
  const maxC = Math.max(range.startCol, range.endCol);
49
- const cells = wrapper.querySelectorAll('[data-row-index][data-col-index]');
50
- for (let i = 0; i < cells.length; i++) {
51
- const el = cells[i];
52
- const r = parseInt(el.getAttribute('data-row-index'), 10);
53
- const c = parseInt(el.getAttribute('data-col-index'), 10) - colOffset;
54
- const inRange = r >= minR && r <= maxR && c >= minC && c <= maxC;
55
- if (inRange) {
56
- if (!el.hasAttribute(DRAG_ATTR))
57
- el.setAttribute(DRAG_ATTR, '');
69
+ const colOff = getColOffset();
70
+ // Un-mark cells no longer in range
71
+ for (const el of markedCells) {
72
+ const r = parseInt(el.getAttribute('data-row-index') ?? '', 10);
73
+ const c = parseInt(el.getAttribute('data-col-index') ?? '', 10) - colOff;
74
+ if (!(r >= minR && r <= maxR && c >= minC && c <= maxC)) {
75
+ el.removeAttribute(DRAG_ATTR);
76
+ markedCells.delete(el);
58
77
  }
59
- else {
60
- if (el.hasAttribute(DRAG_ATTR))
61
- el.removeAttribute(DRAG_ATTR);
78
+ }
79
+ // Look up only cells in the new range — O(range size) via Map lookup
80
+ for (let r = minR; r <= maxR; r++) {
81
+ for (let c = minC; c <= maxC; c++) {
82
+ const key = `${r},${c + colOff}`;
83
+ let el = fillCellIndex?.get(key);
84
+ // Handle virtual scroll recycling — if element is stale, rebuild index once
85
+ if (el && !el.isConnected) {
86
+ buildFillCellIndex();
87
+ el = fillCellIndex?.get(key);
88
+ }
89
+ if (el) {
90
+ if (!el.hasAttribute(DRAG_ATTR))
91
+ el.setAttribute(DRAG_ATTR, '');
92
+ markedCells.add(el);
93
+ }
62
94
  }
63
95
  }
64
96
  };
65
97
  const clearDragAttrs = () => {
66
- const wrapper = wrapperRef.value;
67
- if (!wrapper)
68
- return;
69
- const marked = wrapper.querySelectorAll(`[${DRAG_ATTR}]`);
70
- for (let i = 0; i < marked.length; i++)
71
- marked[i].removeAttribute(DRAG_ATTR);
98
+ for (const el of markedCells) {
99
+ el.removeAttribute(DRAG_ATTR);
100
+ }
101
+ markedCells.clear();
102
+ fillCellIndex = null;
72
103
  };
73
104
  let lastFillMousePos = null;
74
105
  const resolveRange = (cx, cy) => {
@@ -78,6 +109,7 @@ export function useFillHandle(params) {
78
109
  return null;
79
110
  const r = parseInt(cell.getAttribute('data-row-index') ?? '', 10);
80
111
  const c = parseInt(cell.getAttribute('data-col-index') ?? '', 10);
112
+ const colOffset = getColOffset();
81
113
  if (Number.isNaN(r) || Number.isNaN(c) || c < colOffset)
82
114
  return null;
83
115
  const dataCol = c - colOffset;
@@ -134,35 +166,18 @@ export function useFillHandle(params) {
134
166
  norm.endRow = Math.min(norm.endRow, vr.endIndex);
135
167
  }
136
168
  setSelectionRange(norm);
137
- setActiveCell({ rowIndex: end.endRow, columnIndex: end.endCol + colOffset });
169
+ setActiveCell({ rowIndex: end.endRow, columnIndex: end.endCol + getColOffset() });
138
170
  const currentItems = items.value;
139
171
  const currentCols = visibleCols.value;
140
172
  const callback = onCellValueChanged.value;
141
- const startItem = currentItems[norm.startRow];
142
- const startColDef = currentCols[norm.startCol];
143
- if (startItem && startColDef && callback) {
144
- const startValue = getCellValue(startItem, startColDef);
145
- beginBatch?.();
146
- for (let row = norm.startRow; row <= norm.endRow; row++) {
147
- for (let col = norm.startCol; col <= norm.endCol; col++) {
148
- if (row === drag.startRow && col === drag.startCol)
149
- continue;
150
- if (row >= currentItems.length || col >= currentCols.length)
151
- continue;
152
- const item = currentItems[row];
153
- const colDef = currentCols[col];
154
- const colEditable = colDef.editable === true ||
155
- (typeof colDef.editable === 'function' && colDef.editable(item));
156
- if (!colEditable)
157
- continue;
158
- const oldValue = getCellValue(item, colDef);
159
- const result = parseValue(startValue, oldValue, item, colDef);
160
- if (!result.valid)
161
- continue;
162
- callback({ item, columnId: colDef.columnId, oldValue, newValue: result.value, rowIndex: row });
163
- }
173
+ if (callback) {
174
+ const fillEvents = applyFillValues(norm, drag.startRow, drag.startCol, currentItems, currentCols);
175
+ if (fillEvents.length > 0) {
176
+ beginBatch?.();
177
+ for (const evt of fillEvents)
178
+ callback(evt);
179
+ endBatch?.();
164
180
  }
165
- endBatch?.();
166
181
  }
167
182
  fillDrag.value = null;
168
183
  liveFillRange = null;
@@ -170,10 +185,12 @@ export function useFillHandle(params) {
170
185
  };
171
186
  window.addEventListener('mousemove', moveListener, true);
172
187
  window.addEventListener('mouseup', upListener, true);
173
- // Return cleanup function - Vue will call this BEFORE next watch run
174
- return () => {
188
+ // Register cleanup via onCleanup Vue calls this BEFORE next watch run
189
+ // and on unmount. Compatible with Vue 3.3+ (unlike return-value cleanup
190
+ // which requires Vue 3.5+).
191
+ onCleanup(() => {
175
192
  cleanup();
176
- };
193
+ });
177
194
  });
178
195
  onUnmounted(() => cleanup());
179
196
  const handleFillHandleMouseDown = (e) => {
@@ -1,17 +1,18 @@
1
- import { computed } from 'vue';
2
- import { normalizeSelectionRange, getCellValue, parseValue, findCtrlArrowTarget, computeTabNavigation } from '@alaarab/ogrid-core';
1
+ import { isRef } from 'vue';
2
+ import { getCellValue, computeTabNavigation, computeArrowNavigation, applyCellDeletion } from '@alaarab/ogrid-core';
3
3
  import { useLatestRef } from './useLatestRef';
4
4
  /**
5
5
  * Handles all keyboard navigation, shortcuts, and cell editing triggers for the grid.
6
6
  */
7
7
  export function useKeyboardNavigation(params) {
8
8
  // Store latest params in a ref so handleGridKeyDown is a stable callback
9
- const paramsRef = useLatestRef(computed(() => params));
9
+ const paramsRef = useLatestRef(params);
10
10
  const handleGridKeyDown = (e) => {
11
11
  const { data, state, handlers, features } = paramsRef.value;
12
12
  const items = data.items.value;
13
13
  const visibleCols = data.visibleCols.value;
14
- const { colOffset, getRowId } = data;
14
+ const { getRowId } = data;
15
+ const colOffset = isRef(data.colOffset) ? data.colOffset.value : data.colOffset;
15
16
  const hasCheckboxCol = data.hasCheckboxCol.value;
16
17
  const visibleColumnCount = data.visibleColumnCount.value;
17
18
  const activeCell = state.activeCell.value;
@@ -69,96 +70,26 @@ export function useKeyboardNavigation(params) {
69
70
  void handlePaste();
70
71
  }
71
72
  break;
72
- case 'ArrowDown': {
73
- e.preventDefault();
74
- const ctrl = e.ctrlKey || e.metaKey;
75
- const newRow = ctrl
76
- ? findCtrlArrowTarget(rowIndex, maxRowIndex, 1, (r) => isEmptyAt(r, Math.max(0, dataColIndex)))
77
- : Math.min(rowIndex + 1, maxRowIndex);
78
- if (shift) {
79
- setSelectionRange(normalizeSelectionRange({
80
- startRow: selectionRange?.startRow ?? rowIndex,
81
- startCol: selectionRange?.startCol ?? dataColIndex,
82
- endRow: newRow,
83
- endCol: selectionRange?.endCol ?? dataColIndex,
84
- }));
85
- }
86
- else {
87
- setSelectionRange({ startRow: newRow, startCol: dataColIndex, endRow: newRow, endCol: dataColIndex });
88
- }
89
- setActiveCell({ rowIndex: newRow, columnIndex });
90
- scrollToRow?.(newRow, 'center');
91
- break;
92
- }
93
- case 'ArrowUp': {
94
- e.preventDefault();
95
- const ctrl = e.ctrlKey || e.metaKey;
96
- const newRowUp = ctrl
97
- ? findCtrlArrowTarget(rowIndex, 0, -1, (r) => isEmptyAt(r, Math.max(0, dataColIndex)))
98
- : Math.max(rowIndex - 1, 0);
99
- if (shift) {
100
- setSelectionRange(normalizeSelectionRange({
101
- startRow: selectionRange?.startRow ?? rowIndex,
102
- startCol: selectionRange?.startCol ?? dataColIndex,
103
- endRow: newRowUp,
104
- endCol: selectionRange?.endCol ?? dataColIndex,
105
- }));
106
- }
107
- else {
108
- setSelectionRange({ startRow: newRowUp, startCol: dataColIndex, endRow: newRowUp, endCol: dataColIndex });
109
- }
110
- setActiveCell({ rowIndex: newRowUp, columnIndex });
111
- scrollToRow?.(newRowUp, 'center');
112
- break;
113
- }
114
- case 'ArrowRight': {
115
- e.preventDefault();
116
- const ctrl = e.ctrlKey || e.metaKey;
117
- let newCol;
118
- if (ctrl && dataColIndex >= 0) {
119
- newCol = findCtrlArrowTarget(dataColIndex, visibleCols.length - 1, 1, (c) => isEmptyAt(rowIndex, c)) + colOffset;
120
- }
121
- else {
122
- newCol = Math.min(columnIndex + 1, maxColIndex);
123
- }
124
- const newDataCol = newCol - colOffset;
125
- if (shift) {
126
- setSelectionRange(normalizeSelectionRange({
127
- startRow: selectionRange?.startRow ?? rowIndex,
128
- startCol: selectionRange?.startCol ?? dataColIndex,
129
- endRow: selectionRange?.endRow ?? rowIndex,
130
- endCol: newDataCol,
131
- }));
132
- }
133
- else {
134
- setSelectionRange({ startRow: rowIndex, startCol: newDataCol, endRow: rowIndex, endCol: newDataCol });
135
- }
136
- setActiveCell({ rowIndex, columnIndex: newCol });
137
- break;
138
- }
73
+ case 'ArrowDown':
74
+ case 'ArrowUp':
75
+ case 'ArrowRight':
139
76
  case 'ArrowLeft': {
140
77
  e.preventDefault();
141
- const ctrl = e.ctrlKey || e.metaKey;
142
- let newColLeft;
143
- if (ctrl && dataColIndex >= 0) {
144
- newColLeft = findCtrlArrowTarget(dataColIndex, 0, -1, (c) => isEmptyAt(rowIndex, c)) + colOffset;
145
- }
146
- else {
147
- newColLeft = Math.max(columnIndex - 1, colOffset);
78
+ const { newRowIndex, newColumnIndex, newRange } = computeArrowNavigation({
79
+ direction: e.key,
80
+ rowIndex, columnIndex, dataColIndex, colOffset,
81
+ maxRowIndex, maxColIndex,
82
+ visibleColCount: visibleCols.length,
83
+ isCtrl: e.ctrlKey || e.metaKey,
84
+ isShift: shift,
85
+ selectionRange,
86
+ isEmptyAt,
87
+ });
88
+ setSelectionRange(newRange);
89
+ setActiveCell({ rowIndex: newRowIndex, columnIndex: newColumnIndex });
90
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
91
+ scrollToRow?.(newRowIndex, 'center');
148
92
  }
149
- const newDataColLeft = newColLeft - colOffset;
150
- if (shift) {
151
- setSelectionRange(normalizeSelectionRange({
152
- startRow: selectionRange?.startRow ?? rowIndex,
153
- startCol: selectionRange?.startCol ?? dataColIndex,
154
- endRow: selectionRange?.endRow ?? rowIndex,
155
- endCol: newDataColLeft,
156
- }));
157
- }
158
- else {
159
- setSelectionRange({ startRow: rowIndex, startCol: newDataColLeft, endRow: rowIndex, endCol: newDataColLeft });
160
- }
161
- setActiveCell({ rowIndex, columnIndex: newColLeft });
162
93
  break;
163
94
  }
164
95
  case 'Tab': {
@@ -269,24 +200,9 @@ export function useKeyboardNavigation(params) {
269
200
  if (range == null)
270
201
  break;
271
202
  e.preventDefault();
272
- const norm = normalizeSelectionRange(range);
273
- for (let r = norm.startRow; r <= norm.endRow; r++) {
274
- for (let c = norm.startCol; c <= norm.endCol; c++) {
275
- if (r >= items.length || c >= visibleCols.length)
276
- continue;
277
- const item = items[r];
278
- const col = visibleCols[c];
279
- const colEditable = col.editable === true ||
280
- (typeof col.editable === 'function' && col.editable(item));
281
- if (!colEditable)
282
- continue;
283
- const oldValue = getCellValue(item, col);
284
- const result = parseValue('', oldValue, item, col);
285
- if (!result.valid)
286
- continue;
287
- onCellValueChanged({ item, columnId: col.columnId, oldValue, newValue: result.value, rowIndex: r });
288
- }
289
- }
203
+ const deleteEvents = applyCellDeletion(range, items, visibleCols);
204
+ for (const evt of deleteEvents)
205
+ onCellValueChanged(evt);
290
206
  break;
291
207
  }
292
208
  case 'F10':
@@ -1,4 +1,4 @@
1
- import { customRef, unref } from 'vue';
1
+ import { customRef, isRef, unref } from 'vue';
2
2
  /**
3
3
  * Returns a ref that always holds the latest value.
4
4
  * Useful for capturing volatile state in stable callbacks
@@ -13,7 +13,7 @@ export function useLatestRef(source) {
13
13
  return customRef((track, trigger) => ({
14
14
  get() {
15
15
  // Update value from source on every read (if source is a ref)
16
- if (typeof source === 'object' && source !== null && 'value' in source) {
16
+ if (isRef(source)) {
17
17
  value = source.value;
18
18
  }
19
19
  // Don't call track() - we don't want to add this to reactive dependencies
@@ -8,7 +8,7 @@ export function useMultiSelectFilterState(params) {
8
8
  const searchText = ref('');
9
9
  const debouncedSearchText = useDebounce(searchText, SEARCH_DEBOUNCE_MS);
10
10
  // Sync temp state when popover opens
11
- watch(() => params.isFilterOpen(), (open) => {
11
+ watch(params.isFilterOpen, (open) => {
12
12
  if (open) {
13
13
  tempSelected.value = new Set(params.selectedValues ?? EMPTY_OPTIONS);
14
14
  searchText.value = '';
@@ -1,5 +1,5 @@
1
- import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
2
- import { mergeFilter, deriveFilterOptionsFromData, getMultiSelectFilterFields, flattenColumns, processClientSideData, computeNextSortState, } from '@alaarab/ogrid-core';
1
+ import { ref, computed, watch, shallowRef, onMounted, onUnmounted } from 'vue';
2
+ import { mergeFilter, deriveFilterOptionsFromData, getMultiSelectFilterFields, flattenColumns, processClientSideData, computeNextSortState, validateColumns, validateRowIds, } from '@alaarab/ogrid-core';
3
3
  import { useFilterOptions } from './useFilterOptions';
4
4
  import { useSideBarState } from './useSideBarState';
5
5
  const DEFAULT_PAGE_SIZE = 25;
@@ -25,10 +25,15 @@ export function useOGrid(props) {
25
25
  // Group 2: Data identity (stable or rarely changes)
26
26
  const dataProps = computed(() => {
27
27
  const p = props.value;
28
+ const data = ('data' in p ? p.data : undefined);
29
+ const dataSource = ('dataSource' in p ? p.dataSource : undefined);
30
+ if (data && dataSource) {
31
+ console.warn('[OGrid] Both data and dataSource provided. dataSource takes precedence.');
32
+ }
28
33
  return {
29
34
  getRowId: p.getRowId,
30
- data: ('data' in p ? p.data : undefined),
31
- dataSource: ('dataSource' in p ? p.dataSource : undefined),
35
+ data,
36
+ dataSource,
32
37
  };
33
38
  });
34
39
  // Group 3: Controlled state (changes on user interaction)
@@ -171,7 +176,7 @@ export function useOGrid(props) {
171
176
  // --- Server-side fetching ---
172
177
  const serverItems = ref([]);
173
178
  const serverTotalCount = ref(0);
174
- const loading = ref(true);
179
+ const loading = ref(false);
175
180
  let fetchId = 0;
176
181
  let isDestroyed = false;
177
182
  const refreshCounter = ref(0);
@@ -208,11 +213,14 @@ export function useOGrid(props) {
208
213
  loading.value = false;
209
214
  });
210
215
  };
211
- // Initial fetch on mount
216
+ // Validate columns once on mount
212
217
  onMounted(() => {
218
+ validateColumns(columns.value);
213
219
  doFetch();
214
220
  });
215
- // Subsequent fetches on page/sort/filter changes (no immediate — onMounted handles initial)
221
+ // Subsequent fetches on page/sort/filter changes (no immediate — onMounted handles initial).
222
+ // Getter functions are used for nested properties (sort.value.field) that Vue
223
+ // can't track through a raw ref; top-level refs are passed directly.
216
224
  watch([() => dataProps.value.dataSource, page, pageSize, () => sort.value.field, () => sort.value.direction, filters, refreshCounter], () => {
217
225
  doFetch();
218
226
  });
@@ -225,13 +233,18 @@ export function useOGrid(props) {
225
233
  const displayTotalCount = computed(() => isClientSide.value && clientItemsAndTotal.value
226
234
  ? clientItemsAndTotal.value.totalCount
227
235
  : serverTotalCount.value);
228
- // Fire onFirstDataRendered once
236
+ // Fire onFirstDataRendered once; also validate row IDs on first data
229
237
  let firstDataRendered = false;
238
+ let rowIdsValidated = false;
230
239
  watch(displayItems, (items) => {
231
240
  if (!firstDataRendered && items.length > 0) {
232
241
  firstDataRendered = true;
233
242
  callbacks.value.onFirstDataRendered?.();
234
243
  }
244
+ if (!rowIdsValidated && items.length > 0) {
245
+ rowIdsValidated = true;
246
+ validateRowIds(items, dataProps.value.getRowId);
247
+ }
235
248
  });
236
249
  // With discriminated union, any defined value is active
237
250
  const hasActiveFilters = computed(() => Object.values(filters.value).some((v) => v !== undefined));
@@ -270,16 +283,21 @@ export function useOGrid(props) {
270
283
  columnProps.value.onColumnPinned?.(columnId, pinned);
271
284
  };
272
285
  // --- Side bar ---
273
- const sideBarState = useSideBarState({ config: props.value.sideBar });
286
+ // Use a shallowRef to hold sideBarState so sideBarProps computed re-runs when config changes
287
+ const sideBarStateRef = shallowRef(useSideBarState({ config: props.value.sideBar }));
288
+ watch(() => props.value.sideBar, (newConfig) => {
289
+ sideBarStateRef.value = useSideBarState({ config: newConfig });
290
+ });
274
291
  const filterableColumns = computed(() => columns.value
275
292
  .filter((c) => c.filterable && c.filterable.type)
276
293
  .map((c) => ({
277
294
  columnId: c.columnId,
278
295
  name: c.name,
279
- filterField: c.filterable.filterField ?? c.columnId,
280
- filterType: c.filterable.type,
296
+ filterField: c.filterable?.filterField ?? c.columnId,
297
+ filterType: c.filterable?.type,
281
298
  })));
282
299
  const sideBarProps = computed(() => {
300
+ const sideBarState = sideBarStateRef.value;
283
301
  if (!sideBarState.isEnabled)
284
302
  return null;
285
303
  // Re-read reactive deps so the computed tracks them, but use getters for