Skip to content

Commit 880d895

Browse files
authored
Simplifying bindings API + Add very basic local search recipe (#99)
* add local search recipe * support in-place bindings and event detail picking * use Signal instead of object in repeat * remove now obsolete bindings
1 parent bc2428d commit 880d895

File tree

4 files changed

+170
-74
lines changed

4 files changed

+170
-74
lines changed

typescript/packages/common-ui/src/hyperscript/render.ts

Lines changed: 44 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,13 @@ import {
33
Signal,
44
WriteableSignal,
55
effect,
6+
isSignal,
67
} from "@commontools/common-frp/signal";
7-
import { Stream, WriteableStream } from "@commontools/common-frp/stream";
8+
import {
9+
Stream,
10+
WriteableStream,
11+
isStream,
12+
} from "@commontools/common-frp/stream";
813
import {
914
isBinding,
1015
VNode,
@@ -81,6 +86,13 @@ const readEvent = (event: Event) => {
8186
}
8287
};
8388

89+
const modifyPropsForSchemaValidation = (props: object) =>
90+
Object.fromEntries(
91+
Object.entries(props).filter(
92+
([_, value]) => !isSignal(value) && !isStream(value) && !isBinding(value)
93+
)
94+
);
95+
8496
/** Render a VNode tree, binding reactive data sources. */
8597
const renderVNode = (vnode: VNode, context: RenderContext): Node => {
8698
// Make sure we have a view for this tag. If we don't it is not whitelisted.
@@ -91,10 +103,10 @@ const renderVNode = (vnode: VNode, context: RenderContext): Node => {
91103
}
92104

93105
// Validate props against the view's schema.
94-
if (!view.props.validate(vnode.props)) {
106+
if (!view.props.validate(modifyPropsForSchemaValidation(vnode.props))) {
95107
throw new TypeError(`Invalid props for tag: ${vnode.tag}.
96-
Props: ${JSON.stringify(vnode.props)}
97-
Schema: ${JSON.stringify(view.props.schema)}`);
108+
Props: ${JSON.stringify(vnode.props)}, ${JSON.stringify(modifyPropsForSchemaValidation(vnode.props), undefined, 2)}
109+
Schema: ${JSON.stringify(view.props.schema, undefined, 2)}`);
98110
}
99111

100112
// Create the element
@@ -103,29 +115,34 @@ const renderVNode = (vnode: VNode, context: RenderContext): Node => {
103115
// Bind each prop to a reactive value (if any) and collect cancels
104116
const cancels: Array<Cancel> = [];
105117

106-
for (const [key, value] of Object.entries(vnode.props)) {
118+
for (const [prop, value] of Object.entries(vnode.props)) {
119+
const [key, detail] = prop.split("#", 2);
107120
// Don't bind properties that aren't whitelisted in the schema.
108121
if (!Object.hasOwn(view.props.schema.properties, key)) {
109122
continue;
110123
}
111124

112-
if (isEventKey(key) && isBinding(value)) {
113-
const bound = context[value.name];
114-
if (isSendable(bound)) {
115-
const { send } = bound;
116-
const event = readEventNameFromEventKey(key);
117-
const cancel = listen(element, event, (event: Event) => {
118-
send(readEvent(event));
119-
});
120-
cancels.push(cancel);
121-
}
122-
} else if (isBinding(value)) {
123-
const boundValue = context[value.name];
124-
if (boundValue != null) {
125-
const cancel = effect([boundValue], (value) => {
126-
setProp(element, key, value);
127-
});
128-
cancels.push(cancel);
125+
if (isBinding(value) || isSignal(value) || isStream(value)) {
126+
const bound =
127+
isSignal(value) || isStream(value) ? value : context[value.name];
128+
if (isEventKey(key)) {
129+
if (isSendable(bound)) {
130+
const { send } = bound;
131+
const event = readEventNameFromEventKey(key);
132+
const cancel = listen(element, event, (event: Event) => {
133+
let vdomEvent = readEvent(event);
134+
if (detail) vdomEvent = vdomEvent.detail[detail];
135+
send(vdomEvent);
136+
});
137+
cancels.push(cancel);
138+
}
139+
} else {
140+
if (bound) {
141+
const cancel = effect([bound], (value) => {
142+
setProp(element, key, value);
143+
});
144+
cancels.push(cancel);
145+
}
129146
}
130147
} else {
131148
setProp(element, key, value);
@@ -138,13 +155,16 @@ const renderVNode = (vnode: VNode, context: RenderContext): Node => {
138155
element[__cancel__] = cancel;
139156

140157
if (isRepeatBinding(vnode.children)) {
141-
const { name, template } = vnode.children;
142-
const scopedContext = context[name];
158+
const { name, template, signal } = vnode.children;
159+
const scopedContext = signal ? signal : context[name];
143160
const cancel = renderDynamicChildren(element, template, scopedContext);
144161
cancels.push(cancel);
145162
} else if (isBinding(vnode.children)) {
146163
const { name } = vnode.children;
147164
renderText(element, context[name]);
165+
} else if (isSignal(vnode.children)) {
166+
const signal = vnode.children;
167+
renderText(element, signal);
148168
} else {
149169
renderStaticChildren(element, vnode.children, context);
150170
}
Lines changed: 39 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,37 @@
1-
import * as Schema from '../shared/schema.js';
1+
import { isSignal, Signal } from "@commontools/common-frp/signal";
2+
import * as Schema from "../shared/schema.js";
23
import {
34
AnyJSONObjectSchema,
45
JSONSchemaRecord,
5-
bindable
6-
} from './schema-helpers.js';
7-
import {deepFreeze} from '../shared/deep-freeze.js';
6+
bindable,
7+
} from "./schema-helpers.js";
8+
import { deepFreeze } from "../shared/deep-freeze.js";
89

910
export type Binding = {
1011
"@type": "binding";
1112
name: string;
12-
}
13+
};
1314

1415
/** Is value a binding to a reactive value? */
1516
export const isBinding = (value: any): value is Binding => {
1617
return (
17-
value &&
18-
value["@type"] === "binding" &&
19-
typeof value.name === "string"
18+
value && value["@type"] === "binding" && typeof value.name === "string"
2019
);
21-
}
20+
};
2221

2322
/** Create a template binding */
2423
export const binding = (name: string): Binding => ({
2524
"@type": "binding",
26-
name
25+
name,
2726
});
2827

2928
/** A repeat binding repeats items in a dynamic list using a template */
3029
export type RepeatBinding = {
3130
"@type": "repeat";
3231
name: string;
32+
signal?: Signal<any>;
3333
template: VNode;
34-
}
34+
};
3535

3636
/** Is value a binding to a reactive value? */
3737
export const isRepeatBinding = (value: any): value is RepeatBinding => {
@@ -41,16 +41,17 @@ export const isRepeatBinding = (value: any): value is RepeatBinding => {
4141
typeof value.name === "string" &&
4242
isVNode(value.template)
4343
);
44-
}
44+
};
4545

4646
/** Create a template binding */
4747
export const repeat = (
48-
name: string,
48+
name: string | Signal<any>,
4949
template: VNode
5050
): RepeatBinding => ({
5151
"@type": "repeat",
52-
name,
53-
template
52+
name: typeof name === "string" ? name : "signal",
53+
template,
54+
...(isSignal(name) ? { signal: name } : {}),
5455
});
5556

5657
export type Value = string | number | boolean | null | object;
@@ -59,7 +60,7 @@ export type ReactiveValue = Binding | Value;
5960

6061
export type Props = {
6162
[key: string]: ReactiveValue;
62-
}
63+
};
6364

6465
export type Tag = string;
6566

@@ -69,7 +70,7 @@ export type VNode = {
6970
tag: Tag;
7071
props: Props;
7172
children: Children;
72-
}
73+
};
7374

7475
// NOTE: don't freeze this object, since the validator will want to mutate it.
7576
export const VNodeSchema = {
@@ -89,32 +90,25 @@ export const VNodeSchema = {
8990
{ type: "null" },
9091
{ type: "object" },
9192
{ type: "array" },
92-
{ type: "signal" }
93-
]
94-
}
93+
{ type: "signal" },
94+
],
95+
},
9596
},
9697
children: {
9798
type: "array",
9899
items: {
99-
oneOf: [
100-
{ type: "string" },
101-
{ $ref: "#" }
102-
]
103-
}
104-
}
100+
oneOf: [{ type: "string" }, { $ref: "#" }],
101+
},
102+
},
105103
},
106-
required: ["tag", "props", "children"]
107-
}
104+
required: ["tag", "props", "children"],
105+
};
108106

109107
/** Internal helper for creating VNodes */
110-
const vnode = (
111-
tag: string,
112-
props: Props = {},
113-
children: Children
114-
): VNode => ({
108+
const vnode = (tag: string, props: Props = {}, children: Children): VNode => ({
115109
tag,
116110
props,
117-
children
111+
children,
118112
});
119113

120114
/** Is object a VNode? */
@@ -125,23 +119,20 @@ export const isVNode = (value: any): value is VNode => {
125119
typeof value.props === "object" &&
126120
value.children != null
127121
);
128-
}
122+
};
129123

130124
export type Factory = {
131-
(): VNode
125+
(): VNode;
132126

133-
(props: Props): VNode
127+
(props: Props): VNode;
134128

135-
(
136-
props: Props,
137-
children: Children
138-
): VNode
129+
(props: Props, children: Children): VNode;
139130
};
140131

141132
export type PropsDescription = {
142133
schema: AnyJSONObjectSchema;
143134
validate: (data: any) => boolean;
144-
}
135+
};
145136

146137
export type View = Factory & {
147138
tag: Tag;
@@ -164,16 +155,16 @@ export const view = (
164155
type: "object",
165156
properties: Object.fromEntries(
166157
Object.entries(propertySchema).map(([key, value]) => {
167-
return [key, bindable(value)]
158+
return [key, bindable(value)];
168159
})
169-
)
160+
),
170161
};
171162

172163
// Compile props validator for fast validation at runtime.
173164
const validate = Schema.compile({
174165
...schema,
175166
// Allow additional properties when validating props.
176-
additionalProperties: true
167+
additionalProperties: true,
177168
});
178169

179170
/**
@@ -183,13 +174,11 @@ export const view = (
183174
* @param children - child nodes
184175
* @returns VNode
185176
*/
186-
const create = (
187-
props: Props = {},
188-
children: Children = []
189-
) => vnode(tag, props, children);
177+
const create = (props: Props = {}, children: Children = []) =>
178+
vnode(tag, props, children);
190179

191180
create.tag = tag;
192-
create.props = {validate, schema};
181+
create.props = { validate, schema };
193182

194183
return deepFreeze(create);
195184
};

typescript/packages/lookslike-high-level/src/data.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { Gem } from "./recipe.js";
44
const { state } = signal;
55

66
import { todoList, todoTask } from "./recipes/todo-list.js";
7+
import { localSearch } from "./recipes/local-search.js";
8+
79
import "./recipes/todo-list-as-task.js"; // Necessary, so that suggestions are indexed.
810

911
export const dataGems = state<Gem[]>([]);
@@ -45,4 +47,9 @@ export const recipes: RecipeManifest[] = [
4547
recipe: todoList,
4648
inputs: { title: "", items: [] },
4749
},
50+
{
51+
name: "Find places",
52+
recipe: localSearch,
53+
inputs: { query: "", location: "" },
54+
},
4855
];

0 commit comments

Comments
 (0)