@barefootjs/client 0.26.4 → 0.28.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 (44) hide show
  1. package/dist/reactive.d.ts.map +1 -1
  2. package/dist/runtime/claim-slots.d.ts +222 -0
  3. package/dist/runtime/claim-slots.d.ts.map +1 -0
  4. package/dist/runtime/component.d.ts +40 -18
  5. package/dist/runtime/component.d.ts.map +1 -1
  6. package/dist/runtime/dynamic-text.d.ts +24 -1
  7. package/dist/runtime/dynamic-text.d.ts.map +1 -1
  8. package/dist/runtime/index.d.ts +4 -5
  9. package/dist/runtime/index.d.ts.map +1 -1
  10. package/dist/runtime/index.js +467 -259
  11. package/dist/runtime/loop-markers.d.ts +26 -0
  12. package/dist/runtime/loop-markers.d.ts.map +1 -0
  13. package/dist/runtime/map-array-lazy.d.ts +164 -0
  14. package/dist/runtime/map-array-lazy.d.ts.map +1 -0
  15. package/dist/runtime/map-array.d.ts +35 -0
  16. package/dist/runtime/map-array.d.ts.map +1 -1
  17. package/dist/runtime/qsa-item.d.ts +7 -0
  18. package/dist/runtime/qsa-item.d.ts.map +1 -1
  19. package/dist/runtime/registry.d.ts.map +1 -1
  20. package/dist/runtime/standalone.js +455 -248
  21. package/package.json +2 -2
  22. package/src/reactive.ts +2 -1
  23. package/src/runtime/claim-slots.ts +647 -0
  24. package/src/runtime/component.ts +153 -70
  25. package/src/runtime/dynamic-text.ts +24 -1
  26. package/src/runtime/index.ts +20 -7
  27. package/src/runtime/insert.ts +1 -1
  28. package/src/runtime/loop-markers.ts +100 -0
  29. package/src/runtime/map-array-lazy.ts +470 -0
  30. package/src/runtime/map-array.ts +68 -11
  31. package/src/runtime/qsa-item.ts +9 -3
  32. package/src/runtime/registry.ts +5 -3
  33. package/dist/runtime/client-marker.d.ts +0 -21
  34. package/dist/runtime/client-marker.d.ts.map +0 -1
  35. package/dist/runtime/list.d.ts +0 -21
  36. package/dist/runtime/list.d.ts.map +0 -1
  37. package/dist/runtime/patch-slot-range.d.ts +0 -47
  38. package/dist/runtime/patch-slot-range.d.ts.map +0 -1
  39. package/dist/runtime/reconcile-elements.d.ts +0 -44
  40. package/dist/runtime/reconcile-elements.d.ts.map +0 -1
  41. package/src/runtime/client-marker.ts +0 -46
  42. package/src/runtime/list.ts +0 -47
  43. package/src/runtime/patch-slot-range.ts +0 -105
  44. package/src/runtime/reconcile-elements.ts +0 -391
