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.
- package/LICENSE +201 -0
- package/README.md +218 -2
- package/dist/bind/bind-props.d.mts +9 -0
- package/dist/bind/bind-props.d.mts.map +1 -0
- package/dist/bind/bind-props.mjs +117 -0
- package/dist/bind/bind-props.mjs.map +1 -0
- package/dist/bind/index.d.mts +2 -0
- package/dist/bind/index.d.mts.map +1 -0
- package/dist/bind/index.mjs +2 -0
- package/dist/bind/index.mjs.map +1 -0
- package/dist/constant/bindable.d.mts +11 -0
- package/dist/constant/bindable.d.mts.map +1 -0
- package/dist/constant/bindable.mjs +2 -0
- package/dist/constant/bindable.mjs.map +1 -0
- package/dist/constant/constant.d.mts +20 -0
- package/dist/constant/constant.d.mts.map +1 -0
- package/dist/constant/constant.mjs +10 -0
- package/dist/constant/constant.mjs.map +1 -0
- package/dist/constant/derive.d.mts +16 -0
- package/dist/constant/derive.d.mts.map +1 -0
- package/dist/constant/derive.mjs +28 -0
- package/dist/constant/derive.mjs.map +1 -0
- package/dist/constant/index.d.mts +7 -0
- package/dist/constant/index.d.mts.map +1 -0
- package/dist/constant/index.mjs +7 -0
- package/dist/constant/index.mjs.map +1 -0
- package/dist/constant/map-bindable.d.mts +10 -0
- package/dist/constant/map-bindable.d.mts.map +1 -0
- package/dist/constant/map-bindable.mjs +16 -0
- package/dist/constant/map-bindable.mjs.map +1 -0
- package/dist/constant/to-observable.d.mts +12 -0
- package/dist/constant/to-observable.d.mts.map +1 -0
- package/dist/constant/to-observable.mjs +16 -0
- package/dist/constant/to-observable.mjs.map +1 -0
- package/dist/constant/view.d.mts +25 -0
- package/dist/constant/view.d.mts.map +1 -0
- package/dist/constant/view.mjs +15 -0
- package/dist/constant/view.mjs.map +1 -0
- package/dist/control/for.d.mts +29 -0
- package/dist/control/for.d.mts.map +1 -0
- package/dist/control/for.mjs +201 -0
- package/dist/control/for.mjs.map +1 -0
- package/dist/control/if.d.mts +16 -0
- package/dist/control/if.d.mts.map +1 -0
- package/dist/control/if.mjs +12 -0
- package/dist/control/if.mjs.map +1 -0
- package/dist/control/index.d.mts +6 -0
- package/dist/control/index.d.mts.map +1 -0
- package/dist/control/index.mjs +6 -0
- package/dist/control/index.mjs.map +1 -0
- package/dist/control/longest-increasing-subsequence.d.mts +7 -0
- package/dist/control/longest-increasing-subsequence.d.mts.map +1 -0
- package/dist/control/longest-increasing-subsequence.mjs +37 -0
- package/dist/control/longest-increasing-subsequence.mjs.map +1 -0
- package/dist/control/match-optional.d.mts +19 -0
- package/dist/control/match-optional.d.mts.map +1 -0
- package/dist/control/match-optional.mjs +21 -0
- package/dist/control/match-optional.mjs.map +1 -0
- package/dist/control/match-result.d.mts +19 -0
- package/dist/control/match-result.d.mts.map +1 -0
- package/dist/control/match-result.mjs +27 -0
- package/dist/control/match-result.mjs.map +1 -0
- package/dist/control/mount-switch.d.mts +18 -0
- package/dist/control/mount-switch.d.mts.map +1 -0
- package/dist/control/mount-switch.mjs +51 -0
- package/dist/control/mount-switch.mjs.map +1 -0
- package/dist/control/portal.d.mts +15 -0
- package/dist/control/portal.d.mts.map +1 -0
- package/dist/control/portal.mjs +22 -0
- package/dist/control/portal.mjs.map +1 -0
- package/dist/index.d.mts +5 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +5 -0
- package/dist/index.mjs.map +1 -0
- package/dist/internal/as-constant.d.mts +9 -0
- package/dist/internal/as-constant.d.mts.map +1 -0
- package/dist/internal/as-constant.mjs +10 -0
- package/dist/internal/as-constant.mjs.map +1 -0
- package/dist/internal/bind-value.d.mts +14 -0
- package/dist/internal/bind-value.d.mts.map +1 -0
- package/dist/internal/bind-value.mjs +39 -0
- package/dist/internal/bind-value.mjs.map +1 -0
- package/dist/internal/dom-tables.d.mts +30 -0
- package/dist/internal/dom-tables.d.mts.map +1 -0
- package/dist/internal/dom-tables.mjs +167 -0
- package/dist/internal/dom-tables.mjs.map +1 -0
- package/dist/internal/index.d.mts +7 -0
- package/dist/internal/index.d.mts.map +1 -0
- package/dist/internal/index.mjs +7 -0
- package/dist/internal/index.mjs.map +1 -0
- package/dist/internal/is-view.d.mts +3 -0
- package/dist/internal/is-view.d.mts.map +1 -0
- package/dist/internal/is-view.mjs +4 -0
- package/dist/internal/is-view.mjs.map +1 -0
- package/dist/internal/mountable-tag.d.mts +6 -0
- package/dist/internal/mountable-tag.d.mts.map +1 -0
- package/dist/internal/mountable-tag.mjs +6 -0
- package/dist/internal/mountable-tag.mjs.map +1 -0
- package/dist/internal/view-tag.d.mts +7 -0
- package/dist/internal/view-tag.d.mts.map +1 -0
- package/dist/internal/view-tag.mjs +7 -0
- package/dist/internal/view-tag.mjs.map +1 -0
- package/dist/jsx/child.d.mts +20 -0
- package/dist/jsx/child.d.mts.map +1 -0
- package/dist/jsx/child.mjs +33 -0
- package/dist/jsx/child.mjs.map +1 -0
- package/dist/jsx/element-props.d.mts +78 -0
- package/dist/jsx/element-props.d.mts.map +1 -0
- package/dist/jsx/element-props.mjs +2 -0
- package/dist/jsx/element-props.mjs.map +1 -0
- package/dist/jsx/event-prop-names.d.mts +120 -0
- package/dist/jsx/event-prop-names.d.mts.map +1 -0
- package/dist/jsx/event-prop-names.mjs +2 -0
- package/dist/jsx/event-prop-names.mjs.map +1 -0
- package/dist/jsx/index.d.mts +7 -0
- package/dist/jsx/index.d.mts.map +1 -0
- package/dist/jsx/index.mjs +7 -0
- package/dist/jsx/index.mjs.map +1 -0
- package/dist/jsx/jsx-namespace.d.mts +11 -0
- package/dist/jsx/jsx-namespace.d.mts.map +1 -0
- package/dist/jsx/jsx-namespace.mjs +2 -0
- package/dist/jsx/jsx-namespace.mjs.map +1 -0
- package/dist/jsx/jsx.d.mts +18 -0
- package/dist/jsx/jsx.d.mts.map +1 -0
- package/dist/jsx/jsx.mjs +46 -0
- package/dist/jsx/jsx.mjs.map +1 -0
- package/dist/jsx/mountable.d.mts +25 -0
- package/dist/jsx/mountable.d.mts.map +1 -0
- package/dist/jsx/mountable.mjs +7 -0
- package/dist/jsx/mountable.mjs.map +1 -0
- package/dist/jsx-dev-runtime.d.mts +7 -0
- package/dist/jsx-dev-runtime.d.mts.map +1 -0
- package/dist/jsx-dev-runtime.mjs +7 -0
- package/dist/jsx-dev-runtime.mjs.map +1 -0
- package/dist/jsx-runtime.d.mts +7 -0
- package/dist/jsx-runtime.d.mts.map +1 -0
- package/dist/jsx-runtime.mjs +7 -0
- package/dist/jsx-runtime.mjs.map +1 -0
- package/dist/render.d.mts +14 -0
- package/dist/render.d.mts.map +1 -0
- package/dist/render.mjs +25 -0
- package/dist/render.mjs.map +1 -0
- package/package.json +97 -3
- package/src/bind/bind-props.mts +174 -0
- package/src/bind/index.mts +1 -0
- package/src/constant/bindable.mts +19 -0
- package/src/constant/constant.mts +24 -0
- package/src/constant/constant.test.mts +70 -0
- package/src/constant/derive.mts +51 -0
- package/src/constant/derive.test.mts +64 -0
- package/src/constant/index.mts +6 -0
- package/src/constant/map-bindable.mts +23 -0
- package/src/constant/map-bindable.test.mts +63 -0
- package/src/constant/to-observable.mts +20 -0
- package/src/constant/to-observable.test.mts +37 -0
- package/src/constant/view.mts +35 -0
- package/src/constant/view.test.mts +40 -0
- package/src/control/for.mts +362 -0
- package/src/control/if.mts +28 -0
- package/src/control/index.mts +5 -0
- package/src/control/longest-increasing-subsequence.mts +59 -0
- package/src/control/longest-increasing-subsequence.test.mts +26 -0
- package/src/control/match-optional.mts +41 -0
- package/src/control/match-result.mts +48 -0
- package/src/control/mount-switch.mts +75 -0
- package/src/control/portal.mts +32 -0
- package/src/index.mts +4 -0
- package/src/internal/as-constant.mts +11 -0
- package/src/internal/bind-value.mts +58 -0
- package/src/internal/dom-tables.mts +176 -0
- package/src/internal/index.mts +6 -0
- package/src/internal/is-view.mts +6 -0
- package/src/internal/mountable-tag.mts +5 -0
- package/src/internal/view-tag.mts +6 -0
- package/src/jsx/child.mts +51 -0
- package/src/jsx/element-props.mts +132 -0
- package/src/jsx/element-props.test.mts +28 -0
- package/src/jsx/event-prop-names.mts +119 -0
- package/src/jsx/index.mts +6 -0
- package/src/jsx/jsx-namespace.mts +11 -0
- package/src/jsx/jsx.mts +63 -0
- package/src/jsx/mountable.mts +31 -0
- package/src/jsx-dev-runtime.mts +6 -0
- package/src/jsx-runtime.mts +11 -0
- package/src/render.mts +32 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { isObservable, onScopeDispose } from 'synstate';
|
|
2
|
+
import { hasKey, isBoolean, isRecord, unknownToString } from 'ts-data-forge';
|
|
3
|
+
import type { ReadonlyRecord } from 'ts-type-forge';
|
|
4
|
+
import type { Bindable } from '../constant/index.mjs';
|
|
5
|
+
import {
|
|
6
|
+
attributeNameOf,
|
|
7
|
+
bindValue,
|
|
8
|
+
propertyNames,
|
|
9
|
+
viewTag,
|
|
10
|
+
} from '../internal/index.mjs';
|
|
11
|
+
|
|
12
|
+
/**
|
|
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.
|
|
17
|
+
*/
|
|
18
|
+
export const bindProps = (
|
|
19
|
+
element: Element,
|
|
20
|
+
props: ReadonlyRecord<string, unknown>,
|
|
21
|
+
): void => {
|
|
22
|
+
for (const [key, value] of Object.entries(props)) {
|
|
23
|
+
bindProp(element, key, value);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
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);
|
|
40
|
+
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (isEventKey(key) && isEventHandler(value)) {
|
|
45
|
+
bindEvent(element, key.slice(2).toLowerCase(), value);
|
|
46
|
+
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (propertyNameSet.has(key)) {
|
|
51
|
+
bindValue(value, (v) => {
|
|
52
|
+
Reflect.set(element, key, v);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const name = attributeNameOf[key] ?? key;
|
|
59
|
+
|
|
60
|
+
bindValue(value, (v) => {
|
|
61
|
+
writeAttribute(element, name, v);
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const bindClass = (element: Element, value: Bindable<unknown>): void => {
|
|
66
|
+
if (!isPlainObject(value)) {
|
|
67
|
+
bindValue(value, (v) => {
|
|
68
|
+
writeAttribute(element, 'class', v);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
for (const [name, on] of Object.entries(value)) {
|
|
75
|
+
if (/\s/u.test(name)) {
|
|
76
|
+
console.error(
|
|
77
|
+
`synstate-dom: the class name ${JSON.stringify(name)} contains whitespace; give each class its own key.`,
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (isBindable(on)) {
|
|
84
|
+
bindValue(on, (v) => {
|
|
85
|
+
element.classList.toggle(name, v === true);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const bindStyle = (element: Element, value: Bindable<unknown>): void => {
|
|
92
|
+
if (!(element instanceof HTMLElement || element instanceof SVGElement)) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const { style } = element;
|
|
97
|
+
|
|
98
|
+
if (!isPlainObject(value)) {
|
|
99
|
+
bindValue(value, (v) => {
|
|
100
|
+
writeAttribute(element, 'style', v);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
for (const [name, v] of Object.entries(value)) {
|
|
107
|
+
if (isBindable(v)) {
|
|
108
|
+
bindValue(v, (current) => {
|
|
109
|
+
if (current === undefined || current === null) {
|
|
110
|
+
style.removeProperty(name);
|
|
111
|
+
} else {
|
|
112
|
+
style.setProperty(name, unknownToString(current));
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const bindEvent = (
|
|
120
|
+
element: Element,
|
|
121
|
+
type: string,
|
|
122
|
+
handler: EventListener,
|
|
123
|
+
): void => {
|
|
124
|
+
element.addEventListener(type, handler);
|
|
125
|
+
|
|
126
|
+
onScopeDispose(() => {
|
|
127
|
+
element.removeEventListener(type, handler);
|
|
128
|
+
});
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* `true` writes an empty attribute, and `false`, `undefined` and `null`
|
|
133
|
+
* remove it.
|
|
134
|
+
*/
|
|
135
|
+
const writeAttribute = (
|
|
136
|
+
element: Element,
|
|
137
|
+
name: string,
|
|
138
|
+
value: unknown,
|
|
139
|
+
): void => {
|
|
140
|
+
if (isBoolean(value)) {
|
|
141
|
+
element.toggleAttribute(name, value);
|
|
142
|
+
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (value === undefined || value === null) {
|
|
147
|
+
element.removeAttribute(name);
|
|
148
|
+
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
element.setAttribute(name, unknownToString(value));
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const propertyNameSet: ReadonlySet<string> = new Set(propertyNames);
|
|
156
|
+
|
|
157
|
+
/**
|
|
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.
|
|
160
|
+
*/
|
|
161
|
+
const isBindable = (value: unknown): value is Bindable<unknown> =>
|
|
162
|
+
value !== undefined && value !== null;
|
|
163
|
+
|
|
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);
|
|
169
|
+
|
|
170
|
+
/** `onClick`, `onPointerDown`: `on` and an upper-case letter. */
|
|
171
|
+
const isEventKey = (key: string): boolean => /^on[A-Z]/u.test(key);
|
|
172
|
+
|
|
173
|
+
const isEventHandler = (value: unknown): value is EventListener =>
|
|
174
|
+
typeof value === 'function';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './bind-props.mjs';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { InitializedObservable, Observable } from 'synstate';
|
|
2
|
+
import type { Constant } from './constant.mjs';
|
|
3
|
+
import type { View } from './view.mjs';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What a slot that takes data accepts: an observable with an initial value, an
|
|
7
|
+
* explicit {@link Constant}, or a {@link View}. A plain value is not accepted.
|
|
8
|
+
*/
|
|
9
|
+
export type Bindable<T> = Constant<T> | InitializedObservable<T> | View<T>;
|
|
10
|
+
|
|
11
|
+
/** The value type a {@link Bindable} carries. */
|
|
12
|
+
export type BindableValue<B> =
|
|
13
|
+
B extends View<infer T>
|
|
14
|
+
? T
|
|
15
|
+
: B extends Observable<infer T>
|
|
16
|
+
? T
|
|
17
|
+
: B extends Constant<infer T>
|
|
18
|
+
? T
|
|
19
|
+
: never;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Observable } from 'synstate';
|
|
2
|
+
import type { Brand } from 'ts-type-forge';
|
|
3
|
+
import { asConstant } from '../internal/index.mjs';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A value that is written once and never updated. A brand that exists only in
|
|
7
|
+
* the type system: at run time it is the value itself.
|
|
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
|
|
11
|
+
* error rather than a binding that silently stays stale.
|
|
12
|
+
*/
|
|
13
|
+
export type Constant<T> = Brand<T, 'synstate-dom/Constant'>;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Marks a value as a {@link Constant}. Returns the value unchanged.
|
|
17
|
+
*
|
|
18
|
+
* `null` and `undefined` are rejected: an absent attribute is an omitted prop,
|
|
19
|
+
* and a value that comes and goes is an `InitializedObservable<T | undefined>`.
|
|
20
|
+
* An observable is rejected too, since wrapping one would bind it once.
|
|
21
|
+
*/
|
|
22
|
+
export const constant = <const T,>(
|
|
23
|
+
value: T extends Observable<unknown> | null | undefined ? never : T,
|
|
24
|
+
): Constant<T> => asConstant<T>(value);
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { createState } from 'synstate';
|
|
2
|
+
import { expectType } from 'ts-data-forge';
|
|
3
|
+
import type { Bindable } from './bindable.mjs';
|
|
4
|
+
import { constant, type Constant } from './constant.mjs';
|
|
5
|
+
|
|
6
|
+
describe(constant, () => {
|
|
7
|
+
test('returns the value unchanged', () => {
|
|
8
|
+
const value = { a: 1 } as const;
|
|
9
|
+
|
|
10
|
+
assert.strictEqual(constant(value), value);
|
|
11
|
+
|
|
12
|
+
assert.strictEqual(constant('btn'), 'btn');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('keeps the literal type', () => {
|
|
16
|
+
const c = constant('btn');
|
|
17
|
+
|
|
18
|
+
expectType<typeof c, Constant<'btn'>>('=');
|
|
19
|
+
|
|
20
|
+
assert.strictEqual<unknown>(c, 'btn');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('a constant can be read as its value', () => {
|
|
24
|
+
const n: number = constant(1);
|
|
25
|
+
|
|
26
|
+
assert.strictEqual(n, 1);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('rejects null, undefined and observables', () => {
|
|
30
|
+
const [count$] = createState(0);
|
|
31
|
+
|
|
32
|
+
// @ts-expect-error null is not a constant: omit the prop instead
|
|
33
|
+
constant(null);
|
|
34
|
+
|
|
35
|
+
// @ts-expect-error undefined is not a constant: omit the prop instead
|
|
36
|
+
constant(undefined);
|
|
37
|
+
|
|
38
|
+
// @ts-expect-error wrapping an observable would bind it once
|
|
39
|
+
constant(count$);
|
|
40
|
+
|
|
41
|
+
const maybe = Math.random() > 2 ? 'a' : undefined;
|
|
42
|
+
|
|
43
|
+
// @ts-expect-error a value that may be undefined is an observable of `T | undefined`
|
|
44
|
+
constant(maybe);
|
|
45
|
+
|
|
46
|
+
assert.isTrue(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('a plain value is not a Bindable', () => {
|
|
50
|
+
const [count$] = createState(0);
|
|
51
|
+
|
|
52
|
+
// @ts-expect-error a plain value would be written once and never updated
|
|
53
|
+
const plain: Bindable<string> = 'btn';
|
|
54
|
+
|
|
55
|
+
// @ts-expect-error the snapshot is read once
|
|
56
|
+
const snapshot: Bindable<number> = count$.getSnapshot().value;
|
|
57
|
+
|
|
58
|
+
const fromConstant: Bindable<string> = constant('btn');
|
|
59
|
+
|
|
60
|
+
const fromObservable: Bindable<number> = count$;
|
|
61
|
+
|
|
62
|
+
assert.strictEqual<unknown>(plain, 'btn');
|
|
63
|
+
|
|
64
|
+
assert.strictEqual<unknown>(snapshot, 0);
|
|
65
|
+
|
|
66
|
+
assert.strictEqual<unknown>(fromConstant, 'btn');
|
|
67
|
+
|
|
68
|
+
assert.strictEqual(fromObservable, count$);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import {
|
|
2
|
+
combine,
|
|
3
|
+
isObservable,
|
|
4
|
+
map,
|
|
5
|
+
type InitializedObservable,
|
|
6
|
+
} from 'synstate';
|
|
7
|
+
import { Arr } from 'ts-data-forge';
|
|
8
|
+
import type { NonEmptyTuple } from 'ts-type-forge';
|
|
9
|
+
import { asConstant, isView } from '../internal/index.mjs';
|
|
10
|
+
import type { Bindable, BindableValue } from './bindable.mjs';
|
|
11
|
+
import { toObservable } from './to-observable.mjs';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Combines several {@link Bindable}s into one.
|
|
15
|
+
*
|
|
16
|
+
* When every input is a constant, `fn` runs once and the result is a constant:
|
|
17
|
+
* a static part of the view never enters the graph. Otherwise the constants
|
|
18
|
+
* become `just` and the views are turned into observables, and the result is
|
|
19
|
+
* `combine(...).pipe(map(...))`.
|
|
20
|
+
*/
|
|
21
|
+
export const derive = <const BS extends NonEmptyTuple<Bindable<unknown>>, R>(
|
|
22
|
+
bindables: BS,
|
|
23
|
+
fn: (...values: BindableValues<BS>) => R,
|
|
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>));
|
|
32
|
+
|
|
33
|
+
if (Arr.every(bindables, isConstant)) {
|
|
34
|
+
return asConstant(apply(bindables));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const observables: NonEmptyTuple<InitializedObservable<unknown>> = Arr.map(
|
|
38
|
+
bindables,
|
|
39
|
+
toObservable,
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
return combine(observables).pipe(map(apply));
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** The value types of a tuple of {@link Bindable}s, position by position. */
|
|
46
|
+
export type BindableValues<BS extends readonly Bindable<unknown>[]> = Readonly<{
|
|
47
|
+
[K in keyof BS]: BindableValue<BS[K]>;
|
|
48
|
+
}>;
|
|
49
|
+
|
|
50
|
+
const isConstant = (bindable: Bindable<unknown>): boolean =>
|
|
51
|
+
!isView(bindable) && !isObservable(bindable);
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { createState, isObservable } from 'synstate';
|
|
2
|
+
import { expectType } from 'ts-data-forge';
|
|
3
|
+
import type { Bindable } from './bindable.mjs';
|
|
4
|
+
import { constant } from './constant.mjs';
|
|
5
|
+
import { derive } from './derive.mjs';
|
|
6
|
+
import { toObservable } from './to-observable.mjs';
|
|
7
|
+
import { view } from './view.mjs';
|
|
8
|
+
|
|
9
|
+
describe(derive, () => {
|
|
10
|
+
test('constants only give a constant without creating a node', () => {
|
|
11
|
+
const fn = vi.fn((a: number, b: string) => `${b}${a}`);
|
|
12
|
+
|
|
13
|
+
const result = derive([constant(1), constant('x')], fn);
|
|
14
|
+
|
|
15
|
+
expectType<typeof result, Bindable<string>>('=');
|
|
16
|
+
|
|
17
|
+
assert.isFalse(isObservable(result));
|
|
18
|
+
|
|
19
|
+
assert.strictEqual<unknown>(result, 'x1');
|
|
20
|
+
|
|
21
|
+
expect(fn).toHaveBeenCalledExactlyOnceWith(1, 'x');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('passes each value with its own type', () => {
|
|
25
|
+
const [count$] = createState(0);
|
|
26
|
+
|
|
27
|
+
derive([count$, constant('a'), view(count$, (n) => n > 0)], (n, s, b) => {
|
|
28
|
+
expectType<typeof n, number>('=');
|
|
29
|
+
|
|
30
|
+
expectType<typeof s, 'a'>('=');
|
|
31
|
+
|
|
32
|
+
expectType<typeof b, boolean>('=');
|
|
33
|
+
|
|
34
|
+
return 0;
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
assert.isTrue(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('observables, constants and views are combined', () => {
|
|
41
|
+
const [count$, setCount] = createState(1);
|
|
42
|
+
|
|
43
|
+
const [label$, setLabel] = createState('a');
|
|
44
|
+
|
|
45
|
+
const result = derive(
|
|
46
|
+
[count$, constant('-'), view(label$, (s) => s.toUpperCase())],
|
|
47
|
+
(n, sep, label) => `${n}${sep}${label}`,
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
assert.isTrue(isObservable(result));
|
|
51
|
+
|
|
52
|
+
const result$ = toObservable(result);
|
|
53
|
+
|
|
54
|
+
assert.strictEqual(result$.getSnapshot().value, '1-A');
|
|
55
|
+
|
|
56
|
+
setCount(2);
|
|
57
|
+
|
|
58
|
+
assert.strictEqual(result$.getSnapshot().value, '2-A');
|
|
59
|
+
|
|
60
|
+
setLabel('b');
|
|
61
|
+
|
|
62
|
+
assert.strictEqual(result$.getSnapshot().value, '2-B');
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { isObservable, map } from 'synstate';
|
|
2
|
+
import { asConstant, isView } from '../internal/index.mjs';
|
|
3
|
+
import type { Bindable } from './bindable.mjs';
|
|
4
|
+
import { view } from './view.mjs';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Applies `fn` to the value of a {@link Bindable}, keeping its kind.
|
|
8
|
+
*
|
|
9
|
+
* - A constant gives a constant: `fn` runs once and no node is created.
|
|
10
|
+
* - A view gives a view with the two functions composed: no node is created.
|
|
11
|
+
* - An observable gives `bindable.pipe(map(fn))`.
|
|
12
|
+
*/
|
|
13
|
+
export const mapBindable = <A, B>(
|
|
14
|
+
bindable: Bindable<A>,
|
|
15
|
+
fn: (value: A) => B,
|
|
16
|
+
): Bindable<B> =>
|
|
17
|
+
isView(bindable)
|
|
18
|
+
? bindable.open((source, inner) =>
|
|
19
|
+
view(source, (value) => fn(inner(value))),
|
|
20
|
+
)
|
|
21
|
+
: isObservable(bindable)
|
|
22
|
+
? bindable.pipe(map(fn))
|
|
23
|
+
: asConstant(fn(bindable));
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { createState, isObservable } from 'synstate';
|
|
2
|
+
import { expectType } from 'ts-data-forge';
|
|
3
|
+
import { isView } from '../internal/index.mjs';
|
|
4
|
+
import type { Bindable } from './bindable.mjs';
|
|
5
|
+
import { constant } from './constant.mjs';
|
|
6
|
+
import { mapBindable } from './map-bindable.mjs';
|
|
7
|
+
import { toObservable } from './to-observable.mjs';
|
|
8
|
+
import { view } from './view.mjs';
|
|
9
|
+
|
|
10
|
+
describe(mapBindable, () => {
|
|
11
|
+
test('a constant gives a constant without creating a node', () => {
|
|
12
|
+
const fn = vi.fn((n: number) => n * 2);
|
|
13
|
+
|
|
14
|
+
const result = mapBindable(constant(3), fn);
|
|
15
|
+
|
|
16
|
+
expectType<typeof result, Bindable<number>>('=');
|
|
17
|
+
|
|
18
|
+
assert.isFalse(isObservable(result));
|
|
19
|
+
|
|
20
|
+
assert.isFalse(isView(result));
|
|
21
|
+
|
|
22
|
+
assert.strictEqual<unknown>(result, 6);
|
|
23
|
+
|
|
24
|
+
expect(fn).toHaveBeenCalledExactlyOnceWith(3);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('a view gives a view with the functions composed', () => {
|
|
28
|
+
const [count$, setCount] = createState(1);
|
|
29
|
+
|
|
30
|
+
const result = mapBindable(
|
|
31
|
+
view(count$, (n) => n + 1),
|
|
32
|
+
String,
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
assert.isTrue(isView(result));
|
|
36
|
+
|
|
37
|
+
assert.isFalse(isObservable(result));
|
|
38
|
+
|
|
39
|
+
const result$ = toObservable(result);
|
|
40
|
+
|
|
41
|
+
assert.strictEqual(result$.getSnapshot().value, '2');
|
|
42
|
+
|
|
43
|
+
setCount(5);
|
|
44
|
+
|
|
45
|
+
assert.strictEqual(result$.getSnapshot().value, '6');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('an observable gives a mapped observable', () => {
|
|
49
|
+
const [count$, setCount] = createState(1);
|
|
50
|
+
|
|
51
|
+
const result = mapBindable(count$, (n) => n * 10);
|
|
52
|
+
|
|
53
|
+
assert.isTrue(isObservable(result));
|
|
54
|
+
|
|
55
|
+
const result$ = toObservable(result);
|
|
56
|
+
|
|
57
|
+
assert.strictEqual(result$.getSnapshot().value, 10);
|
|
58
|
+
|
|
59
|
+
setCount(2);
|
|
60
|
+
|
|
61
|
+
assert.strictEqual(result$.getSnapshot().value, 20);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { isObservable, just, map, type InitializedObservable } from 'synstate';
|
|
2
|
+
import { isView } from '../internal/index.mjs';
|
|
3
|
+
import type { Bindable } from './bindable.mjs';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Turns a {@link Bindable} into an observable: a constant becomes `just(value)`,
|
|
7
|
+
* a view becomes `source.pipe(map(fn))`, and an observable is returned as is.
|
|
8
|
+
*
|
|
9
|
+
* Only for where an observable is really needed, such as the projection of a
|
|
10
|
+
* `switchMap`: a constant made into an observable joins the graph, which
|
|
11
|
+
* {@link mapBindable} and {@link derive} avoid.
|
|
12
|
+
*/
|
|
13
|
+
export const toObservable = <T,>(
|
|
14
|
+
bindable: Bindable<T>,
|
|
15
|
+
): InitializedObservable<T> =>
|
|
16
|
+
isView(bindable)
|
|
17
|
+
? bindable.open((source, fn) => source.pipe(map(fn)))
|
|
18
|
+
: isObservable(bindable)
|
|
19
|
+
? bindable
|
|
20
|
+
: just(bindable);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createState, isObservable } from 'synstate';
|
|
2
|
+
import { expectType } from 'ts-data-forge';
|
|
3
|
+
import { constant } from './constant.mjs';
|
|
4
|
+
import { toObservable } from './to-observable.mjs';
|
|
5
|
+
import { view } from './view.mjs';
|
|
6
|
+
|
|
7
|
+
describe(toObservable, () => {
|
|
8
|
+
test('a constant becomes a completed observable holding it', () => {
|
|
9
|
+
const result$ = toObservable(constant('a'));
|
|
10
|
+
|
|
11
|
+
assert.isTrue(isObservable(result$));
|
|
12
|
+
|
|
13
|
+
assert.isTrue(result$.isCompleted);
|
|
14
|
+
|
|
15
|
+
assert.strictEqual(result$.getSnapshot().value, 'a');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('an observable is returned as is', () => {
|
|
19
|
+
const [count$] = createState(0);
|
|
20
|
+
|
|
21
|
+
assert.strictEqual(toObservable(count$), count$);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('a view becomes the source mapped by its function', () => {
|
|
25
|
+
const [count$, setCount] = createState(1);
|
|
26
|
+
|
|
27
|
+
const result$ = toObservable(view(count$, (n) => n > 1));
|
|
28
|
+
|
|
29
|
+
expectType<ReturnType<typeof result$.getSnapshot>['value'], boolean>('<=');
|
|
30
|
+
|
|
31
|
+
assert.isFalse(result$.getSnapshot().value);
|
|
32
|
+
|
|
33
|
+
setCount(2);
|
|
34
|
+
|
|
35
|
+
assert.isTrue(result$.getSnapshot().value);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { InitializedObservable } from 'synstate';
|
|
2
|
+
import { viewTag } from '../internal/index.mjs';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* An observable and a function to apply to each of its values, bound without
|
|
6
|
+
* creating a node in the graph. Made by {@link view}.
|
|
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.
|
|
11
|
+
*/
|
|
12
|
+
export type View<T> = Readonly<{
|
|
13
|
+
[viewTag]: true;
|
|
14
|
+
open: <R>(
|
|
15
|
+
use: <A>(source: InitializedObservable<A>, fn: (value: A) => T) => R,
|
|
16
|
+
) => R;
|
|
17
|
+
}>;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Pairs an observable with a light transformation, for a slot that takes a
|
|
21
|
+
* `Bindable`. The transformation runs in the binding's subscriber, so no node
|
|
22
|
+
* is added to the graph.
|
|
23
|
+
*
|
|
24
|
+
* `fn` runs once per value per binding: a view passed to two slots runs it
|
|
25
|
+
* twice. A heavy transformation, or one shared by several bindings, belongs in
|
|
26
|
+
* `source.pipe(map(fn))`.
|
|
27
|
+
*/
|
|
28
|
+
export const view = <A, T>(
|
|
29
|
+
source: InitializedObservable<A>,
|
|
30
|
+
fn: (value: A) => T,
|
|
31
|
+
): View<T> =>
|
|
32
|
+
({
|
|
33
|
+
[viewTag]: true,
|
|
34
|
+
open: (use) => use(source, fn),
|
|
35
|
+
}) as const;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createState } from 'synstate';
|
|
2
|
+
import { expectType } from 'ts-data-forge';
|
|
3
|
+
import { isView } from '../internal/index.mjs';
|
|
4
|
+
import type { Bindable } from './bindable.mjs';
|
|
5
|
+
import { view, type View } from './view.mjs';
|
|
6
|
+
|
|
7
|
+
const fn = (n: number): string => `${n} items` as const;
|
|
8
|
+
|
|
9
|
+
describe(view, () => {
|
|
10
|
+
test('opens to the source and the function it was made from', () => {
|
|
11
|
+
const [count$] = createState(2);
|
|
12
|
+
|
|
13
|
+
const v = view(count$, fn);
|
|
14
|
+
|
|
15
|
+
expectType<typeof v, View<string>>('=');
|
|
16
|
+
|
|
17
|
+
v.open((source, f) => {
|
|
18
|
+
assert.strictEqual<unknown>(source, count$);
|
|
19
|
+
|
|
20
|
+
assert.strictEqual<unknown>(f, fn);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('ties the function parameter to the source value type', () => {
|
|
25
|
+
const [count$] = createState(0);
|
|
26
|
+
|
|
27
|
+
// @ts-expect-error a `string` parameter does not take the `number` values of `count$`
|
|
28
|
+
view(count$, (s: string) => s.length);
|
|
29
|
+
|
|
30
|
+
assert.isTrue(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('is accepted where a Bindable is', () => {
|
|
34
|
+
const [count$] = createState(0);
|
|
35
|
+
|
|
36
|
+
const b: Bindable<boolean> = view(count$, (n) => n === 0);
|
|
37
|
+
|
|
38
|
+
assert.isTrue(isView(b));
|
|
39
|
+
});
|
|
40
|
+
});
|