pulse-js-framework 1.8.0 → 1.8.2
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/package.json +1 -1
- package/runtime/dom-event-delegate.js +32 -4
- package/runtime/dom-list.js +50 -0
- package/runtime/dom-virtual-list.js +100 -14
- package/runtime/pulse.js +6 -1
- package/runtime/router.js +548 -170
- package/types/dom.d.ts +133 -0
- package/types/index.d.ts +15 -1
package/package.json
CHANGED
|
@@ -145,22 +145,50 @@ export function delegatedList(getItems, template, keyFn, options = {}) {
|
|
|
145
145
|
// Internal map: key -> { item, index }
|
|
146
146
|
const itemMap = new Map();
|
|
147
147
|
|
|
148
|
+
// Track active keys per render cycle to clean stale itemMap entries
|
|
149
|
+
let activeKeys = new Set();
|
|
150
|
+
|
|
148
151
|
// Wrap template to add data-pulse-key attribute
|
|
149
152
|
const wrappedTemplate = (item, index) => {
|
|
150
|
-
const
|
|
153
|
+
const rawKey = keyFn(item, index);
|
|
154
|
+
// Security: ensure key is a string or number to prevent collisions
|
|
155
|
+
// Objects would all stringify to "[object Object]"
|
|
156
|
+
const key = (typeof rawKey === 'string' || typeof rawKey === 'number')
|
|
157
|
+
? String(rawKey)
|
|
158
|
+
: String(index);
|
|
151
159
|
const node = template(item, index);
|
|
152
160
|
const root = Array.isArray(node) ? node[0] : node;
|
|
153
161
|
|
|
154
162
|
if (root && dom.isElement(root)) {
|
|
155
|
-
dom.setAttribute(root, KEY_ATTR,
|
|
163
|
+
dom.setAttribute(root, KEY_ATTR, key);
|
|
156
164
|
}
|
|
157
165
|
|
|
158
|
-
itemMap.set(
|
|
166
|
+
itemMap.set(key, { item, index });
|
|
167
|
+
activeKeys.add(key);
|
|
159
168
|
return node;
|
|
160
169
|
};
|
|
161
170
|
|
|
171
|
+
// Wrap getItems to track render cycles and prune stale entries
|
|
172
|
+
const wrappedGetItems = typeof getItems === 'function'
|
|
173
|
+
? () => {
|
|
174
|
+
activeKeys = new Set();
|
|
175
|
+
const result = getItems();
|
|
176
|
+
// Schedule cleanup after current effect cycle completes
|
|
177
|
+
if (typeof queueMicrotask === 'function') {
|
|
178
|
+
queueMicrotask(() => {
|
|
179
|
+
for (const key of itemMap.keys()) {
|
|
180
|
+
if (!activeKeys.has(key)) {
|
|
181
|
+
itemMap.delete(key);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
return result;
|
|
187
|
+
}
|
|
188
|
+
: getItems; // Pulse source — template calls track keys directly
|
|
189
|
+
|
|
162
190
|
// Create list with wrapped template
|
|
163
|
-
const fragment = list(
|
|
191
|
+
const fragment = list(wrappedGetItems, wrappedTemplate, keyFn, listOptions);
|
|
164
192
|
|
|
165
193
|
// Set up delegation on the fragment's parent when it's mounted
|
|
166
194
|
const cleanups = [];
|
package/runtime/dom-list.js
CHANGED
|
@@ -142,10 +142,60 @@ export function list(getItems, template, keyFn = (item, i) => i, options = {}) {
|
|
|
142
142
|
});
|
|
143
143
|
|
|
144
144
|
// Phase 2: Batch create new nodes using DocumentFragment
|
|
145
|
+
// When recycling is enabled, try to acquire root elements from the pool
|
|
146
|
+
// before falling back to createElement
|
|
145
147
|
if (newItems.length > 0) {
|
|
146
148
|
for (const { key, item, index } of newItems) {
|
|
147
149
|
const result = template(item, index);
|
|
148
150
|
const nodes = Array.isArray(result) ? result : [result];
|
|
151
|
+
|
|
152
|
+
// Pool acquire: if the root node is an element and pool has a matching
|
|
153
|
+
// tag, transfer children/attributes from template result to recycled element
|
|
154
|
+
if (pool && nodes.length === 1 && dom.isElement(nodes[0])) {
|
|
155
|
+
const original = nodes[0];
|
|
156
|
+
const tagName = (original.tagName || original.nodeName || '').toLowerCase();
|
|
157
|
+
if (tagName) {
|
|
158
|
+
const recycled = pool.acquire(tagName);
|
|
159
|
+
// Only use recycled element if it came from the pool (not freshly created)
|
|
160
|
+
// Pool.acquire creates new if empty, so always returns a valid element
|
|
161
|
+
if (recycled !== original) {
|
|
162
|
+
// Transfer attributes (skip event handler attributes for security)
|
|
163
|
+
if (original.attributes) {
|
|
164
|
+
const attrs = original.attributes;
|
|
165
|
+
for (let a = 0; a < attrs.length; a++) {
|
|
166
|
+
const attrName = attrs[a].name;
|
|
167
|
+
// Security: skip inline event handlers (onclick, onerror, etc.)
|
|
168
|
+
if (attrName.length > 2 && attrName.charCodeAt(0) === 111 &&
|
|
169
|
+
attrName.charCodeAt(1) === 110 && attrName.charCodeAt(2) > 96) {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
dom.setAttribute(recycled, attrName, attrs[a].value);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// Transfer children
|
|
176
|
+
let child = dom.getFirstChild(original);
|
|
177
|
+
while (child) {
|
|
178
|
+
const next = dom.getNextSibling(child);
|
|
179
|
+
dom.appendChild(recycled, child);
|
|
180
|
+
child = next;
|
|
181
|
+
}
|
|
182
|
+
// Transfer event listeners if tracked
|
|
183
|
+
if (original._eventListeners) {
|
|
184
|
+
recycled._eventListeners = original._eventListeners;
|
|
185
|
+
}
|
|
186
|
+
// Transfer inline styles
|
|
187
|
+
if (original.style && original.style.cssText) {
|
|
188
|
+
recycled.style.cssText = original.style.cssText;
|
|
189
|
+
}
|
|
190
|
+
// Transfer className
|
|
191
|
+
if (original.className) {
|
|
192
|
+
recycled.className = original.className;
|
|
193
|
+
}
|
|
194
|
+
nodes[0] = recycled;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
149
199
|
newItemNodes.set(key, { nodes, cleanup: null, item });
|
|
150
200
|
}
|
|
151
201
|
}
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { pulse, effect, computed, batch } from './pulse.js';
|
|
13
13
|
import { getAdapter } from './dom-adapter.js';
|
|
14
14
|
import { list } from './dom-list.js';
|
|
15
|
+
import { delegate } from './dom-event-delegate.js';
|
|
15
16
|
|
|
16
17
|
// ============================================================================
|
|
17
18
|
// Constants
|
|
@@ -20,7 +21,8 @@ import { list } from './dom-list.js';
|
|
|
20
21
|
const DEFAULT_OPTIONS = {
|
|
21
22
|
overscan: 5,
|
|
22
23
|
containerHeight: 400,
|
|
23
|
-
recycle: false
|
|
24
|
+
recycle: false,
|
|
25
|
+
on: null
|
|
24
26
|
};
|
|
25
27
|
|
|
26
28
|
// ============================================================================
|
|
@@ -38,7 +40,8 @@ const DEFAULT_OPTIONS = {
|
|
|
38
40
|
* @param {number} [options.overscan=5] - Extra items above/below viewport
|
|
39
41
|
* @param {number|string} [options.containerHeight=400] - Viewport height in px or 'auto'
|
|
40
42
|
* @param {boolean} [options.recycle=false] - Enable element recycling for removed items
|
|
41
|
-
* @
|
|
43
|
+
* @param {Object} [options.on] - Delegated event handlers: { eventType: (event, item, index) => void }
|
|
44
|
+
* @returns {Element} Scroll container element with scrollToIndex(index) and _dispose() methods
|
|
42
45
|
*
|
|
43
46
|
* @example
|
|
44
47
|
* const vlist = virtualList(
|
|
@@ -51,7 +54,7 @@ const DEFAULT_OPTIONS = {
|
|
|
51
54
|
*/
|
|
52
55
|
export function virtualList(getItems, template, keyFn, options = {}) {
|
|
53
56
|
const config = { ...DEFAULT_OPTIONS, ...options };
|
|
54
|
-
const { itemHeight, overscan, containerHeight, recycle } = config;
|
|
57
|
+
const { itemHeight, overscan, containerHeight, recycle, on: eventHandlers } = config;
|
|
55
58
|
|
|
56
59
|
if (!itemHeight || itemHeight <= 0) {
|
|
57
60
|
throw new Error('[Pulse] virtualList requires a positive itemHeight');
|
|
@@ -66,6 +69,7 @@ export function virtualList(getItems, template, keyFn, options = {}) {
|
|
|
66
69
|
|
|
67
70
|
// Outer container: fixed height, scrollable
|
|
68
71
|
const container = dom.createElement('div');
|
|
72
|
+
container.scrollTop = 0; // Initialize for mock/SSR compatibility
|
|
69
73
|
dom.setAttribute(container, 'role', 'list');
|
|
70
74
|
dom.setAttribute(container, 'aria-label', 'Virtual scrolling list');
|
|
71
75
|
setStyles(dom, container, {
|
|
@@ -107,10 +111,13 @@ export function virtualList(getItems, template, keyFn, options = {}) {
|
|
|
107
111
|
|
|
108
112
|
// ---- Compute visible items slice ----
|
|
109
113
|
const visibleSlice = pulse([]);
|
|
114
|
+
const currentStartIndex = pulse(0);
|
|
115
|
+
let allItems = [];
|
|
110
116
|
|
|
111
117
|
effect(() => {
|
|
112
118
|
const items = typeof getItems === 'function' ? getItems() : getItems.get();
|
|
113
|
-
|
|
119
|
+
allItems = Array.isArray(items) ? items : [];
|
|
120
|
+
const totalItems = allItems.length;
|
|
114
121
|
const top = scrollTop.get();
|
|
115
122
|
|
|
116
123
|
// Update spacer height
|
|
@@ -121,20 +128,21 @@ export function virtualList(getItems, template, keyFn, options = {}) {
|
|
|
121
128
|
? containerHeight
|
|
122
129
|
: (container.clientHeight || 400);
|
|
123
130
|
|
|
124
|
-
let
|
|
125
|
-
let
|
|
126
|
-
|
|
127
|
-
|
|
131
|
+
let startIdx = Math.floor(top / itemHeight) - overscan;
|
|
132
|
+
let endIdx = Math.ceil((top + height) / itemHeight) + overscan;
|
|
133
|
+
startIdx = Math.max(0, startIdx);
|
|
134
|
+
endIdx = Math.min(totalItems, endIdx);
|
|
128
135
|
|
|
129
136
|
// Position viewport at start offset
|
|
130
|
-
dom.setStyle(viewport, 'top', `${
|
|
137
|
+
dom.setStyle(viewport, 'top', `${startIdx * itemHeight}px`);
|
|
131
138
|
|
|
132
139
|
// ARIA: announce total count
|
|
133
140
|
dom.setAttribute(container, 'aria-rowcount', String(totalItems));
|
|
134
141
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
142
|
+
batch(() => {
|
|
143
|
+
currentStartIndex.set(startIdx);
|
|
144
|
+
visibleSlice.set(allItems.slice(startIdx, endIdx));
|
|
145
|
+
});
|
|
138
146
|
});
|
|
139
147
|
|
|
140
148
|
// ---- Render visible items using list() ----
|
|
@@ -143,14 +151,30 @@ export function virtualList(getItems, template, keyFn, options = {}) {
|
|
|
143
151
|
listOptions.recycle = true;
|
|
144
152
|
}
|
|
145
153
|
|
|
154
|
+
// Item map for event delegation
|
|
155
|
+
const itemMap = new Map();
|
|
156
|
+
const KEY_ATTR = 'data-pulse-key';
|
|
157
|
+
|
|
146
158
|
const rendered = list(
|
|
147
159
|
() => visibleSlice.get(),
|
|
148
160
|
(item, relativeIndex) => {
|
|
149
|
-
const
|
|
150
|
-
|
|
161
|
+
const absIndex = currentStartIndex.peek() + relativeIndex;
|
|
162
|
+
const node = template(item, absIndex);
|
|
151
163
|
const root = Array.isArray(node) ? node[0] : node;
|
|
152
164
|
if (root && dom.isElement(root)) {
|
|
153
165
|
dom.setAttribute(root, 'role', 'listitem');
|
|
166
|
+
// ARIA: set 1-based row index for screen readers
|
|
167
|
+
dom.setAttribute(root, 'aria-rowindex', String(absIndex + 1));
|
|
168
|
+
// Mark for event delegation
|
|
169
|
+
if (eventHandlers) {
|
|
170
|
+
const rawKey = keyFn(item, absIndex);
|
|
171
|
+
// Security: ensure key is a primitive to prevent collisions
|
|
172
|
+
const key = (typeof rawKey === 'string' || typeof rawKey === 'number')
|
|
173
|
+
? String(rawKey)
|
|
174
|
+
: String(absIndex);
|
|
175
|
+
dom.setAttribute(root, KEY_ATTR, key);
|
|
176
|
+
itemMap.set(key, { item, index: absIndex });
|
|
177
|
+
}
|
|
154
178
|
}
|
|
155
179
|
return node;
|
|
156
180
|
},
|
|
@@ -160,6 +184,65 @@ export function virtualList(getItems, template, keyFn, options = {}) {
|
|
|
160
184
|
|
|
161
185
|
dom.appendChild(viewport, rendered);
|
|
162
186
|
|
|
187
|
+
// ---- Event delegation on viewport ----
|
|
188
|
+
const delegateCleanups = [];
|
|
189
|
+
|
|
190
|
+
if (eventHandlers && typeof eventHandlers === 'object') {
|
|
191
|
+
// Set up delegation after microtask (viewport needs to be in DOM)
|
|
192
|
+
const setupDelegation = () => {
|
|
193
|
+
for (const [eventType, handler] of Object.entries(eventHandlers)) {
|
|
194
|
+
const cleanup = delegate(viewport, eventType, `[${KEY_ATTR}]`, (event, matchedEl) => {
|
|
195
|
+
const key = dom.getAttribute(matchedEl, KEY_ATTR);
|
|
196
|
+
const entry = itemMap.get(key);
|
|
197
|
+
if (entry) {
|
|
198
|
+
handler(event, entry.item, entry.index);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
delegateCleanups.push(cleanup);
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
if (typeof queueMicrotask === 'function') {
|
|
206
|
+
queueMicrotask(setupDelegation);
|
|
207
|
+
} else {
|
|
208
|
+
setupDelegation();
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ---- Programmatic scroll API ----
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Scroll to bring a specific item index into view.
|
|
216
|
+
* @param {number} index - Zero-based item index
|
|
217
|
+
* @param {Object} [scrollOptions] - Options
|
|
218
|
+
* @param {'start'|'center'|'end'} [scrollOptions.align='start'] - Alignment in viewport
|
|
219
|
+
*/
|
|
220
|
+
container.scrollToIndex = (index, scrollOptions = {}) => {
|
|
221
|
+
const { align = 'start' } = scrollOptions;
|
|
222
|
+
const totalItems = allItems.length;
|
|
223
|
+
const clampedIndex = Math.max(0, Math.min(index, totalItems - 1));
|
|
224
|
+
|
|
225
|
+
const height = typeof containerHeight === 'number'
|
|
226
|
+
? containerHeight
|
|
227
|
+
: (container.clientHeight || 400);
|
|
228
|
+
|
|
229
|
+
let targetTop;
|
|
230
|
+
if (align === 'center') {
|
|
231
|
+
targetTop = clampedIndex * itemHeight - (height / 2) + (itemHeight / 2);
|
|
232
|
+
} else if (align === 'end') {
|
|
233
|
+
targetTop = (clampedIndex + 1) * itemHeight - height;
|
|
234
|
+
} else {
|
|
235
|
+
targetTop = clampedIndex * itemHeight;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
targetTop = Math.max(0, targetTop);
|
|
239
|
+
|
|
240
|
+
if (container.scrollTop !== undefined) {
|
|
241
|
+
container.scrollTop = targetTop;
|
|
242
|
+
}
|
|
243
|
+
scrollTop.set(targetTop);
|
|
244
|
+
};
|
|
245
|
+
|
|
163
246
|
// ---- Cleanup method ----
|
|
164
247
|
container._dispose = () => {
|
|
165
248
|
dom.removeEventListener(container, 'scroll', onScroll);
|
|
@@ -167,6 +250,9 @@ export function virtualList(getItems, template, keyFn, options = {}) {
|
|
|
167
250
|
(typeof cancelAnimationFrame === 'function' ? cancelAnimationFrame : clearTimeout)(rafId);
|
|
168
251
|
rafId = null;
|
|
169
252
|
}
|
|
253
|
+
for (const cleanup of delegateCleanups) cleanup();
|
|
254
|
+
delegateCleanups.length = 0;
|
|
255
|
+
itemMap.clear();
|
|
170
256
|
};
|
|
171
257
|
|
|
172
258
|
return container;
|
package/runtime/pulse.js
CHANGED
|
@@ -526,7 +526,12 @@ export class Pulse {
|
|
|
526
526
|
* unsub(); // Stop listening
|
|
527
527
|
*/
|
|
528
528
|
subscribe(fn) {
|
|
529
|
-
const
|
|
529
|
+
const self = this;
|
|
530
|
+
const subscriber = {
|
|
531
|
+
run() { fn(self.peek()); },
|
|
532
|
+
dependencies: new Set(),
|
|
533
|
+
_isSubscriber: true
|
|
534
|
+
};
|
|
530
535
|
this.#subscribers.add(subscriber);
|
|
531
536
|
return () => this.#subscribers.delete(subscriber);
|
|
532
537
|
}
|