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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pulse-js-framework",
3
- "version": "1.8.0",
3
+ "version": "1.8.2",
4
4
  "description": "A declarative DOM framework with CSS selector-based structure and reactive pulsations",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -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 key = keyFn(item, index);
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, String(key));
163
+ dom.setAttribute(root, KEY_ATTR, key);
156
164
  }
157
165
 
158
- itemMap.set(String(key), { item, index });
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(getItems, wrappedTemplate, keyFn, listOptions);
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 = [];
@@ -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
- * @returns {Element} Scroll container element
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
- const totalItems = Array.isArray(items) ? items.length : 0;
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 startIndex = Math.floor(top / itemHeight) - overscan;
125
- let endIndex = Math.ceil((top + height) / itemHeight) + overscan;
126
- startIndex = Math.max(0, startIndex);
127
- endIndex = Math.min(totalItems, endIndex);
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', `${startIndex * itemHeight}px`);
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
- // Extract visible slice
136
- const slice = Array.isArray(items) ? items.slice(startIndex, endIndex) : [];
137
- visibleSlice.set(slice);
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 node = template(item, relativeIndex);
150
- // Set ARIA rowindex for accessibility
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 subscriber = { run: fn, dependencies: new Set() };
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
  }