@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.
- package/dist/esm/components/createDataGridTable.js +5 -1
- package/dist/esm/components/createOGrid.js +10 -7
- package/dist/esm/composables/useCellSelection.js +101 -61
- package/dist/esm/composables/useClipboard.js +15 -55
- package/dist/esm/composables/useColumnChooserState.js +4 -7
- package/dist/esm/composables/useColumnHeaderFilterState.js +10 -7
- package/dist/esm/composables/useColumnHeaderMenuState.js +2 -4
- package/dist/esm/composables/useColumnPinning.js +2 -2
- package/dist/esm/composables/useColumnReorder.js +8 -1
- package/dist/esm/composables/useDataGridState.js +33 -30
- package/dist/esm/composables/useDateFilterState.js +1 -1
- package/dist/esm/composables/useFillHandle.js +67 -50
- package/dist/esm/composables/useKeyboardNavigation.js +25 -109
- package/dist/esm/composables/useLatestRef.js +2 -2
- package/dist/esm/composables/useMultiSelectFilterState.js +1 -1
- package/dist/esm/composables/useOGrid.js +29 -11
- package/dist/esm/composables/usePeopleFilterState.js +2 -2
- package/dist/esm/composables/useRowSelection.js +13 -16
- package/dist/esm/composables/useTableLayout.js +11 -11
- package/dist/esm/composables/useTextFilterState.js +1 -1
- package/dist/esm/composables/useVirtualScroll.js +20 -17
- package/dist/types/composables/index.d.ts +1 -0
- package/dist/types/composables/useCellSelection.d.ts +1 -1
- package/dist/types/composables/useClipboard.d.ts +1 -1
- package/dist/types/composables/useDateFilterState.d.ts +2 -2
- package/dist/types/composables/useFillHandle.d.ts +1 -1
- package/dist/types/composables/useKeyboardNavigation.d.ts +4 -6
- package/dist/types/composables/useLatestRef.d.ts +3 -1
- package/dist/types/composables/useMultiSelectFilterState.d.ts +1 -1
- package/dist/types/composables/usePeopleFilterState.d.ts +1 -1
- package/dist/types/composables/useTextFilterState.d.ts +2 -2
- package/dist/types/index.d.ts +1 -1
- 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
|
|
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:
|
|
39
|
+
const undoRedo = useUndoRedo({ onCellValueChanged: props.value.onCellValueChanged });
|
|
48
40
|
const onCellValueChanged = computed(() => undoRedo.onCellValueChanged);
|
|
49
|
-
|
|
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 =
|
|
63
|
-
const order =
|
|
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
|
|
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:
|
|
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:
|
|
172
|
-
onColumnResized:
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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 && !!
|
|
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:
|
|
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:
|
|
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(
|
|
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,
|
|
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,
|
|
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
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const r = parseInt(el.getAttribute('data-row-index'), 10);
|
|
53
|
-
const c = parseInt(el.getAttribute('data-col-index'), 10) -
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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 +
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
//
|
|
174
|
-
|
|
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 {
|
|
2
|
-
import {
|
|
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(
|
|
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 {
|
|
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
|
-
|
|
74
|
-
|
|
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
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
273
|
-
for (
|
|
274
|
-
|
|
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 (
|
|
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(
|
|
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
|
|
31
|
-
dataSource
|
|
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(
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
280
|
-
filterType: c.filterable
|
|
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
|