@nisli/core 0.43.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.md +100 -0
- package/dist/component.d.ts +75 -0
- package/dist/component.js +260 -0
- package/dist/component.js.map +1 -0
- package/dist/context.d.ts +43 -0
- package/dist/context.js +55 -0
- package/dist/context.js.map +1 -0
- package/dist/emitter.d.ts +51 -0
- package/dist/emitter.js +90 -0
- package/dist/emitter.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +34 -0
- package/dist/index.js.map +1 -0
- package/dist/injector.d.ts +65 -0
- package/dist/injector.js +100 -0
- package/dist/injector.js.map +1 -0
- package/dist/lifecycle.d.ts +57 -0
- package/dist/lifecycle.js +107 -0
- package/dist/lifecycle.js.map +1 -0
- package/dist/query.d.ts +81 -0
- package/dist/query.js +231 -0
- package/dist/query.js.map +1 -0
- package/dist/ref.d.ts +36 -0
- package/dist/ref.js +43 -0
- package/dist/ref.js.map +1 -0
- package/dist/signal.d.ts +93 -0
- package/dist/signal.js +403 -0
- package/dist/signal.js.map +1 -0
- package/dist/template.d.ts +49 -0
- package/dist/template.js +753 -0
- package/dist/template.js.map +1 -0
- package/package.json +42 -0
package/dist/template.js
ADDED
|
@@ -0,0 +1,753 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* template.ts — Tagged template engine with targeted DOM binding
|
|
3
|
+
*
|
|
4
|
+
* The html tagged template literal parses HTML with expression slots,
|
|
5
|
+
* creates bindings for signals, and produces a TemplateResult that
|
|
6
|
+
* can be mounted into the DOM with surgical updates.
|
|
7
|
+
*
|
|
8
|
+
* Two phases:
|
|
9
|
+
* 1. First render: parse template, clone, create bindings, mount
|
|
10
|
+
* 2. Updates: signal changes trigger individual binding updates (O(1))
|
|
11
|
+
*/
|
|
12
|
+
import { signal, isSignal, effect, computed, } from './signal.js';
|
|
13
|
+
import { isRef } from './ref.js';
|
|
14
|
+
// ── Template cache ──────────────────────────────────────────────────
|
|
15
|
+
/**
|
|
16
|
+
* Cache parsed templates by their static string parts.
|
|
17
|
+
* Since tagged templates always produce the same static strings array
|
|
18
|
+
* reference per call site, we use WeakMap for efficient caching.
|
|
19
|
+
*/
|
|
20
|
+
const templateCache = new WeakMap();
|
|
21
|
+
// ── Marker for expression slots ─────────────────────────────────────
|
|
22
|
+
const MARKER_PREFIX = '<!--bk-';
|
|
23
|
+
const MARKER_SUFFIX = '-->';
|
|
24
|
+
const ATTR_MARKER = 'bk-';
|
|
25
|
+
/**
|
|
26
|
+
* Generate a unique marker for each expression slot.
|
|
27
|
+
* We use HTML comments as markers in text positions and
|
|
28
|
+
* special attribute prefixes for attribute positions.
|
|
29
|
+
*/
|
|
30
|
+
function createMarker(index) {
|
|
31
|
+
return `${MARKER_PREFIX}${index}${MARKER_SUFFIX}`;
|
|
32
|
+
}
|
|
33
|
+
// ── html tagged template ────────────────────────────────────────────
|
|
34
|
+
/**
|
|
35
|
+
* Tagged template literal for creating reactive DOM templates.
|
|
36
|
+
*
|
|
37
|
+
* ```ts
|
|
38
|
+
* const name = signal('World');
|
|
39
|
+
* const greeting = html`<h1>Hello, ${name}!</h1>`;
|
|
40
|
+
* ```
|
|
41
|
+
*
|
|
42
|
+
* Signals in expression slots are detected automatically and
|
|
43
|
+
* create fine-grained bindings. When the signal changes, only
|
|
44
|
+
* the specific text node or attribute updates — no diffing needed.
|
|
45
|
+
*/
|
|
46
|
+
export function html(strings, ...values) {
|
|
47
|
+
const bindings = [];
|
|
48
|
+
const disposers = [];
|
|
49
|
+
return {
|
|
50
|
+
__templateResult: true,
|
|
51
|
+
mount(host) {
|
|
52
|
+
// Build the HTML string with markers.
|
|
53
|
+
// Track HTML parsing state to auto-quote markers in unquoted
|
|
54
|
+
// attribute positions where the > in --> would close the tag.
|
|
55
|
+
// See ADR 0069.
|
|
56
|
+
let htmlStr = '';
|
|
57
|
+
let inTag = false;
|
|
58
|
+
let quoteChar = null;
|
|
59
|
+
for (let i = 0; i < strings.length; i++) {
|
|
60
|
+
const s = strings[i] ?? '';
|
|
61
|
+
for (let c = 0; c < s.length; c++) {
|
|
62
|
+
const ch = s.charAt(c);
|
|
63
|
+
if (quoteChar) {
|
|
64
|
+
if (ch === quoteChar)
|
|
65
|
+
quoteChar = null;
|
|
66
|
+
}
|
|
67
|
+
else if (inTag) {
|
|
68
|
+
if (ch === '>')
|
|
69
|
+
inTag = false;
|
|
70
|
+
else if (ch === '"' || ch === "'")
|
|
71
|
+
quoteChar = ch;
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
if (ch === '<')
|
|
75
|
+
inTag = true;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
htmlStr += s;
|
|
79
|
+
if (i < values.length) {
|
|
80
|
+
const needsQuotes = inTag && !quoteChar && /=\s*$/.test(s);
|
|
81
|
+
htmlStr += needsQuotes ? `"${createMarker(i)}"` : createMarker(i);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
// Parse the HTML
|
|
85
|
+
const template = document.createElement('template');
|
|
86
|
+
template.innerHTML = htmlStr;
|
|
87
|
+
const fragment = template.content.cloneNode(true);
|
|
88
|
+
// Walk the DOM tree and replace markers with bindings
|
|
89
|
+
processNode(fragment, values, bindings, disposers);
|
|
90
|
+
// Mount into host
|
|
91
|
+
host.appendChild(fragment);
|
|
92
|
+
},
|
|
93
|
+
dispose() {
|
|
94
|
+
for (const d of disposers) {
|
|
95
|
+
try {
|
|
96
|
+
d();
|
|
97
|
+
}
|
|
98
|
+
catch (_) { }
|
|
99
|
+
}
|
|
100
|
+
disposers.length = 0;
|
|
101
|
+
for (const b of bindings) {
|
|
102
|
+
if ('dispose' in b && b.dispose) {
|
|
103
|
+
try {
|
|
104
|
+
b.dispose();
|
|
105
|
+
}
|
|
106
|
+
catch (_) { }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
bindings.length = 0;
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
// ── DOM walking and binding creation ────────────────────────────────
|
|
114
|
+
function processNode(node, values, bindings, disposers) {
|
|
115
|
+
// Process element attributes
|
|
116
|
+
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
117
|
+
const el = node;
|
|
118
|
+
processAttributes(el, values, bindings, disposers);
|
|
119
|
+
}
|
|
120
|
+
// Process text nodes and comments (markers)
|
|
121
|
+
if (node.nodeType === Node.COMMENT_NODE) {
|
|
122
|
+
const comment = node;
|
|
123
|
+
const text = comment.textContent || '';
|
|
124
|
+
// Check if this is one of our markers
|
|
125
|
+
if (text.startsWith('bk-') && !isNaN(Number(text.slice(3)))) {
|
|
126
|
+
const index = Number(text.slice(3));
|
|
127
|
+
const value = values[index];
|
|
128
|
+
replaceMarkerWithBinding(comment, value, bindings, disposers);
|
|
129
|
+
return; // Don't recurse into replaced content
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// Process child nodes (make a copy since we may mutate)
|
|
133
|
+
const children = [...node.childNodes];
|
|
134
|
+
for (const child of children) {
|
|
135
|
+
processNode(child, values, bindings, disposers);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function processAttributes(el, values, bindings, disposers) {
|
|
139
|
+
// We need to find attributes that contain markers
|
|
140
|
+
const attrsToRemove = [];
|
|
141
|
+
for (const attr of [...el.attributes]) {
|
|
142
|
+
const name = attr.name;
|
|
143
|
+
const attrValue = attr.value;
|
|
144
|
+
// Check for @event bindings
|
|
145
|
+
if (name.startsWith('@')) {
|
|
146
|
+
const eventParts = name.slice(1).split('.');
|
|
147
|
+
const eventName = eventParts[0] ?? '';
|
|
148
|
+
const modifiers = eventParts.slice(1);
|
|
149
|
+
// The value should be a marker containing the handler
|
|
150
|
+
const markerMatch = attrValue.match(/<!--bk-(\d+)-->/);
|
|
151
|
+
if (markerMatch) {
|
|
152
|
+
const index = Number(markerMatch[1]);
|
|
153
|
+
const handler = values[index];
|
|
154
|
+
if (typeof handler === 'function') {
|
|
155
|
+
bindEvent(el, eventName, handler, modifiers, bindings);
|
|
156
|
+
disposers.push(() => el.removeEventListener(eventName, handler));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
attrsToRemove.push(name);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
// Check for class:name bindings
|
|
163
|
+
if (name.startsWith('class:')) {
|
|
164
|
+
const className = name.slice(6);
|
|
165
|
+
const markerMatch = attrValue.match(/<!--bk-(\d+)-->/);
|
|
166
|
+
if (markerMatch) {
|
|
167
|
+
const index = Number(markerMatch[1]);
|
|
168
|
+
const value = values[index];
|
|
169
|
+
bindClass(el, className, value, bindings, disposers);
|
|
170
|
+
}
|
|
171
|
+
attrsToRemove.push(name);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
// Check for html:inner binding (trusted HTML rendering)
|
|
175
|
+
if (name === 'html:inner') {
|
|
176
|
+
const markerMatch = attrValue.match(/<!--bk-(\d+)-->/);
|
|
177
|
+
if (markerMatch) {
|
|
178
|
+
const index = Number(markerMatch[1]);
|
|
179
|
+
const value = values[index];
|
|
180
|
+
bindInnerHtml(el, value, bindings, disposers);
|
|
181
|
+
}
|
|
182
|
+
attrsToRemove.push(name);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
// Check for ref binding: ref="${myRef}"
|
|
186
|
+
if (name === 'ref' && attrValue.includes(MARKER_PREFIX)) {
|
|
187
|
+
const markerMatch = attrValue.match(/<!--bk-(\d+)-->/);
|
|
188
|
+
if (markerMatch) {
|
|
189
|
+
const index = Number(markerMatch[1]);
|
|
190
|
+
const value = values[index];
|
|
191
|
+
if (isRef(value)) {
|
|
192
|
+
value.current = el;
|
|
193
|
+
disposers.push(() => { value.current = null; });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
attrsToRemove.push(name);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
// Check for regular attribute bindings with markers
|
|
200
|
+
if (attrValue.includes(MARKER_PREFIX)) {
|
|
201
|
+
const markers = [...attrValue.matchAll(/<!--bk-(\d+)-->/g)];
|
|
202
|
+
if (markers.length) {
|
|
203
|
+
const first = markers[0];
|
|
204
|
+
// Single expression = entire value: preserve raw type and signal
|
|
205
|
+
if (markers.length === 1 && first && attrValue === first[0]) {
|
|
206
|
+
bindAttribute(el, name, values[Number(first[1])], bindings, disposers);
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
// Mixed static + dynamic: resolve markers into string
|
|
210
|
+
const resolve = () => attrValue.replace(/<!--bk-(\d+)-->/g, (_, i) => {
|
|
211
|
+
const v = values[Number(i)];
|
|
212
|
+
const raw = isSignal(v) ? v.value : v;
|
|
213
|
+
return raw == null || raw === false ? '' : String(raw);
|
|
214
|
+
});
|
|
215
|
+
const hasSignals = markers.some(m => isSignal(values[Number(m[1])]));
|
|
216
|
+
bindAttribute(el, name, hasSignals ? computed(resolve) : resolve(), bindings, disposers);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
for (const name of attrsToRemove) {
|
|
223
|
+
el.removeAttribute(name);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Mount a factory result (from component() factory functions) into a DOM element.
|
|
228
|
+
* Handles prop forwarding (with signal subscriptions) and host-level class attrs.
|
|
229
|
+
*/
|
|
230
|
+
function mountFactoryResult(factory, disposers) {
|
|
231
|
+
const el = document.createElement(factory.tagName);
|
|
232
|
+
for (const [key, raw] of Object.entries(factory.props)) {
|
|
233
|
+
if (isSignal(raw)) {
|
|
234
|
+
const sig = raw;
|
|
235
|
+
el._setProp?.(key, sig.value);
|
|
236
|
+
const unsub = sig.subscribe((newVal) => {
|
|
237
|
+
el._setProp?.(key, newVal);
|
|
238
|
+
});
|
|
239
|
+
disposers.push(unsub);
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
el._setProp?.(key, raw);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (factory.hostAttrs?.class != null) {
|
|
246
|
+
const classVal = factory.hostAttrs.class;
|
|
247
|
+
if (isSignal(classVal)) {
|
|
248
|
+
const sig = classVal;
|
|
249
|
+
let prevClasses = [];
|
|
250
|
+
const applyClasses = (raw) => {
|
|
251
|
+
const next = raw ? raw.split(/\s+/).filter(Boolean) : [];
|
|
252
|
+
for (const cls of prevClasses) {
|
|
253
|
+
if (!next.includes(cls))
|
|
254
|
+
el.classList.remove(cls);
|
|
255
|
+
}
|
|
256
|
+
for (const cls of next) {
|
|
257
|
+
if (!prevClasses.includes(cls))
|
|
258
|
+
el.classList.add(cls);
|
|
259
|
+
}
|
|
260
|
+
prevClasses = next;
|
|
261
|
+
};
|
|
262
|
+
applyClasses(sig.value);
|
|
263
|
+
const unsub = sig.subscribe(applyClasses);
|
|
264
|
+
disposers.push(unsub);
|
|
265
|
+
}
|
|
266
|
+
else if (typeof classVal === 'string' && classVal) {
|
|
267
|
+
for (const cls of classVal.split(/\s+/).filter(Boolean)) {
|
|
268
|
+
el.classList.add(cls);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return el;
|
|
273
|
+
}
|
|
274
|
+
function replaceMarkerWithBinding(comment, value, bindings, disposers) {
|
|
275
|
+
const parent = comment.parentNode;
|
|
276
|
+
if (!parent)
|
|
277
|
+
return;
|
|
278
|
+
if (isSignal(value)) {
|
|
279
|
+
const signalValue = value.value;
|
|
280
|
+
// Check if the signal holds a TemplateResult, factory, null, undefined, or array
|
|
281
|
+
// (e.g., from reactive when(), computed(() => items.map(renderFn)), or factory results)
|
|
282
|
+
const isReactiveSlot = signalValue === null || signalValue === undefined
|
|
283
|
+
|| (signalValue && typeof signalValue === 'object' && '__templateResult' in signalValue)
|
|
284
|
+
|| (signalValue && typeof signalValue === 'object' && '__type' in signalValue)
|
|
285
|
+
|| Array.isArray(signalValue);
|
|
286
|
+
if (isReactiveSlot) {
|
|
287
|
+
// Reactive template slot — mount/unmount templates as signal changes
|
|
288
|
+
const startMarker = document.createComment('slot-start');
|
|
289
|
+
const endMarker = document.createComment('slot-end');
|
|
290
|
+
parent.replaceChild(endMarker, comment);
|
|
291
|
+
parent.insertBefore(startMarker, endMarker);
|
|
292
|
+
let currentResults = [];
|
|
293
|
+
let currentNodes = [];
|
|
294
|
+
const dispose = effect(() => {
|
|
295
|
+
const newValue = value.value;
|
|
296
|
+
// Use endMarker.parentNode — the captured `parent` may be a
|
|
297
|
+
// DocumentFragment that was already appended to the real DOM,
|
|
298
|
+
// leaving the markers reparented under the actual DOM element.
|
|
299
|
+
const liveParent = endMarker.parentNode;
|
|
300
|
+
if (!liveParent)
|
|
301
|
+
return;
|
|
302
|
+
// Remove previous content
|
|
303
|
+
for (const r of currentResults) {
|
|
304
|
+
try {
|
|
305
|
+
r.dispose();
|
|
306
|
+
}
|
|
307
|
+
catch (_) { }
|
|
308
|
+
}
|
|
309
|
+
for (const node of currentNodes) {
|
|
310
|
+
node.parentNode?.removeChild(node);
|
|
311
|
+
}
|
|
312
|
+
currentNodes = [];
|
|
313
|
+
currentResults = [];
|
|
314
|
+
if (newValue == null) {
|
|
315
|
+
// null/undefined — nothing to mount
|
|
316
|
+
}
|
|
317
|
+
else if (Array.isArray(newValue)) {
|
|
318
|
+
// Array of TemplateResults (or mixed content)
|
|
319
|
+
for (const item of newValue) {
|
|
320
|
+
if (item && typeof item === 'object' && '__templateResult' in item) {
|
|
321
|
+
const tpl = item;
|
|
322
|
+
const wrapper = document.createDocumentFragment();
|
|
323
|
+
tpl.mount(wrapper);
|
|
324
|
+
const nodes = [...wrapper.childNodes];
|
|
325
|
+
currentNodes.push(...nodes);
|
|
326
|
+
currentResults.push(tpl);
|
|
327
|
+
liveParent.insertBefore(wrapper, endMarker);
|
|
328
|
+
}
|
|
329
|
+
else if (item && typeof item === 'object' && '__type' in item && item.__type === 'factory') {
|
|
330
|
+
const el = mountFactoryResult(item, disposers);
|
|
331
|
+
currentNodes.push(el);
|
|
332
|
+
liveParent.insertBefore(el, endMarker);
|
|
333
|
+
}
|
|
334
|
+
else if (item != null && item !== false) {
|
|
335
|
+
const textNode = document.createTextNode(String(item));
|
|
336
|
+
currentNodes.push(textNode);
|
|
337
|
+
liveParent.insertBefore(textNode, endMarker);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
else if (typeof newValue === 'object' && '__templateResult' in newValue) {
|
|
342
|
+
// Single TemplateResult
|
|
343
|
+
const tpl = newValue;
|
|
344
|
+
const wrapper = document.createDocumentFragment();
|
|
345
|
+
tpl.mount(wrapper);
|
|
346
|
+
currentNodes = [...wrapper.childNodes];
|
|
347
|
+
currentResults.push(tpl);
|
|
348
|
+
liveParent.insertBefore(wrapper, endMarker);
|
|
349
|
+
}
|
|
350
|
+
else if (typeof newValue === 'object' && '__type' in newValue && newValue.__type === 'factory') {
|
|
351
|
+
// Factory result — create child element
|
|
352
|
+
const el = mountFactoryResult(newValue, disposers);
|
|
353
|
+
currentNodes.push(el);
|
|
354
|
+
liveParent.insertBefore(el, endMarker);
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
disposers.push(dispose);
|
|
358
|
+
}
|
|
359
|
+
else {
|
|
360
|
+
// Primitive signal — create a reactive text node
|
|
361
|
+
const textNode = document.createTextNode(String(signalValue));
|
|
362
|
+
parent.replaceChild(textNode, comment);
|
|
363
|
+
const binding = { type: 'text', node: textNode };
|
|
364
|
+
bindings.push(binding);
|
|
365
|
+
const dispose = effect(() => {
|
|
366
|
+
textNode.data = String(value.value);
|
|
367
|
+
});
|
|
368
|
+
binding.dispose = dispose;
|
|
369
|
+
disposers.push(dispose);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
else if (value && typeof value === 'object' && '__templateResult' in value) {
|
|
373
|
+
// Nested template result — mount it
|
|
374
|
+
const result = value;
|
|
375
|
+
const startMarker = document.createComment('tpl-start');
|
|
376
|
+
const endMarker = document.createComment('tpl-end');
|
|
377
|
+
parent.replaceChild(endMarker, comment);
|
|
378
|
+
parent.insertBefore(startMarker, endMarker);
|
|
379
|
+
// Create a wrapper element to mount into
|
|
380
|
+
const wrapper = document.createDocumentFragment();
|
|
381
|
+
result.mount(wrapper);
|
|
382
|
+
parent.insertBefore(wrapper, endMarker);
|
|
383
|
+
disposers.push(() => result.dispose());
|
|
384
|
+
}
|
|
385
|
+
else if (value && typeof value === 'object' && '__type' in value && value.__type === 'factory') {
|
|
386
|
+
// Factory result — create child element
|
|
387
|
+
const el = mountFactoryResult(value, disposers);
|
|
388
|
+
parent.replaceChild(el, comment);
|
|
389
|
+
}
|
|
390
|
+
else if (Array.isArray(value)) {
|
|
391
|
+
// Array of template results
|
|
392
|
+
const startMarker = document.createComment('list-start');
|
|
393
|
+
const endMarker = document.createComment('list-end');
|
|
394
|
+
parent.replaceChild(endMarker, comment);
|
|
395
|
+
parent.insertBefore(startMarker, endMarker);
|
|
396
|
+
for (const item of value) {
|
|
397
|
+
if (item && typeof item === 'object' && '__templateResult' in item) {
|
|
398
|
+
const wrapper = document.createDocumentFragment();
|
|
399
|
+
item.mount(wrapper);
|
|
400
|
+
parent.insertBefore(wrapper, endMarker);
|
|
401
|
+
disposers.push(() => item.dispose());
|
|
402
|
+
}
|
|
403
|
+
else {
|
|
404
|
+
const textNode = document.createTextNode(String(item));
|
|
405
|
+
parent.insertBefore(textNode, endMarker);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
else if (value == null || value === false) {
|
|
410
|
+
// null, undefined, false — render nothing
|
|
411
|
+
parent.removeChild(comment);
|
|
412
|
+
}
|
|
413
|
+
else {
|
|
414
|
+
// Primitive value — render as text
|
|
415
|
+
const textNode = document.createTextNode(String(value));
|
|
416
|
+
parent.replaceChild(textNode, comment);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
function bindAttribute(el, name, value, bindings, disposers) {
|
|
420
|
+
// class attribute gets special handling to avoid conflicts with
|
|
421
|
+
// class:name directives (see bindClassAttribute for details).
|
|
422
|
+
if (name === 'class') {
|
|
423
|
+
bindClassAttribute(el, value, bindings, disposers);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
// Auto-resolution: framework components get _setProp for custom props
|
|
427
|
+
// (preserves types). Standard HTML attributes (id, style, data-*,
|
|
428
|
+
// aria-*) always use setAttribute even on framework components.
|
|
429
|
+
const isHtmlAttr = name === 'id' || name === 'style'
|
|
430
|
+
|| name === 'slot' || name.startsWith('data-') || name.startsWith('aria-');
|
|
431
|
+
const hasPropSetter = !isHtmlAttr && typeof el._setProp === 'function';
|
|
432
|
+
if (hasPropSetter) {
|
|
433
|
+
if (isSignal(value)) {
|
|
434
|
+
const binding = { type: 'attribute', element: el, name };
|
|
435
|
+
bindings.push(binding);
|
|
436
|
+
const dispose = effect(() => {
|
|
437
|
+
el._setProp(name, value.value);
|
|
438
|
+
});
|
|
439
|
+
binding.dispose = dispose;
|
|
440
|
+
disposers.push(dispose);
|
|
441
|
+
}
|
|
442
|
+
else {
|
|
443
|
+
el._setProp(name, value);
|
|
444
|
+
}
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
if (isSignal(value)) {
|
|
448
|
+
const binding = { type: 'attribute', element: el, name };
|
|
449
|
+
bindings.push(binding);
|
|
450
|
+
const dispose = effect(() => {
|
|
451
|
+
const v = value.value;
|
|
452
|
+
if (v == null || v === false) {
|
|
453
|
+
el.removeAttribute(name);
|
|
454
|
+
}
|
|
455
|
+
else if (v === true) {
|
|
456
|
+
el.setAttribute(name, '');
|
|
457
|
+
}
|
|
458
|
+
else {
|
|
459
|
+
el.setAttribute(name, String(v));
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
binding.dispose = dispose;
|
|
463
|
+
disposers.push(dispose);
|
|
464
|
+
}
|
|
465
|
+
else {
|
|
466
|
+
if (value == null || value === false) {
|
|
467
|
+
el.removeAttribute(name);
|
|
468
|
+
}
|
|
469
|
+
else if (value === true) {
|
|
470
|
+
el.setAttribute(name, '');
|
|
471
|
+
}
|
|
472
|
+
else {
|
|
473
|
+
el.setAttribute(name, String(value));
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Bind a reactive class attribute using classList.add/remove instead of
|
|
479
|
+
* setAttribute('class', ...). This prevents the class attribute binding
|
|
480
|
+
* from overwriting classes toggled by class:name directives.
|
|
481
|
+
*
|
|
482
|
+
* The problem: setAttribute('class', 'foo bar') replaces ALL classes,
|
|
483
|
+
* wiping out any classes added by classList.toggle() from class:name
|
|
484
|
+
* bindings. By tracking which classes "belong" to the class attribute
|
|
485
|
+
* and using classList operations, we only manage our own classes.
|
|
486
|
+
*/
|
|
487
|
+
function bindClassAttribute(el, value, bindings, disposers) {
|
|
488
|
+
// On first call, we must clear the parser-set class attribute which
|
|
489
|
+
// contains raw marker text (e.g. "badge status-<!--bk-0-->").
|
|
490
|
+
// We snapshot any non-marker classes set by the parser before clearing.
|
|
491
|
+
let initialized = false;
|
|
492
|
+
let prevClasses = [];
|
|
493
|
+
const applyClasses = (raw) => {
|
|
494
|
+
if (!initialized) {
|
|
495
|
+
// Clear the parser's class attribute (contains marker text)
|
|
496
|
+
el.setAttribute('class', '');
|
|
497
|
+
initialized = true;
|
|
498
|
+
}
|
|
499
|
+
const str = raw == null || raw === false ? '' : String(raw);
|
|
500
|
+
const next = str.split(/\s+/).filter(Boolean);
|
|
501
|
+
// Remove classes no longer in the attribute value
|
|
502
|
+
for (const cls of prevClasses) {
|
|
503
|
+
if (!next.includes(cls)) {
|
|
504
|
+
el.classList.remove(cls);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
// Add new classes
|
|
508
|
+
for (const cls of next) {
|
|
509
|
+
if (!prevClasses.includes(cls)) {
|
|
510
|
+
el.classList.add(cls);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
prevClasses = next;
|
|
514
|
+
};
|
|
515
|
+
if (isSignal(value)) {
|
|
516
|
+
const binding = { type: 'attribute', element: el, name: 'class' };
|
|
517
|
+
bindings.push(binding);
|
|
518
|
+
const dispose = effect(() => {
|
|
519
|
+
applyClasses(value.value);
|
|
520
|
+
});
|
|
521
|
+
binding.dispose = dispose;
|
|
522
|
+
disposers.push(dispose);
|
|
523
|
+
}
|
|
524
|
+
else {
|
|
525
|
+
applyClasses(value);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
function bindClass(el, className, value, bindings, disposers) {
|
|
529
|
+
if (isSignal(value)) {
|
|
530
|
+
const binding = { type: 'class', element: el, className };
|
|
531
|
+
bindings.push(binding);
|
|
532
|
+
const dispose = effect(() => {
|
|
533
|
+
const v = value.value;
|
|
534
|
+
el.classList.toggle(className, !!v);
|
|
535
|
+
});
|
|
536
|
+
binding.dispose = dispose;
|
|
537
|
+
disposers.push(dispose);
|
|
538
|
+
}
|
|
539
|
+
else {
|
|
540
|
+
el.classList.toggle(className, !!value);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Bind trusted HTML content to an element's innerHTML.
|
|
545
|
+
*
|
|
546
|
+
* WARNING: This is for trusted content only (e.g., highlighted search results
|
|
547
|
+
* from @orama/highlight, diff2html output). NEVER use with user-generated input.
|
|
548
|
+
*
|
|
549
|
+
* Supports both static strings and reactive Signal<string> values.
|
|
550
|
+
* When the signal changes, innerHTML is updated reactively.
|
|
551
|
+
*
|
|
552
|
+
* Usage in templates:
|
|
553
|
+
* html`<span html:inner="${highlightedHtml}"></span>`
|
|
554
|
+
*/
|
|
555
|
+
function bindInnerHtml(el, value, bindings, disposers) {
|
|
556
|
+
if (isSignal(value)) {
|
|
557
|
+
const binding = { type: 'innerHtml', element: el };
|
|
558
|
+
bindings.push(binding);
|
|
559
|
+
const dispose = effect(() => {
|
|
560
|
+
const v = value.value;
|
|
561
|
+
el.innerHTML = v == null ? '' : String(v);
|
|
562
|
+
});
|
|
563
|
+
binding.dispose = dispose;
|
|
564
|
+
disposers.push(dispose);
|
|
565
|
+
}
|
|
566
|
+
else {
|
|
567
|
+
el.innerHTML = value == null ? '' : String(value);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
function bindEvent(el, eventName, handler, modifiers, bindings) {
|
|
571
|
+
let wrappedHandler = handler;
|
|
572
|
+
// Apply modifiers
|
|
573
|
+
if (modifiers.includes('stop')) {
|
|
574
|
+
const original = wrappedHandler;
|
|
575
|
+
wrappedHandler = (e) => {
|
|
576
|
+
e.stopPropagation();
|
|
577
|
+
original(e);
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
if (modifiers.includes('prevent')) {
|
|
581
|
+
const original = wrappedHandler;
|
|
582
|
+
wrappedHandler = (e) => {
|
|
583
|
+
e.preventDefault();
|
|
584
|
+
original(e);
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
if (modifiers.includes('once')) {
|
|
588
|
+
const original = wrappedHandler;
|
|
589
|
+
wrappedHandler = (e) => {
|
|
590
|
+
el.removeEventListener(eventName, wrappedHandler);
|
|
591
|
+
original(e);
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
// Keyboard modifiers
|
|
595
|
+
for (const mod of modifiers) {
|
|
596
|
+
if (['enter', 'escape', 'space', 'tab'].includes(mod)) {
|
|
597
|
+
const keyMap = {
|
|
598
|
+
enter: 'Enter',
|
|
599
|
+
escape: 'Escape',
|
|
600
|
+
space: ' ',
|
|
601
|
+
tab: 'Tab',
|
|
602
|
+
};
|
|
603
|
+
const original = wrappedHandler;
|
|
604
|
+
wrappedHandler = (e) => {
|
|
605
|
+
if (e.key === keyMap[mod]) {
|
|
606
|
+
original(e);
|
|
607
|
+
}
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
// Wrap in try/catch for error containment
|
|
612
|
+
const safeHandler = (e) => {
|
|
613
|
+
try {
|
|
614
|
+
wrappedHandler(e);
|
|
615
|
+
}
|
|
616
|
+
catch (err) {
|
|
617
|
+
console.error(`Event handler error for '${eventName}':`, err);
|
|
618
|
+
}
|
|
619
|
+
};
|
|
620
|
+
el.addEventListener(eventName, safeHandler);
|
|
621
|
+
bindings.push({
|
|
622
|
+
type: 'event',
|
|
623
|
+
element: el,
|
|
624
|
+
eventName,
|
|
625
|
+
handler: safeHandler,
|
|
626
|
+
modifiers,
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Conditional rendering helper.
|
|
631
|
+
* Shows the template when condition is truthy.
|
|
632
|
+
*
|
|
633
|
+
* Supports both static and reactive (signal) conditions.
|
|
634
|
+
* For signal conditions, returns a computed that reactively switches
|
|
635
|
+
* between the template and null.
|
|
636
|
+
*
|
|
637
|
+
* The template argument can be a TemplateResult or a lazy callback
|
|
638
|
+
* `() => TemplateResult` to avoid evaluating expensive branches.
|
|
639
|
+
*/
|
|
640
|
+
// ── each() reactive list rendering ──────────────────────────────────
|
|
641
|
+
/** Brand for each result detection */
|
|
642
|
+
const EACH_BRAND = Symbol.for('backlog.each');
|
|
643
|
+
/**
|
|
644
|
+
* Reactive list rendering with keyed reconciliation.
|
|
645
|
+
*
|
|
646
|
+
* Renders a list of items from a signal, tracking each item by key.
|
|
647
|
+
* When the array changes, only affected DOM nodes are added, removed,
|
|
648
|
+
* or reordered — existing items update in-place via their signals.
|
|
649
|
+
*
|
|
650
|
+
* ```ts
|
|
651
|
+
* const tasks = signal([{ id: '1', title: 'A' }, { id: '2', title: 'B' }]);
|
|
652
|
+
* html`<ul>${each(tasks, t => t.id, (task, index) =>
|
|
653
|
+
* html`<li>${computed(() => task.value.title)}</li>`
|
|
654
|
+
* )}</ul>`
|
|
655
|
+
* ```
|
|
656
|
+
*/
|
|
657
|
+
export function each(items, keyFn, templateFn) {
|
|
658
|
+
let startMarker;
|
|
659
|
+
let endMarker;
|
|
660
|
+
let entries = [];
|
|
661
|
+
let effectDispose = null;
|
|
662
|
+
return {
|
|
663
|
+
__templateResult: true,
|
|
664
|
+
mount(host) {
|
|
665
|
+
startMarker = document.createComment('each-start');
|
|
666
|
+
endMarker = document.createComment('each-end');
|
|
667
|
+
host.appendChild(startMarker);
|
|
668
|
+
host.appendChild(endMarker);
|
|
669
|
+
effectDispose = effect(() => {
|
|
670
|
+
const newItems = items.value;
|
|
671
|
+
reconcile(newItems);
|
|
672
|
+
});
|
|
673
|
+
},
|
|
674
|
+
dispose() {
|
|
675
|
+
if (effectDispose) {
|
|
676
|
+
effectDispose();
|
|
677
|
+
effectDispose = null;
|
|
678
|
+
}
|
|
679
|
+
for (const entry of entries) {
|
|
680
|
+
entry.templateResult.dispose();
|
|
681
|
+
}
|
|
682
|
+
entries = [];
|
|
683
|
+
},
|
|
684
|
+
};
|
|
685
|
+
function reconcile(newItems) {
|
|
686
|
+
const parent = endMarker.parentNode;
|
|
687
|
+
if (!parent)
|
|
688
|
+
return;
|
|
689
|
+
// Build old key → entry map
|
|
690
|
+
const oldMap = new Map();
|
|
691
|
+
for (const entry of entries) {
|
|
692
|
+
oldMap.set(entry.key, entry);
|
|
693
|
+
}
|
|
694
|
+
// Build new entries list
|
|
695
|
+
const newEntries = [];
|
|
696
|
+
const newKeys = new Set();
|
|
697
|
+
for (let i = 0; i < newItems.length; i++) {
|
|
698
|
+
const item = newItems[i];
|
|
699
|
+
if (item === undefined)
|
|
700
|
+
continue;
|
|
701
|
+
const key = keyFn(item, i);
|
|
702
|
+
newKeys.add(key);
|
|
703
|
+
const existing = oldMap.get(key);
|
|
704
|
+
if (existing) {
|
|
705
|
+
// Reuse — update signals in place
|
|
706
|
+
existing.itemSignal.value = item;
|
|
707
|
+
existing.indexSignal.value = i;
|
|
708
|
+
newEntries.push(existing);
|
|
709
|
+
}
|
|
710
|
+
else {
|
|
711
|
+
// Create new entry with a stable wrapper element.
|
|
712
|
+
// Using display:contents so the wrapper is invisible to layout —
|
|
713
|
+
// inner reactive content can freely swap DOM nodes without
|
|
714
|
+
// invalidating the each() reconciler's node tracking.
|
|
715
|
+
const itemSignal = signal(item);
|
|
716
|
+
const indexSignal = signal(i);
|
|
717
|
+
const wrapper = document.createElement('each-item');
|
|
718
|
+
wrapper.style.display = 'contents';
|
|
719
|
+
const templateResult = templateFn(itemSignal, indexSignal);
|
|
720
|
+
templateResult.mount(wrapper);
|
|
721
|
+
newEntries.push({ key, itemSignal, indexSignal, templateResult, wrapper });
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
// Remove entries whose key is gone
|
|
725
|
+
for (const entry of entries) {
|
|
726
|
+
if (!newKeys.has(entry.key)) {
|
|
727
|
+
entry.templateResult.dispose();
|
|
728
|
+
entry.wrapper.parentNode?.removeChild(entry.wrapper);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
// Reorder DOM nodes to match new order.
|
|
732
|
+
// Each entry has exactly one stable wrapper element, so positioning
|
|
733
|
+
// is simple and immune to inner reactive content swaps.
|
|
734
|
+
let cursor = startMarker;
|
|
735
|
+
for (const entry of newEntries) {
|
|
736
|
+
const nextSibling = cursor.nextSibling;
|
|
737
|
+
if (nextSibling !== entry.wrapper) {
|
|
738
|
+
parent.insertBefore(entry.wrapper, nextSibling);
|
|
739
|
+
}
|
|
740
|
+
cursor = entry.wrapper;
|
|
741
|
+
}
|
|
742
|
+
entries = newEntries;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
export function when(condition, template) {
|
|
746
|
+
const resolveTemplate = () => typeof template === 'function' ? template() : template;
|
|
747
|
+
if (isSignal(condition)) {
|
|
748
|
+
// Reactive: return a computed that re-evaluates when the signal changes
|
|
749
|
+
return computed(() => condition.value ? resolveTemplate() : null);
|
|
750
|
+
}
|
|
751
|
+
return condition ? resolveTemplate() : null;
|
|
752
|
+
}
|
|
753
|
+
//# sourceMappingURL=template.js.map
|