galath 1.0.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.
Files changed (55) hide show
  1. package/AGENTS.md +1 -0
  2. package/README.md +206 -0
  3. package/TODO.md +140 -0
  4. package/index.html +188 -0
  5. package/logo.jpg +0 -0
  6. package/logo.svg +96 -0
  7. package/package.json +32 -0
  8. package/packages/galath/package.json +28 -0
  9. package/packages/galath/src/behavior.js +193 -0
  10. package/packages/galath/src/binding.js +247 -0
  11. package/packages/galath/src/boot.js +52 -0
  12. package/packages/galath/src/command.js +117 -0
  13. package/packages/galath/src/component.js +505 -0
  14. package/packages/galath/src/controller.js +181 -0
  15. package/packages/galath/src/core.js +190 -0
  16. package/packages/galath/src/imports.js +132 -0
  17. package/packages/galath/src/index.js +38 -0
  18. package/packages/galath/src/instance-model.js +343 -0
  19. package/packages/galath/src/morph.js +237 -0
  20. package/packages/galath/src/rendering.js +556 -0
  21. package/packages/galath/src/signals.js +215 -0
  22. package/packages/galath/src/templates.js +24 -0
  23. package/packages/galath/src/xml-events.js +53 -0
  24. package/packages/galath-css/css/bootstrap-icons.min.css +5 -0
  25. package/packages/galath-css/css/bootstrap.min.css +6 -0
  26. package/packages/galath-css/css/fonts/bootstrap-icons.json +2077 -0
  27. package/packages/galath-css/css/fonts/bootstrap-icons.woff +0 -0
  28. package/packages/galath-css/css/fonts/bootstrap-icons.woff2 +0 -0
  29. package/packages/galath-css/js/bootstrap.bundle.min.js +7 -0
  30. package/packages/galath-css/package.json +13 -0
  31. package/playground/app.xml +214 -0
  32. package/playground/chapters/01-welcome.xml +94 -0
  33. package/playground/chapters/02-signals.xml +166 -0
  34. package/playground/chapters/03-instance.xml +130 -0
  35. package/playground/chapters/04-bindings.xml +156 -0
  36. package/playground/chapters/05-lists.xml +138 -0
  37. package/playground/chapters/06-commands.xml +144 -0
  38. package/playground/chapters/07-controller.xml +115 -0
  39. package/playground/chapters/08-events.xml +126 -0
  40. package/playground/chapters/09-behaviors.xml +210 -0
  41. package/playground/chapters/10-components.xml +152 -0
  42. package/playground/chapters/11-imports.xml +108 -0
  43. package/playground/chapters/12-expressions.xml +161 -0
  44. package/playground/chapters/13-paths.xml +197 -0
  45. package/playground/components/chapter-shell.xml +29 -0
  46. package/playground/components/highlighter.js +111 -0
  47. package/playground/components/run-snippet.js +120 -0
  48. package/public/basic/bootstrap-icons.min.css +5 -0
  49. package/public/basic/bootstrap.bundle.min.js +7 -0
  50. package/public/basic/bootstrap.min.css +6 -0
  51. package/public/basic/fonts/bootstrap-icons.json +2077 -0
  52. package/public/basic/fonts/bootstrap-icons.woff +0 -0
  53. package/public/basic/fonts/bootstrap-icons.woff2 +0 -0
  54. package/public/basic/theme.css +209 -0
  55. package/seed.html +321 -0
