@qorejs/qore 0.6.0 → 0.7.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/src/dom.js ADDED
@@ -0,0 +1,527 @@
1
+ import { effect, isSignal } from './signal.js';
2
+
3
+ // Store mount cleanup directly on the root node so remounts can tear down old scopes.
4
+ const ROOT_CLEANUP = Symbol('qore.dom.cleanup');
5
+
6
+ let activeScope = null;
7
+
8
+ // Guard DOM helpers so they only run in browser-like environments.
9
+ function assertDocument() {
10
+ if (typeof document === 'undefined') {
11
+ throw new Error('Qore DOM APIs require a browser-like environment');
12
+ }
13
+ }
14
+
15
+ // A scope collects effect disposers created while rendering a subtree.
16
+ function createScope() {
17
+ return { cleanups: [] };
18
+ }
19
+
20
+ // Temporarily switch the active scope while a subtree is being materialized.
21
+ function withScope(scope, fn) {
22
+ const previousScope = activeScope;
23
+ activeScope = scope;
24
+
25
+ try {
26
+ return fn();
27
+ } finally {
28
+ activeScope = previousScope;
29
+ }
30
+ }
31
+
32
+ // Register a cleanup callback on the currently active scope, if one exists.
33
+ function registerCleanup(cleanup) {
34
+ if (typeof cleanup === 'function' && activeScope) {
35
+ activeScope.cleanups.push(cleanup);
36
+ }
37
+
38
+ return cleanup;
39
+ }
40
+
41
+ // Dispose nested resources in reverse order so teardown mirrors setup.
42
+ function disposeScope(scope) {
43
+ if (!scope) {
44
+ return;
45
+ }
46
+
47
+ for (let index = scope.cleanups.length - 1; index >= 0; index -= 1) {
48
+ try {
49
+ scope.cleanups[index]();
50
+ } catch {
51
+ // Ignore cleanup errors during teardown so the rest of the scope can unwind.
52
+ }
53
+ }
54
+
55
+ scope.cleanups.length = 0;
56
+ }
57
+
58
+ // Treat signals and getters as reactive values the DOM layer should subscribe to.
59
+ function isReactiveValue(value) {
60
+ return isSignal(value) || typeof value === 'function';
61
+ }
62
+
63
+ // Read the current value regardless of whether the input is static or reactive.
64
+ function resolveAccessor(value) {
65
+ return isReactiveValue(value) ? value() : value;
66
+ }
67
+
68
+ // Accept either a literal template value or a render callback.
69
+ function resolveTemplate(template, value) {
70
+ return typeof template === 'function' ? template(value) : template;
71
+ }
72
+
73
+ // Normalize class payloads so callers can pass strings, arrays, or object maps.
74
+ function normalizeClassName(value) {
75
+ if (Array.isArray(value)) {
76
+ return value
77
+ .map((entry) => normalizeClassName(entry))
78
+ .filter(Boolean)
79
+ .join(' ');
80
+ }
81
+
82
+ if (value && typeof value === 'object') {
83
+ return Object.entries(value)
84
+ .filter(([, active]) => Boolean(active))
85
+ .map(([className]) => className)
86
+ .join(' ');
87
+ }
88
+
89
+ if (value == null || value === false) {
90
+ return '';
91
+ }
92
+
93
+ return String(value);
94
+ }
95
+
96
+ // Apply styles from either a raw string or a property map.
97
+ function setStyleValue(element, value) {
98
+ element.style.cssText = '';
99
+
100
+ if (typeof value === 'string') {
101
+ element.style.cssText = value;
102
+ return;
103
+ }
104
+
105
+ if (!value || typeof value !== 'object') {
106
+ if (value == null || value === false) {
107
+ element.removeAttribute('style');
108
+ }
109
+
110
+ return;
111
+ }
112
+
113
+ for (const [property, propertyValue] of Object.entries(value)) {
114
+ element.style[property] = propertyValue == null || propertyValue === false
115
+ ? ''
116
+ : String(propertyValue);
117
+ }
118
+ }
119
+
120
+ // Centralize DOM property and attribute writes behind one compatibility layer.
121
+ function setDomProperty(element, key, value) {
122
+ const attributeName = key === 'className' ? 'class' : key;
123
+
124
+ if (key === 'className' || key === 'class') {
125
+ element.className = normalizeClassName(value);
126
+ return;
127
+ }
128
+
129
+ if (key === 'style') {
130
+ setStyleValue(element, value);
131
+ return;
132
+ }
133
+
134
+ if (value == null || value === false) {
135
+ if (key in element && key !== 'list' && key !== 'form') {
136
+ try {
137
+ if (typeof element[key] === 'boolean') {
138
+ element[key] = false;
139
+ } else if (key === 'value') {
140
+ element[key] = '';
141
+ } else {
142
+ element[key] = '';
143
+ }
144
+ } catch {
145
+ // Ignore readonly DOM properties and fall back to attribute cleanup.
146
+ }
147
+ }
148
+
149
+ element.removeAttribute(attributeName);
150
+ return;
151
+ }
152
+
153
+ if (value === true) {
154
+ if (key in element && key !== 'list' && key !== 'form') {
155
+ try {
156
+ element[key] = true;
157
+ } catch {
158
+ // Fall through to attribute mode.
159
+ }
160
+ }
161
+
162
+ element.setAttribute(attributeName, '');
163
+ return;
164
+ }
165
+
166
+ if (key in element && key !== 'list' && key !== 'form') {
167
+ try {
168
+ element[key] = value;
169
+ return;
170
+ } catch {
171
+ // Fall back to setAttribute for readonly DOM properties.
172
+ }
173
+ }
174
+
175
+ element.setAttribute(attributeName, String(value));
176
+ }
177
+
178
+ // Bind events and only treat signal-like values as reactive handler containers.
179
+ function bindEvent(element, key, handler) {
180
+ const eventName = key.slice(2).toLowerCase();
181
+
182
+ if (isSignal(handler)) {
183
+ let activeHandler = null;
184
+ const stop = effect(() => {
185
+ const nextHandler = handler();
186
+
187
+ if (activeHandler) {
188
+ element.removeEventListener(eventName, activeHandler);
189
+ }
190
+
191
+ activeHandler = typeof nextHandler === 'function' ? nextHandler : null;
192
+
193
+ if (activeHandler) {
194
+ element.addEventListener(eventName, activeHandler);
195
+ }
196
+ });
197
+
198
+ registerCleanup(() => {
199
+ stop();
200
+
201
+ if (activeHandler) {
202
+ element.removeEventListener(eventName, activeHandler);
203
+ }
204
+ });
205
+
206
+ return;
207
+ }
208
+
209
+ if (typeof handler === 'function') {
210
+ element.addEventListener(eventName, handler);
211
+ registerCleanup(() => element.removeEventListener(eventName, handler));
212
+ }
213
+ }
214
+
215
+ // Bind a prop key, upgrading reactive values into tracked effects when needed.
216
+ function bindProp(element, key, value) {
217
+ if (key === 'ref') {
218
+ if (typeof value === 'function') {
219
+ value(element);
220
+ }
221
+
222
+ return;
223
+ }
224
+
225
+ if (key.startsWith('on')) {
226
+ bindEvent(element, key, value);
227
+ return;
228
+ }
229
+
230
+ if (isReactiveValue(value)) {
231
+ const stop = effect(() => {
232
+ setDomProperty(element, key, resolveAccessor(value));
233
+ });
234
+
235
+ registerCleanup(stop);
236
+ return;
237
+ }
238
+
239
+ setDomProperty(element, key, value);
240
+ }
241
+
242
+ // Convert supported child types into concrete nodes that can be inserted into the DOM.
243
+ function materializeChild(buffer, child) {
244
+ if (Array.isArray(child)) {
245
+ for (const entry of child) {
246
+ materializeChild(buffer, entry);
247
+ }
248
+
249
+ return;
250
+ }
251
+
252
+ if (child == null || child === false || child === true) {
253
+ return;
254
+ }
255
+
256
+ if (isReactiveValue(child)) {
257
+ buffer.push(dynamic(child));
258
+ return;
259
+ }
260
+
261
+ if (child instanceof Node) {
262
+ buffer.push(child);
263
+ return;
264
+ }
265
+
266
+ if (typeof child === 'string' || typeof child === 'number' || typeof child === 'bigint') {
267
+ buffer.push(document.createTextNode(String(child)));
268
+ return;
269
+ }
270
+
271
+ throw new TypeError(
272
+ `Unsupported Qore child: ${typeof child}. Use strings, numbers, DOM nodes, arrays, or reactive getters.`
273
+ );
274
+ }
275
+
276
+ // Flatten any child payload into a linear list of nodes.
277
+ function materialize(value) {
278
+ const nodes = [];
279
+ materializeChild(nodes, value);
280
+ return nodes;
281
+ }
282
+
283
+ // Append normalized child content to a parent node or fragment.
284
+ function appendChild(parent, child) {
285
+ for (const node of materialize(child)) {
286
+ parent.appendChild(node);
287
+ }
288
+ }
289
+
290
+ // Remove every node between the two markers of a dynamic region.
291
+ function clearRange(start, end) {
292
+ let current = start.nextSibling;
293
+
294
+ while (current && current !== end) {
295
+ const next = current.nextSibling;
296
+ current.remove();
297
+ current = next;
298
+ }
299
+ }
300
+
301
+ // Replace a dynamic region without recreating its boundary markers.
302
+ function replaceRange(start, end, nextValue) {
303
+ const parent = end.parentNode;
304
+
305
+ if (!parent) {
306
+ return;
307
+ }
308
+
309
+ clearRange(start, end);
310
+
311
+ for (const node of materialize(nextValue)) {
312
+ parent.insertBefore(node, end);
313
+ }
314
+ }
315
+
316
+ // Read a response into one plain object so templates can branch on status cleanly.
317
+ function readResponseState(responseState) {
318
+ return {
319
+ response: responseState,
320
+ status: responseState.status(),
321
+ value: responseState.value(),
322
+ error: responseState.error(),
323
+ chunks: responseState.chunks(),
324
+ startedAt: responseState.startedAt(),
325
+ finishedAt: responseState.finishedAt(),
326
+ pending: responseState.pending(),
327
+ streaming: responseState.streaming(),
328
+ completed: responseState.completed(),
329
+ failed: responseState.failed(),
330
+ aborted: responseState.aborted(),
331
+ chunkCount: responseState.chunkCount()
332
+ };
333
+ }
334
+
335
+ // Pick the best matching view override for the current response lifecycle state.
336
+ function pickResponseTemplate(status, views) {
337
+ switch (status) {
338
+ case 'idle':
339
+ return views.idle ?? views.pending ?? views.default;
340
+ case 'pending':
341
+ return views.pending ?? views.default;
342
+ case 'streaming':
343
+ return views.streaming ?? views.pending ?? views.default;
344
+ case 'completed':
345
+ return views.completed ?? views.default;
346
+ case 'error':
347
+ return views.error ?? views.default;
348
+ case 'aborted':
349
+ return views.aborted ?? views.default;
350
+ default:
351
+ return views.default;
352
+ }
353
+ }
354
+
355
+ // Allow mount targets to be passed as selectors or direct nodes.
356
+ function resolveRoot(root) {
357
+ if (typeof root === 'string') {
358
+ const element = document.querySelector(root);
359
+
360
+ if (!element) {
361
+ throw new Error(`Qore could not find a mount target for selector: ${root}`);
362
+ }
363
+
364
+ return element;
365
+ }
366
+
367
+ return root;
368
+ }
369
+
370
+ // Build a fragment from a variadic list of children.
371
+ export function fragment(...children) {
372
+ assertDocument();
373
+
374
+ const node = document.createDocumentFragment();
375
+
376
+ for (const child of children) {
377
+ appendChild(node, child);
378
+ }
379
+
380
+ return node;
381
+ }
382
+
383
+ // Render a live region between comment markers and refresh it when the source changes.
384
+ export function dynamic(source, render = (value) => value) {
385
+ assertDocument();
386
+
387
+ const start = document.createComment('qore-dynamic-start');
388
+ const end = document.createComment('qore-dynamic-end');
389
+ const node = document.createDocumentFragment();
390
+
391
+ node.append(start, end);
392
+
393
+ let childScope = null;
394
+ const stop = effect(() => {
395
+ const nextValue = resolveAccessor(source);
396
+
397
+ disposeScope(childScope);
398
+ childScope = createScope();
399
+
400
+ const renderedValue = withScope(childScope, () => resolveTemplate(render, nextValue));
401
+ replaceRange(start, end, renderedValue);
402
+ });
403
+
404
+ registerCleanup(() => {
405
+ stop();
406
+ disposeScope(childScope);
407
+ });
408
+
409
+ return node;
410
+ }
411
+
412
+ // Conditionally render one branch or a fallback from a truthy source.
413
+ export function show(source, render, fallback = null) {
414
+ const truthyView = render === undefined ? (value) => value : render;
415
+ return dynamic(source, (value) => value
416
+ ? resolveTemplate(truthyView, value)
417
+ : resolveTemplate(fallback, value));
418
+ }
419
+
420
+ // Render a list reactively, or a fallback when the collection is empty.
421
+ export function list(source, render, options = {}) {
422
+ const { fallback = null } = options;
423
+
424
+ return dynamic(source, (value) => {
425
+ const items = value == null
426
+ ? []
427
+ : Array.isArray(value)
428
+ ? value
429
+ : Array.from(value);
430
+
431
+ if (items.length === 0) {
432
+ return resolveTemplate(fallback, items);
433
+ }
434
+
435
+ return items.map((item, index) => render(item, index));
436
+ });
437
+ }
438
+
439
+ // Render response state through status-aware template overrides.
440
+ export function renderResponse(responseState, views = {}) {
441
+ return dynamic(() => readResponseState(responseState), (state) => {
442
+ const template = pickResponseTemplate(state.status, views);
443
+
444
+ if (template !== undefined) {
445
+ return resolveTemplate(template, state);
446
+ }
447
+
448
+ if (state.status === 'error') {
449
+ return state.error?.message ?? 'Qore response failed.';
450
+ }
451
+
452
+ return state.value;
453
+ });
454
+ }
455
+
456
+ // Create a DOM element or invoke a component function with normalized children.
457
+ export function h(tag, props = null, ...children) {
458
+ assertDocument();
459
+
460
+ if (typeof tag === 'function') {
461
+ return tag({
462
+ ...(props ?? {}),
463
+ children
464
+ });
465
+ }
466
+
467
+ const element = document.createElement(tag);
468
+
469
+ if (props) {
470
+ for (const [key, value] of Object.entries(props)) {
471
+ bindProp(element, key, value);
472
+ }
473
+ }
474
+
475
+ for (const child of children) {
476
+ appendChild(element, child);
477
+ }
478
+
479
+ return element;
480
+ }
481
+
482
+ // Create a text node and keep it in sync with a reactive getter when necessary.
483
+ export function text(valueOrGetter) {
484
+ assertDocument();
485
+
486
+ const node = document.createTextNode('');
487
+
488
+ if (isReactiveValue(valueOrGetter)) {
489
+ const stop = effect(() => {
490
+ const nextValue = resolveAccessor(valueOrGetter);
491
+ node.textContent = nextValue == null ? '' : String(nextValue);
492
+ });
493
+
494
+ registerCleanup(stop);
495
+ return node;
496
+ }
497
+
498
+ node.textContent = valueOrGetter == null ? '' : String(valueOrGetter);
499
+ return node;
500
+ }
501
+
502
+ // Mount a view into a root element and return a disposer for its reactive scope.
503
+ export function mount(root, view) {
504
+ assertDocument();
505
+
506
+ const target = resolveRoot(root);
507
+ target[ROOT_CLEANUP]?.();
508
+
509
+ const scope = createScope();
510
+ const content = withScope(scope, () => typeof view === 'function' ? view() : view);
511
+
512
+ target.replaceChildren();
513
+ appendChild(target, content);
514
+
515
+ const dispose = () => {
516
+ if (target[ROOT_CLEANUP] === dispose) {
517
+ delete target[ROOT_CLEANUP];
518
+ }
519
+
520
+ disposeScope(scope);
521
+ target.replaceChildren();
522
+ return target;
523
+ };
524
+
525
+ target[ROOT_CLEANUP] = dispose;
526
+ return dispose;
527
+ }