@@ -1,391 +0,0 @@
1
- /**
2
- * BarefootJS - Element-based List Reconciliation
3
- *
4
- * Key-based DOM reconciliation for component-based list rendering.
5
- * Used when renderItem returns HTMLElement (via createComponent).
6
- */
7
-
8
- import { hydratedScopes } from './hydration-state.ts'
9
- import { BF_SCOPE, BF_SLOT, BF_COND, BF_KEY, BF_LOOP_START, BF_LOOP_END, loopStartMarker, loopEndMarker } from '@barefootjs/shared'
10
-
11
- /**
12
- * Find loop boundary comment markers in a container.
13
- *
14
- * `markerId` scopes the lookup to `<!--bf-loop:<id>-->` / `<!--bf-/loop:<id>-->`
15
- * so sibling loops under the same parent disambiguate (#1087). Without an id,
16
- * accepts the legacy unscoped form too — used by tests that build containers
17
- * without compiler-emitted markers.
18
- */
19
- function findLoopMarkers(
20
- container: HTMLElement,
21
- markerId?: string,
22
- ): { startMarker: Comment | null; endMarker: Comment | null } {
23
- let startMarker: Comment | null = null
24
- let endMarker: Comment | null = null
25
- if (markerId) {
26
- const startVal = loopStartMarker(markerId)
27
- const endVal = loopEndMarker(markerId)
28
- for (const node of Array.from(container.childNodes)) {
29
- if (node.nodeType !== Node.COMMENT_NODE) continue
30
- const value = (node as Comment).nodeValue
31
- if (value === startVal) startMarker = node as Comment
32
- else if (value === endVal) endMarker = node as Comment
33
- }
34
- } else {
35
- const startPrefix = `${BF_LOOP_START}:`
36
- const endPrefix = `${BF_LOOP_END}:`
37
- for (const node of Array.from(container.childNodes)) {
38
- if (node.nodeType !== Node.COMMENT_NODE) continue
39
- const value = (node as Comment).nodeValue ?? ''
40
- if (!startMarker && (value === BF_LOOP_START || value.startsWith(startPrefix))) {
41
- startMarker = node as Comment
42
- } else if (!endMarker && (value === BF_LOOP_END || value.startsWith(endPrefix))) {
43
- endMarker = node as Comment
44
- }
45
- }
46
- }
47
- if (startMarker && endMarker) return { startMarker, endMarker }
48
- return { startMarker: null, endMarker: null }
49
- }
50
-
51
- /** Get all Element nodes between start and end comment markers. */
52
- function getElementsBetweenMarkers(start: Comment, end: Comment): Element[] {
53
- const elements: Element[] = []
54
- let node: Node | null = start.nextSibling
55
- while (node && node !== end) {
56
- if (node.nodeType === Node.ELEMENT_NODE) {
57
- elements.push(node as Element)
58
- }
59
- node = node.nextSibling
60
- }
61
- return elements
62
- }
63
-
64
- /** Remove all nodes between start and end comment markers (preserves the markers). */
65
- function removeElementsBetweenMarkers(start: Comment, end: Comment): void {
66
- let node: Node | null = start.nextSibling
67
- while (node && node !== end) {
68
- const next: Node | null = node.nextSibling
69
- node.parentNode?.removeChild(node)
70
- node = next
71
- }
72
- }
73
-
74
- /**
75
- * Get loop children from a container, respecting bf-loop boundary markers.
76
- * When markers are present, returns only elements between them.
77
- * When absent, returns all children (backward compatible).
78
- * Exported for use by compiler-generated hydration code.
79
- */
80
- export function getLoopChildren(container: HTMLElement, markerId?: string): HTMLElement[] {
81
- const { startMarker, endMarker } = findLoopMarkers(container, markerId)
82
- if (startMarker && endMarker) {
83
- return getElementsBetweenMarkers(startMarker, endMarker) as HTMLElement[]
84
- }
85
- return Array.from(container.children) as HTMLElement[]
86
- }
87
-
88
- /**
89
- * Like {@link getLoopChildren}, but returns every node between the loop
90
- * boundary markers — Comments (per-item `<!--bf-loop-i-->` markers) and
91
- * text included. The branch-clearing path needs to remove the per-item
92
- * marker comments alongside elements; otherwise stale markers would
93
- * accumulate when a branch swap forces mapArray to start over (#1212).
94
- */
95
- export function getLoopNodes(container: HTMLElement, markerId?: string): Node[] {
96
- const { startMarker, endMarker } = findLoopMarkers(container, markerId)
97
- const nodes: Node[] = []
98
- if (startMarker && endMarker) {
99
- let node: Node | null = startMarker.nextSibling
100
- while (node && node !== endMarker) {
101
- nodes.push(node)
102
- node = node.nextSibling
103
- }
104
- return nodes
105
- }
106
- return Array.from(container.childNodes)
107
- }
108
-
109
- /**
110
- * Ensure loop boundary markers exist in a container for SSR-rendered content.
111
- * SSR HTML doesn't include markers, so we insert them during hydration.
112
- * Uses itemCount to identify the last N children as loop items (rest are siblings).
113
- */
114
- export function ensureLoopMarkers(container: HTMLElement, itemCount: number, markerId?: string): void {
115
- // Already has markers
116
- const { startMarker } = findLoopMarkers(container, markerId)
117
- if (startMarker) return
118
-
119
- const children = Array.from(container.children)
120
- if (children.length === 0) return
121
-
122
- // Loop items are the LAST itemCount children (siblings come first in HTML order)
123
- const loopStartIdx = Math.max(0, children.length - itemCount)
124
- const firstLoopChild = children[loopStartIdx]
125
-
126
- const start = document.createComment(markerId ? loopStartMarker(markerId) : BF_LOOP_START)
127
- const end = document.createComment(markerId ? loopEndMarker(markerId) : BF_LOOP_END)
128
- container.insertBefore(start, firstLoopChild)
129
- container.appendChild(end)
130
- }
131
-
132
- /**
133
- * Reconcile a list container using HTMLElement mode (for createComponent).
134
- * Reuses existing elements by key, creates new elements as needed.
135
- *
136
- * @param container - The parent element containing list items
137
- * @param items - Array of items to render
138
- * @param getKey - Function to extract a unique key from each item (or null to use index)
139
- * @param renderItem - Function that returns an HTMLElement for each item
140
- * @param firstElement - Pre-created element for first item (avoids duplicate creation when caller already rendered item 0)
141
- */
142
- export function reconcileElements<T>(
143
- container: HTMLElement | null,
144
- items: T[],
145
- getKey: ((item: T, index: number) => string) | null,
146
- renderItem: (item: T, index: number) => HTMLElement,
147
- firstElement?: HTMLElement,
148
- markerId?: string,
149
- ): void {
150
- if (!container || !items) return
151
-
152
- // Find loop boundary markers if present.
153
- // When markers exist, only elements between <!--bf-loop--> and <!--/bf-loop-->
154
- // participate in reconciliation — siblings outside the range are preserved.
155
- const { startMarker, endMarker } = findLoopMarkers(container, markerId)
156
-
157
- // Collect existing keyed elements (only within loop range if markers exist)
158
- const existingByKey = new Map<string, HTMLElement>()
159
- let hasKeyedChildren = false
160
- const loopChildren = startMarker
161
- ? getElementsBetweenMarkers(startMarker, endMarker!)
162
- : Array.from(container.children)
163
- for (const child of loopChildren) {
164
- const el = child as HTMLElement
165
- const key = el.dataset?.key
166
- if (key !== undefined) {
167
- existingByKey.set(key, el)
168
- hasKeyedChildren = true
169
- }
170
- }
171
-
172
- // When no keyed children exist (initial SSR render or all-unkeyed container),
173
- // use the simple clear-and-replace path. Non-keyed children in this case are
174
- // SSR-rendered loop items that haven't been through hydration yet.
175
- if (!hasKeyedChildren) {
176
- if (items.length === 0) {
177
- if (startMarker) {
178
- removeElementsBetweenMarkers(startMarker, endMarker!)
179
- } else {
180
- container.innerHTML = ''
181
- }
182
- return
183
- }
184
-
185
- const fragment = document.createDocumentFragment()
186
- for (let i = 0; i < items.length; i++) {
187
- const el = (i === 0 && firstElement) ? firstElement : renderItem(items[i], i)
188
- const key = getKey ? getKey(items[i], i) : String(i)
189
- if (!el.dataset.key) el.setAttribute(BF_KEY, key)
190
- fragment.appendChild(el)
191
- }
192
- if (startMarker) {
193
- removeElementsBetweenMarkers(startMarker, endMarker!)
194
- endMarker!.parentNode!.insertBefore(fragment, endMarker)
195
- } else {
196
- container.innerHTML = ''
197
- container.appendChild(fragment)
198
- }
199
- return
200
- }
201
-
202
- // Insert anchor: end marker (if present) or first non-keyed sibling after keyed region.
203
- let insertAnchor: Node | null = endMarker ?? null
204
- if (!startMarker) {
205
- let foundKeyed = false
206
- for (const child of Array.from(container.childNodes)) {
207
- if (child.nodeType === Node.ELEMENT_NODE && (child as HTMLElement).dataset.key !== undefined) {
208
- foundKeyed = true
209
- } else if (foundKeyed) {
210
- insertAnchor = child
211
- break
212
- }
213
- }
214
- }
215
-
216
- // --- Phase 1: Detect focus (before ANY DOM mutation) ---
217
- // Only text inputs have ongoing user state (cursor, selection, typed text)
218
- // that must survive reconciliation. Button focus has no state to preserve.
219
- let focusedKey: string | null = null
220
- const activeEl = document.activeElement
221
- if (activeEl && activeEl !== document.body) {
222
- const tag = activeEl.tagName
223
- if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'
224
- || (activeEl as HTMLElement).isContentEditable) {
225
- for (const [key, el] of existingByKey) {
226
- if (el.contains(activeEl)) {
227
- focusedKey = key
228
- break
229
- }
230
- }
231
- }
232
- }
233
-
234
- // --- Phase 2: Build desired element list ---
235
- // For each item, decide: reuse existing (focus), create new, or skip.
236
- // Track old elements to remove explicitly — no bulk remove-all.
237
- const desiredElements: HTMLElement[] = []
238
- const toRemove: Element[] = []
239
- let focusTarget: FocusTransferInfo | null = null
240
-
241
- for (let i = 0; i < items.length; i++) {
242
- const item = items[i]
243
- const key = getKey ? getKey(item, i) : String(i)
244
- const createEl = () => (i === 0 && firstElement) ? firstElement : renderItem(item, i)
245
-
246
- const existing = existingByKey.get(key)
247
- if (existing) {
248
- existingByKey.delete(key)
249
-
250
- if (existing.getAttribute(BF_SCOPE) && !hydratedScopes.has(existing)) {
251
- // Uninitialized SSR element — replace with client-rendered element
252
- const newEl = createEl()
253
- if (!newEl.dataset.key) newEl.setAttribute(BF_KEY, key)
254
- desiredElements.push(newEl)
255
- toRemove.push(existing)
256
- } else if (focusedKey === key) {
257
- // Element contains a focused text input. Create the new element (with
258
- // updated inner loops, conditionals, etc.), copy input state now,
259
- // defer focus() to after DOM insertion to avoid flicker.
260
- const newEl = createEl()
261
- if (!newEl.dataset.key) newEl.setAttribute(BF_KEY, key)
262
- focusTarget = prepareInputTransfer(existing, newEl)
263
- desiredElements.push(newEl)
264
- toRemove.push(existing)
265
- } else {
266
- // Normal update — use new element
267
- const newEl = createEl()
268
- if (!newEl.dataset.key) newEl.setAttribute(BF_KEY, key)
269
- desiredElements.push(newEl)
270
- toRemove.push(existing)
271
- }
272
- } else {
273
- // Brand new key
274
- const el = createEl()
275
- if (!el.dataset.key) el.setAttribute(BF_KEY, key)
276
- desiredElements.push(el)
277
- }
278
- }
279
-
280
- // Remaining entries in existingByKey are orphans (key no longer in items)
281
- for (const el of existingByKey.values()) {
282
- toRemove.push(el)
283
- }
284
-
285
- // --- Phase 3: Remove old elements ---
286
- for (const el of toRemove) {
287
- if (el.parentNode) el.remove()
288
- }
289
-
290
- // --- Phase 4: Insert/move desired elements in correct order ---
291
- // insertBefore moves already-connected elements; inserts new ones.
292
- for (const el of desiredElements) {
293
- container.insertBefore(el, insertAnchor)
294
- }
295
-
296
- // --- Phase 5: Restore focus synchronously (element is now in DOM) ---
297
- if (focusTarget) {
298
- focusTarget.target.focus()
299
- if (typeof focusTarget.selectionStart === 'number') {
300
- focusTarget.target.selectionStart = focusTarget.selectionStart
301
- focusTarget.target.selectionEnd = focusTarget.selectionEnd
302
- }
303
- }
304
- }
305
-
306
- interface FocusTransferInfo {
307
- target: HTMLInputElement
308
- selectionStart: number | null
309
- selectionEnd: number | null
310
- }
311
-
312
- /**
313
- * Prepare focus transfer: copy value + selection state from old focused input
314
- * to the matching input in newEl. Returns info needed to call focus() later
315
- * (after the new element is inserted into the DOM).
316
- */
317
- function prepareInputTransfer(oldEl: HTMLElement, newEl: HTMLElement): FocusTransferInfo | null {
318
- const focused = oldEl.contains(document.activeElement) ? document.activeElement as HTMLInputElement : null
319
- if (!focused) return null
320
-
321
- const tag = focused.tagName
322
- const oldInputs = Array.from(oldEl.querySelectorAll(tag))
323
- const idx = oldInputs.indexOf(focused)
324
- if (idx < 0) return null
325
-
326
- const newInputs = Array.from(newEl.querySelectorAll(tag)) as HTMLInputElement[]
327
- const target = newInputs[idx]
328
- if (!target) return null
329
-
330
- target.value = focused.value
331
- return {
332
- target,
333
- selectionStart: focused.selectionStart,
334
- selectionEnd: focused.selectionEnd,
335
- }
336
- }
337
-
338
- /**
339
- * Sync reactive DOM state from a source element to a target element.
340
- * Copies class names, replaces conditional elements, and syncs text content.
341
- */
342
- export function syncElementState(target: HTMLElement, source: HTMLElement): void {
343
- // Sync class list (for reactive classes like 'done' on TodoItem)
344
- target.className = source.className
345
-
346
- // First, sync conditional elements by replacing them entirely
347
- const sourceCondSlots = Array.from(source.querySelectorAll(`[${BF_COND}]`))
348
- for (const sourceCondSlot of sourceCondSlots) {
349
- const condId = (sourceCondSlot as HTMLElement).getAttribute(BF_COND)
350
- if (condId) {
351
- const targetCondSlot = target.querySelector(`[${BF_COND}="${condId}"]`)
352
- if (targetCondSlot) {
353
- targetCondSlot.replaceWith(sourceCondSlot)
354
- }
355
- }
356
- }
357
-
358
- // Then sync text content of bf slots that are NOT inside conditional elements.
359
- // Use querySelectorAll on BOTH source and target, then match by position index
360
- // within each slot ID group. This handles multiple component instances that share
361
- // the same internal slot ID (e.g., multiple Badge components each with bf="s0").
362
- const sourceSlots = source.querySelectorAll(`[${BF_SLOT}]`)
363
- const targetSlotsByID = new Map<string, Element[]>()
364
- const targetAllSlots = target.querySelectorAll(`[${BF_SLOT}]`)
365
- for (const targetSlot of Array.from(targetAllSlots)) {
366
- const id = (targetSlot as HTMLElement).getAttribute(BF_SLOT)
367
- if (id) {
368
- if (!targetSlotsByID.has(id)) targetSlotsByID.set(id, [])
369
- targetSlotsByID.get(id)!.push(targetSlot)
370
- }
371
- }
372
-
373
- // Track which index we're at for each slot ID
374
- const slotIndexCounters = new Map<string, number>()
375
-
376
- for (const sourceSlot of Array.from(sourceSlots)) {
377
- const slotId = (sourceSlot as HTMLElement).getAttribute(BF_SLOT)
378
- if (slotId) {
379
- if (sourceSlot.closest(`[${BF_COND}]`)) continue
380
- const idx = slotIndexCounters.get(slotId) ?? 0
381
- slotIndexCounters.set(slotId, idx + 1)
382
- const targets = targetSlotsByID.get(slotId)
383
- const targetSlot = targets?.[idx]
384
- if (targetSlot && sourceSlot.textContent !== null) {
385
- if (sourceSlot.children.length === 0) {
386
- targetSlot.textContent = sourceSlot.textContent
387
- }
388
- }
389
- }
390
- }
391
- }