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.
Files changed (182) hide show
  1. package/README.github.md +104 -97
  2. package/README.md +46 -33
  3. package/dist/constants.d.ts +11 -6
  4. package/dist/constants.js +83 -0
  5. package/dist/core/create.d.ts +10 -0
  6. package/dist/core/create.js +740 -0
  7. package/dist/core/dom.d.ts +8 -0
  8. package/dist/core/dom.js +47 -0
  9. package/dist/core/hooks.d.ts +16 -0
  10. package/dist/core/hooks.js +67 -0
  11. package/dist/core/index.d.ts +17 -0
  12. package/dist/core/index.js +13 -0
  13. package/dist/core/pipeline.d.ts +51 -0
  14. package/dist/core/pipeline.js +307 -0
  15. package/dist/core/pool.d.ts +9 -0
  16. package/dist/core/pool.js +42 -0
  17. package/dist/core/scroll.d.ts +32 -0
  18. package/dist/core/scroll.js +137 -0
  19. package/dist/core/sizes.d.ts +8 -0
  20. package/dist/core/sizes.js +6 -0
  21. package/dist/core/state.d.ts +47 -0
  22. package/dist/core/state.js +56 -0
  23. package/dist/core/types.d.ts +187 -0
  24. package/dist/core/types.js +7 -0
  25. package/dist/{builder → core}/velocity.d.ts +1 -1
  26. package/dist/core/velocity.js +33 -0
  27. package/dist/events/emitter.js +60 -0
  28. package/dist/events/index.js +6 -0
  29. package/dist/index.d.ts +28 -19
  30. package/dist/index.js +28 -1
  31. package/dist/internals.d.ts +11 -7
  32. package/dist/internals.js +60 -1
  33. package/dist/plugins/a11y/index.d.ts +2 -0
  34. package/dist/plugins/a11y/index.js +1 -0
  35. package/dist/plugins/a11y/plugin.d.ts +13 -0
  36. package/dist/plugins/a11y/plugin.js +259 -0
  37. package/dist/{features → plugins}/async/index.d.ts +1 -1
  38. package/dist/plugins/async/index.js +12 -0
  39. package/dist/{features → plugins}/async/manager.d.ts +5 -1
  40. package/dist/plugins/async/manager.js +568 -0
  41. package/dist/plugins/async/placeholder.js +154 -0
  42. package/dist/plugins/async/plugin.d.ts +48 -0
  43. package/dist/plugins/async/plugin.js +311 -0
  44. package/dist/plugins/async/sparse.js +540 -0
  45. package/dist/plugins/autosize/index.d.ts +5 -0
  46. package/dist/plugins/autosize/index.js +4 -0
  47. package/dist/plugins/autosize/plugin.d.ts +19 -0
  48. package/dist/plugins/autosize/plugin.js +185 -0
  49. package/dist/plugins/grid/index.d.ts +7 -0
  50. package/dist/plugins/grid/index.js +5 -0
  51. package/dist/plugins/grid/layout.js +275 -0
  52. package/dist/plugins/grid/plugin.d.ts +23 -0
  53. package/dist/plugins/grid/plugin.js +347 -0
  54. package/dist/plugins/grid/renderer.js +525 -0
  55. package/dist/plugins/grid/types.js +11 -0
  56. package/dist/plugins/groups/async-bridge.js +246 -0
  57. package/dist/{features → plugins}/groups/index.d.ts +1 -1
  58. package/dist/plugins/groups/index.js +13 -0
  59. package/dist/plugins/groups/layout.js +294 -0
  60. package/dist/plugins/groups/plugin.d.ts +22 -0
  61. package/dist/plugins/groups/plugin.js +571 -0
  62. package/dist/plugins/groups/sticky.js +255 -0
  63. package/dist/plugins/groups/types.js +12 -0
  64. package/dist/plugins/masonry/index.d.ts +8 -0
  65. package/dist/plugins/masonry/index.js +6 -0
  66. package/dist/plugins/masonry/layout.js +261 -0
  67. package/dist/plugins/masonry/plugin.d.ts +32 -0
  68. package/dist/plugins/masonry/plugin.js +381 -0
  69. package/dist/plugins/masonry/renderer.js +354 -0
  70. package/dist/plugins/masonry/types.js +9 -0
  71. package/dist/plugins/page/index.d.ts +5 -0
  72. package/dist/plugins/page/index.js +5 -0
  73. package/dist/plugins/page/plugin.d.ts +21 -0
  74. package/dist/plugins/page/plugin.js +166 -0
  75. package/dist/plugins/scale/index.d.ts +5 -0
  76. package/dist/plugins/scale/index.js +4 -0
  77. package/dist/plugins/scale/plugin.d.ts +24 -0
  78. package/dist/plugins/scale/plugin.js +507 -0
  79. package/dist/plugins/scrollbar/controller.js +574 -0
  80. package/dist/plugins/scrollbar/index.d.ts +7 -0
  81. package/dist/plugins/scrollbar/index.js +6 -0
  82. package/dist/plugins/scrollbar/plugin.d.ts +20 -0
  83. package/dist/plugins/scrollbar/plugin.js +93 -0
  84. package/dist/plugins/scrollbar/scrollbar.js +556 -0
  85. package/dist/plugins/selection/index.d.ts +6 -0
  86. package/dist/plugins/selection/index.js +7 -0
  87. package/dist/plugins/selection/plugin.d.ts +16 -0
  88. package/dist/plugins/selection/plugin.js +601 -0
  89. package/dist/{features → plugins}/selection/state.d.ts +8 -0
  90. package/dist/plugins/selection/state.js +332 -0
  91. package/dist/plugins/snapshots/index.d.ts +5 -0
  92. package/dist/plugins/snapshots/index.js +5 -0
  93. package/dist/plugins/snapshots/plugin.d.ts +17 -0
  94. package/dist/plugins/snapshots/plugin.js +301 -0
  95. package/dist/plugins/sortable/index.d.ts +6 -0
  96. package/dist/plugins/sortable/index.js +6 -0
  97. package/dist/plugins/sortable/plugin.d.ts +34 -0
  98. package/dist/plugins/sortable/plugin.js +753 -0
  99. package/dist/plugins/table/header.js +501 -0
  100. package/dist/{features → plugins}/table/index.d.ts +1 -1
  101. package/dist/plugins/table/index.js +12 -0
  102. package/dist/plugins/table/layout.js +211 -0
  103. package/dist/plugins/table/plugin.d.ts +20 -0
  104. package/dist/plugins/table/plugin.js +391 -0
  105. package/dist/plugins/table/renderer.js +625 -0
  106. package/dist/plugins/table/types.js +12 -0
  107. package/dist/plugins/transition/index.d.ts +5 -0
  108. package/dist/plugins/transition/index.js +5 -0
  109. package/dist/plugins/transition/plugin.d.ts +22 -0
  110. package/dist/plugins/transition/plugin.js +405 -0
  111. package/dist/rendering/aria.js +23 -0
  112. package/dist/rendering/index.js +18 -0
  113. package/dist/rendering/measured.js +98 -0
  114. package/dist/rendering/renderer.js +586 -0
  115. package/dist/rendering/scale.js +267 -0
  116. package/dist/rendering/scroll.js +71 -0
  117. package/dist/rendering/sizes.js +193 -0
  118. package/dist/rendering/sort.js +65 -0
  119. package/dist/rendering/viewport.js +268 -0
  120. package/dist/size.json +1 -1
  121. package/dist/types.js +5 -0
  122. package/dist/utils/padding.d.ts +2 -4
  123. package/dist/utils/padding.js +49 -0
  124. package/dist/utils/stats.js +124 -0
  125. package/dist/vlist-grid.css +1 -1
  126. package/dist/vlist-masonry.css +1 -1
  127. package/dist/vlist-table.css +1 -1
  128. package/dist/vlist.css +1 -1
  129. package/package.json +9 -4
  130. package/dist/builder/a11y.d.ts +0 -16
  131. package/dist/builder/api.d.ts +0 -21
  132. package/dist/builder/context.d.ts +0 -36
  133. package/dist/builder/core.d.ts +0 -16
  134. package/dist/builder/data.d.ts +0 -71
  135. package/dist/builder/dom.d.ts +0 -15
  136. package/dist/builder/index.d.ts +0 -25
  137. package/dist/builder/materialize.d.ts +0 -166
  138. package/dist/builder/pool.d.ts +0 -10
  139. package/dist/builder/range.d.ts +0 -10
  140. package/dist/builder/scroll.d.ts +0 -24
  141. package/dist/builder/types.d.ts +0 -512
  142. package/dist/features/async/feature.d.ts +0 -72
  143. package/dist/features/autosize/feature.d.ts +0 -34
  144. package/dist/features/autosize/index.d.ts +0 -2
  145. package/dist/features/grid/feature.d.ts +0 -48
  146. package/dist/features/grid/index.d.ts +0 -9
  147. package/dist/features/groups/feature.d.ts +0 -75
  148. package/dist/features/masonry/feature.d.ts +0 -45
  149. package/dist/features/masonry/index.d.ts +0 -9
  150. package/dist/features/page/feature.d.ts +0 -109
  151. package/dist/features/page/index.d.ts +0 -9
  152. package/dist/features/scale/feature.d.ts +0 -42
  153. package/dist/features/scale/index.d.ts +0 -10
  154. package/dist/features/scrollbar/feature.d.ts +0 -81
  155. package/dist/features/scrollbar/index.d.ts +0 -8
  156. package/dist/features/selection/feature.d.ts +0 -91
  157. package/dist/features/selection/index.d.ts +0 -7
  158. package/dist/features/snapshots/feature.d.ts +0 -79
  159. package/dist/features/snapshots/index.d.ts +0 -9
  160. package/dist/features/sortable/feature.d.ts +0 -101
  161. package/dist/features/sortable/index.d.ts +0 -6
  162. package/dist/features/table/feature.d.ts +0 -67
  163. package/dist/features/transition/feature.d.ts +0 -30
  164. package/dist/features/transition/index.d.ts +0 -9
  165. /package/dist/{features → plugins}/async/placeholder.d.ts +0 -0
  166. /package/dist/{features → plugins}/async/sparse.d.ts +0 -0
  167. /package/dist/{features → plugins}/grid/layout.d.ts +0 -0
  168. /package/dist/{features → plugins}/grid/renderer.d.ts +0 -0
  169. /package/dist/{features → plugins}/grid/types.d.ts +0 -0
  170. /package/dist/{features → plugins}/groups/async-bridge.d.ts +0 -0
  171. /package/dist/{features → plugins}/groups/layout.d.ts +0 -0
  172. /package/dist/{features → plugins}/groups/sticky.d.ts +0 -0
  173. /package/dist/{features → plugins}/groups/types.d.ts +0 -0
  174. /package/dist/{features → plugins}/masonry/layout.d.ts +0 -0
  175. /package/dist/{features → plugins}/masonry/renderer.d.ts +0 -0
  176. /package/dist/{features → plugins}/masonry/types.d.ts +0 -0
  177. /package/dist/{features → plugins}/scrollbar/controller.d.ts +0 -0
  178. /package/dist/{features → plugins}/scrollbar/scrollbar.d.ts +0 -0
  179. /package/dist/{features → plugins}/table/header.d.ts +0 -0
  180. /package/dist/{features → plugins}/table/layout.d.ts +0 -0
  181. /package/dist/{features → plugins}/table/renderer.d.ts +0 -0
  182. /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,5 @@
1
+ /**
2
+ * vlist/snapshots - Scroll Save/Restore
3
+ */
4
+ export { snapshots, type SnapshotsPluginConfig } from "./plugin";
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,5 @@
1
+ /**
2
+ * vlist/snapshots - Scroll Save/Restore
3
+ */
4
+ // v2 plugin
5
+ export { snapshots } from "./plugin";
@@ -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
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * vlist - Sortable Domain
3
+ * Drag-and-drop reordering for virtual lists
4
+ */
5
+ export { sortable, type SortablePluginConfig } from "./plugin";
6
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,6 @@
1
+ /**
2
+ * vlist - Sortable Domain
3
+ * Drag-and-drop reordering for virtual lists
4
+ */
5
+ // v2 Plugin
6
+ export { sortable } from "./plugin";