vlist 1.9.0 → 2.0.0
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/README.github.md +104 -97
- package/README.md +46 -33
- package/dist/constants.d.ts +11 -6
- package/dist/constants.js +83 -0
- package/dist/core/create.d.ts +10 -0
- package/dist/core/create.js +740 -0
- package/dist/core/dom.d.ts +8 -0
- package/dist/core/dom.js +47 -0
- package/dist/core/hooks.d.ts +16 -0
- package/dist/core/hooks.js +67 -0
- package/dist/core/index.d.ts +17 -0
- package/dist/core/index.js +13 -0
- package/dist/core/pipeline.d.ts +51 -0
- package/dist/core/pipeline.js +307 -0
- package/dist/core/pool.d.ts +9 -0
- package/dist/core/pool.js +42 -0
- package/dist/core/scroll.d.ts +32 -0
- package/dist/core/scroll.js +137 -0
- package/dist/core/sizes.d.ts +8 -0
- package/dist/core/sizes.js +6 -0
- package/dist/core/state.d.ts +47 -0
- package/dist/core/state.js +56 -0
- package/dist/core/types.d.ts +187 -0
- package/dist/core/types.js +7 -0
- package/dist/{builder → core}/velocity.d.ts +1 -1
- package/dist/core/velocity.js +33 -0
- package/dist/events/emitter.js +60 -0
- package/dist/events/index.js +6 -0
- package/dist/index.d.ts +28 -19
- package/dist/index.js +28 -1
- package/dist/internals.d.ts +11 -7
- package/dist/internals.js +60 -1
- package/dist/plugins/a11y/index.d.ts +2 -0
- package/dist/plugins/a11y/index.js +1 -0
- package/dist/plugins/a11y/plugin.d.ts +13 -0
- package/dist/plugins/a11y/plugin.js +259 -0
- package/dist/{features → plugins}/async/index.d.ts +1 -1
- package/dist/plugins/async/index.js +12 -0
- package/dist/{features → plugins}/async/manager.d.ts +5 -1
- package/dist/plugins/async/manager.js +568 -0
- package/dist/plugins/async/placeholder.js +154 -0
- package/dist/plugins/async/plugin.d.ts +48 -0
- package/dist/plugins/async/plugin.js +311 -0
- package/dist/plugins/async/sparse.js +540 -0
- package/dist/plugins/autosize/index.d.ts +5 -0
- package/dist/plugins/autosize/index.js +4 -0
- package/dist/plugins/autosize/plugin.d.ts +19 -0
- package/dist/plugins/autosize/plugin.js +185 -0
- package/dist/plugins/grid/index.d.ts +7 -0
- package/dist/plugins/grid/index.js +5 -0
- package/dist/plugins/grid/layout.js +275 -0
- package/dist/plugins/grid/plugin.d.ts +23 -0
- package/dist/plugins/grid/plugin.js +347 -0
- package/dist/plugins/grid/renderer.js +525 -0
- package/dist/plugins/grid/types.js +11 -0
- package/dist/plugins/groups/async-bridge.js +246 -0
- package/dist/{features → plugins}/groups/index.d.ts +1 -1
- package/dist/plugins/groups/index.js +13 -0
- package/dist/plugins/groups/layout.js +294 -0
- package/dist/plugins/groups/plugin.d.ts +22 -0
- package/dist/plugins/groups/plugin.js +571 -0
- package/dist/plugins/groups/sticky.js +255 -0
- package/dist/plugins/groups/types.js +12 -0
- package/dist/plugins/masonry/index.d.ts +8 -0
- package/dist/plugins/masonry/index.js +6 -0
- package/dist/plugins/masonry/layout.js +261 -0
- package/dist/plugins/masonry/plugin.d.ts +32 -0
- package/dist/plugins/masonry/plugin.js +381 -0
- package/dist/plugins/masonry/renderer.js +354 -0
- package/dist/plugins/masonry/types.js +9 -0
- package/dist/plugins/page/index.d.ts +5 -0
- package/dist/plugins/page/index.js +5 -0
- package/dist/plugins/page/plugin.d.ts +21 -0
- package/dist/plugins/page/plugin.js +166 -0
- package/dist/plugins/scale/index.d.ts +5 -0
- package/dist/plugins/scale/index.js +4 -0
- package/dist/plugins/scale/plugin.d.ts +24 -0
- package/dist/plugins/scale/plugin.js +507 -0
- package/dist/plugins/scrollbar/controller.js +574 -0
- package/dist/plugins/scrollbar/index.d.ts +7 -0
- package/dist/plugins/scrollbar/index.js +6 -0
- package/dist/plugins/scrollbar/plugin.d.ts +20 -0
- package/dist/plugins/scrollbar/plugin.js +93 -0
- package/dist/plugins/scrollbar/scrollbar.js +556 -0
- package/dist/plugins/selection/index.d.ts +6 -0
- package/dist/plugins/selection/index.js +7 -0
- package/dist/plugins/selection/plugin.d.ts +16 -0
- package/dist/plugins/selection/plugin.js +601 -0
- package/dist/{features → plugins}/selection/state.d.ts +8 -0
- package/dist/plugins/selection/state.js +332 -0
- package/dist/plugins/snapshots/index.d.ts +5 -0
- package/dist/plugins/snapshots/index.js +5 -0
- package/dist/plugins/snapshots/plugin.d.ts +17 -0
- package/dist/plugins/snapshots/plugin.js +301 -0
- package/dist/plugins/sortable/index.d.ts +6 -0
- package/dist/plugins/sortable/index.js +6 -0
- package/dist/plugins/sortable/plugin.d.ts +34 -0
- package/dist/plugins/sortable/plugin.js +753 -0
- package/dist/plugins/table/header.js +501 -0
- package/dist/{features → plugins}/table/index.d.ts +1 -1
- package/dist/plugins/table/index.js +12 -0
- package/dist/plugins/table/layout.js +211 -0
- package/dist/plugins/table/plugin.d.ts +20 -0
- package/dist/plugins/table/plugin.js +391 -0
- package/dist/plugins/table/renderer.js +625 -0
- package/dist/plugins/table/types.js +12 -0
- package/dist/plugins/transition/index.d.ts +5 -0
- package/dist/plugins/transition/index.js +5 -0
- package/dist/plugins/transition/plugin.d.ts +22 -0
- package/dist/plugins/transition/plugin.js +405 -0
- package/dist/rendering/aria.js +23 -0
- package/dist/rendering/index.js +18 -0
- package/dist/rendering/measured.js +98 -0
- package/dist/rendering/renderer.js +586 -0
- package/dist/rendering/scale.js +267 -0
- package/dist/rendering/scroll.js +71 -0
- package/dist/rendering/sizes.js +193 -0
- package/dist/rendering/sort.js +65 -0
- package/dist/rendering/viewport.js +268 -0
- package/dist/size.json +1 -1
- package/dist/types.js +5 -0
- package/dist/utils/padding.d.ts +2 -4
- package/dist/utils/padding.js +49 -0
- package/dist/utils/stats.js +124 -0
- package/dist/vlist-grid.css +1 -1
- package/dist/vlist-masonry.css +1 -1
- package/dist/vlist-table.css +1 -1
- package/dist/vlist.css +1 -1
- package/package.json +9 -4
- package/dist/builder/a11y.d.ts +0 -16
- package/dist/builder/api.d.ts +0 -21
- package/dist/builder/context.d.ts +0 -36
- package/dist/builder/core.d.ts +0 -16
- package/dist/builder/data.d.ts +0 -71
- package/dist/builder/dom.d.ts +0 -15
- package/dist/builder/index.d.ts +0 -25
- package/dist/builder/materialize.d.ts +0 -166
- package/dist/builder/pool.d.ts +0 -10
- package/dist/builder/range.d.ts +0 -10
- package/dist/builder/scroll.d.ts +0 -24
- package/dist/builder/types.d.ts +0 -512
- package/dist/features/async/feature.d.ts +0 -72
- package/dist/features/autosize/feature.d.ts +0 -34
- package/dist/features/autosize/index.d.ts +0 -2
- package/dist/features/grid/feature.d.ts +0 -48
- package/dist/features/grid/index.d.ts +0 -9
- package/dist/features/groups/feature.d.ts +0 -75
- package/dist/features/masonry/feature.d.ts +0 -45
- package/dist/features/masonry/index.d.ts +0 -9
- package/dist/features/page/feature.d.ts +0 -109
- package/dist/features/page/index.d.ts +0 -9
- package/dist/features/scale/feature.d.ts +0 -42
- package/dist/features/scale/index.d.ts +0 -10
- package/dist/features/scrollbar/feature.d.ts +0 -81
- package/dist/features/scrollbar/index.d.ts +0 -8
- package/dist/features/selection/feature.d.ts +0 -91
- package/dist/features/selection/index.d.ts +0 -7
- package/dist/features/snapshots/feature.d.ts +0 -79
- package/dist/features/snapshots/index.d.ts +0 -9
- package/dist/features/sortable/feature.d.ts +0 -101
- package/dist/features/sortable/index.d.ts +0 -6
- package/dist/features/table/feature.d.ts +0 -67
- package/dist/features/transition/feature.d.ts +0 -30
- package/dist/features/transition/index.d.ts +0 -9
- /package/dist/{features → plugins}/async/placeholder.d.ts +0 -0
- /package/dist/{features → plugins}/async/sparse.d.ts +0 -0
- /package/dist/{features → plugins}/grid/layout.d.ts +0 -0
- /package/dist/{features → plugins}/grid/renderer.d.ts +0 -0
- /package/dist/{features → plugins}/grid/types.d.ts +0 -0
- /package/dist/{features → plugins}/groups/async-bridge.d.ts +0 -0
- /package/dist/{features → plugins}/groups/layout.d.ts +0 -0
- /package/dist/{features → plugins}/groups/sticky.d.ts +0 -0
- /package/dist/{features → plugins}/groups/types.d.ts +0 -0
- /package/dist/{features → plugins}/masonry/layout.d.ts +0 -0
- /package/dist/{features → plugins}/masonry/renderer.d.ts +0 -0
- /package/dist/{features → plugins}/masonry/types.d.ts +0 -0
- /package/dist/{features → plugins}/scrollbar/controller.d.ts +0 -0
- /package/dist/{features → plugins}/scrollbar/scrollbar.d.ts +0 -0
- /package/dist/{features → plugins}/table/header.d.ts +0 -0
- /package/dist/{features → plugins}/table/layout.d.ts +0 -0
- /package/dist/{features → plugins}/table/renderer.d.ts +0 -0
- /package/dist/{features → plugins}/table/types.d.ts +0 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vlist - Selection State Management
|
|
3
|
+
* Pure functions for managing selection state
|
|
4
|
+
*/
|
|
5
|
+
import { PLACEHOLDER_ID_PREFIX } from "../../constants";
|
|
6
|
+
// =============================================================================
|
|
7
|
+
// Placeholder Selection Transfer
|
|
8
|
+
// =============================================================================
|
|
9
|
+
/**
|
|
10
|
+
* Transfer selection from a placeholder ID to a real item ID.
|
|
11
|
+
*
|
|
12
|
+
* When select-all or shift-click ranges include unloaded async items, the
|
|
13
|
+
* selection Set contains index-based placeholder IDs (`__placeholder_{index}`).
|
|
14
|
+
* When those items load, this function swaps the placeholder entry for the
|
|
15
|
+
* real item ID so the item renders as selected.
|
|
16
|
+
*
|
|
17
|
+
* @returns `true` if a transfer occurred (the item was selected via its placeholder).
|
|
18
|
+
*/
|
|
19
|
+
export const claimPlaceholderSelection = (selectedIds, index, itemId) => {
|
|
20
|
+
if (String(itemId).startsWith(PLACEHOLDER_ID_PREFIX))
|
|
21
|
+
return false;
|
|
22
|
+
const phId = PLACEHOLDER_ID_PREFIX + index;
|
|
23
|
+
if (selectedIds.has(phId)) {
|
|
24
|
+
selectedIds.delete(phId);
|
|
25
|
+
selectedIds.add(itemId);
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
return false;
|
|
29
|
+
};
|
|
30
|
+
// =============================================================================
|
|
31
|
+
// State Creation
|
|
32
|
+
// =============================================================================
|
|
33
|
+
/**
|
|
34
|
+
* Create initial selection state
|
|
35
|
+
* Pure function - no side effects
|
|
36
|
+
*/
|
|
37
|
+
export const createSelectionState = (initial) => ({
|
|
38
|
+
selected: new Set(initial ?? []),
|
|
39
|
+
focusedIndex: -1,
|
|
40
|
+
focusVisible: false,
|
|
41
|
+
});
|
|
42
|
+
// =============================================================================
|
|
43
|
+
// Selection Operations
|
|
44
|
+
// =============================================================================
|
|
45
|
+
/**
|
|
46
|
+
* Select items by ID
|
|
47
|
+
* Pure function - returns new state
|
|
48
|
+
*/
|
|
49
|
+
export const selectItems = (state, ids, mode) => {
|
|
50
|
+
if (mode === "none")
|
|
51
|
+
return state;
|
|
52
|
+
const newSelected = new Set(state.selected);
|
|
53
|
+
if (mode === "single") {
|
|
54
|
+
// Single mode: replace selection
|
|
55
|
+
newSelected.clear();
|
|
56
|
+
if (ids.length > 0) {
|
|
57
|
+
newSelected.add(ids[0]);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
// Multiple mode: add to selection
|
|
62
|
+
for (const id of ids) {
|
|
63
|
+
newSelected.add(id);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
...state,
|
|
68
|
+
selected: newSelected,
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* Deselect items by ID
|
|
73
|
+
* Pure function - returns new state
|
|
74
|
+
*/
|
|
75
|
+
export const deselectItems = (state, ids) => {
|
|
76
|
+
const newSelected = new Set(state.selected);
|
|
77
|
+
for (const id of ids) {
|
|
78
|
+
newSelected.delete(id);
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
...state,
|
|
82
|
+
selected: newSelected,
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
/**
|
|
86
|
+
* Toggle item selection
|
|
87
|
+
* Pure function - returns new state
|
|
88
|
+
*/
|
|
89
|
+
export const toggleSelection = (state, id, mode) => {
|
|
90
|
+
if (mode === "none")
|
|
91
|
+
return state;
|
|
92
|
+
if (state.selected.has(id)) {
|
|
93
|
+
return deselectItems(state, [id]);
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
return selectItems(state, [id], mode);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
/**
|
|
100
|
+
* Select all items
|
|
101
|
+
* Pure function - returns new state
|
|
102
|
+
*/
|
|
103
|
+
export const selectAll = (state, items, mode) => {
|
|
104
|
+
if (mode !== "multiple")
|
|
105
|
+
return state;
|
|
106
|
+
return {
|
|
107
|
+
...state,
|
|
108
|
+
selected: new Set(items.map((item) => item.id)),
|
|
109
|
+
};
|
|
110
|
+
};
|
|
111
|
+
/**
|
|
112
|
+
* Clear all selection
|
|
113
|
+
* Pure function - returns new state
|
|
114
|
+
*/
|
|
115
|
+
export const clearSelection = (state) => ({
|
|
116
|
+
...state,
|
|
117
|
+
selected: new Set(),
|
|
118
|
+
});
|
|
119
|
+
// =============================================================================
|
|
120
|
+
// Focus Management
|
|
121
|
+
// =============================================================================
|
|
122
|
+
/**
|
|
123
|
+
* Set focused index
|
|
124
|
+
* Mutates state in-place to avoid allocation on hot path
|
|
125
|
+
*/
|
|
126
|
+
export const setFocusedIndex = (state, index) => {
|
|
127
|
+
state.focusedIndex = index;
|
|
128
|
+
return state;
|
|
129
|
+
};
|
|
130
|
+
/**
|
|
131
|
+
* Move focus up
|
|
132
|
+
* Mutates state in-place to avoid allocation on hot path
|
|
133
|
+
* @param delta - Number of items to move by (default 1). Useful for grid layouts where row navigation moves by `columns`.
|
|
134
|
+
*/
|
|
135
|
+
export const moveFocusUp = (state, totalItems, wrap = false, delta = 1) => {
|
|
136
|
+
if (totalItems === 0)
|
|
137
|
+
return state;
|
|
138
|
+
let newIndex = state.focusedIndex - delta;
|
|
139
|
+
if (newIndex < 0) {
|
|
140
|
+
newIndex = wrap ? totalItems - 1 : 0;
|
|
141
|
+
}
|
|
142
|
+
state.focusedIndex = newIndex;
|
|
143
|
+
return state;
|
|
144
|
+
};
|
|
145
|
+
/**
|
|
146
|
+
* Move focus down
|
|
147
|
+
* Mutates state in-place to avoid allocation on hot path
|
|
148
|
+
* @param delta - Number of items to move by (default 1). Useful for grid layouts where row navigation moves by `columns`.
|
|
149
|
+
*/
|
|
150
|
+
export const moveFocusDown = (state, totalItems, wrap = false, delta = 1) => {
|
|
151
|
+
if (totalItems === 0)
|
|
152
|
+
return state;
|
|
153
|
+
let newIndex = state.focusedIndex + delta;
|
|
154
|
+
if (newIndex >= totalItems) {
|
|
155
|
+
newIndex = wrap ? 0 : totalItems - 1;
|
|
156
|
+
}
|
|
157
|
+
state.focusedIndex = newIndex;
|
|
158
|
+
return state;
|
|
159
|
+
};
|
|
160
|
+
/**
|
|
161
|
+
* Move focus to first item
|
|
162
|
+
* Mutates state in-place to avoid allocation on hot path
|
|
163
|
+
*/
|
|
164
|
+
export const moveFocusToFirst = (state, totalItems) => {
|
|
165
|
+
if (totalItems === 0)
|
|
166
|
+
return state;
|
|
167
|
+
state.focusedIndex = 0;
|
|
168
|
+
return state;
|
|
169
|
+
};
|
|
170
|
+
/**
|
|
171
|
+
* Move focus to last item
|
|
172
|
+
* Mutates state in-place to avoid allocation on hot path
|
|
173
|
+
*/
|
|
174
|
+
export const moveFocusToLast = (state, totalItems) => {
|
|
175
|
+
if (totalItems === 0)
|
|
176
|
+
return state;
|
|
177
|
+
state.focusedIndex = totalItems - 1;
|
|
178
|
+
return state;
|
|
179
|
+
};
|
|
180
|
+
/**
|
|
181
|
+
* Move focus by page (for Page Up/Down)
|
|
182
|
+
* Mutates state in-place to avoid allocation on hot path
|
|
183
|
+
*/
|
|
184
|
+
export const moveFocusByPage = (state, totalItems, pageSize, direction) => {
|
|
185
|
+
if (totalItems === 0)
|
|
186
|
+
return state;
|
|
187
|
+
let newIndex = direction === "up"
|
|
188
|
+
? state.focusedIndex - pageSize
|
|
189
|
+
: state.focusedIndex + pageSize;
|
|
190
|
+
// Clamp to valid range
|
|
191
|
+
newIndex = Math.max(0, Math.min(totalItems - 1, newIndex));
|
|
192
|
+
state.focusedIndex = newIndex;
|
|
193
|
+
return state;
|
|
194
|
+
};
|
|
195
|
+
// =============================================================================
|
|
196
|
+
// Queries
|
|
197
|
+
// =============================================================================
|
|
198
|
+
/**
|
|
199
|
+
* Check if an item is selected
|
|
200
|
+
* Pure function - no side effects
|
|
201
|
+
*/
|
|
202
|
+
export const isSelected = (state, id) => {
|
|
203
|
+
return state.selected.has(id);
|
|
204
|
+
};
|
|
205
|
+
/**
|
|
206
|
+
* Get selected IDs as array
|
|
207
|
+
* Pure function - no side effects
|
|
208
|
+
*/
|
|
209
|
+
export const getSelectedIds = (state) => {
|
|
210
|
+
return Array.from(state.selected);
|
|
211
|
+
};
|
|
212
|
+
/**
|
|
213
|
+
* Get selected items using ID lookup (O(k) where k = selected count)
|
|
214
|
+
* Pure function - no side effects
|
|
215
|
+
*/
|
|
216
|
+
export const getSelectedItems = (state, getItemById) => {
|
|
217
|
+
const items = [];
|
|
218
|
+
for (const id of state.selected) {
|
|
219
|
+
const item = getItemById(id);
|
|
220
|
+
if (item) {
|
|
221
|
+
items.push(item);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return items;
|
|
225
|
+
};
|
|
226
|
+
/**
|
|
227
|
+
* Get selection count
|
|
228
|
+
* Pure function - no side effects
|
|
229
|
+
*/
|
|
230
|
+
export const getSelectionCount = (state) => {
|
|
231
|
+
return state.selected.size;
|
|
232
|
+
};
|
|
233
|
+
/**
|
|
234
|
+
* Check if selection is empty
|
|
235
|
+
* Pure function - no side effects
|
|
236
|
+
*/
|
|
237
|
+
export const isSelectionEmpty = (state) => {
|
|
238
|
+
return state.selected.size === 0;
|
|
239
|
+
};
|
|
240
|
+
// =============================================================================
|
|
241
|
+
// Keyboard Selection Helpers
|
|
242
|
+
// =============================================================================
|
|
243
|
+
/**
|
|
244
|
+
* Handle keyboard selection (Space/Enter on focused item)
|
|
245
|
+
* Pure function - returns new state
|
|
246
|
+
*/
|
|
247
|
+
export const selectFocused = (state, items, mode) => {
|
|
248
|
+
if (mode === "none" ||
|
|
249
|
+
state.focusedIndex < 0 ||
|
|
250
|
+
state.focusedIndex >= items.length) {
|
|
251
|
+
return state;
|
|
252
|
+
}
|
|
253
|
+
const item = items[state.focusedIndex];
|
|
254
|
+
if (!item)
|
|
255
|
+
return state;
|
|
256
|
+
return toggleSelection(state, item.id, mode);
|
|
257
|
+
};
|
|
258
|
+
/**
|
|
259
|
+
* Handle shift+click range selection
|
|
260
|
+
* Pure function - returns new state
|
|
261
|
+
*/
|
|
262
|
+
export const selectRange = (state, items, fromIndex, toIndex, mode) => {
|
|
263
|
+
if (mode !== "multiple")
|
|
264
|
+
return state;
|
|
265
|
+
const start = Math.min(fromIndex, toIndex);
|
|
266
|
+
const end = Math.max(fromIndex, toIndex);
|
|
267
|
+
const idsToSelect = [];
|
|
268
|
+
for (let i = start; i <= end; i++) {
|
|
269
|
+
const item = items[i];
|
|
270
|
+
if (item) {
|
|
271
|
+
idsToSelect.push(item.id);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return selectItems(state, idsToSelect, mode);
|
|
275
|
+
};
|
|
276
|
+
// =============================================================================
|
|
277
|
+
// v2 Plugin Helpers — mutable, zero-allocation variants for hot path
|
|
278
|
+
// =============================================================================
|
|
279
|
+
export function selectOne(selected, id, mode) {
|
|
280
|
+
if (mode === "single")
|
|
281
|
+
selected.clear();
|
|
282
|
+
if (mode !== "none")
|
|
283
|
+
selected.add(id);
|
|
284
|
+
}
|
|
285
|
+
export function toggleOne(selected, id, mode) {
|
|
286
|
+
if (mode === "none")
|
|
287
|
+
return;
|
|
288
|
+
if (selected.has(id)) {
|
|
289
|
+
selected.delete(id);
|
|
290
|
+
}
|
|
291
|
+
else {
|
|
292
|
+
if (mode === "single")
|
|
293
|
+
selected.clear();
|
|
294
|
+
selected.add(id);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
export function selectAllItems(selected, items) {
|
|
298
|
+
for (let i = 0; i < items.length; i++)
|
|
299
|
+
selected.add(items[i].id);
|
|
300
|
+
}
|
|
301
|
+
export function selectRangeMut(selected, items, fromIndex, toIndex) {
|
|
302
|
+
const start = Math.min(fromIndex, toIndex);
|
|
303
|
+
const end = Math.max(fromIndex, toIndex);
|
|
304
|
+
for (let i = start; i <= end; i++) {
|
|
305
|
+
const item = items[i];
|
|
306
|
+
if (item)
|
|
307
|
+
selected.add(item.id);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
export function moveFocus(state, delta, totalItems, reverse) {
|
|
311
|
+
if (totalItems === 0)
|
|
312
|
+
return;
|
|
313
|
+
const d = reverse ? -delta : delta;
|
|
314
|
+
let idx = state.focusedIndex + d;
|
|
315
|
+
if (idx < 0)
|
|
316
|
+
idx = 0;
|
|
317
|
+
if (idx >= totalItems)
|
|
318
|
+
idx = totalItems - 1;
|
|
319
|
+
state.focusedIndex = idx;
|
|
320
|
+
}
|
|
321
|
+
export function getSelectedArray(selected) {
|
|
322
|
+
return Array.from(selected);
|
|
323
|
+
}
|
|
324
|
+
export { getSelectedItems as getSelectedItemsImmutable };
|
|
325
|
+
export function getSelectedItemsMut(selected, items) {
|
|
326
|
+
const result = [];
|
|
327
|
+
for (const item of items) {
|
|
328
|
+
if (selected.has(item.id))
|
|
329
|
+
result.push(item);
|
|
330
|
+
}
|
|
331
|
+
return result;
|
|
332
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vlist v2 — Snapshots Plugin
|
|
3
|
+
*
|
|
4
|
+
* Scroll save/restore for SPA navigation and tab switching.
|
|
5
|
+
* Captures the first visible item index + sub-pixel offset,
|
|
6
|
+
* surviving list recreation and compression mode changes.
|
|
7
|
+
*
|
|
8
|
+
* Priority: 50 (runs late — needs other plugins initialized)
|
|
9
|
+
*/
|
|
10
|
+
import type { VListItem, ScrollSnapshot } from "../../types";
|
|
11
|
+
import type { VListPlugin } from "../../core/types";
|
|
12
|
+
export interface SnapshotsPluginConfig {
|
|
13
|
+
restore?: ScrollSnapshot;
|
|
14
|
+
autoSave?: string;
|
|
15
|
+
}
|
|
16
|
+
export declare function snapshots<T extends VListItem = VListItem>(config?: SnapshotsPluginConfig): VListPlugin<T>;
|
|
17
|
+
//# sourceMappingURL=plugin.d.ts.map
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vlist v2 — Snapshots Plugin
|
|
3
|
+
*
|
|
4
|
+
* Scroll save/restore for SPA navigation and tab switching.
|
|
5
|
+
* Captures the first visible item index + sub-pixel offset,
|
|
6
|
+
* surviving list recreation and compression mode changes.
|
|
7
|
+
*
|
|
8
|
+
* Priority: 50 (runs late — needs other plugins initialized)
|
|
9
|
+
*/
|
|
10
|
+
// =============================================================================
|
|
11
|
+
// Helpers
|
|
12
|
+
// =============================================================================
|
|
13
|
+
const readSnapshot = (key) => {
|
|
14
|
+
try {
|
|
15
|
+
const raw = sessionStorage.getItem(key);
|
|
16
|
+
if (!raw)
|
|
17
|
+
return undefined;
|
|
18
|
+
return JSON.parse(raw);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
// =============================================================================
|
|
25
|
+
// Factory
|
|
26
|
+
// =============================================================================
|
|
27
|
+
export function snapshots(config) {
|
|
28
|
+
const autoSaveKey = config?.autoSave;
|
|
29
|
+
const restoreSnapshot = autoSaveKey
|
|
30
|
+
? readSnapshot(autoSaveKey)
|
|
31
|
+
: config?.restore;
|
|
32
|
+
let disposed = false;
|
|
33
|
+
let saveToStorage = null;
|
|
34
|
+
return {
|
|
35
|
+
name: "snapshots",
|
|
36
|
+
priority: 50,
|
|
37
|
+
setup(ctx) {
|
|
38
|
+
const { sizeCache, emitter } = ctx;
|
|
39
|
+
const state = ctx.getState();
|
|
40
|
+
// ── getScrollSnapshot ──────────────────────────────────────
|
|
41
|
+
const getScrollSnapshot = () => {
|
|
42
|
+
const scrollTop = state.scrollPosition;
|
|
43
|
+
const totalItems = state.totalItems;
|
|
44
|
+
const getSelected = ctx.getMethod("getSelected");
|
|
45
|
+
const selectedIds = getSelected?.();
|
|
46
|
+
if (totalItems === 0) {
|
|
47
|
+
const snap = { index: 0, offsetInItem: 0, total: 0 };
|
|
48
|
+
if (selectedIds?.length)
|
|
49
|
+
snap.selectedIds = selectedIds;
|
|
50
|
+
return snap;
|
|
51
|
+
}
|
|
52
|
+
let index;
|
|
53
|
+
let offsetInItem;
|
|
54
|
+
if (state.isCompressed && state.compressionRatio !== 1) {
|
|
55
|
+
const virtualSize = state.totalSize;
|
|
56
|
+
const scrollRatio = scrollTop / virtualSize;
|
|
57
|
+
const exactIndex = scrollRatio * totalItems;
|
|
58
|
+
index = Math.max(0, Math.min(Math.floor(exactIndex), totalItems - 1));
|
|
59
|
+
const fraction = exactIndex - index;
|
|
60
|
+
offsetInItem = fraction * sizeCache.getSize(index);
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
index = sizeCache.indexAtOffset(scrollTop);
|
|
64
|
+
offsetInItem = scrollTop - sizeCache.getOffset(index);
|
|
65
|
+
}
|
|
66
|
+
offsetInItem = Math.max(0, offsetInItem);
|
|
67
|
+
const snap = {
|
|
68
|
+
index,
|
|
69
|
+
offsetInItem,
|
|
70
|
+
total: totalItems,
|
|
71
|
+
scrollTop,
|
|
72
|
+
};
|
|
73
|
+
const itemSize = sizeCache.getSize(index);
|
|
74
|
+
if (itemSize)
|
|
75
|
+
snap.offsetRatio = offsetInItem / itemSize;
|
|
76
|
+
const layoutToData = ctx.getMethod("_layoutToDataIndex");
|
|
77
|
+
if (layoutToData) {
|
|
78
|
+
let di = layoutToData(index);
|
|
79
|
+
if (di < 0) {
|
|
80
|
+
for (let i = index + 1; i < totalItems; i++) {
|
|
81
|
+
di = layoutToData(i);
|
|
82
|
+
if (di >= 0)
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (di >= 0)
|
|
87
|
+
snap.dataIndex = di;
|
|
88
|
+
}
|
|
89
|
+
const getDataTotal = ctx.getMethod("_getTotal");
|
|
90
|
+
if (getDataTotal)
|
|
91
|
+
snap.dataTotal = getDataTotal();
|
|
92
|
+
if (selectedIds?.length)
|
|
93
|
+
snap.selectedIds = selectedIds;
|
|
94
|
+
const getFocusedId = ctx.getMethod("_getFocusedId");
|
|
95
|
+
if (getFocusedId) {
|
|
96
|
+
const focusedId = getFocusedId();
|
|
97
|
+
if (focusedId !== undefined)
|
|
98
|
+
snap.focusedId = focusedId;
|
|
99
|
+
}
|
|
100
|
+
return snap;
|
|
101
|
+
};
|
|
102
|
+
ctx.registerMethod("getScrollSnapshot", getScrollSnapshot);
|
|
103
|
+
// ── restoreScroll ──────────────────────────────────────────
|
|
104
|
+
const restoreScroll = (snapshot, restoreSelection = true) => {
|
|
105
|
+
const { index, offsetInItem, selectedIds, focusedId } = snapshot;
|
|
106
|
+
let effectiveTotal = state.totalItems;
|
|
107
|
+
const bootstrapTotal = snapshot.dataTotal ?? snapshot.total;
|
|
108
|
+
if (effectiveTotal === 0 && bootstrapTotal && bootstrapTotal > 0) {
|
|
109
|
+
const setTotal = ctx.getMethod("_setTotal");
|
|
110
|
+
if (setTotal) {
|
|
111
|
+
setTotal(bootstrapTotal);
|
|
112
|
+
effectiveTotal = state.totalItems;
|
|
113
|
+
}
|
|
114
|
+
sizeCache.rebuild(effectiveTotal);
|
|
115
|
+
const updateCompression = ctx.getMethod("_updateCompressionMode");
|
|
116
|
+
if (updateCompression)
|
|
117
|
+
updateCompression();
|
|
118
|
+
if (!state.isCompressed) {
|
|
119
|
+
ctx.updateContentSize(sizeCache.getTotalSize());
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (effectiveTotal === 0)
|
|
123
|
+
return;
|
|
124
|
+
if (!Number.isFinite(index) || !Number.isFinite(offsetInItem))
|
|
125
|
+
return;
|
|
126
|
+
const sizeCacheTotal = sizeCache.getTotal();
|
|
127
|
+
if (sizeCacheTotal !== effectiveTotal) {
|
|
128
|
+
sizeCache.rebuild(effectiveTotal);
|
|
129
|
+
const updateCompression = ctx.getMethod("_updateCompressionMode");
|
|
130
|
+
if (updateCompression)
|
|
131
|
+
updateCompression();
|
|
132
|
+
if (!state.isCompressed) {
|
|
133
|
+
ctx.updateContentSize(sizeCache.getTotalSize());
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const dataToLayout = ctx.getMethod("_dataToLayoutIndex");
|
|
137
|
+
let resolvedIndex = index;
|
|
138
|
+
if (snapshot.dataIndex !== undefined && snapshot.dataIndex >= 0) {
|
|
139
|
+
resolvedIndex = dataToLayout
|
|
140
|
+
? dataToLayout(snapshot.dataIndex)
|
|
141
|
+
: snapshot.dataIndex;
|
|
142
|
+
}
|
|
143
|
+
else if (dataToLayout) {
|
|
144
|
+
resolvedIndex = dataToLayout(index);
|
|
145
|
+
}
|
|
146
|
+
const safeIndex = Math.max(0, Math.min(resolvedIndex, effectiveTotal - 1));
|
|
147
|
+
const currentItemSize = sizeCache.getSize(safeIndex);
|
|
148
|
+
const resolvedOffset = snapshot.offsetRatio !== undefined
|
|
149
|
+
? snapshot.offsetRatio * currentItemSize
|
|
150
|
+
: Math.min(offsetInItem, currentItemSize);
|
|
151
|
+
let scrollPosition;
|
|
152
|
+
if (state.isCompressed && state.compressionRatio !== 1) {
|
|
153
|
+
const currentDataTotal = ctx.getMethod("_getTotal")?.();
|
|
154
|
+
const dataTotalMatch = currentDataTotal !== undefined
|
|
155
|
+
&& snapshot.dataTotal === currentDataTotal;
|
|
156
|
+
if (snapshot.scrollTop !== undefined && (snapshot.total === effectiveTotal || dataTotalMatch)) {
|
|
157
|
+
scrollPosition = snapshot.scrollTop;
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
const fraction = currentItemSize ? resolvedOffset / currentItemSize : 0;
|
|
161
|
+
scrollPosition =
|
|
162
|
+
((safeIndex + fraction) / effectiveTotal) * state.totalSize;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
scrollPosition = sizeCache.getOffset(safeIndex) + resolvedOffset;
|
|
167
|
+
}
|
|
168
|
+
const maxScroll = Math.max(0, state.totalSize - state.containerSize);
|
|
169
|
+
scrollPosition = Math.max(0, Math.min(scrollPosition, maxScroll));
|
|
170
|
+
const usedShortcut = scrollPosition === snapshot.scrollTop;
|
|
171
|
+
if (snapshot.dataIndex !== undefined && snapshot.dataIndex >= 0) {
|
|
172
|
+
const fraction = currentItemSize ? resolvedOffset / currentItemSize : 0;
|
|
173
|
+
ctx.registerMethod("_restoreAnchor", {
|
|
174
|
+
dataIndex: snapshot.dataIndex,
|
|
175
|
+
fraction,
|
|
176
|
+
skipAdjust: usedShortcut,
|
|
177
|
+
});
|
|
178
|
+
ctx.registerMethod("_suppressSave", 1);
|
|
179
|
+
}
|
|
180
|
+
ctx.scrollTo(scrollPosition);
|
|
181
|
+
if (restoreSelection && selectedIds?.length) {
|
|
182
|
+
const selectFn = ctx.getMethod("select");
|
|
183
|
+
if (selectFn)
|
|
184
|
+
selectFn(...selectedIds);
|
|
185
|
+
}
|
|
186
|
+
const loadVisibleFn = ctx.getMethod("loadVisibleRange");
|
|
187
|
+
const restoreFocus = () => {
|
|
188
|
+
if (focusedId === undefined)
|
|
189
|
+
return;
|
|
190
|
+
const focusByIdFn = ctx.getMethod("_focusById");
|
|
191
|
+
if (focusByIdFn)
|
|
192
|
+
focusByIdFn(focusedId);
|
|
193
|
+
};
|
|
194
|
+
if (loadVisibleFn) {
|
|
195
|
+
let polls = 0;
|
|
196
|
+
const pollUntilReady = () => {
|
|
197
|
+
if (state.containerSize > 0) {
|
|
198
|
+
if (Math.abs(state.scrollPosition - scrollPosition) > 1) {
|
|
199
|
+
ctx.scrollTo(scrollPosition);
|
|
200
|
+
}
|
|
201
|
+
loadVisibleFn().then(restoreFocus);
|
|
202
|
+
}
|
|
203
|
+
else if (++polls < 10) {
|
|
204
|
+
requestAnimationFrame(pollUntilReady);
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
requestAnimationFrame(pollUntilReady);
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
const reloadFn = ctx.getMethod("reload");
|
|
211
|
+
if (reloadFn) {
|
|
212
|
+
requestAnimationFrame(() => { reloadFn().then(restoreFocus); });
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
requestAnimationFrame(restoreFocus);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
ctx.registerMethod("restoreScroll", restoreScroll);
|
|
220
|
+
// ── Auto-save ──────────────────────────────────────────────
|
|
221
|
+
let restoreGuard = !!(restoreSnapshot && autoSaveKey);
|
|
222
|
+
ctx.registerDestroyHandler(() => { disposed = true; });
|
|
223
|
+
if (autoSaveKey) {
|
|
224
|
+
saveToStorage = () => {
|
|
225
|
+
if (disposed || restoreGuard || ctx.getMethod("_suppressSave"))
|
|
226
|
+
return;
|
|
227
|
+
const snap = getScrollSnapshot();
|
|
228
|
+
try {
|
|
229
|
+
sessionStorage.setItem(autoSaveKey, JSON.stringify(snap));
|
|
230
|
+
}
|
|
231
|
+
catch { /* sessionStorage full or unavailable */ }
|
|
232
|
+
};
|
|
233
|
+
ctx.registerMethod("_saveSnapshot", saveToStorage);
|
|
234
|
+
let saveTimer = 0;
|
|
235
|
+
const debouncedSave = () => {
|
|
236
|
+
if (saveTimer)
|
|
237
|
+
return;
|
|
238
|
+
saveTimer = requestAnimationFrame(() => {
|
|
239
|
+
saveTimer = 0;
|
|
240
|
+
saveToStorage();
|
|
241
|
+
});
|
|
242
|
+
};
|
|
243
|
+
emitter.on("selection:change", debouncedSave);
|
|
244
|
+
emitter.on("focus:change", debouncedSave);
|
|
245
|
+
let scrollSaveTimer = 0;
|
|
246
|
+
const scrollSaveCallback = () => {
|
|
247
|
+
scrollSaveTimer = 0;
|
|
248
|
+
saveToStorage();
|
|
249
|
+
};
|
|
250
|
+
const debouncedScrollSave = () => {
|
|
251
|
+
if (scrollSaveTimer)
|
|
252
|
+
clearTimeout(scrollSaveTimer);
|
|
253
|
+
scrollSaveTimer = setTimeout(scrollSaveCallback, 300);
|
|
254
|
+
};
|
|
255
|
+
emitter.on("scroll", debouncedScrollSave);
|
|
256
|
+
const onBeforeUnload = () => { saveToStorage?.(); };
|
|
257
|
+
window.addEventListener("beforeunload", onBeforeUnload);
|
|
258
|
+
ctx.registerDestroyHandler(() => {
|
|
259
|
+
if (scrollSaveTimer)
|
|
260
|
+
clearTimeout(scrollSaveTimer);
|
|
261
|
+
window.removeEventListener("beforeunload", onBeforeUnload);
|
|
262
|
+
});
|
|
263
|
+
if (restoreSnapshot && restoreSnapshot.total && restoreSnapshot.total > 0) {
|
|
264
|
+
const cancelAutoLoad = ctx.getMethod("_cancelAutoLoad");
|
|
265
|
+
if (cancelAutoLoad)
|
|
266
|
+
cancelAutoLoad();
|
|
267
|
+
const setTotal = ctx.getMethod("_setTotal");
|
|
268
|
+
if (setTotal)
|
|
269
|
+
setTotal(restoreSnapshot.total);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
// ── Auto-restore ───────────────────────────────────────────
|
|
273
|
+
if (restoreSnapshot) {
|
|
274
|
+
let restoreSelection = true;
|
|
275
|
+
if (restoreSnapshot.selectedIds?.length) {
|
|
276
|
+
const seedFn = ctx.getMethod("_seedSelection");
|
|
277
|
+
if (seedFn) {
|
|
278
|
+
seedFn(restoreSnapshot.selectedIds);
|
|
279
|
+
restoreSelection = false;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
queueMicrotask(() => {
|
|
283
|
+
restoreScroll(restoreSnapshot, restoreSelection);
|
|
284
|
+
restoreGuard = false;
|
|
285
|
+
if (saveToStorage)
|
|
286
|
+
saveToStorage();
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
},
|
|
290
|
+
hooks: {
|
|
291
|
+
onIdle() {
|
|
292
|
+
if (saveToStorage && !disposed)
|
|
293
|
+
saveToStorage();
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
destroy() {
|
|
297
|
+
disposed = true;
|
|
298
|
+
saveToStorage = null;
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
}
|