synstate-dom 0.3.0 → 0.5.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 (66) hide show
  1. package/README.md +4 -2
  2. package/dist/bind/bind-props.d.mts +4 -4
  3. package/dist/bind/bind-props.d.mts.map +1 -1
  4. package/dist/bind/bind-props.mjs +109 -39
  5. package/dist/bind/bind-props.mjs.map +1 -1
  6. package/dist/constant/constant.d.mts +2 -2
  7. package/dist/constant/derive.d.mts +2 -1
  8. package/dist/constant/derive.d.mts.map +1 -1
  9. package/dist/constant/derive.mjs +5 -5
  10. package/dist/constant/derive.mjs.map +1 -1
  11. package/dist/constant/view.d.mts +3 -3
  12. package/dist/control/for.d.mts +2 -2
  13. package/dist/control/for.mjs.map +1 -1
  14. package/dist/control/if.d.mts +2 -2
  15. package/dist/control/longest-increasing-subsequence.d.mts +3 -3
  16. package/dist/control/match-optional.d.mts +4 -4
  17. package/dist/control/match-result.d.mts +5 -6
  18. package/dist/control/match-result.d.mts.map +1 -1
  19. package/dist/control/match-result.mjs +0 -1
  20. package/dist/control/match-result.mjs.map +1 -1
  21. package/dist/control/mount-switch.d.mts +5 -5
  22. package/dist/control/portal.d.mts +2 -2
  23. package/dist/index.d.mts +1 -0
  24. package/dist/index.d.mts.map +1 -1
  25. package/dist/index.mjs +1 -0
  26. package/dist/index.mjs.map +1 -1
  27. package/dist/internal/bind-value.d.mts.map +1 -1
  28. package/dist/internal/bind-value.mjs +7 -8
  29. package/dist/internal/bind-value.mjs.map +1 -1
  30. package/dist/jsx/child.d.mts +2 -2
  31. package/dist/jsx/element-props.d.mts +2 -2
  32. package/dist/jsx/jsx.mjs +1 -1
  33. package/dist/jsx/jsx.mjs.map +1 -1
  34. package/dist/jsx/mountable.d.mts +4 -4
  35. package/dist/jsx-dev-runtime.d.mts +2 -2
  36. package/dist/scope/index.d.mts +2 -0
  37. package/dist/scope/index.d.mts.map +1 -0
  38. package/dist/scope/index.mjs +2 -0
  39. package/dist/scope/index.mjs.map +1 -0
  40. package/dist/scope/scope-effects.d.mts +25 -0
  41. package/dist/scope/scope-effects.d.mts.map +1 -0
  42. package/dist/scope/scope-effects.mjs +43 -0
  43. package/dist/scope/scope-effects.mjs.map +1 -0
  44. package/package.json +10 -10
  45. package/src/bind/bind-props.mts +169 -66
  46. package/src/constant/constant.mts +2 -2
  47. package/src/constant/derive.mts +11 -12
  48. package/src/constant/derive.test.mts +25 -1
  49. package/src/constant/view.mts +3 -3
  50. package/src/control/for.mts +10 -10
  51. package/src/control/if.mts +2 -2
  52. package/src/control/longest-increasing-subsequence.mts +3 -3
  53. package/src/control/match-optional.mts +4 -4
  54. package/src/control/match-result.mts +5 -6
  55. package/src/control/mount-switch.mts +5 -5
  56. package/src/control/portal.mts +2 -2
  57. package/src/index.mts +1 -0
  58. package/src/internal/bind-value.mts +7 -13
  59. package/src/jsx/child.mts +2 -2
  60. package/src/jsx/element-props.mts +2 -2
  61. package/src/jsx/jsx.mts +5 -5
  62. package/src/jsx/mountable.mts +4 -4
  63. package/src/jsx-dev-runtime.mts +2 -2
  64. package/src/scope/index.mts +1 -0
  65. package/src/scope/scope-effects.mts +60 -0
  66. package/src/scope/scope-effects.test.mts +89 -0
package/dist/jsx/jsx.mjs CHANGED
@@ -23,6 +23,7 @@ export const createMountable = (type, props, key) => {
23
23
  };
24
24
 
25
25
  export const Fragment = (props) => mountable(() => mountChild(props.children ?? []));
