@nisli/core 0.57.0 → 0.57.1

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 CHANGED
@@ -14,6 +14,9 @@ npm install @nisli/core
14
14
  `@nisli/core` has no runtime dependencies. It works with TypeScript or plain
15
15
  JavaScript and does not require a framework-specific build step.
16
16
 
17
+ See the [framework tenets](https://github.com/gkoreli/nisli/blob/main/docs/TENETS.md)
18
+ for design constraints.
19
+
17
20
  ## Quick start
18
21
 
19
22
  ```ts
@@ -36,6 +39,30 @@ Signals are read with `.value` in TypeScript and passed directly to templates.
36
39
  Only the bound text, attribute, class, or child slot updates when a signal
37
40
  changes.
38
41
 
42
+ ## Native form bindings
43
+
44
+ `html` and `el()` synchronize native `value` (input, textarea, select), `checked`
45
+ (input), and `selected` (option) properties as well as their attributes. An
46
+ external signal change therefore replaces a user-edited value without a ref or
47
+ an application effect:
48
+
49
+ ```ts
50
+ const name = signal('');
51
+ html`<input value=${name} @input=${(event: Event) => {
52
+ name.value = (event.target as HTMLInputElement).value;
53
+ }}>`;
54
+ ```
55
+
56
+ Bindings remain one-way; native events update your model only through handlers.
57
+ Equal text-value writes preserve the caret. Select values initialize after
58
+ options and reconcile in a mutation-observer microtask when options change.
59
+ A select value is scalar; bind option `selected` states for multiple selections.
60
+ Nonempty file values cannot populate uploads; an empty value can clear them.
61
+
62
+ Form reset keeps native defaults: input value/checked and option selected
63
+ attributes, textarea child text, and select option defaults. Reset does not write
64
+ signals. Ordinary attributes and custom-element props keep their existing paths.
65
+
39
66
  ## Components that also work as HTML
40
67
 
41
68
  `component()` registers a standard custom element and returns its typed factory.
@@ -264,9 +291,11 @@ per-key DOM identity does the rest, with no generated names:
264
291
  - Safe light-DOM templates: signal bindings, typed events and modifiers,
265
292
  `class:*`, branded `html:inner` (trusted or sanitizer-routed), refs, and
266
293
  dynamic HTML tags through `el()`.
267
- - Stable control flow: lazy `when()` branches and keyed `each()` lists that
268
- preserve focus, scroll position, and component state, moved atomically with
269
- `moveBefore()` where the engine has it.
294
+ - Stable control flow: lazy `when()` branches and keyed `each()` lists retain
295
+ component state and DOM identity. Reorders use `moveBefore()` where available;
296
+ the fallback keeps a retained focused row attached without refocusing it.
297
+ Other moved rows can lose native state such as iframe documents or popovers
298
+ on the fallback path; document Selection ranges are not preserved.
270
299
  - Two scopes of dependency injection: app-wide singleton services with
271
300
  `inject`/`provide`, and portal-safe subtree state with `createContext`.
272
301
  - Lifecycle and resilience: `onMount`, `onCleanup`, `useHostEvent`, automatic
@@ -105,8 +105,10 @@ function html(strings, ...values) {
105
105
  mounted = true;
106
106
  const template = parseTemplate(strings, values.length);
107
107
  const fragment = template.content.cloneNode(true);
108
- processNode(fragment, values, bindings, disposers);
108
+ const afterMount = [];
109
+ processNode(fragment, values, bindings, disposers, afterMount);
109
110
  host.appendChild(fragment);
111
+ for (const sync of afterMount) sync();
110
112
  },
111
113
  dispose() {
112
114
  for (const d of disposers) {
@@ -129,10 +131,11 @@ function html(strings, ...values) {
129
131
  }
130
132
  };
131
133
  }
132
- function processNode(node, values, bindings, disposers) {
134
+ function processNode(node, values, bindings, disposers, afterMount) {
135
+ let afterChildren;
133
136
  if (node.nodeType === Node.ELEMENT_NODE) {
134
137
  const el2 = node;
135
- processAttributes(el2, values, bindings, disposers);
138
+ afterChildren = processAttributes(el2, values, bindings, disposers, afterMount);
136
139
  }
137
140
  if (node.nodeType === Node.COMMENT_NODE) {
138
141
  const comment = node;
@@ -146,11 +149,13 @@ function processNode(node, values, bindings, disposers) {
146
149
  }
147
150
  const children = [...node.childNodes];
148
151
  for (const child of children) {
149
- processNode(child, values, bindings, disposers);
152
+ processNode(child, values, bindings, disposers, afterMount);
150
153
  }
154
+ if (afterChildren) for (const bind of afterChildren) bind();
151
155
  }
152
- function processAttributes(el2, values, bindings, disposers) {
156
+ function processAttributes(el2, values, bindings, disposers, afterMount) {
153
157
  const attrsToRemove = [];
158
+ let afterChildren;
154
159
  for (const attr of [...el2.attributes]) {
155
160
  const name = attr.name;
156
161
  const attrValue = attr.value;
@@ -209,8 +214,9 @@ function processAttributes(el2, values, bindings, disposers) {
209
214
  const markers = [...attrValue.matchAll(/<!--bk-(\d+)-->/g)];
210
215
  if (markers.length) {
211
216
  const first = markers[0];
217
+ let value;
212
218
  if (markers.length === 1 && first && attrValue === first[0]) {
213
- bindAttribute(el2, name, values[Number(first[1])], bindings, disposers);
219
+ value = values[Number(first[1])];
214
220
  } else {
215
221
  const resolve = () => attrValue.replace(/<!--bk-(\d+)-->/g, (_, i) => {
216
222
  const v = values[Number(i)];
@@ -218,7 +224,12 @@ function processAttributes(el2, values, bindings, disposers) {
218
224
  return raw2 == null || raw2 === false ? "" : String(raw2);
219
225
  });
220
226
  const hasSignals = markers.some((m) => isSignal(values[Number(m[1])]));
221
- bindAttribute(el2, name, hasSignals ? computed(resolve) : resolve(), bindings, disposers);
227
+ value = hasSignals ? computed(resolve) : resolve();
228
+ }
229
+ if (nativeFormProperty(el2, name)) {
230
+ (afterChildren ??= []).push(() => bindAttribute(el2, name, value, bindings, disposers, afterMount));
231
+ } else {
232
+ bindAttribute(el2, name, value, bindings, disposers, afterMount);
222
233
  }
223
234
  }
224
235
  continue;
@@ -227,6 +238,7 @@ function processAttributes(el2, values, bindings, disposers) {
227
238
  for (const name of attrsToRemove) {
228
239
  el2.removeAttribute(name);
229
240
  }
241
+ return afterChildren;
230
242
  }
231
243
  function mountFactoryResult(factory) {
232
244
  const el2 = document.createElement(factory.tagName);
@@ -425,7 +437,7 @@ function replaceMarkerWithBinding(comment, value, bindings, disposers) {
425
437
  parent.replaceChild(textNode, comment);
426
438
  }
427
439
  }
428
- function bindAttribute(el2, name, value, bindings, disposers) {
440
+ function bindAttribute(el2, name, value, bindings, disposers, afterMount) {
429
441
  if (name === "class") {
430
442
  bindClassAttribute(el2, value, bindings, disposers);
431
443
  return;
@@ -446,32 +458,69 @@ function bindAttribute(el2, name, value, bindings, disposers) {
446
458
  }
447
459
  return;
448
460
  }
449
- bindPlainAttribute(el2, name, value, bindings, disposers);
461
+ bindPlainAttribute(el2, name, value, bindings, disposers, afterMount);
450
462
  }
451
- function bindPlainAttribute(el2, name, value, bindings, disposers) {
463
+ function bindPlainAttribute(el2, name, value, bindings, disposers, afterMount) {
464
+ const property = nativeFormProperty(el2, name);
465
+ let current;
466
+ const apply = (v) => {
467
+ current = v;
468
+ if (v == null || v === false) {
469
+ el2.removeAttribute(name);
470
+ } else {
471
+ el2.setAttribute(name, v === true ? "" : String(v));
472
+ }
473
+ if (property) syncFormProperty(el2, property, v);
474
+ };
452
475
  if (isSignal(value)) {
453
476
  const binding = { type: "attribute", element: el2, name };
454
477
  bindings.push(binding);
455
- const dispose = effect(() => {
456
- const v = value.value;
457
- if (v == null || v === false) {
458
- el2.removeAttribute(name);
459
- } else if (v === true) {
460
- el2.setAttribute(name, "");
461
- } else {
462
- el2.setAttribute(name, String(v));
463
- }
464
- });
478
+ const dispose = effect(() => apply(value.value));
465
479
  binding.dispose = dispose;
466
480
  disposers.push(dispose);
467
481
  } else {
468
- if (value == null || value === false) {
469
- el2.removeAttribute(name);
470
- } else if (value === true) {
471
- el2.setAttribute(name, "");
472
- } else {
473
- el2.setAttribute(name, String(value));
474
- }
482
+ apply(value);
483
+ }
484
+ if (property === "value" && el2.localName === "select") {
485
+ let active = true;
486
+ const sync = () => {
487
+ if (active) syncFormProperty(el2, "value", current);
488
+ };
489
+ const observer = new MutationObserver(sync);
490
+ observer.observe(el2, {
491
+ childList: true,
492
+ subtree: true,
493
+ characterData: true,
494
+ attributes: true,
495
+ attributeFilter: ["value", "selected"]
496
+ });
497
+ disposers.push(() => {
498
+ active = false;
499
+ observer.disconnect();
500
+ });
501
+ afterMount.push(sync);
502
+ queueMicrotask(sync);
503
+ }
504
+ }
505
+ function nativeFormProperty(el2, name) {
506
+ const attribute = name.toLowerCase();
507
+ if (attribute !== "value" && attribute !== "checked" && attribute !== "selected") return;
508
+ if (el2.namespaceURI !== "http://www.w3.org/1999/xhtml") return;
509
+ const tag = el2.localName;
510
+ if (attribute === "value" && (tag === "input" || tag === "textarea" || tag === "select")) return attribute;
511
+ if (attribute === "checked" && tag === "input") return attribute;
512
+ if (attribute === "selected" && tag === "option") return attribute;
513
+ }
514
+ function syncFormProperty(el2, property, value) {
515
+ if (property === "value") {
516
+ const control = el2;
517
+ const next = value == null || typeof value === "boolean" ? "" : String(value);
518
+ if (el2.localName === "input" && el2.type === "file" && next !== "") return;
519
+ if (el2.localName === "select" || control.value !== next) control.value = next;
520
+ } else {
521
+ const control = el2;
522
+ const next = value != null && value !== false;
523
+ if (control[property] !== next) control[property] = next;
475
524
  }
476
525
  }
477
526
  function bindClassAttribute(el2, value, bindings, disposers) {
@@ -728,11 +777,28 @@ function each(items, keyFn, templateFn) {
728
777
  entry.wrapper.parentNode?.removeChild(entry.wrapper);
729
778
  }
730
779
  }
731
- let cursor = startMarker;
732
- for (const entry of newEntries) {
780
+ const atomic = canMoveWithin(parent);
781
+ let focusedIndex = -1;
782
+ if (!atomic) {
783
+ const root = parent.getRootNode();
784
+ let active = "activeElement" in root ? root.activeElement : null;
785
+ while (active && active.parentNode !== parent) active = active.parentElement;
786
+ if (active) focusedIndex = newEntries.findIndex((entry) => entry.wrapper === active);
787
+ }
788
+ if (focusedIndex !== -1) {
789
+ let next = newEntries[focusedIndex].wrapper;
790
+ for (let i = focusedIndex - 1; i >= 0; i--) {
791
+ const wrapper = newEntries[i].wrapper;
792
+ if (wrapper.nextSibling !== next) parent.insertBefore(wrapper, next);
793
+ next = wrapper;
794
+ }
795
+ }
796
+ let cursor = focusedIndex === -1 ? startMarker : newEntries[focusedIndex].wrapper;
797
+ for (let i = focusedIndex + 1; i < newEntries.length; i++) {
798
+ const entry = newEntries[i];
733
799
  const nextSibling = cursor.nextSibling;
734
800
  if (nextSibling !== entry.wrapper) {
735
- if (entry.wrapper.parentNode === parent && canMoveWithin(parent)) {
801
+ if (entry.wrapper.parentNode === parent && atomic) {
736
802
  parent.moveBefore(entry.wrapper, nextSibling);
737
803
  } else {
738
804
  parent.insertBefore(entry.wrapper, nextSibling);
@@ -772,8 +838,11 @@ function el(tag, props = {}, children) {
772
838
  mount(host) {
773
839
  const element = document.createElement(tag);
774
840
  node = element;
841
+ let afterChildren;
842
+ const afterMount = [];
775
843
  for (const [key, val] of Object.entries(props)) {
776
- if (val === void 0) continue;
844
+ const formProperty = nativeFormProperty(element, key);
845
+ if (val === void 0 && !formProperty) continue;
777
846
  if (key === "ref") {
778
847
  if (isRef(val)) {
779
848
  val.current = element;
@@ -793,7 +862,11 @@ function el(tag, props = {}, children) {
793
862
  bindClassAttribute(element, val, bindings, disposers);
794
863
  continue;
795
864
  }
796
- bindPlainAttribute(element, key, val, bindings, disposers);
865
+ if (formProperty) {
866
+ (afterChildren ??= []).push(() => bindPlainAttribute(element, key, val, bindings, disposers, afterMount));
867
+ } else {
868
+ bindPlainAttribute(element, key, val, bindings, disposers, afterMount);
869
+ }
797
870
  }
798
871
  const flat = [];
799
872
  flattenChildren(children, flat);
@@ -802,7 +875,9 @@ function el(tag, props = {}, children) {
802
875
  element.appendChild(anchor);
803
876
  replaceMarkerWithBinding(anchor, child, bindings, disposers);
804
877
  }
878
+ if (afterChildren) for (const bind of afterChildren) bind();
805
879
  host.appendChild(element);
880
+ for (const sync of afterMount) sync();
806
881
  },
807
882
  dispose() {
808
883
  for (const d of disposers) {