@@ -0,0 +1,556 @@
1
+ // =============================================================================
2
+ // rendering.js
3
+ //
4
+ // The view -> HTML compiler and DOM wiring layer.
5
+ //
6
+ // This is the single biggest feature, so it's worth understanding its three
7
+ // phases before reading the code:
8
+ //
9
+ // PHASE 1: render to HTML string + binding records
10
+ //
11
+ // We walk the `<view>` subtree and produce an HTML string. Whenever we
12
+ // encounter a directive that needs *runtime* logic (e.g. an event
13
+ // handler, a two-way bind, an attached behavior), we record a small
14
+ // `binding` object describing what should happen and stamp the element
15
+ // with `data-xes-id="...."` so we can find it after the morph.
16
+ //
17
+ // PHASE 2: morph the live DOM
18
+ //
19
+ // The component's `renderNow()` (in component.js) parses our string,
20
+ // then calls `morph` to update the live DOM in place. This preserves
21
+ // focus, value, scroll, etc.
22
+ //
23
+ // PHASE 3: install bindings on live elements
24
+ //
25
+ // Once the DOM is fresh, we walk the `bindings` array, look up each
26
+ // element by its `data-xes-id`, and attach event listeners, two-way
27
+ // binds, behaviors, and drag/drop. Every listener is collected by the
28
+ // instance's `renderScope`, which is disposed at the start of the next
29
+ // render pass - no listener leaks.
30
+ //
31
+ // Special tags handled directly (NOT emitted as plain HTML):
32
+ //
33
+ // <repeat ref="path" as="x">..</repeat> - iterate over selected nodes
34
+ // <items source="path" template="name" /> - iterate using a datatemplate
35
+ // <if test="expr">..[<else>..</else>]</if> - conditional with optional else
36
+ // <text value="path|expr" /> - escaped text output
37
+ //
38
+ // Special directives parsed off ANY element:
39
+ //
40
+ // on:click="expr | #actionName" - event listener
41
+ // bind:property="path|signal" - two-way bind
42
+ // use:behavior="value" - install attached behavior
43
+ // drag:source="payload" - mark draggable
44
+ // drop:target="payload" - mark drop zone
45
+ // drop:command="cmd" - command run on drop
46
+ // class:foo="expr" - toggle class `foo`
47
+ // command="cmd" - bind a button to a command
48
+ // disabled="{expr}" - conditional disabled
49
+ // class="..." - regular interpolated class
50
+ // anything-else="..." - interpolated attribute
51
+ // =============================================================================
52
+
53
+ export function renderingFeature(language) {
54
+ // HTML void tags - we self-close these with `<tag ...>` and never emit a
55
+ // closing tag. The off-screen template would tolerate either, but the
56
+ // morph compares text length and we want predictable output.
57
+ const voidTags = new Set([
58
+ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link',
59
+ 'meta', 'source', 'track', 'wbr',
60
+ ]);
61
+
62
+ // Tags that the renderer should *display* as their serialized XML rather
63
+ // than recurse into. They are "control plane" tags - if you accidentally
64
+ // write `<commandset>` inside `<view>`, you get a code box, not silent
65
+ // misbehavior.
66
+ const controlTags = new Set([
67
+ 'xes', 'galath', 'component', 'application', 'model', 'instance',
68
+ 'data', 'view', 'commandset', 'controller', 'listeners', 'datatemplate',
69
+ 'on:mount', 'on:unmount', 'style', 'computed', 'map', 'import',
70
+ ]);
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Public: render a list of nodes
74
+ // ---------------------------------------------------------------------------
75
+ language.renderChildren = (nodes, instance, local, bindings) =>
76
+ nodes.map(node => renderNode(node, instance, local, bindings)).join('');
77
+
78
+ // Dispatch one node to the right renderer.
79
+ function renderNode(node, instance, local, bindings) {
80
+ if (node.nodeType === Node.TEXT_NODE) {
81
+ // Skip pure whitespace text - leaves in HTML are noisy. Non-empty
82
+ // text is interpolated and HTML-escaped.
83
+ return node.textContent.trim()
84
+ ? language.interpolate(node.textContent, instance, local)
85
+ : '';
86
+ }
87
+ if (node.nodeType !== Node.ELEMENT_NODE) return '';
88
+
89
+ if (node.localName === 'repeat') return renderRepeat(node, instance, local, bindings);
90
+ if (node.localName === 'items') return renderItems(node, instance, local, bindings);
91
+ if (node.localName === 'if') return renderIf(node, instance, local, bindings);
92
+ if (node.localName === 'switch') return renderSwitch(node, instance, local, bindings);
93
+ if (node.localName === 'slot') return renderSlot(node, instance, local, bindings);
94
+ if (node.localName === 'text') {
95
+ // <text value="path|expr" /> - resolve and HTML-escape.
96
+ return language.escapeHtml(
97
+ language.valueOf(node.getAttribute('value') || '', instance, local),
98
+ );
99
+ }
100
+ return renderElement(node, instance, local, bindings);
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // <repeat>: low-level loop over a node-set
105
+ //
106
+ // Optional `key="@id"` (or any expression) makes the renderer stamp the
107
+ // first emitted element of each iteration with `data-xes-key="..."`. The
108
+ // morph layer detects fully keyed siblings and reorders by key instead of
109
+ // by position - so reorders preserve focus / scroll / state inside rows.
110
+ // ---------------------------------------------------------------------------
111
+ function renderRepeat(node, instance, local, bindings) {
112
+ const ref =
113
+ node.getAttribute('ref') ||
114
+ node.getAttribute('nodeset') ||
115
+ node.getAttribute('each');
116
+ const as = node.getAttribute('as') || 'item';
117
+ const keyExpr = node.getAttribute('key');
118
+ const items = instance.tree?.select(ref, local) ?? [];
119
+ return items
120
+ .map((item, index) => {
121
+ const childLocal = { ...local, [as]: item, [`$${as}`]: item, index };
122
+ const html = language.renderChildren(
123
+ [...node.childNodes],
124
+ instance,
125
+ childLocal,
126
+ bindings,
127
+ );
128
+ if (!keyExpr) return html;
129
+ const key = String(language.evaluate(keyExpr, instance, childLocal, '') ?? '');
130
+ return injectKeyAttribute(html, key);
131
+ })
132
+ .join('');
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // <items>: high-level loop using a datatemplate
137
+ // Same `key` semantics as <repeat>; honored on either the <items> tag or
138
+ // its <datatemplate> definition.
139
+ // ---------------------------------------------------------------------------
140
+ function renderItems(node, instance, local, bindings) {
141
+ const template = instance.templates.get(node.getAttribute('template'));
142
+ if (!template) {
143
+ console.warn(
144
+ `[galath] no <datatemplate name="${node.getAttribute('template')}"> found`,
145
+ );
146
+ return '';
147
+ }
148
+ const as = node.getAttribute('as') || template.getAttribute('for') || 'item';
149
+ const keyExpr = node.getAttribute('key') || template.getAttribute('key');
150
+ const items = instance.tree?.select(node.getAttribute('source'), local) ?? [];
151
+ return items
152
+ .map((item, index) => {
153
+ const childLocal = { ...local, [as]: item, [`$${as}`]: item, index };
154
+ const html = language.renderChildren(
155
+ [...template.childNodes],
156
+ instance,
157
+ childLocal,
158
+ bindings,
159
+ );
160
+ if (!keyExpr) return html;
161
+ const key = String(language.evaluate(keyExpr, instance, childLocal, '') ?? '');
162
+ return injectKeyAttribute(html, key);
163
+ })
164
+ .join('');
165
+ }
166
+
167
+ // Stamp `data-xes-key="..."` onto the first element opening tag in `html`.
168
+ // Leaves leading whitespace and any leading text alone. This is purely a
169
+ // string rewrite because the rendering pipeline emits HTML strings.
170
+ function injectKeyAttribute(html, key) {
171
+ const safe = String(key).replace(/&/g, '&amp;').replace(/"/g, '&quot;');
172
+ return html.replace(
173
+ /(<\s*[a-zA-Z][\w:-]*)/,
174
+ (m) => `${m} data-xes-key="${safe}"`,
175
+ );
176
+ }
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // <if test="..."> ...optional <else>... </if>
180
+ // ---------------------------------------------------------------------------
181
+ function renderIf(node, instance, local, bindings) {
182
+ const test = language.evaluate(node.getAttribute('test') || 'false', instance, local);
183
+ // Children that are NOT <else> are rendered when test is truthy. The
184
+ // (single) <else> child is rendered when test is falsy. Multiple
185
+ // <else>s render in order, in case authors really want that.
186
+ const elseChildren = [...node.children].filter(c => c.localName === 'else');
187
+ const thenChildren = [...node.childNodes].filter(
188
+ n => !(n.nodeType === Node.ELEMENT_NODE && n.localName === 'else'),
189
+ );
190
+ if (test) return language.renderChildren(thenChildren, instance, local, bindings);
191
+ return elseChildren
192
+ .flatMap(el => [...el.childNodes])
193
+ .map(n => renderNode(n, instance, local, bindings))
194
+ .join('');
195
+ }
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // <switch on="expr">
199
+ // <case value="literal">...</case>
200
+ // <case test="expr">...</case> (alternate: explicit boolean expression)
201
+ // <default>...</default>
202
+ // </switch>
203
+ //
204
+ // Picks the first matching <case> and renders its children. Falls back to
205
+ // <default> when nothing matches. Cleaner than chaining <if>s.
206
+ // ---------------------------------------------------------------------------
207
+ function renderSwitch(node, instance, local, bindings) {
208
+ const onAttr = node.getAttribute('on');
209
+ const subject = onAttr != null
210
+ ? language.evaluate(onAttr, instance, local)
211
+ : undefined;
212
+ const cases = [...node.children].filter(c => c.localName === 'case');
213
+ for (const c of cases) {
214
+ let match = false;
215
+ if (c.hasAttribute('value')) {
216
+ const candidate = language.evaluate(c.getAttribute('value'), instance, local);
217
+ // String-coerced equality so "2" matches 2 and "true" matches true.
218
+ match = String(subject) === String(candidate);
219
+ } else if (c.hasAttribute('test')) {
220
+ match = Boolean(language.evaluate(c.getAttribute('test'), instance, local));
221
+ }
222
+ if (match) return language.renderChildren([...c.childNodes], instance, local, bindings);
223
+ }
224
+ const fallback = [...node.children].find(c => c.localName === 'default');
225
+ if (fallback) return language.renderChildren([...fallback.childNodes], instance, local, bindings);
226
+ return '';
227
+ }
228
+
229
+ // ---------------------------------------------------------------------------
230
+ // <slot />: insert children that the host wrote between <my-tag>...</my-tag>.
231
+ //
232
+ // The children were captured in connectedCallback before our xesRoot was
233
+ // attached, and live on `instance.slotNodes`. We render a marker the install
234
+ // phase will replace with the captured DOM. The marker carries
235
+ // `data-xes-frozen` so subsequent morphs leave it alone (see morph.js).
236
+ //
237
+ // If the host provided no slot content, we render the <slot>'s own children
238
+ // as a default (familiar from web-components / Vue / Svelte).
239
+ // ---------------------------------------------------------------------------
240
+ function renderSlot(node, instance, local, bindings) {
241
+ const hasSlotContent = (instance.slotNodes?.length ?? 0) > 0;
242
+ if (!hasSlotContent) {
243
+ return language.renderChildren([...node.childNodes], instance, local, bindings);
244
+ }
245
+ const id = `s${bindings.length.toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
246
+ bindings.push({
247
+ id,
248
+ slot: true,
249
+ events: [],
250
+ binds: [],
251
+ behaviors: [],
252
+ drag: null,
253
+ drop: null,
254
+ dropCommand: null,
255
+ command: null,
256
+ local,
257
+ });
258
+ // The wrapper inherits any tag the author chose ("slot" by default), so
259
+ // styling/layout still works. `data-xes-frozen` keeps morph out of its
260
+ // children once we install slot DOM in there.
261
+ return `<slot data-xes-id="${id}" data-xes-slot="1"></slot>`;
262
+ }
263
+
264
+ // ---------------------------------------------------------------------------
265
+ // Generic element rendering. Walks attributes, parses framework
266
+ // directives, and emits HTML.
267
+ // ---------------------------------------------------------------------------
268
+ function renderElement(node, instance, local, bindings) {
269
+ if (controlTags.has(node.localName)) {
270
+ // The author wrote a control-plane tag inside the view. Show its
271
+ // serialization instead of pretending it works.
272
+ return `<pre class="xes-code rounded p-3"><code>${language.escapeHtml(language.serialize(node))}</code></pre>`;
273
+ }
274
+
275
+ // Stable id used to find this element after the morph.
276
+ const id = `x${bindings.length.toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
277
+ const binding = {
278
+ id,
279
+ events: [],
280
+ binds: [],
281
+ behaviors: [],
282
+ drag: null,
283
+ drop: null,
284
+ dropCommand: null,
285
+ command: null,
286
+ local,
287
+ };
288
+ const attrs = [`data-xes-id="${id}"`];
289
+ // Class is special: `class="..."` and `class:foo="expr"` may both
290
+ // appear in any order. We accumulate every class fragment here and
291
+ // emit a single `class="..."` at the end so we never produce two
292
+ // class attributes (which would be invalid HTML).
293
+ const classParts = [];
294
+
295
+ for (const attr of [...node.attributes]) {
296
+ const name = attr.name;
297
+ const value = attr.value;
298
+
299
+ // -- on:event="code|#action" ----------------------------------------
300
+ if (name.startsWith('on:')) {
301
+ binding.events.push({ event: name.slice(3), code: value });
302
+ continue;
303
+ }
304
+
305
+ // -- bind:prop="signalName|path" ------------------------------------
306
+ if (name.startsWith('bind:')) {
307
+ const property = name.slice(5);
308
+ binding.binds.push({ property, target: value });
309
+ const current = readBindingValue(value, instance, local);
310
+ if (property === 'checked') {
311
+ // Booleans render as a presence-only HTML attribute.
312
+ if (Boolean(current)) attrs.push('checked');
313
+ } else {
314
+ attrs.push(`${property}="${language.escapeHtml(current)}"`);
315
+ }
316
+ continue;
317
+ }
318
+
319
+ // -- use:behavior="value" -------------------------------------------
320
+ if (name.startsWith('use:')) {
321
+ binding.behaviors.push({ name: name.slice(4), value });
322
+ continue;
323
+ }
324
+
325
+ // -- drag:* / drop:* ------------------------------------------------
326
+ // We collect them on the binding record so installBindings can hook
327
+ // them up against the live element.
328
+ if (name.startsWith('drag:')) {
329
+ binding.drag = { kind: name.slice(5), value };
330
+ continue;
331
+ }
332
+ if (name.startsWith('drop:')) {
333
+ const which = name.slice(5);
334
+ if (which === 'command') binding.dropCommand = value;
335
+ else binding.drop = { kind: which, value };
336
+ continue;
337
+ }
338
+
339
+ // -- class:foo="expr" -----------------------------------------------
340
+ if (name.startsWith('class:')) {
341
+ if (language.evaluate(value, instance, local)) classParts.push(name.slice(6));
342
+ continue;
343
+ }
344
+
345
+ // -- command="..." --------------------------------------------------
346
+ if (name === 'command') {
347
+ binding.command = value;
348
+ if (!language.commandEnabled(instance, value, local)) attrs.push('disabled');
349
+ attrs.push(`data-command="${language.escapeHtml(value)}"`);
350
+ continue;
351
+ }
352
+
353
+ // -- disabled="{expr}" ----------------------------------------------
354
+ if (name === 'disabled' && value.startsWith('{') && value.endsWith('}')) {
355
+ if (language.evaluate(value.slice(1, -1), instance, local)) attrs.push('disabled');
356
+ continue;
357
+ }
358
+
359
+ // -- class="..." (interpolated; merged with class:* below) ----------
360
+ if (name === 'class') {
361
+ classParts.unshift(language.interpolate(value, instance, local));
362
+ continue;
363
+ }
364
+
365
+ // -- everything else: interpolate ------------------------------------
366
+ attrs.push(`${name}="${language.interpolate(value, instance, local)}"`);
367
+ }
368
+
369
+ // Single class attribute, regardless of source ordering.
370
+ if (classParts.length) {
371
+ const merged = classParts.filter(Boolean).join(' ').trim();
372
+ if (merged) attrs.push(`class="${merged}"`);
373
+ }
374
+
375
+ bindings.push(binding);
376
+
377
+ const children = language.renderChildren([...node.childNodes], instance, local, bindings);
378
+ if (voidTags.has(node.localName)) return `<${node.localName} ${attrs.join(' ')}>`;
379
+ return `<${node.localName} ${attrs.join(' ')}>${children}</${node.localName}>`;
380
+ }
381
+
382
+ // ---------------------------------------------------------------------------
383
+ // Reading and writing the value behind a `bind:`. `/path/@attr` and `$x`
384
+ // hit the instance tree; bare names hit the signal map.
385
+ // ---------------------------------------------------------------------------
386
+ function readBindingValue(target, instance, local) {
387
+ if (target.startsWith('/') || target.startsWith('$')) {
388
+ return instance.tree?.valueOf(target, local) ?? '';
389
+ }
390
+ const sig = instance.scope.signal(target);
391
+ if (sig) {
392
+ // Two-way binds re-render when the underlying signal moves; record
393
+ // the read so the per-render subscription pass can pick it up.
394
+ instance.readSignals?.add(target);
395
+ return sig.value;
396
+ }
397
+ return '';
398
+ }
399
+
400
+ function writeBindingValue(target, value, instance, local) {
401
+ if (target.startsWith('/') || target.startsWith('$')) {
402
+ instance.tree?.setValue(target, value, local);
403
+ } else {
404
+ const sig = instance.scope.signal(target);
405
+ if (sig) sig.value = value;
406
+ }
407
+ }
408
+
409
+ // ---------------------------------------------------------------------------
410
+ // Phase 3: install all bindings on the freshly morphed DOM.
411
+ //
412
+ // Every listener registered here is collected by the instance's
413
+ // renderScope. The next render pass disposes that scope, which
414
+ // automatically detaches all listeners. No leaks.
415
+ // ---------------------------------------------------------------------------
416
+ language.installBindings = (instance, bindings) => {
417
+ for (const binding of bindings) {
418
+ const element = instance.xesRoot?.querySelector(`[data-xes-id="${binding.id}"]`);
419
+ if (!element) continue;
420
+
421
+ // <slot> markers: move (don't clone) captured slot nodes into the
422
+ // wrapper, then freeze it so morph won't touch the children. Idempotent
423
+ // - if already frozen and populated, we do nothing.
424
+ if (binding.slot) {
425
+ if (!element.hasAttribute('data-xes-frozen')) {
426
+ for (const slotNode of instance.slotNodes ?? []) {
427
+ if (slotNode.parentNode) slotNode.parentNode.removeChild(slotNode);
428
+ element.appendChild(slotNode);
429
+ }
430
+ element.setAttribute('data-xes-frozen', '1');
431
+ }
432
+ continue;
433
+ }
434
+
435
+ // Buttons / menu items wired to a named command.
436
+ if (binding.command) {
437
+ const handler = event =>
438
+ language.executeCommand(instance, binding.command, binding.local, event);
439
+ element.addEventListener('click', handler);
440
+ instance.renderScope.collect(() =>
441
+ element.removeEventListener('click', handler),
442
+ );
443
+ }
444
+
445
+ // on:event="code|#action"
446
+ for (const eventBinding of binding.events) {
447
+ const handler = event =>
448
+ eventBinding.code.startsWith('#')
449
+ ? language.executeAction(
450
+ instance,
451
+ eventBinding.code.slice(1),
452
+ binding.local,
453
+ event,
454
+ )
455
+ : language.run(eventBinding.code, instance, binding.local, event);
456
+ element.addEventListener(eventBinding.event, handler);
457
+ instance.renderScope.collect(() =>
458
+ element.removeEventListener(eventBinding.event, handler),
459
+ );
460
+ }
461
+
462
+ // bind:property="path|signal"
463
+ for (const bind of binding.binds) {
464
+ // Pick a sensible event for each property: form fields use
465
+ // `input`/`change`, others use `change` as a safe default.
466
+ const eventName = bind.property === 'checked' ? 'change' : 'input';
467
+ const handler = () =>
468
+ writeBindingValue(
469
+ bind.target,
470
+ readElementBindingValue(element, bind.property),
471
+ instance,
472
+ binding.local,
473
+ );
474
+ element.addEventListener(eventName, handler);
475
+ instance.renderScope.collect(() =>
476
+ element.removeEventListener(eventName, handler),
477
+ );
478
+ }
479
+
480
+ // use:* attached behaviors.
481
+ for (const behavior of binding.behaviors) {
482
+ language.installBehavior(
483
+ behavior.name,
484
+ element,
485
+ behavior.value,
486
+ instance,
487
+ binding.local,
488
+ );
489
+ }
490
+
491
+ // Drag/drop: only meaningful when one of the four pieces is present.
492
+ if (binding.drag) {
493
+ instance.renderScope.collect(
494
+ language.installDragDrop(
495
+ element,
496
+ 'source',
497
+ binding.drag.value,
498
+ instance,
499
+ binding.local,
500
+ null,
501
+ ),
502
+ );
503
+ }
504
+ if (binding.drop || binding.dropCommand) {
505
+ instance.renderScope.collect(
506
+ language.installDragDrop(
507
+ element,
508
+ 'target',
509
+ binding.drop?.value ?? '',
510
+ instance,
511
+ binding.local,
512
+ binding.dropCommand,
513
+ ),
514
+ );
515
+ }
516
+ }
517
+ };
518
+
519
+ function readElementBindingValue(element, property) {
520
+ if (property === 'checked') return element.checked;
521
+ if (property === 'value' && isNumericInput(element)) {
522
+ return element.value === '' ? '' : element.valueAsNumber;
523
+ }
524
+ return element[property];
525
+ }
526
+
527
+ function isNumericInput(element) {
528
+ const type = String(element.getAttribute?.('type') || element.type || '').toLowerCase();
529
+ return element.localName === 'input' && (type === 'number' || type === 'range');
530
+ }
531
+
532
+ // ---------------------------------------------------------------------------
533
+ // Self-tests
534
+ // ---------------------------------------------------------------------------
535
+ language.test('rendering: <text> escapes embedded markup', () => {
536
+ const fake = { scope: new language.Concern('fake'), tree: null };
537
+ fake.scope.signal('snippet', new language.Signal('<x-live></x-live>'));
538
+ const xml = new DOMParser().parseFromString('<text value="snippet"/>', 'application/xml').documentElement;
539
+ const html = language.renderChildren([xml], fake, {}, []);
540
+ if (!html.includes('&lt;x-live&gt;')) {
541
+ throw new Error('snippet was rendered as live markup');
542
+ }
543
+ });
544
+
545
+ language.test('rendering: number inputs bind numeric values', () => {
546
+ const fake = { scope: new language.Concern('fake'), tree: null };
547
+ fake.scope.signal('step', new language.Signal(1));
548
+ const input = document.createElement('input');
549
+ input.type = 'number';
550
+ input.value = '10';
551
+ writeBindingValue('step', readElementBindingValue(input, 'value'), fake, {});
552
+ if (fake.scope.signal('step').value !== 10) {
553
+ throw new Error('number input value was not written as a number');
554
+ }
555
+ });
556
+ }