26
+ const isRefCallback = (value) => typeof value === 'function';
26
27
  const mountElement = (tag, props) => {
27
28
  const element = svgTagNameSet.has(tag)
28
29
  ? document.createElementNS(svgNamespace, tag)
@@ -42,5 +43,4 @@ const mountElement = (tag, props) => {
42
43
  };
43
44
  const svgNamespace = 'http://www.w3.org/2000/svg';
44
45
  const svgTagNameSet = new Set(svgTagNames);
45
- const isRefCallback = (value) => typeof value === 'function';
46
46
  //# sourceMappingURL=jsx.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"jsx.mjs","sourceRoot":"","sources":["../../src/jsx/jsx.mts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AACjD,OAAO,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AAC9C,OAAO,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AACpD,OAAO,EAAE,OAAO,EAAE,UAAU,EAAc,MAAM,aAAa,CAAC;AAC9D,OAAO,EAAE,SAAS,EAAkB,MAAM,iBAAiB,CAAC;AAE5D;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,eAAe,GAAG,CAC7B,IAAiD,EACjD,KAAQ,EACR,GAAa,EACF,EAAE;IACb,0EAA0E;IAC1E,yEAAyE;IACzE,MAAM,YAAY,GAAG,GAAG,KAAK,SAAS,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAE,EAAE,GAAG,KAAK,EAAE,GAAG,EAAY,CAAC;IAE9E,OAAO,OAAO,IAAI,KAAK,QAAQ;QAC7B,CAAC,CAAC,SAAS,CAAC,GAAG,EAAE,CAAC,YAAY,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;QAC5C,CAAC,CAAC,SAAS,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC;AAClD,CAAC,CAAC;AAEF,iEAAiE;AACjE,MAAM,CAAC,MAAM,QAAQ,GAAG,CAAC,KAAqC,EAAa,EAAE,CAC3E,SAAS,CAAC,GAAG,EAAE,CAAC,UAAU,CAAC,KAAK,CAAC,QAAQ,IAAI,EAAE,CAAC,CAAC,CAAC;AAEpD,MAAM,YAAY,GAAG,CAAC,GAAW,EAAE,KAAc,EAAW,EAAE;IAC5D,MAAM,OAAO,GAAG,aAAa,CAAC,GAAG,CAAC,GAAG,CAAC;QACpC,CAAC,CAAC,QAAQ,CAAC,eAAe,CAAC,YAAY,EAAE,GAAG,CAAC;QAC7C,CAAC,CAAC,QAAQ,CAAC,aAAa,CAAC,GAAG,CAAC,CAAC;IAEhC,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC;QACrB,OAAO,OAAO,CAAC;IACjB,CAAC;IAED,IAAI,MAAM,CAAC,KAAK,EAAE,KAAK,CAAC,IAAI,aAAa,CAAC,KAAK,CAAC,GAAG,CAAC,EAAE,CAAC;QACrD,KAAK,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;IACrB,CAAC;IAED,wEAAwE;IACxE,IAAI,MAAM,CAAC,KAAK,EAAE,UAAU,CAAC,IAAI,OAAO,CAAC,KAAK,CAAC,QAAQ,CAAC,EAAE,CAAC;QACzD,OAAO,CAAC,MAAM,CAAC,GAAG,UAAU,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC;IAChD,CAAC;IAED,SAAS,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC;IAE1B,OAAO,OAAO,CAAC;AACjB,CAAC,CAAC;AAEF,MAAM,YAAY,GAAG,4BAA4B,CAAC;AAElD,MAAM,aAAa,GAAwB,IAAI,GAAG,CAAC,WAAW,CAAC,CAAC;AAEhE,MAAM,aAAa,GAAG,CAAC,KAAc,EAAuC,EAAE,CAC5E,OAAO,KAAK,KAAK,UAAU,CAAC"}
1
+ {"version":3,"file":"jsx.mjs","sourceRoot":"","sources":["../../src/jsx/jsx.mts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AACjD,OAAO,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AAC9C,OAAO,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AACpD,OAAO,EAAE,OAAO,EAAE,UAAU,EAAc,MAAM,aAAa,CAAC;AAC9D,OAAO,EAAE,SAAS,EAAkB,MAAM,iBAAiB,CAAC;AAE5D;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,eAAe,GAAG,CAC7B,IAAiD,EACjD,KAAQ,EACR,GAAa,EACF,EAAE;IACb,6EAA6E;IAC7E,sEAAsE;IACtE,MAAM,YAAY,GAAG,GAAG,KAAK,SAAS,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAE,EAAE,GAAG,KAAK,EAAE,GAAG,EAAY,CAAC;IAE9E,OAAO,OAAO,IAAI,KAAK,QAAQ;QAC7B,CAAC,CAAC,SAAS,CAAC,GAAG,EAAE,CAAC,YAAY,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;QAC5C,CAAC,CAAC,SAAS,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC;AAClD,CAAC,CAAC;AAEF,iEAAiE;AACjE,MAAM,CAAC,MAAM,QAAQ,GAAG,CAAC,KAAqC,EAAa,EAAE,CAC3E,SAAS,CAAC,GAAG,EAAE,CAAC,UAAU,CAAC,KAAK,CAAC,QAAQ,IAAI,EAAE,CAAC,CAAC,CAAC;AAEpD,MAAM,aAAa,GAAG,CAAC,KAAc,EAAuC,EAAE,CAC5E,OAAO,KAAK,KAAK,UAAU,CAAC;AAE9B,MAAM,YAAY,GAAG,CAAC,GAAW,EAAE,KAAc,EAAW,EAAE;IAC5D,MAAM,OAAO,GAAG,aAAa,CAAC,GAAG,CAAC,GAAG,CAAC;QACpC,CAAC,CAAC,QAAQ,CAAC,eAAe,CAAC,YAAY,EAAE,GAAG,CAAC;QAC7C,CAAC,CAAC,QAAQ,CAAC,aAAa,CAAC,GAAG,CAAC,CAAC;IAEhC,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC;QACrB,OAAO,OAAO,CAAC;IACjB,CAAC;IAED,IAAI,MAAM,CAAC,KAAK,EAAE,KAAK,CAAC,IAAI,aAAa,CAAC,KAAK,CAAC,GAAG,CAAC,EAAE,CAAC;QACrD,KAAK,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;IACrB,CAAC;IAED,wEAAwE;IACxE,IAAI,MAAM,CAAC,KAAK,EAAE,UAAU,CAAC,IAAI,OAAO,CAAC,KAAK,CAAC,QAAQ,CAAC,EAAE,CAAC;QACzD,OAAO,CAAC,MAAM,CAAC,GAAG,UAAU,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC;IAChD,CAAC;IAED,SAAS,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC;IAE1B,OAAO,OAAO,CAAC;AACjB,CAAC,CAAC;AAEF,MAAM,YAAY,GAAG,4BAA4B,CAAC;AAElD,MAAM,aAAa,GAAwB,IAAI,GAAG,CAAC,WAAW,CAAC,CAAC"}
@@ -1,7 +1,7 @@
1
1
  import { mountableTag } from '../internal/index.mjs';
2
2
  /**
3
- * What a JSX expression evaluates to: a description that creates DOM nodes
4
- * when mounted, not the nodes themselves (SPEC.md 6.1).
3
+ * What a JSX expression evaluates to: a description that creates DOM nodes when
4
+ * mounted, not the nodes themselves (SPEC.md 6.1).
5
5
  *
6
6
  * The children of a JSX element are evaluated before the element, so a
7
7
  * description that created its nodes at once would create both branches of
@@ -10,8 +10,8 @@ import { mountableTag } from '../internal/index.mjs';
10
10
  * It is an object rather than a function: a props object with a `then` prop
11
11
  * holding a function would be taken for a thenable.
12
12
  *
13
- * The same description may be mounted any number of times; each mount
14
- * creates new nodes.
13
+ * The same description may be mounted any number of times; each mount creates
14
+ * new nodes.
15
15
  */
16
16
  export type Mountable = Readonly<{
17
17
  [mountableTag]: true;
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The module `"jsxImportSource": "synstate-dom"` makes TypeScript import for
3
- * `"jsx": "react-jsxdev"`. The extra arguments (`key`, source location) are
4
- * not used.
3
+ * `"jsx": "react-jsxdev"`. The extra arguments (`key`, source location) are not
4
+ * used.
5
5
  */
6
6
  export { Fragment, createMountable as jsxDEV, type JSX } from './jsx/index.mjs';
7
7
  //# sourceMappingURL=jsx-dev-runtime.d.mts.map
@@ -0,0 +1,2 @@
1
+ export * from './scope-effects.mjs';
2
+ //# sourceMappingURL=index.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.mts","sourceRoot":"","sources":["../../src/scope/index.mts"],"names":[],"mappings":"AAAA,cAAc,qBAAqB,CAAC"}
@@ -0,0 +1,2 @@
1
+ export * from './scope-effects.mjs';
2
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","sourceRoot":"","sources":["../../src/scope/index.mts"],"names":[],"mappings":"AAAA,cAAc,qBAAqB,CAAC"}
@@ -0,0 +1,25 @@
1
+ import { type Observable } from 'synstate';
2
+ import type { StrictPick } from 'ts-type-forge';
3
+ /**
4
+ * Calls `next` with every value of `source`, the current one first, until the
5
+ * current scope is disposed — what a component that is called once does in
6
+ * place of an effect.
7
+ */
8
+ export declare const subscribeInScope: <A>(source: Observable<A>, next: (value: A) => void) => void;
9
+ /** Listens to `type` on `target` until the current scope is disposed. */
10
+ export declare const listenInScope: (target: Readonly<StrictPick<EventTarget, 'addEventListener' | 'removeEventListener'>>, type: string, listener: (event: Event) => void, options?: Readonly<{
11
+ capture?: boolean;
12
+ passive?: boolean;
13
+ }>) => void;
14
+ /**
15
+ * Makes the function that runs work once the update under way has finished:
16
+ * in a microtask, so before the next paint, with the DOM bound to the update
17
+ * already written — and nothing written to a state while another is still
18
+ * propagating, which synstate warns of. Work for a scope disposed by then is
19
+ * dropped.
20
+ *
21
+ * Also what runs after the first mount: a component's nodes are attached to the
22
+ * document only once the whole description they are in is mounted.
23
+ */
24
+ export declare const createAfterUpdate: () => ((work: () => void) => void);
25
+ //# sourceMappingURL=scope-effects.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scope-effects.d.mts","sourceRoot":"","sources":["../../src/scope/scope-effects.mts"],"names":[],"mappings":"AAAA,OAAO,EAAkB,KAAK,UAAU,EAAE,MAAM,UAAU,CAAC;AAC3D,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAEhD;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,GAAI,CAAC,UACxB,UAAU,CAAC,CAAC,CAAC,QACf,CAAC,KAAK,EAAE,CAAC,KAAK,IAAI,KACvB,IAMF,CAAC;AAEF,yEAAyE;AACzE,eAAO,MAAM,aAAa,WAChB,QAAQ,CACd,UAAU,CAAC,WAAW,EAAE,kBAAkB,GAAG,qBAAqB,CAAC,CACpE,QACK,MAAM,YACF,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,YACtB,QAAQ,CAAC;IAAE,OAAO,CAAC,EAAE,OAAO,CAAC;IAAC,OAAO,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC,KAC3D,IAMF,CAAC;AAEF;;;;;;;;;GASG;AACH,eAAO,MAAM,iBAAiB,QAAO,CAAC,CAAC,IAAI,EAAE,MAAM,IAAI,KAAK,IAAI,CAc/D,CAAC"}
@@ -0,0 +1,43 @@
1
+ import { onScopeDispose } from 'synstate';
2
+
3
+
4
+
5
+
6
+
7
+ export const subscribeInScope = (source, next) => {
8
+ const subscription = source.subscribe(next);
9
+ onScopeDispose(() => {
10
+ subscription.unsubscribe();
11
+ });
12
+ };
13
+
14
+ export const listenInScope = (target, type, listener, options) => {
15
+ target.addEventListener(type, listener, options);
16
+ onScopeDispose(() => {
17
+ target.removeEventListener(type, listener, options);
18
+ });
19
+ };
20
+
21
+
22
+
23
+
24
+
25
+
26
+
27
+
28
+
29
+
30
+ export const createAfterUpdate = () => {
31
+ let mut_alive = true;
32
+ onScopeDispose(() => {
33
+ mut_alive = false;
34
+ });
35
+ return (work) => {
36
+ queueMicrotask(() => {
37
+ if (mut_alive) {
38
+ work();
39
+ }
40
+ });
41
+ };
42
+ };
43
+ //# sourceMappingURL=scope-effects.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scope-effects.mjs","sourceRoot":"","sources":["../../src/scope/scope-effects.mts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAmB,MAAM,UAAU,CAAC;AAG3D;;;;GAIG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAC9B,MAAqB,EACrB,IAAwB,EAClB,EAAE;IACR,MAAM,YAAY,GAAG,MAAM,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC;IAE5C,cAAc,CAAC,GAAG,EAAE;QAClB,YAAY,CAAC,WAAW,EAAE,CAAC;IAC7B,CAAC,CAAC,CAAC;AACL,CAAC,CAAC;AAEF,yEAAyE;AACzE,MAAM,CAAC,MAAM,aAAa,GAAG,CAC3B,MAEC,EACD,IAAY,EACZ,QAAgC,EAChC,OAA4D,EACtD,EAAE;IACR,MAAM,CAAC,gBAAgB,CAAC,IAAI,EAAE,QAAQ,EAAE,OAAO,CAAC,CAAC;IAEjD,cAAc,CAAC,GAAG,EAAE;QAClB,MAAM,CAAC,mBAAmB,CAAC,IAAI,EAAE,QAAQ,EAAE,OAAO,CAAC,CAAC;IACtD,CAAC,CAAC,CAAC;AACL,CAAC,CAAC;AAEF;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,iBAAiB,GAAG,GAAiC,EAAE;IAClE,IAAI,SAAS,GAAG,IAAI,CAAC;IAErB,cAAc,CAAC,GAAG,EAAE;QAClB,SAAS,GAAG,KAAK,CAAC;IACpB,CAAC,CAAC,CAAC;IAEH,OAAO,CAAC,IAAI,EAAE,EAAE;QACd,cAAc,CAAC,GAAG,EAAE;YAClB,IAAI,SAAS,EAAE,CAAC;gBACd,IAAI,EAAE,CAAC;YACT,CAAC;QACH,CAAC,CAAC,CAAC;IACL,CAAC,CAAC;AACJ,CAAC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synstate-dom",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Compiler-free fine-grained UI library that binds synstate observables directly to the DOM",
5
5
  "repository": {
6
6
  "type": "git",
@@ -44,24 +44,24 @@
44
44
  },
45
45
  "devDependencies": {
46
46
  "@vitest/browser-playwright": "5.0.5",
47
- "eslint-config-typed": "5.19.2",
48
- "eslint-plugin-ts-data-forge": "0.10.3",
49
- "eslint-plugin-ts-fortress": "1.3.8",
50
- "eslint-plugin-ts-type-forge": "0.8.13",
47
+ "eslint-config-typed": "7.3.0",
48
+ "eslint-plugin-ts-data-forge": "0.10.7",
49
+ "eslint-plugin-ts-fortress": "1.3.12",
50
+ "eslint-plugin-ts-type-forge": "0.8.17",
51
51
  "playwright": "1.64.1",
52
52
  "prettier": "3.9.10",
53
53
  "prettier-plugin-organize-imports": "4.3.0",
54
54
  "prettier-plugin-packagejson": "3.0.2",
55
- "synstate": "6.0.0",
56
- "ts-data-forge": "14.10.0",
55
+ "synstate": "6.1.1",
56
+ "ts-data-forge": "14.10.1",
57
57
  "ts-repo-utils": "10.7.1"
58
58
  },
59
59
  "peerDependencies": {
60
- "synstate": ">=6.0.0",
61
- "ts-data-forge": ">=14.10.0"
60
+ "synstate": ">=6.1.1",
61
+ "ts-data-forge": ">=14.10.1"
62
62
  },
63
63
  "engines": {
64
- "node": ">=22.22.2",
64
+ "node": ">=22.23.4",
65
65
  "pnpm": ">=9.0.0"
66
66
  },
67
67
  "publishConfig": {
@@ -8,12 +8,13 @@ import {
8
8
  propertyNames,
9
9
  viewTag,
10
10
  } from '../internal/index.mjs';
11
+ import { listenInScope } from '../scope/index.mjs';
11
12
 
12
13
  /**
13
- * Binds every prop of an intrinsic element except `ref` and `children`
14
- * (SPEC.md 7): `class`, `style`, event handlers, the DOM properties of
15
- * `propertyNames`, and attributes for the rest. Subscriptions and listeners
16
- * are released when the current scope is disposed.
14
+ * Binds every prop of an intrinsic element except `ref` and `children` (SPEC.md
15
+ * 7): `class`, `style`, event handlers, the DOM properties of `propertyNames`,
16
+ * and attributes for the rest. Subscriptions and listeners are released when
17
+ * the current scope is disposed.
17
18
  */
18
19
  export const bindProps = (
19
20
  element: Element,
@@ -24,43 +25,42 @@ export const bindProps = (
24
25
  }
25
26
  };
26
27
 
27
- const bindProp = (element: Element, key: string, value: unknown): void => {
28
- if (key === 'children' || key === 'ref' || !isBindable(value)) {
29
- return;
30
- }
31
-
32
- if (key === 'class') {
33
- bindClass(element, value);
34
-
35
- return;
36
- }
37
-
38
- if (key === 'style') {
39
- bindStyle(element, value);
28
+ /**
29
+ * `true` writes an empty attribute, and `false`, `undefined` and `null` remove
30
+ * it.
31
+ */
32
+ const writeAttribute = (
33
+ element: Element,
34
+ name: string,
35
+ value: unknown,
36
+ ): void => {
37
+ if (isBoolean(value)) {
38
+ element.toggleAttribute(name, value);
40
39
 
41
40
  return;
42
41
  }
43
42
 
44
- if (isEventKey(key) && isEventHandler(value)) {
45
- bindEvent(element, key.slice(2).toLowerCase(), value);
43
+ if (value === undefined || value === null) {
44
+ element.removeAttribute(name);
46
45
 
47
46
  return;
48
47
  }
49
48
 
50
- if (propertyNameSet.has(key)) {
51
- bindValue(value, (v) => {
52
- Reflect.set(element, key, v);
53
- });
54
-
55
- return;
56
- }
49
+ element.setAttribute(name, unknownToString(value));
50
+ };
57
51
 
58
- const name = attributeNameOf[key] ?? key;
52
+ /**
53
+ * Every value is a `Bindable` at run time: what is neither an observable nor a
54
+ * view is a constant. An omitted prop is `undefined`, which is not.
55
+ */
56
+ const isBindable = (value: unknown): value is Bindable<unknown> =>
57
+ value !== undefined && value !== null;
59
58
 
60
- bindValue(value, (v) => {
61
- writeAttribute(element, name, v);
62
- });
63
- };
59
+ /** `class` and `style` in their object form: neither an observable nor a view. */
60
+ const isPlainObject = (
61
+ value: unknown,
62
+ ): value is ReadonlyRecord<string, unknown> =>
63
+ isRecord(value) && !isObservable(value) && !hasKey(value, viewTag);
64
64
 
65
65
  const bindClass = (element: Element, value: Bindable<unknown>): void => {
66
66
  if (!isPlainObject(value)) {
@@ -116,59 +116,162 @@ const bindStyle = (element: Element, value: Bindable<unknown>): void => {
116
116
  }
117
117
  };
118
118
 
119
- const bindEvent = (
120
- element: Element,
121
- type: string,
122
- handler: EventListener,
123
- ): void => {
124
- element.addEventListener(type, handler);
119
+ /**
120
+ * The radio buttons of `input`'s group: those of the same `name` and form in
121
+ * the same document or shadow root. Any other input is a group of its own.
122
+ */
123
+ const radioGroupOf = (input: HTMLInputElement): readonly HTMLInputElement[] => {
124
+ const root = input.getRootNode();
125
+
126
+ if (
127
+ input.type !== 'radio' ||
128
+ input.name === '' ||
129
+ !(root instanceof Document || root instanceof ShadowRoot)
130
+ ) {
131
+ return [input];
132
+ }
125
133
 
126
- onScopeDispose(() => {
127
- element.removeEventListener(type, handler);
128
- });
134
+ return Array.from(
135
+ root.querySelectorAll<HTMLInputElement>(
136
+ `input[type="radio"][name="${CSS.escape(input.name)}"]`,
137
+ ),
138
+ ).filter((radio) => radio.form === input.form);
129
139
  };
130
140
 
131
141
  /**
132
- * `true` writes an empty attribute, and `false`, `undefined` and `null`
133
- * remove it.
142
+ * The controlled properties of an element, created with the `change` listener
143
+ * that sets them back. The listener is a capturing one, which runs before the
144
+ * element's own handlers, so no handler on the element can stop it.
134
145
  */
135
- const writeAttribute = (
136
- element: Element,
137
- name: string,
138
- value: unknown,
139
- ): void => {
140
- if (isBoolean(value)) {
141
- element.toggleAttribute(name, value);
146
+ const controlledOf = (element: HTMLInputElement): Controlled => {
147
+ const existing = controlled.get(element);
142
148
 
143
- return;
149
+ if (existing !== undefined) {
150
+ return existing;
144
151
  }
145
152
 
146
- if (value === undefined || value === null) {
147
- element.removeAttribute(name);
153
+ const mut_restorers = new Map<ControlledKey, () => void>();
154
+
155
+ const created: Controlled = {
156
+ add: (key, restore) => {
157
+ mut_restorers.set(key, restore);
158
+ },
159
+ restore: () => {
160
+ for (const restore of mut_restorers.values()) {
161
+ restore();
162
+ }
163
+ },
164
+ } as const;
165
+
166
+ const onChange = (): void => {
167
+ requestAnimationFrame(() => {
168
+ for (const input of radioGroupOf(element)) {
169
+ controlled.get(input)?.restore();
170
+ }
171
+ });
172
+ };
148
173
 
149
- return;
150
- }
174
+ controlled.set(element, created);
151
175
 
152
- element.setAttribute(name, unknownToString(value));
153
- };
176
+ listenInScope(element, 'change', onChange, { capture: true });
154
177
 
155
- const propertyNameSet: ReadonlySet<string> = new Set(propertyNames);
178
+ onScopeDispose(() => {
179
+ controlled.delete(element);
180
+ });
181
+
182
+ return created;
183
+ };
156
184
 
157
185
  /**
158
- * Every value is a `Bindable` at run time: what is neither an observable nor
159
- * a view is a constant. An omitted prop is `undefined`, which is not.
186
+ * `checked` and `indeterminate` are controlled (SPEC.md 7.2): after a `change`,
187
+ * the element is drawn as the bound values again, so a change its owner did not
188
+ * take undoes itself. This waits for the next frame, after every handler of the
189
+ * event, the ancestors' included, has seen the user's change, and before it is
190
+ * painted. Checking a radio button unchecks the others of its group with no
191
+ * event on them, so a change on one sets back every bound button of the group.
160
192
  */
161
- const isBindable = (value: unknown): value is Bindable<unknown> =>
162
- value !== undefined && value !== null;
193
+ const bindControlled = (
194
+ element: HTMLInputElement,
195
+ key: ControlledKey,
196
+ value: Bindable<unknown>,
197
+ ): void => {
198
+ const mut_bound: { current: unknown } = { current: undefined };
163
199
 
164
- /** `class` and `style` in their object form: neither an observable nor a view. */
165
- const isPlainObject = (
166
- value: unknown,
167
- ): value is ReadonlyRecord<string, unknown> =>
168
- isRecord(value) && !isObservable(value) && !hasKey(value, viewTag);
200
+ bindValue(value, (v) => {
201
+ mut_bound.current = v;
202
+
203
+ Reflect.set(element, key, v);
204
+ });
205
+
206
+ controlledOf(element).add(key, () => {
207
+ if (Reflect.get(element, key) !== mut_bound.current) {
208
+ Reflect.set(element, key, mut_bound.current);
209
+ }
210
+ });
211
+ };
212
+
213
+ type ControlledKey = 'checked' | 'indeterminate';
214
+
215
+ type Controlled = Readonly<{
216
+ add: (key: ControlledKey, restore: () => void) => void;
217
+ /** Writes every bound property back to the element. */
218
+ restore: () => void;
219
+ }>;
220
+
221
+ const isControlledKey = (key: string): key is ControlledKey =>
222
+ key === 'checked' || key === 'indeterminate';
223
+
224
+ /** The elements bound by {@link bindControlled}. */
225
+ const controlled = new WeakMap<HTMLInputElement, Controlled>();
169
226
 
170
227
  /** `onClick`, `onPointerDown`: `on` and an upper-case letter. */
171
228
  const isEventKey = (key: string): boolean => /^on[A-Z]/u.test(key);
172
229
 
173
230
  const isEventHandler = (value: unknown): value is EventListener =>
174
231
  typeof value === 'function';
232
+
233
+ const bindProp = (element: Element, key: string, value: unknown): void => {
234
+ if (key === 'children' || key === 'ref' || !isBindable(value)) {
235
+ return;
236
+ }
237
+
238
+ if (key === 'class') {
239
+ bindClass(element, value);
240
+
241
+ return;
242
+ }
243
+
244
+ if (key === 'style') {
245
+ bindStyle(element, value);
246
+
247
+ return;
248
+ }
249
+
250
+ if (isEventKey(key) && isEventHandler(value)) {
251
+ listenInScope(element, key.slice(2).toLowerCase(), value);
252
+
253
+ return;
254
+ }
255
+
256
+ if (isControlledKey(key) && element instanceof HTMLInputElement) {
257
+ bindControlled(element, key, value);
258
+
259
+ return;
260
+ }
261
+
262
+ if (propertyNameSet.has(key)) {
263
+ bindValue(value, (v) => {
264
+ Reflect.set(element, key, v);
265
+ });
266
+
267
+ return;
268
+ }
269
+
270
+ const name = attributeNameOf[key] ?? key;
271
+
272
+ bindValue(value, (v) => {
273
+ writeAttribute(element, name, v);
274
+ });
275
+ };
276
+
277
+ const propertyNameSet: ReadonlySet<string> = new Set(propertyNames);
@@ -6,8 +6,8 @@ import { asConstant } from '../internal/index.mjs';
6
6
  * A value that is written once and never updated. A brand that exists only in
7
7
  * the type system: at run time it is the value itself.
8
8
  *
9
- * A slot that takes data accepts a `Constant<T>` but not a plain `T`, so that
10
- * a value read once and never updated (`count$.getSnapshot().value`) is a type
9
+ * A slot that takes data accepts a `Constant<T>` but not a plain `T`, so that a
10
+ * value read once and never updated (`count$.getSnapshot().value`) is a type
11
11
  * error rather than a binding that silently stays stale.
12
12
  */
13
13
  export type Constant<T> = Brand<T, 'synstate-dom/Constant'>;
@@ -1,7 +1,6 @@
1
1
  import {
2
- combine,
2
+ derive as deriveObservable,
3
3
  isObservable,
4
- map,
5
4
  type InitializedObservable,
6
5
  } from 'synstate';
7
6
  import { Arr } from 'ts-data-forge';
@@ -16,22 +15,22 @@ import { toObservable } from './to-observable.mjs';
16
15
  * When every input is a constant, `fn` runs once and the result is a constant:
17
16
  * a static part of the view never enters the graph. Otherwise the constants
18
17
  * become `just` and the views are turned into observables, and the result is
19
- * `combine(...).pipe(map(...))`.
18
+ * synstate's `derive` over them: one observable, which passes on a result only
19
+ * when it is not `Object.is` the one it holds.
20
20
  */
21
21
  export const derive = <const BS extends NonEmptyTuple<Bindable<unknown>>, R>(
22
22
  bindables: BS,
23
23
  fn: (...values: BindableValues<BS>) => R,
24
24
  ): Bindable<R> => {
25
- // `values` holds, position by position, the value of each of `bindables`:
26
- // the constants themselves, or what `combine` emits for their observables.
27
- // The checker cannot follow a position through `Arr.map` and `combine`,
28
- // which carry one element type for the whole tuple.
29
- const apply = (values: readonly unknown[]): R =>
30
- // eslint-disable-next-line total-functions/no-unsafe-type-assertion -- see above
31
- fn(...(values as BindableValues<BS>));
25
+ // `fn` is called with the value of each of `bindables`, position by position:
26
+ // the constants themselves, or their observables' values. The checker cannot
27
+ // follow a position through `Arr.map`, which carries one element type for the
28
+ // whole tuple.
29
+ // eslint-disable-next-line total-functions/no-unsafe-type-assertion -- see above
30
+ const project = fn as (...values: readonly unknown[]) => R;
32
31
 
33
32
  if (Arr.every(bindables, isConstant)) {
34
- return asConstant(apply(bindables));
33
+ return asConstant(project(...bindables));
35
34
  }
36
35
 
37
36
  const observables: NonEmptyTuple<InitializedObservable<unknown>> = Arr.map(
@@ -39,7 +38,7 @@ export const derive = <const BS extends NonEmptyTuple<Bindable<unknown>>, R>(
39
38
  toObservable,
40
39
  );
41
40
 
42
- return combine(observables).pipe(map(apply));
41
+ return deriveObservable(observables, project);
43
42
  };
44
43
 
45
44
  /** The value types of a tuple of {@link Bindable}s, position by position. */
@@ -1,4 +1,4 @@
1
- import { createState, isObservable } from 'synstate';
1
+ import { createState, isChildObservable, isObservable } from 'synstate';
2
2
  import { expectType } from 'ts-data-forge';
3
3
  import type { Bindable } from './bindable.mjs';
4
4
  import { constant } from './constant.mjs';
@@ -61,4 +61,28 @@ describe(derive, () => {
61
61
 
62
62
  assert.strictEqual(result$.getSnapshot().value, '2-B');
63
63
  });
64
+
65
+ test('observables give one observable, whose parents are the inputs', () => {
66
+ const [a$, setA] = createState(1);
67
+
68
+ const [b$] = createState(2);
69
+
70
+ const fn = vi.fn((a: number, b: number) => a + b);
71
+
72
+ const result = derive([a$, b$], fn);
73
+
74
+ const result$ = toObservable(result);
75
+
76
+ assert.isTrue(isChildObservable(result$));
77
+
78
+ if (isChildObservable(result$)) {
79
+ assert.deepStrictEqual(result$.parents, [a$, b$]);
80
+ }
81
+
82
+ setA(2);
83
+
84
+ assert.strictEqual(result$.getSnapshot().value, 4);
85
+
86
+ expect(fn).toHaveBeenCalledTimes(2);
87
+ });
64
88
  });
@@ -5,9 +5,9 @@ import { viewTag } from '../internal/index.mjs';
5
5
  * An observable and a function to apply to each of its values, bound without
6
6
  * creating a node in the graph. Made by {@link view}.
7
7
  *
8
- * The source's value type is hidden behind `open`, which hands the pair back
9
- * to a callback that is generic in it: whoever opens a view sees a source and
10
- * a function that agree with each other, with no assertion on either side.
8
+ * The source's value type is hidden behind `open`, which hands the pair back to
9
+ * a callback that is generic in it: whoever opens a view sees a source and a
10
+ * function that agree with each other, with no assertion on either side.
11
11
  */
12
12
  export type View<T> = Readonly<{
13
13
  [viewTag]: true;
@@ -31,8 +31,8 @@ import { longestIncreasingSubsequence } from './longest-increasing-subsequence.m
31
31
  * nodes; the rows that stay are moved as little as the longest increasing
32
32
  * subsequence allows.
33
33
  *
34
- * `key` is required, as in React; `(_, i) => i` matches rows by position. A
35
- * key that appears twice is reported with `console.error`, and its later
34
+ * `key` is required, as in React; `(_, i) => i` matches rows by position. A key
35
+ * that appears twice is reported with `console.error`, and its later
36
36
  * occurrences are rebuilt on every update.
37
37
  */
38
38
  export const For = <T,>({
@@ -57,8 +57,8 @@ export const For = <T,>({
57
57
  );
58
58
 
59
59
  // `index$` is a node of its own in every row, visited on every update of
60
- // `items`; a `children` that declares no second parameter is not given
61
- // one, as Solid's `For` does.
60
+ // `items`; a `children` that declares no second parameter is not given one,
61
+ // as Solid's `For` does.
62
62
  const usesIndex = children.length >= 2;
63
63
 
64
64
  const callChildren: BivariantHack<
@@ -132,8 +132,8 @@ export const For = <T,>({
132
132
  /**
133
133
  * When no key stays, takes every row's nodes out in one range, rather than
134
134
  * one by one as each row's scope is disposed: clearing or replacing the
135
- * whole list is then one DOM operation. The scopes are disposed after,
136
- * and find their nodes already detached.
135
+ * whole list is then one DOM operation. The scopes are disposed after, and
136
+ * find their nodes already detached.
137
137
  */
138
138
  const removeAllAtOnceIfEveryKeyGoes = (
139
139
  previousOrder: readonly unknown[],
@@ -295,12 +295,12 @@ const toEntries = <T,>(
295
295
  };
296
296
 
297
297
  /**
298
- * One field of a row's entry, as a single child of `entries$`, where
299
- * `map`, `filter`, `withInitialValue` and `skipIfNoChange` would make four:
298
+ * One field of a row's entry, as a single child of `entries$`, where `map`,
299
+ * `filter`, `withInitialValue` and `skipIfNoChange` would make four:
300
300
  * creating and disposing a row costs roughly its number of nodes.
301
301
  *
302
- * A row whose key has gone keeps its last value; its scope is disposed in
303
- * the same update.
302
+ * A row whose key has gone keeps its last value; its scope is disposed in the
303
+ * same update.
304
304
  */
305
305
  const rowField = <T, A>(
306
306
  entries$: Observable<Entries<T>>,
@@ -3,8 +3,8 @@ import { mountable, type JSX } from '../jsx/index.mjs';
3
3
  import { mountSwitch } from './mount-switch.mjs';
4
4
 
5
5
  /**
6
- * Mounts `then` while `cond` is `true` and `else` while it is `false`
7
- * (SPEC.md 8.2). Nothing is mounted for an omitted `else`.
6
+ * Mounts `then` while `cond` is `true` and `else` while it is `false` (SPEC.md
7
+ * 8.2). Nothing is mounted for an omitted `else`.
8
8
  *
9
9
  * Both branches are descriptions, so only the one chosen is mounted. A branch
10
10
  * is rebuilt only when `cond` flips; flipping disposes the previous branch's