synstate-dom 0.0.0-stage → 0.2.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 (185) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +218 -2
  3. package/dist/bind/bind-props.d.mts +9 -0
  4. package/dist/bind/bind-props.d.mts.map +1 -0
  5. package/dist/bind/bind-props.mjs +117 -0
  6. package/dist/bind/bind-props.mjs.map +1 -0
  7. package/dist/bind/index.d.mts +2 -0
  8. package/dist/bind/index.d.mts.map +1 -0
  9. package/dist/bind/index.mjs +2 -0
  10. package/dist/bind/index.mjs.map +1 -0
  11. package/dist/constant/bindable.d.mts +11 -0
  12. package/dist/constant/bindable.d.mts.map +1 -0
  13. package/dist/constant/bindable.mjs +2 -0
  14. package/dist/constant/bindable.mjs.map +1 -0
  15. package/dist/constant/constant.d.mts +20 -0
  16. package/dist/constant/constant.d.mts.map +1 -0
  17. package/dist/constant/constant.mjs +10 -0
  18. package/dist/constant/constant.mjs.map +1 -0
  19. package/dist/constant/derive.d.mts +16 -0
  20. package/dist/constant/derive.d.mts.map +1 -0
  21. package/dist/constant/derive.mjs +28 -0
  22. package/dist/constant/derive.mjs.map +1 -0
  23. package/dist/constant/index.d.mts +7 -0
  24. package/dist/constant/index.d.mts.map +1 -0
  25. package/dist/constant/index.mjs +7 -0
  26. package/dist/constant/index.mjs.map +1 -0
  27. package/dist/constant/map-bindable.d.mts +10 -0
  28. package/dist/constant/map-bindable.d.mts.map +1 -0
  29. package/dist/constant/map-bindable.mjs +16 -0
  30. package/dist/constant/map-bindable.mjs.map +1 -0
  31. package/dist/constant/to-observable.d.mts +12 -0
  32. package/dist/constant/to-observable.d.mts.map +1 -0
  33. package/dist/constant/to-observable.mjs +16 -0
  34. package/dist/constant/to-observable.mjs.map +1 -0
  35. package/dist/constant/view.d.mts +25 -0
  36. package/dist/constant/view.d.mts.map +1 -0
  37. package/dist/constant/view.mjs +15 -0
  38. package/dist/constant/view.mjs.map +1 -0
  39. package/dist/control/for.d.mts +29 -0
  40. package/dist/control/for.d.mts.map +1 -0
  41. package/dist/control/for.mjs +201 -0
  42. package/dist/control/for.mjs.map +1 -0
  43. package/dist/control/if.d.mts +16 -0
  44. package/dist/control/if.d.mts.map +1 -0
  45. package/dist/control/if.mjs +12 -0
  46. package/dist/control/if.mjs.map +1 -0
  47. package/dist/control/index.d.mts +6 -0
  48. package/dist/control/index.d.mts.map +1 -0
  49. package/dist/control/index.mjs +6 -0
  50. package/dist/control/index.mjs.map +1 -0
  51. package/dist/control/longest-increasing-subsequence.d.mts +7 -0
  52. package/dist/control/longest-increasing-subsequence.d.mts.map +1 -0
  53. package/dist/control/longest-increasing-subsequence.mjs +37 -0
  54. package/dist/control/longest-increasing-subsequence.mjs.map +1 -0
  55. package/dist/control/match-optional.d.mts +19 -0
  56. package/dist/control/match-optional.d.mts.map +1 -0
  57. package/dist/control/match-optional.mjs +21 -0
  58. package/dist/control/match-optional.mjs.map +1 -0
  59. package/dist/control/match-result.d.mts +19 -0
  60. package/dist/control/match-result.d.mts.map +1 -0
  61. package/dist/control/match-result.mjs +27 -0
  62. package/dist/control/match-result.mjs.map +1 -0
  63. package/dist/control/mount-switch.d.mts +18 -0
  64. package/dist/control/mount-switch.d.mts.map +1 -0
  65. package/dist/control/mount-switch.mjs +51 -0
  66. package/dist/control/mount-switch.mjs.map +1 -0
  67. package/dist/control/portal.d.mts +15 -0
  68. package/dist/control/portal.d.mts.map +1 -0
  69. package/dist/control/portal.mjs +22 -0
  70. package/dist/control/portal.mjs.map +1 -0
  71. package/dist/index.d.mts +5 -0
  72. package/dist/index.d.mts.map +1 -0
  73. package/dist/index.mjs +5 -0
  74. package/dist/index.mjs.map +1 -0
  75. package/dist/internal/as-constant.d.mts +9 -0
  76. package/dist/internal/as-constant.d.mts.map +1 -0
  77. package/dist/internal/as-constant.mjs +10 -0
  78. package/dist/internal/as-constant.mjs.map +1 -0
  79. package/dist/internal/bind-value.d.mts +14 -0
  80. package/dist/internal/bind-value.d.mts.map +1 -0
  81. package/dist/internal/bind-value.mjs +39 -0
  82. package/dist/internal/bind-value.mjs.map +1 -0
  83. package/dist/internal/dom-tables.d.mts +30 -0
  84. package/dist/internal/dom-tables.d.mts.map +1 -0
  85. package/dist/internal/dom-tables.mjs +167 -0
  86. package/dist/internal/dom-tables.mjs.map +1 -0
  87. package/dist/internal/index.d.mts +7 -0
  88. package/dist/internal/index.d.mts.map +1 -0
  89. package/dist/internal/index.mjs +7 -0
  90. package/dist/internal/index.mjs.map +1 -0
  91. package/dist/internal/is-view.d.mts +3 -0
  92. package/dist/internal/is-view.d.mts.map +1 -0
  93. package/dist/internal/is-view.mjs +4 -0
  94. package/dist/internal/is-view.mjs.map +1 -0
  95. package/dist/internal/mountable-tag.d.mts +6 -0
  96. package/dist/internal/mountable-tag.d.mts.map +1 -0
  97. package/dist/internal/mountable-tag.mjs +6 -0
  98. package/dist/internal/mountable-tag.mjs.map +1 -0
  99. package/dist/internal/view-tag.d.mts +7 -0
  100. package/dist/internal/view-tag.d.mts.map +1 -0
  101. package/dist/internal/view-tag.mjs +7 -0
  102. package/dist/internal/view-tag.mjs.map +1 -0
  103. package/dist/jsx/child.d.mts +20 -0
  104. package/dist/jsx/child.d.mts.map +1 -0
  105. package/dist/jsx/child.mjs +33 -0
  106. package/dist/jsx/child.mjs.map +1 -0
  107. package/dist/jsx/element-props.d.mts +78 -0
  108. package/dist/jsx/element-props.d.mts.map +1 -0
  109. package/dist/jsx/element-props.mjs +2 -0
  110. package/dist/jsx/element-props.mjs.map +1 -0
  111. package/dist/jsx/event-prop-names.d.mts +120 -0
  112. package/dist/jsx/event-prop-names.d.mts.map +1 -0
  113. package/dist/jsx/event-prop-names.mjs +2 -0
  114. package/dist/jsx/event-prop-names.mjs.map +1 -0
  115. package/dist/jsx/index.d.mts +7 -0
  116. package/dist/jsx/index.d.mts.map +1 -0
  117. package/dist/jsx/index.mjs +7 -0
  118. package/dist/jsx/index.mjs.map +1 -0
  119. package/dist/jsx/jsx-namespace.d.mts +11 -0
  120. package/dist/jsx/jsx-namespace.d.mts.map +1 -0
  121. package/dist/jsx/jsx-namespace.mjs +2 -0
  122. package/dist/jsx/jsx-namespace.mjs.map +1 -0
  123. package/dist/jsx/jsx.d.mts +18 -0
  124. package/dist/jsx/jsx.d.mts.map +1 -0
  125. package/dist/jsx/jsx.mjs +46 -0
  126. package/dist/jsx/jsx.mjs.map +1 -0
  127. package/dist/jsx/mountable.d.mts +25 -0
  128. package/dist/jsx/mountable.d.mts.map +1 -0
  129. package/dist/jsx/mountable.mjs +7 -0
  130. package/dist/jsx/mountable.mjs.map +1 -0
  131. package/dist/jsx-dev-runtime.d.mts +7 -0
  132. package/dist/jsx-dev-runtime.d.mts.map +1 -0
  133. package/dist/jsx-dev-runtime.mjs +7 -0
  134. package/dist/jsx-dev-runtime.mjs.map +1 -0
  135. package/dist/jsx-runtime.d.mts +7 -0
  136. package/dist/jsx-runtime.d.mts.map +1 -0
  137. package/dist/jsx-runtime.mjs +7 -0
  138. package/dist/jsx-runtime.mjs.map +1 -0
  139. package/dist/render.d.mts +14 -0
  140. package/dist/render.d.mts.map +1 -0
  141. package/dist/render.mjs +25 -0
  142. package/dist/render.mjs.map +1 -0
  143. package/package.json +97 -3
  144. package/src/bind/bind-props.mts +174 -0
  145. package/src/bind/index.mts +1 -0
  146. package/src/constant/bindable.mts +19 -0
  147. package/src/constant/constant.mts +24 -0
  148. package/src/constant/constant.test.mts +70 -0
  149. package/src/constant/derive.mts +51 -0
  150. package/src/constant/derive.test.mts +64 -0
  151. package/src/constant/index.mts +6 -0
  152. package/src/constant/map-bindable.mts +23 -0
  153. package/src/constant/map-bindable.test.mts +63 -0
  154. package/src/constant/to-observable.mts +20 -0
  155. package/src/constant/to-observable.test.mts +37 -0
  156. package/src/constant/view.mts +35 -0
  157. package/src/constant/view.test.mts +40 -0
  158. package/src/control/for.mts +362 -0
  159. package/src/control/if.mts +28 -0
  160. package/src/control/index.mts +5 -0
  161. package/src/control/longest-increasing-subsequence.mts +59 -0
  162. package/src/control/longest-increasing-subsequence.test.mts +26 -0
  163. package/src/control/match-optional.mts +41 -0
  164. package/src/control/match-result.mts +48 -0
  165. package/src/control/mount-switch.mts +75 -0
  166. package/src/control/portal.mts +32 -0
  167. package/src/index.mts +4 -0
  168. package/src/internal/as-constant.mts +11 -0
  169. package/src/internal/bind-value.mts +58 -0
  170. package/src/internal/dom-tables.mts +176 -0
  171. package/src/internal/index.mts +6 -0
  172. package/src/internal/is-view.mts +6 -0
  173. package/src/internal/mountable-tag.mts +5 -0
  174. package/src/internal/view-tag.mts +6 -0
  175. package/src/jsx/child.mts +51 -0
  176. package/src/jsx/element-props.mts +132 -0
  177. package/src/jsx/element-props.test.mts +28 -0
  178. package/src/jsx/event-prop-names.mts +119 -0
  179. package/src/jsx/index.mts +6 -0
  180. package/src/jsx/jsx-namespace.mts +11 -0
  181. package/src/jsx/jsx.mts +63 -0
  182. package/src/jsx/mountable.mts +31 -0
  183. package/src/jsx-dev-runtime.mts +6 -0
  184. package/src/jsx-runtime.mts +11 -0
  185. package/src/render.mts +32 -0
@@ -0,0 +1,362 @@
1
+ import {
2
+ createInitializedSyncChildObservable,
3
+ createScope,
4
+ getCurrentScope,
5
+ map,
6
+ onScopeDispose,
7
+ type InitializedObservable,
8
+ type Observable,
9
+ type Scope,
10
+ } from 'synstate';
11
+ import { Arr, Optional } from 'ts-data-forge';
12
+ import type { BivariantHack } from 'ts-type-forge';
13
+ import { toObservable, type Bindable } from '../constant/index.mjs';
14
+ import { bindValue } from '../internal/index.mjs';
15
+ import { mountable, toNodes, type JSX } from '../jsx/index.mjs';
16
+ import { longestIncreasingSubsequence } from './longest-increasing-subsequence.mjs';
17
+
18
+ /**
19
+ * Mounts one row per element of `items`, matched across updates by `key`
20
+ * (SPEC.md 8.4).
21
+ *
22
+ * `children` is called once per key, inside a scope of the row's own, with
23
+ * observables of the element and its index. The index is made only for a
24
+ * `children` that declares a second parameter (`children.length >= 2`); one
25
+ * that reads it otherwise, through a rest parameter or `arguments`, is given
26
+ * `undefined`.
27
+ *
28
+ * A row whose key stays is not rebuilt: its `item$` and `index$` are derived
29
+ * from `items`, so they update in the ordinary propagation, and nothing is
30
+ * written during it. A key that goes disposes its row's scope and removes its
31
+ * nodes; the rows that stay are moved as little as the longest increasing
32
+ * subsequence allows.
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
36
+ * occurrences are rebuilt on every update.
37
+ */
38
+ export const For = <T,>({
39
+ items,
40
+ key,
41
+ children,
42
+ }: Readonly<{
43
+ items: Bindable<readonly T[]>;
44
+ key: (item: T, index: number) => unknown;
45
+ children: (
46
+ item$: InitializedObservable<T>,
47
+ index$: InitializedObservable<number>,
48
+ ) => JSX.Element;
49
+ }>): JSX.Element =>
50
+ mountable(() => {
51
+ const parent = getCurrentScope();
52
+
53
+ const anchor = document.createComment('');
54
+
55
+ const entries$ = toObservable(items).pipe(
56
+ map((list) => toEntries(list, key)),
57
+ );
58
+
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.
62
+ const usesIndex = children.length >= 2;
63
+
64
+ const callChildren: BivariantHack<
65
+ (
66
+ item$: InitializedObservable<T>,
67
+ index$?: InitializedObservable<number>,
68
+ ) => JSX.Element
69
+ > = children;
70
+
71
+ const mut_rows = new Map<unknown, Row>();
72
+
73
+ const rowStarts = new WeakSet<Node>();
74
+
75
+ const mut_initial: { nodes: readonly ChildNode[] } = { nodes: [] };
76
+
77
+ /** Creates a row in a scope of its own and returns its first nodes. */
78
+ const createRow = (
79
+ rowKey: unknown,
80
+ entry: Entry<T>,
81
+ ): readonly ChildNode[] => {
82
+ const scope = createScope(parent);
83
+
84
+ const start = document.createComment('');
85
+
86
+ rowStarts.add(start);
87
+
88
+ const nodes = scope.run(() => {
89
+ const item$ = rowField(entries$, rowKey, entry.item, (e) => e.item);
90
+
91
+ return toNodes(
92
+ (usesIndex
93
+ ? callChildren(
94
+ item$,
95
+ rowField(entries$, rowKey, entry.index, (e) => e.index),
96
+ )
97
+ : callChildren(item$)
98
+ ).mount(),
99
+ );
100
+ });
101
+
102
+ const row: Row = { scope, start } as const;
103
+
104
+ scope.run(() => {
105
+ onScopeDispose(() => {
106
+ for (const node of rowNodes(row)) {
107
+ node.remove();
108
+ }
109
+ });
110
+ });
111
+
112
+ mut_rows.set(rowKey, row);
113
+
114
+ return Arr.toUnshifted(start)(nodes);
115
+ };
116
+
117
+ /** The row's current nodes: from its start to the next row or the anchor. */
118
+ const rowNodes = (row: Row): readonly ChildNode[] => {
119
+ const mut_nodes: ChildNode[] = [row.start];
120
+
121
+ for (
122
+ let mut_node = row.start.nextSibling;
123
+ mut_node !== null && mut_node !== anchor && !rowStarts.has(mut_node);
124
+ mut_node = mut_node.nextSibling
125
+ ) {
126
+ mut_nodes.push(mut_node);
127
+ }
128
+
129
+ return mut_nodes;
130
+ };
131
+
132
+ /**
133
+ * When no key stays, takes every row's nodes out in one range, rather than
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.
137
+ */
138
+ const removeAllAtOnceIfEveryKeyGoes = (
139
+ previousOrder: readonly unknown[],
140
+ entries: Entries<T>,
141
+ ): void => {
142
+ if (
143
+ !Arr.isNonEmpty(previousOrder) ||
144
+ previousOrder.some((rowKey) => entries.byKey.has(rowKey))
145
+ ) {
146
+ return;
147
+ }
148
+
149
+ const first = mut_rows.get(previousOrder[0]);
150
+
151
+ if (first === undefined) {
152
+ return;
153
+ }
154
+
155
+ const range = document.createRange();
156
+
157
+ range.setStartBefore(first.start);
158
+
159
+ range.setEndBefore(anchor);
160
+
161
+ range.deleteContents();
162
+ };
163
+
164
+ bindValue(entries$, (entries) => {
165
+ const previousOrder = Array.from(mut_rows.keys());
166
+
167
+ if (anchor.parentNode !== null) {
168
+ removeAllAtOnceIfEveryKeyGoes(previousOrder, entries);
169
+ }
170
+
171
+ for (const [rowKey, row] of mut_rows) {
172
+ if (entries.byKey.has(rowKey)) {
173
+ continue;
174
+ }
175
+
176
+ row.scope.dispose();
177
+
178
+ mut_rows.delete(rowKey);
179
+ }
180
+
181
+ if (anchor.parentNode === null) {
182
+ mut_initial.nodes = entries.order.flatMap((rowKey) => {
183
+ const entry = entries.byKey.get(rowKey);
184
+
185
+ return entry === undefined ? [] : createRow(rowKey, entry);
186
+ });
187
+
188
+ return;
189
+ }
190
+
191
+ if (Arr.eq(previousOrder, entries.order)) {
192
+ return;
193
+ }
194
+
195
+ const previousPosition = new Map(
196
+ previousOrder
197
+ .filter((rowKey) => mut_rows.has(rowKey))
198
+ .map((rowKey, position) => [rowKey, position]),
199
+ );
200
+
201
+ const positions = entries.order.map(
202
+ (rowKey) => previousPosition.get(rowKey) ?? -1,
203
+ );
204
+
205
+ const stay = longestIncreasingSubsequence(
206
+ positions.filter((position) => position >= 0),
207
+ );
208
+
209
+ const staying = new Set(
210
+ positions
211
+ .filter((position) => position >= 0)
212
+ .filter((_, k) => stay.has(k)),
213
+ );
214
+
215
+ let mut_next: ChildNode = anchor;
216
+
217
+ for (const rowKey of entries.order.toReversed()) {
218
+ const existing = mut_rows.get(rowKey);
219
+
220
+ if (existing === undefined) {
221
+ const entry = entries.byKey.get(rowKey);
222
+
223
+ if (entry === undefined) {
224
+ continue;
225
+ }
226
+
227
+ const [start, ...rest] = createRow(rowKey, entry);
228
+
229
+ if (start !== undefined) {
230
+ mut_next.before(start, ...rest);
231
+
232
+ mut_next = start;
233
+ }
234
+
235
+ continue;
236
+ }
237
+
238
+ if (!staying.has(previousPosition.get(rowKey) ?? -1)) {
239
+ mut_next.before(...rowNodes(existing));
240
+ }
241
+
242
+ mut_next = existing.start;
243
+ }
244
+
245
+ reorderMap(mut_rows, entries.order);
246
+ });
247
+
248
+ return Arr.toPushed(mut_initial.nodes, anchor);
249
+ });
250
+
251
+ type Row = Readonly<{
252
+ /** The row's own scope, a child of the scope `For` was mounted in. */
253
+ scope: Scope;
254
+ /** A comment in front of the row's nodes, which do not otherwise stay put: a switch at the top of the row changes them. */
255
+ start: Comment;
256
+ }>;
257
+
258
+ type Entry<T> = Readonly<{ item: T; index: number }>;
259
+
260
+ type Entries<T> = Readonly<{
261
+ order: readonly unknown[];
262
+ byKey: ReadonlyMap<unknown, Entry<T>>;
263
+ }>;
264
+
265
+ const toEntries = <T,>(
266
+ list: readonly T[],
267
+ key: (item: T, index: number) => unknown,
268
+ ): Entries<T> => {
269
+ const mut_byKey = new Map<unknown, Entry<T>>();
270
+
271
+ const order = list.map((item, index) => {
272
+ const itemKey = key(item, index);
273
+
274
+ if (!mut_byKey.has(itemKey)) {
275
+ mut_byKey.set(itemKey, { item, index });
276
+
277
+ return itemKey;
278
+ }
279
+
280
+ console.error(
281
+ 'synstate-dom: For found the key',
282
+ itemKey,
283
+ 'more than once; keys must be unique.',
284
+ );
285
+
286
+ // A fresh key: the duplicate is rebuilt on every update.
287
+ const duplicateKey = Symbol('duplicate key');
288
+
289
+ mut_byKey.set(duplicateKey, { item, index });
290
+
291
+ return duplicateKey;
292
+ });
293
+
294
+ return { order, byKey: mut_byKey };
295
+ };
296
+
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:
300
+ * creating and disposing a row costs roughly its number of nodes.
301
+ *
302
+ * A row whose key has gone keeps its last value; its scope is disposed in
303
+ * the same update.
304
+ */
305
+ const rowField = <T, A>(
306
+ entries$: Observable<Entries<T>>,
307
+ rowKey: unknown,
308
+ initialValue: A,
309
+ field: (entry: Entry<T>) => A,
310
+ ): InitializedObservable<A> =>
311
+ createInitializedSyncChildObservable(
312
+ { parents: [entries$], initialValue: Optional.some(initialValue) },
313
+ ({ setNext }) => {
314
+ let mut_previous = initialValue;
315
+
316
+ return (updateToken) => {
317
+ const sn = entries$.getSnapshot();
318
+
319
+ if (entries$.updateToken !== updateToken || Optional.isNone(sn)) {
320
+ return;
321
+ }
322
+
323
+ const entry = sn.value.byKey.get(rowKey);
324
+
325
+ if (entry === undefined) {
326
+ return;
327
+ }
328
+
329
+ const next = field(entry);
330
+
331
+ if (Object.is(next, mut_previous)) {
332
+ return;
333
+ }
334
+
335
+ mut_previous = next;
336
+
337
+ setNext(next, updateToken);
338
+ };
339
+ },
340
+ );
341
+
342
+ /** Re-inserts the rows in the new order, so that iteration follows the DOM. */
343
+ const reorderMap = (
344
+ mut_rows: Map<unknown, Row>,
345
+ order: readonly unknown[],
346
+ ): void => {
347
+ const rows = Arr.generate(function* () {
348
+ for (const rowKey of order) {
349
+ const row = mut_rows.get(rowKey);
350
+
351
+ if (row !== undefined) {
352
+ yield [rowKey, row] as const;
353
+ }
354
+ }
355
+ });
356
+
357
+ mut_rows.clear();
358
+
359
+ for (const [rowKey, row] of rows) {
360
+ mut_rows.set(rowKey, row);
361
+ }
362
+ };
@@ -0,0 +1,28 @@
1
+ import type { Bindable } from '../constant/index.mjs';
2
+ import { mountable, type JSX } from '../jsx/index.mjs';
3
+ import { mountSwitch } from './mount-switch.mjs';
4
+
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`.
8
+ *
9
+ * Both branches are descriptions, so only the one chosen is mounted. A branch
10
+ * is rebuilt only when `cond` flips; flipping disposes the previous branch's
11
+ * scope and removes its nodes. A constant `cond` mounts one branch once.
12
+ */
13
+ export const If = ({
14
+ cond,
15
+ then,
16
+ else: elseBranch,
17
+ }: Readonly<{
18
+ cond: Bindable<boolean>;
19
+ then: JSX.Element;
20
+ else?: JSX.Element;
21
+ }>): JSX.Element =>
22
+ mountable(() =>
23
+ mountSwitch(
24
+ cond,
25
+ (value) => value,
26
+ (value) => (value ? then : elseBranch),
27
+ ),
28
+ );
@@ -0,0 +1,5 @@
1
+ export * from './for.mjs';
2
+ export * from './if.mjs';
3
+ export * from './match-optional.mjs';
4
+ export * from './match-result.mjs';
5
+ export * from './portal.mjs';
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The positions in `values` of one longest strictly increasing subsequence,
3
+ * in O(n log n). `For` keeps the rows at these positions in place and moves
4
+ * only the others (SPEC.md 8.4).
5
+ */
6
+ export const longestIncreasingSubsequence = (
7
+ values: readonly number[],
8
+ ): ReadonlySet<number> => {
9
+ /** `tails[k]`: the position of the smallest tail of an increasing run of length k + 1. */
10
+ const mut_tails: number[] = [];
11
+
12
+ /** `previous[i]`: the position before `i` in the run that ends at `i`. */
13
+ const mut_previous: (number | undefined)[] = [];
14
+
15
+ for (const [i, value] of values.entries()) {
16
+ const k = lowerBound(
17
+ mut_tails,
18
+ (position) => (values[position] ?? 0) < value,
19
+ );
20
+
21
+ mut_previous[i] = k > 0 ? mut_tails[k - 1] : undefined;
22
+
23
+ mut_tails[k] = i;
24
+ }
25
+
26
+ const mut_result = new Set<number>();
27
+
28
+ for (
29
+ let mut_i = mut_tails.at(-1);
30
+ mut_i !== undefined;
31
+ mut_i = mut_previous[mut_i]
32
+ ) {
33
+ mut_result.add(mut_i);
34
+ }
35
+
36
+ return mut_result;
37
+ };
38
+
39
+ /** The first index of `sorted` for which `isBefore` is false. */
40
+ const lowerBound = (
41
+ sorted: readonly number[],
42
+ isBefore: (element: number) => boolean,
43
+ ): number => {
44
+ let mut_low = 0;
45
+
46
+ let mut_high = sorted.length;
47
+
48
+ while (mut_low < mut_high) {
49
+ const middle = Math.floor((mut_low + mut_high) / 2);
50
+
51
+ if (isBefore(sorted[middle] ?? 0)) {
52
+ mut_low = middle + 1;
53
+ } else {
54
+ mut_high = middle;
55
+ }
56
+ }
57
+
58
+ return mut_low;
59
+ };
@@ -0,0 +1,26 @@
1
+ import { longestIncreasingSubsequence } from './longest-increasing-subsequence.mjs';
2
+
3
+ describe(longestIncreasingSubsequence, () => {
4
+ test.each([
5
+ { values: [], expected: [] },
6
+ { values: [0, 1, 2], expected: [0, 1, 2] },
7
+ { values: [2, 1, 0], expected: [2] },
8
+ { values: [1, 2, 0], expected: [0, 1] },
9
+ { values: [3, 0, 1, 2], expected: [1, 2, 3] },
10
+ { values: [0, 8, 4, 12, 2, 10, 6, 14, 1, 9], expected: [0, 2, 5, 9] },
11
+ ])('$values', ({ values, expected }) => {
12
+ const result = longestIncreasingSubsequence(values);
13
+
14
+ assert.strictEqual(result.size, expected.length);
15
+
16
+ const picked = Array.from(result).toSorted((a, b) => a - b);
17
+
18
+ assert.isTrue(
19
+ picked.every(
20
+ (position, k) =>
21
+ k === 0 ||
22
+ (values[position] ?? 0) > (values[picked[k - 1] ?? 0] ?? 0),
23
+ ),
24
+ );
25
+ });
26
+ });
@@ -0,0 +1,41 @@
1
+ import {
2
+ filter,
3
+ map,
4
+ withInitialValue,
5
+ type InitializedObservable,
6
+ } from 'synstate';
7
+ import { Optional } from 'ts-data-forge';
8
+ import { toObservable, type Bindable } from '../constant/index.mjs';
9
+ import { mountable, type JSX } from '../jsx/index.mjs';
10
+ import { mountSwitch } from './mount-switch.mjs';
11
+
12
+ /**
13
+ * Mounts `some` while `value` holds a value and `none` while it is empty
14
+ * (SPEC.md 8.3).
15
+ *
16
+ * `some` is called once per change from empty to present, with an observable
17
+ * of the value narrowed to `T`. While the value stays present, new values flow
18
+ * through that observable rather than rebuilding the branch. It is derived
19
+ * from `value`, not written to, so nothing is written during propagation.
20
+ */
21
+ export const MatchOptional = <T,>({
22
+ value,
23
+ some,
24
+ none,
25
+ }: Readonly<{
26
+ value: Bindable<Optional<T>>;
27
+ some: (value$: InitializedObservable<T>) => JSX.Element;
28
+ none?: JSX.Element;
29
+ }>): JSX.Element =>
30
+ mountable(() =>
31
+ mountSwitch(value, Optional.isSome, (isSome, current) =>
32
+ isSome && Optional.isSome(current)
33
+ ? some(
34
+ toObservable(value)
35
+ .pipe(filter(Optional.isSome))
36
+ .pipe(map((o) => o.value))
37
+ .pipe(withInitialValue(current.value)),
38
+ )
39
+ : none,
40
+ ),
41
+ );
@@ -0,0 +1,48 @@
1
+ import {
2
+ filter,
3
+ map,
4
+ withInitialValue,
5
+ type InitializedObservable,
6
+ } from 'synstate';
7
+ import { Result } from 'ts-data-forge';
8
+ import { toObservable, type Bindable } from '../constant/index.mjs';
9
+ import { mountable, type JSX } from '../jsx/index.mjs';
10
+ import { mountSwitch } from './mount-switch.mjs';
11
+
12
+ /**
13
+ * Mounts `ok` while `value` is `Ok` and `err` while it is `Err`
14
+ * (SPEC.md 8.3).
15
+ *
16
+ * Each branch is called once per change of tag, with an observable of the
17
+ * value narrowed to `S` or `E`; while the tag stays the same, new values flow
18
+ * through that observable rather than rebuilding the branch. It is derived
19
+ * from `value`, not written to, so nothing is written during propagation.
20
+ */
21
+ export const MatchResult = <S, E>({
22
+ value,
23
+ ok,
24
+ err,
25
+ }: Readonly<{
26
+ value: Bindable<Result<S, E>>;
27
+ ok: (value$: InitializedObservable<S>) => JSX.Element;
28
+ err: (error$: InitializedObservable<E>) => JSX.Element;
29
+ }>): JSX.Element =>
30
+ mountable(() =>
31
+ mountSwitch(value, Result.isOk, (_isOk, current) => {
32
+ const value$ = toObservable(value);
33
+
34
+ return Result.isOk(current)
35
+ ? ok(
36
+ value$
37
+ .pipe(filter(Result.isOk))
38
+ .pipe(map((r) => r.value))
39
+ .pipe(withInitialValue(current.value)),
40
+ )
41
+ : err(
42
+ value$
43
+ .pipe(filter(Result.isErr))
44
+ .pipe(map((r) => r.value))
45
+ .pipe(withInitialValue(current.value)),
46
+ );
47
+ }),
48
+ );
@@ -0,0 +1,75 @@
1
+ import {
2
+ createScope,
3
+ getCurrentScope,
4
+ onScopeDispose,
5
+ type Scope,
6
+ } from 'synstate';
7
+ import { Arr } from 'ts-data-forge';
8
+ import type { Bindable } from '../constant/index.mjs';
9
+ import { bindValue } from '../internal/index.mjs';
10
+ import { toNodes, type Mountable, type MountedNodes } from '../jsx/index.mjs';
11
+
12
+ /**
13
+ * Mounts one branch at a time in front of an anchor comment, choosing it from
14
+ * the value of `value` (SPEC.md 8.1–8.3).
15
+ *
16
+ * The branch is rebuilt only when `keyOf` gives a different key: within the
17
+ * same key the branch keeps its nodes and its scope, and it is up to the
18
+ * branch to follow the value (`MatchOptional` hands it an observable derived
19
+ * from `value`). Each branch is mounted in a scope of its own, a child of the
20
+ * scope the switch was mounted in; disposing the branch's scope removes its
21
+ * nodes, so the switch's own nodes stay accurate however often it switches.
22
+ *
23
+ * `branch` runs inside the new scope, so whatever it creates belongs to the
24
+ * branch.
25
+ */
26
+ export const mountSwitch = <V, K>(
27
+ value: Bindable<V>,
28
+ keyOf: (current: V) => K,
29
+ branch: (key: K, current: V) => Mountable | undefined,
30
+ ): MountedNodes => {
31
+ const parent = getCurrentScope();
32
+
33
+ const anchor = document.createComment('');
34
+
35
+ const mut_state: {
36
+ current: Readonly<{ key: K; scope: Scope }> | undefined;
37
+ initialNodes: readonly ChildNode[];
38
+ } = { current: undefined, initialNodes: [] };
39
+
40
+ bindValue(value, (v) => {
41
+ const key = keyOf(v);
42
+
43
+ if (mut_state.current !== undefined) {
44
+ if (Object.is(mut_state.current.key, key)) {
45
+ return;
46
+ }
47
+
48
+ mut_state.current.scope.dispose();
49
+ }
50
+
51
+ const scope = createScope(parent);
52
+
53
+ const nodes = scope.run(() => {
54
+ const mounted = toNodes(branch(key, v)?.mount() ?? []);
55
+
56
+ onScopeDispose(() => {
57
+ for (const node of mounted) {
58
+ node.remove();
59
+ }
60
+ });
61
+
62
+ return mounted;
63
+ });
64
+
65
+ mut_state.current = { key, scope };
66
+
67
+ if (anchor.parentNode === null) {
68
+ mut_state.initialNodes = nodes;
69
+ } else {
70
+ anchor.before(...nodes);
71
+ }
72
+ });
73
+
74
+ return Arr.toPushed(mut_state.initialNodes, anchor);
75
+ };
@@ -0,0 +1,32 @@
1
+ import { onScopeDispose } from 'synstate';
2
+ import { mountChild, mountable, type Child, type JSX } from '../jsx/index.mjs';
3
+
4
+ /**
5
+ * Mounts `children` into `target` (`document.body` when omitted) rather than
6
+ * where it is written (SPEC.md 8.6): for a modal, a tooltip or a toast that
7
+ * has to escape an ancestor's `overflow: hidden` or `z-index`.
8
+ *
9
+ * The children belong to the scope where `Portal` is written, so closing the
10
+ * enclosing `If` branch removes them too. Nothing is left in the place it is
11
+ * written.
12
+ */
13
+ export const Portal = ({
14
+ target,
15
+ children,
16
+ }: Readonly<{
17
+ target?: Element;
18
+ children: Child;
19
+ }>): JSX.Element =>
20
+ mountable(() => {
21
+ const nodes = mountChild(children);
22
+
23
+ (target ?? document.body).append(...nodes);
24
+
25
+ onScopeDispose(() => {
26
+ for (const node of nodes) {
27
+ node.remove();
28
+ }
29
+ });
30
+
31
+ return [];
32
+ });
package/src/index.mts ADDED
@@ -0,0 +1,4 @@
1
+ export * from './constant/index.mjs';
2
+ export * from './control/index.mjs';
3
+ export * from './jsx/index.mjs';
4
+ export * from './render.mjs';
@@ -0,0 +1,11 @@
1
+ import type { Constant } from '../constant/index.mjs';
2
+
3
+ /**
4
+ * The one type assertion in the library. `Constant<T>` is a brand that exists
5
+ * only in the type system, so marking a value as one changes nothing at run
6
+ * time; this is what {@link constant} and the helpers that derive one constant
7
+ * from another call.
8
+ */
9
+ export const asConstant = <T,>(value: T): Constant<T> =>
10
+ // eslint-disable-next-line total-functions/no-unsafe-type-assertion -- a brand has no run-time part to check
11
+ value as Constant<T>;