@weftui/core 0.29.0 → 0.31.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/dist/{index-4cTlhojA.d.ts → index-B-dPfhKZ.d.ts} +38 -20
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/types/index.d.ts +1 -1
- package/docs/explanation/boundaries-and-suspense.md +38 -8
- package/docs/explanation/combinator-api.md +17 -11
- package/docs/explanation/reactive-primitives.md +40 -13
- package/docs/explanation/rendering-model.md +14 -2
- package/docs/how-to/add-routing.md +299 -28
- package/docs/how-to/author-components.md +71 -105
- package/docs/how-to/compose-behavior-and-markup.md +141 -54
- package/docs/how-to/handle-forms.md +142 -15
- package/docs/how-to/load-async-data.md +110 -6
- package/docs/how-to/load-data-with-rpc.md +171 -18
- package/docs/how-to/provide-services.md +84 -1
- package/docs/how-to/render-keyed-lists.md +120 -14
- package/docs/how-to/render-on-the-server.md +113 -15
- package/docs/how-to/show-navigation-progress.md +120 -14
- package/docs/how-to/split-routes-lazily.md +106 -3
- package/docs/how-to/style-reactively.md +141 -13
- package/docs/how-to/use-element-refs.md +126 -8
- package/docs/reference/core.md +22 -2
- package/docs/reference/dom.md +43 -0
- package/docs/reference/router.md +2 -2
- package/docs/tutorial/01-your-first-app.md +43 -13
- package/docs/tutorial/02-reactivity.md +29 -30
- package/docs/tutorial/03-services-and-async.md +89 -38
- package/docs/tutorial/04-errors-and-server.md +41 -25
- package/package.json +1 -1
|
@@ -7,11 +7,11 @@ description: Plain functions vs. Component.gen / Component.make, instance scope,
|
|
|
7
7
|
|
|
8
8
|
# Component Authoring
|
|
9
9
|
|
|
10
|
-
Weft
|
|
10
|
+
**Goal:** write a Weft component, a plain function returning a `Node<E, R>`, and pick the right authoring style for its complexity.
|
|
11
11
|
|
|
12
12
|
## Plain functions
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
Static props, no internal state: write and call a plain function.
|
|
15
15
|
|
|
16
16
|
```typescript
|
|
17
17
|
import { h } from "@weftui/core";
|
|
@@ -20,19 +20,18 @@ function Greeting({ name }: { name: string }) {
|
|
|
20
20
|
return h.p(`Hello, ${name}!`);
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
//
|
|
24
|
-
Greeting({ name: "World" });
|
|
23
|
+
Greeting({ name: "World" }); // called directly, no JSX, no deferred descriptor
|
|
25
24
|
```
|
|
26
25
|
|
|
27
|
-
|
|
26
|
+
Reach for this when:
|
|
28
27
|
|
|
29
|
-
- Props are all static (strings, numbers, plain functions)
|
|
30
|
-
- The component has no internal state
|
|
31
|
-
- You don't need the caller's reactive prop types to propagate
|
|
28
|
+
- Props are all static (strings, numbers, plain functions).
|
|
29
|
+
- The component has no internal state.
|
|
30
|
+
- You don't need the caller's reactive prop types to propagate into the return type.
|
|
32
31
|
|
|
33
32
|
## Components with internal state
|
|
34
33
|
|
|
35
|
-
|
|
34
|
+
Set up reactive state with `Effect.gen` before building the tree. The component function still runs once; the setup happens at mount time:
|
|
36
35
|
|
|
37
36
|
```typescript
|
|
38
37
|
import { h } from "@weftui/core";
|
|
@@ -49,68 +48,16 @@ const Counter = () =>
|
|
|
49
48
|
});
|
|
50
49
|
```
|
|
51
50
|
|
|
52
|
-
|
|
51
|
+
`Effect.Effect<Node, never, never>` is itself a valid `Node`, so it composes with any other tree-building call.
|
|
53
52
|
|
|
54
|
-
|
|
53
|
+
Once a component like this is reused or takes props, wrap the same generator in `Component.gen` (below) so the caller's reactive prop and children channels flow into its node type.
|
|
55
54
|
|
|
56
|
-
## Component
|
|
55
|
+
## `Component.gen` / `Component.make`
|
|
57
56
|
|
|
58
|
-
|
|
59
|
-
mount scope created fresh for that instance. Anything bound to the instance scope lives
|
|
60
|
-
exactly as long as the component is mounted. It is torn down automatically when the
|
|
61
|
-
component unmounts (or when its root unmounts via `RootHandle.unmount()`).
|
|
57
|
+
Both build a component whose returned `Node`'s `E`/`R` include the caller's reactive prop and children channels, not just the body's own:
|
|
62
58
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
This matters the moment a component starts **background work**: a subscription, an
|
|
67
|
-
observer of a `ref`, a polling timer, anything you `fork`. The rule:
|
|
68
|
-
|
|
69
|
-
> Fork background work with **`Effect.forkScoped`**, never a bare `Effect.forkChild`.
|
|
70
|
-
|
|
71
|
-
`Effect.forkScoped` attaches the fiber to the instance scope, so it keeps running for
|
|
72
|
-
the component's lifetime and is interrupted on unmount. A bare `Effect.forkChild` instead
|
|
73
|
-
attaches the fiber to the component-body fiber, the one that runs your `Effect.gen` to
|
|
74
|
-
produce the tree. That fiber completes the instant the gen returns its node, so the
|
|
75
|
-
forked work is cancelled almost immediately.
|
|
76
|
-
|
|
77
|
-
Concretely, an observer that runs an effect when a `ref`'s element mounts:
|
|
78
|
-
|
|
79
|
-
```typescript
|
|
80
|
-
import { h } from "@weftui/core";
|
|
81
|
-
import { Effect, Option, pipe, Stream, SubscriptionRef } from "effect";
|
|
82
|
-
|
|
83
|
-
const AutoFocusInput = () =>
|
|
84
|
-
Effect.gen(function* () {
|
|
85
|
-
const inputRef = yield* SubscriptionRef.make<Option.Option<HTMLInputElement>>(Option.none());
|
|
86
|
-
|
|
87
|
-
yield* pipe(
|
|
88
|
-
SubscriptionRef.changes(inputRef),
|
|
89
|
-
Stream.filter(Option.isSome),
|
|
90
|
-
Stream.take(1),
|
|
91
|
-
Stream.runForEach((el) => Effect.sync(() => el.value.focus())),
|
|
92
|
-
Effect.forkScoped, // ✅ tied to the instance scope: survives until unmount
|
|
93
|
-
// Effect.forkChild, // ❌ tied to the body fiber: interrupted when the gen returns
|
|
94
|
-
);
|
|
95
|
-
|
|
96
|
-
return yield* h.input({ ref: inputRef, type: "text" });
|
|
97
|
-
});
|
|
98
|
-
```
|
|
99
|
-
|
|
100
|
-
You do not manage the scope yourself: you do not create it, close it, or pass it
|
|
101
|
-
around. `forkScoped` reads it from context, and unmount closes it for you. If you ever
|
|
102
|
-
fork outside a component body (rare), you must supply a `Scope.Scope` yourself. The
|
|
103
|
-
type system will tell you, because `forkScoped` carries a `Scope.Scope` requirement.
|
|
104
|
-
|
|
105
|
-
See `examples/element-ref` for the auto-focus, measure, and canvas recipes built on
|
|
106
|
-
this pattern.
|
|
107
|
-
|
|
108
|
-
## `Component.gen` / `Component.make` for reusable components
|
|
109
|
-
|
|
110
|
-
When you want the caller's reactive prop types to flow into the returned node's type, use one of the `Component` factories. Both have the same call semantics; pick the body style that fits:
|
|
111
|
-
|
|
112
|
-
- **`Component.make`**: body is a plain function returning any `Effect` (typically a `Node`). Use for one-liners and pipe compositions.
|
|
113
|
-
- **`Component.gen`**: body is a generator. Use when you need `yield*` to set up local state or pull from services.
|
|
59
|
+
- **`Component.make`**: body is a plain function returning any `Effect`. Use for one-liners and pipe compositions.
|
|
60
|
+
- **`Component.gen`**: body is a generator. Use when you need `yield*` for local state or services.
|
|
114
61
|
|
|
115
62
|
```typescript
|
|
116
63
|
import { Component, h, Source } from "@weftui/core";
|
|
@@ -128,40 +75,28 @@ const Card = Component.make((props: CardProps) =>
|
|
|
128
75
|
);
|
|
129
76
|
```
|
|
130
77
|
|
|
131
|
-
`Source.Source<string>` is Weft's caller-facing prop vocabulary: a single type covering a static `string`, a `Stream<string>`, an `Effect<string>`, or a `Subscribable<string>`. You don't hand-write `string | Stream.Stream<string> | …` on every prop.
|
|
132
|
-
|
|
133
|
-
Passing a `Source` straight to `h` (as above) is all you need when the value is just spliced into the tree. The renderer normalizes it.
|
|
134
|
-
|
|
135
|
-
Now the caller's stream types are visible in the returned node:
|
|
136
|
-
|
|
137
78
|
```typescript
|
|
138
79
|
declare const titleStream: Stream.Stream<string, never, I18nService>;
|
|
139
80
|
|
|
140
|
-
// Node<never, I18nService>: I18nService
|
|
81
|
+
// Node<never, I18nService>: I18nService flows out from the prop the caller passed
|
|
141
82
|
const card = Card({ title: titleStream });
|
|
142
83
|
```
|
|
143
84
|
|
|
144
|
-
Without a `Component` factory, a plain function's return type is fixed at definition time and
|
|
85
|
+
Without a `Component` factory, a plain function's return type is fixed at definition time and can't reflect what the caller actually passes.
|
|
145
86
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
You don't declare the body's `E`/`R` channels explicitly. They're inferred from the returned (or yielded) effect:
|
|
149
|
-
|
|
150
|
-
- The body's `E`/`R` come from whatever effects appear inside.
|
|
151
|
-
- The caller's reactive prop channels and reactive children channels are unioned on top at the call site.
|
|
87
|
+
- The body's `E`/`R` come from whatever effects it yields or returns.
|
|
88
|
+
- Caller prop channels and children channels are unioned on top at the call site.
|
|
152
89
|
- Static prop values (`string`, `number`, plain functions) contribute `never`.
|
|
153
90
|
|
|
154
91
|
### Children: array or function
|
|
155
92
|
|
|
156
|
-
Both factories accept an optional second `children` argument, typed as:
|
|
157
|
-
|
|
158
93
|
```typescript
|
|
159
94
|
type Component.Children<Input = never> =
|
|
160
95
|
| readonly Renderable[]
|
|
161
96
|
| ((input: Input) => readonly Renderable[]);
|
|
162
97
|
```
|
|
163
98
|
|
|
164
|
-
The function form is the render-prop / slot pattern. The component
|
|
99
|
+
The function form is the render-prop / slot pattern. The component calls it with whatever `input` it chooses, and the returned array's `E`/`R` propagate out:
|
|
165
100
|
|
|
166
101
|
```typescript
|
|
167
102
|
const ItemList = Component.make(
|
|
@@ -172,9 +107,9 @@ const ItemList = Component.make(
|
|
|
172
107
|
ItemList({ items: ["a", "b"] }, (item) => [h.li(item)]);
|
|
173
108
|
```
|
|
174
109
|
|
|
175
|
-
## Props typing
|
|
110
|
+
## Props typing with `Source`
|
|
176
111
|
|
|
177
|
-
|
|
112
|
+
Type a prop that accepts both static and reactive values as `Source.Source<T>` instead of hand-writing the union:
|
|
178
113
|
|
|
179
114
|
```typescript
|
|
180
115
|
import { Source } from "@weftui/core";
|
|
@@ -186,31 +121,58 @@ interface ButtonProps {
|
|
|
186
121
|
}
|
|
187
122
|
```
|
|
188
123
|
|
|
189
|
-
|
|
124
|
+
`Source.Source<T>` **is** the union `T | Stream<T> | Effect<T> | Subscribable<T>`. A plain string contributes `never` to the node's channels; a `Stream<string, never, SomeService>` contributes `SomeService` to `R`. The extraction is `Source.Success` / `Source.Error` / `Source.Context`.
|
|
190
125
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
Splicing a `Source` straight into `h` (`[props.label]`) is enough when you only place it in the tree. When the body needs to **read or derive** from the value (combine two props, feed a stream operator, drive logic), normalize it first with [`Source.toSubscribable`](https://weftui.dev/docs/reference/core#sourcetosubscribablesource-key). It turns any `Source<A>` into an await-first, hot `Subscribable<A>`:
|
|
126
|
+
Splicing a `Source` straight into `h` (`[props.label]`) is enough when the body only places it in the tree. Reach for `Source.toSubscribable` when the body needs to **read or derive** from it:
|
|
194
127
|
|
|
195
128
|
```typescript
|
|
196
|
-
import { Component, h, Source } from "@weftui/core";
|
|
129
|
+
import { Component, h, Source, Subscribable } from "@weftui/core";
|
|
197
130
|
import { Stream } from "effect";
|
|
198
131
|
|
|
199
132
|
const LoudLabel = Component.gen(function* (props: { label: Source.Source<string> }) {
|
|
200
133
|
const label = yield* Source.toSubscribable(props.label); // Subscribable<string>
|
|
201
|
-
|
|
202
|
-
return yield* h.strong([Stream.map(label.changes, (text) => text.toUpperCase())]);
|
|
134
|
+
return yield* h.strong([Stream.map(Subscribable.changes(label), (text) => text.toUpperCase())]);
|
|
203
135
|
});
|
|
204
136
|
```
|
|
205
137
|
|
|
206
|
-
`
|
|
207
|
-
|
|
208
|
-
-
|
|
209
|
-
- An `Effect` prop is memoized.
|
|
210
|
-
- An existing `Subscribable` is threaded through by reference.
|
|
138
|
+
- An existing `Subscribable` prop is threaded through by reference, no new fiber.
|
|
139
|
+
- A `Stream` prop is pumped by a fiber scoped to the component's instance scope.
|
|
140
|
+
- An `Effect` prop is memoized, so it runs at most once.
|
|
211
141
|
- A static value emits once.
|
|
212
142
|
|
|
213
|
-
|
|
143
|
+
## Instance scope and background effects
|
|
144
|
+
|
|
145
|
+
Every component instance renders under its own **instance scope**, a child of the mount scope created fresh per instance. It closes on unmount (or when its root unmounts via `RootHandle.unmount()`). The renderer supplies it as the ambient `Scope.Scope` while your body runs, so it's already in context.
|
|
146
|
+
|
|
147
|
+
Fork background work (a subscription, a ref observer, a polling timer) with **`Effect.forkScoped`**, never a bare `Effect.forkChild`:
|
|
148
|
+
|
|
149
|
+
```typescript
|
|
150
|
+
import { h } from "@weftui/core";
|
|
151
|
+
import { Effect, Option, pipe, Stream, SubscriptionRef } from "effect";
|
|
152
|
+
|
|
153
|
+
const AutoFocusInput = () =>
|
|
154
|
+
Effect.gen(function* () {
|
|
155
|
+
const inputRef = yield* SubscriptionRef.make<Option.Option<HTMLInputElement>>(Option.none());
|
|
156
|
+
|
|
157
|
+
yield* pipe(
|
|
158
|
+
SubscriptionRef.changes(inputRef),
|
|
159
|
+
Stream.filter(Option.isSome),
|
|
160
|
+
Stream.take(1),
|
|
161
|
+
Stream.runForEach((el) => Effect.sync(() => el.value.focus())),
|
|
162
|
+
Effect.forkScoped, // ✅ tied to the instance scope: survives until unmount
|
|
163
|
+
// Effect.forkChild, // ❌ tied to the body fiber: interrupted when the gen returns
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
return yield* h.input({ ref: inputRef, type: "text" });
|
|
167
|
+
});
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
- `Effect.forkScoped` attaches the fiber to the instance scope, so it keeps running for the component's lifetime and is interrupted on unmount.
|
|
171
|
+
- `Effect.forkChild` attaches to the component-body fiber instead, the one that runs your generator to produce the tree. That fiber completes the instant the body returns its node, so a bare fork is cancelled almost immediately.
|
|
172
|
+
|
|
173
|
+
You never create, close, or pass the scope yourself: `forkScoped` reads it from context, and unmount closes it for you. Forking outside a component body (rare) requires supplying a `Scope.Scope`, and `forkScoped`'s own `Scope.Scope` requirement makes the type system tell you.
|
|
174
|
+
|
|
175
|
+
See [Use Element Refs](https://weftui.dev/docs/how-to/use-element-refs) for the auto-focus, measure, and canvas recipes built on this pattern.
|
|
214
176
|
|
|
215
177
|
## Composing components
|
|
216
178
|
|
|
@@ -228,11 +190,11 @@ function App() {
|
|
|
228
190
|
}
|
|
229
191
|
```
|
|
230
192
|
|
|
231
|
-
Children arrays accumulate `E`/`R` from all their members
|
|
193
|
+
Children arrays accumulate `E`/`R` from all their members; the parent node's type reflects the union.
|
|
232
194
|
|
|
233
195
|
## Components that require services
|
|
234
196
|
|
|
235
|
-
|
|
197
|
+
A service read via `yield*` inside the body appears in the component's `R` channel, regardless of what the caller passes:
|
|
236
198
|
|
|
237
199
|
```typescript
|
|
238
200
|
import { Component, h } from "@weftui/core";
|
|
@@ -243,11 +205,11 @@ const UserAvatar = Component.gen(function* (props: { userId: string }) {
|
|
|
243
205
|
return yield* h.img({ src: user.avatarUrl, alt: user.name });
|
|
244
206
|
});
|
|
245
207
|
|
|
246
|
-
// Node<never, UserService
|
|
208
|
+
// Node<never, UserService>
|
|
247
209
|
const avatar = UserAvatar({ userId: "123" });
|
|
248
210
|
```
|
|
249
211
|
|
|
250
|
-
Give the service to the app layer:
|
|
212
|
+
Give the service to the app layer, not to the mount call:
|
|
251
213
|
|
|
252
214
|
```typescript
|
|
253
215
|
import { WeftApp } from "@weftui/dom/client";
|
|
@@ -257,9 +219,11 @@ const app = WeftApp.make(UserServiceLive);
|
|
|
257
219
|
void Effect.runPromise(WeftApp.mount(app, App(), document.getElementById("root")!));
|
|
258
220
|
```
|
|
259
221
|
|
|
222
|
+
See [Provide Services](https://weftui.dev/docs/how-to/provide-services) for scoped layers, `memoMap` sharing, and why wrapping `Effect.provide` around the mount call doesn't work.
|
|
223
|
+
|
|
260
224
|
## Returning fragments
|
|
261
225
|
|
|
262
|
-
|
|
226
|
+
Return multiple sibling elements without a wrapper using `h.fragment`:
|
|
263
227
|
|
|
264
228
|
```typescript
|
|
265
229
|
import { h } from "@weftui/core";
|
|
@@ -268,11 +232,13 @@ const TableCells = ({ row }: { row: Row }) =>
|
|
|
268
232
|
h.fragment([h.td(row.name), h.td(row.value), h.td(row.status)]);
|
|
269
233
|
```
|
|
270
234
|
|
|
271
|
-
`h.fragment` returns a `Node<E, R>` that accumulates channels from all its children.
|
|
235
|
+
`h.fragment` returns a `Node<E, R>` that accumulates channels from all its children, same as any other `h.*` call.
|
|
272
236
|
|
|
273
237
|
## See also
|
|
274
238
|
|
|
275
239
|
- [The Combinator API](https://weftui.dev/docs/explanation/combinator-api): `h`, `h.fragment`, and how `E`/`R` accumulate
|
|
276
240
|
- [Reactive Primitives](https://weftui.dev/docs/explanation/reactive-primitives): the `Source` vocabulary props accept
|
|
241
|
+
- [Use Element Refs](https://weftui.dev/docs/how-to/use-element-refs): `ref` props and scoped mount observers
|
|
242
|
+
- [Provide Services](https://weftui.dev/docs/how-to/provide-services): app layers, scoped layers, and `memoMap`
|
|
277
243
|
- [Add Routing](https://weftui.dev/docs/how-to/add-routing): route components are `Component` slots
|
|
278
244
|
- [`@weftui/core` reference](https://weftui.dev/docs/reference/core): `Component`, `Source`, and the full surface
|
|
@@ -7,29 +7,26 @@ description: "Use Props.merge to combine a behavior prop bag with your own marku
|
|
|
7
7
|
|
|
8
8
|
# Compose Behavior and Markup
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
giving up ownership of the element it applies to.
|
|
10
|
+
`Props.merge` combines a behavior's prop bag (aria wiring, handlers, refs, reactive state) with markup you own, without either side losing what it contributed.
|
|
12
11
|
|
|
13
12
|
## The problem
|
|
14
13
|
|
|
15
|
-
|
|
16
|
-
handling, `aria-expanded` wiring and anchor ref are worth writing once. The
|
|
17
|
-
button itself is yours: your classes, your text, your extra handlers.
|
|
14
|
+
Object spread can't combine two prop bags safely:
|
|
18
15
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
16
|
+
```ts
|
|
17
|
+
const merged = { ...behavior, ...mine };
|
|
18
|
+
// mine.onclick replaces behavior.onclick entirely, and mine.ref replaces
|
|
19
|
+
// behavior.ref entirely. Nothing warns you.
|
|
20
|
+
```
|
|
22
21
|
|
|
23
|
-
`Props.merge` reconciles
|
|
22
|
+
`Props.merge` reconciles the collision per key instead of silently dropping a side.
|
|
24
23
|
|
|
25
24
|
## Behavior as a prop bag
|
|
26
25
|
|
|
27
|
-
A behavior primitive is a plain Effect that yields a prop bag. There
|
|
28
|
-
component wrapper and no hook rules, so you can `yield*` it anywhere and hold
|
|
29
|
-
the result.
|
|
26
|
+
A behavior primitive is a plain Effect that yields a prop bag. There's no component wrapper and no hook rules, so you `yield*` it anywhere and hold the result:
|
|
30
27
|
|
|
31
28
|
```ts
|
|
32
|
-
import { Effect, Option, SubscriptionRef } from "effect";
|
|
29
|
+
import { Effect, Option, Stream, SubscriptionRef } from "effect";
|
|
33
30
|
|
|
34
31
|
const makeDisclosure = () =>
|
|
35
32
|
Effect.gen(function* () {
|
|
@@ -38,7 +35,11 @@ const makeDisclosure = () =>
|
|
|
38
35
|
|
|
39
36
|
const trigger = {
|
|
40
37
|
ref: anchor,
|
|
41
|
-
|
|
38
|
+
// A boolean value renders as presence-only (`setAttribute(name, "")`),
|
|
39
|
+
// which is wrong for `aria-*`. Map to the literal string instead.
|
|
40
|
+
"aria-expanded": Stream.map(SubscriptionRef.changes(isOpen), (open) =>
|
|
41
|
+
open ? ("true" as const) : ("false" as const),
|
|
42
|
+
),
|
|
42
43
|
onclick: () => SubscriptionRef.update(isOpen, (open) => !open),
|
|
43
44
|
};
|
|
44
45
|
|
|
@@ -46,13 +47,11 @@ const makeDisclosure = () =>
|
|
|
46
47
|
});
|
|
47
48
|
```
|
|
48
49
|
|
|
49
|
-
`makeDisclosure` returns a plain object, not a `DomProps`-typed value. `merge`
|
|
50
|
-
accepts it as-is: it dispatches on each key's name, not on the bag's declared
|
|
51
|
-
type.
|
|
50
|
+
`makeDisclosure` returns a plain object, not a `DomProps`-typed value. `merge` accepts it as-is: it dispatches on each key's name, not on the bag's declared type.
|
|
52
51
|
|
|
53
|
-
## Merge
|
|
52
|
+
## Merge onto your element
|
|
54
53
|
|
|
55
|
-
You write the element
|
|
54
|
+
You write the element; the bag merges onto it:
|
|
56
55
|
|
|
57
56
|
```ts
|
|
58
57
|
import { h } from "@weftui/core";
|
|
@@ -74,24 +73,32 @@ const Panel = () =>
|
|
|
74
73
|
});
|
|
75
74
|
```
|
|
76
75
|
|
|
77
|
-
|
|
76
|
+
- **Handlers chain.** The disclosure toggles, then `trackClick` runs; a failure in one never blocks the other.
|
|
77
|
+
- **Refs fan out.** `anchor` and `measure` both receive the element; spread would have kept only one.
|
|
78
|
+
- **`class` takes a reactive condition.** `btn--open` follows `isOpen` through `Props.cx`.
|
|
79
|
+
|
|
80
|
+
Type an inline handler's event explicitly (`(ev: MouseEvent)` above). `merge` doesn't know which element the bag will land on, so it can't infer it.
|
|
81
|
+
|
|
82
|
+
## Per-key rules
|
|
83
|
+
|
|
84
|
+
| Key | Rule |
|
|
85
|
+
| ------------- | -------------------------------------------------------------------------------------------- |
|
|
86
|
+
| `on*` | Chained left to right. Both bodies run; failures from both sides are aggregated. |
|
|
87
|
+
| `class` | Concatenated. All-static stays a `string`; either side reactive makes it a `Stream<string>`. |
|
|
88
|
+
| `style` | Two per-property objects merge per key, right wins. Any other shape is last-wins. |
|
|
89
|
+
| `ref` | Fan out: concatenates into an array, and every ref receives the element. |
|
|
90
|
+
| anything else | Last-wins. |
|
|
78
91
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
would have kept only one, silently.
|
|
84
|
-
3. **`cx` takes a reactive condition.** `btn--open` follows `isOpen`, and only
|
|
85
|
-
the class attribute updates.
|
|
92
|
+
```ts
|
|
93
|
+
Props.merge({ style: { color: "red" } }, { style: { fontWeight: "bold" } });
|
|
94
|
+
// => { style: { color: "red", fontWeight: "bold" } }
|
|
95
|
+
```
|
|
86
96
|
|
|
87
|
-
|
|
88
|
-
does not know which element the bag will land on, so it cannot infer it.
|
|
97
|
+
A key present on only one side passes through untouched.
|
|
89
98
|
|
|
90
|
-
## Typed errors flow through
|
|
99
|
+
## Typed errors and services flow through
|
|
91
100
|
|
|
92
|
-
A handler that fails with a tagged error, or needs a service, keeps both
|
|
93
|
-
channels through the merge. They surface on the component's `Node<E, R>`, so the
|
|
94
|
-
app must provide the service and can catch the error at a boundary.
|
|
101
|
+
A handler that fails with a tagged error, or needs a service, keeps both channels through the merge. They surface on the component's `Node<E, R>`, so the app must provide the service and can catch the error at a boundary:
|
|
95
102
|
|
|
96
103
|
```ts
|
|
97
104
|
declare const rowBehavior: object;
|
|
@@ -109,35 +116,115 @@ h.button(Props.merge(rowBehavior, { onclick: () => deleteItem }), "Delete");
|
|
|
109
116
|
|
|
110
117
|
## Two gotchas that differ from spread
|
|
111
118
|
|
|
112
|
-
- **`false` on a handler is an explicit opt-out and wins.** `null`
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
119
|
+
- **`false` on a handler is an explicit opt-out and wins.** `null`/`undefined` mean "not provided", so the other side survives instead:
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
Props.merge({ onclick: () => track() }, { onclick: false }); // handler is off
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
- **Every other key is genuinely last-wins.** Forwarding an omitted optional prop (`{ id: props.id }`) still overwrites a default with `undefined`, the same as `{ ...base, ...override }` would. Guard at the call site if that matters.
|
|
126
|
+
|
|
127
|
+
The [reference](https://weftui.dev/docs/reference/dom#propsmerge) has the full per-key rule table, including the `style` and reactive-class cases above.
|
|
118
128
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
129
|
+
## Complete example
|
|
130
|
+
|
|
131
|
+
A disclosure behavior merged onto a caller-owned button, with a click counter and a ref the behavior doesn't know about. This is the whole file set, copy/paste runnable in a `vite` + `@weftui/core`/`@weftui/dom` project.
|
|
132
|
+
|
|
133
|
+
```html
|
|
134
|
+
<!-- index.html -->
|
|
135
|
+
<!doctype html>
|
|
136
|
+
<html lang="en">
|
|
137
|
+
<head>
|
|
138
|
+
<meta charset="UTF-8" />
|
|
139
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
140
|
+
<title>Compose behavior and markup demo</title>
|
|
141
|
+
</head>
|
|
142
|
+
<body>
|
|
143
|
+
<div id="root"></div>
|
|
144
|
+
<script type="module" src="/src/main.ts"></script>
|
|
145
|
+
</body>
|
|
146
|
+
</html>
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
```typescript
|
|
150
|
+
// src/app.ts
|
|
151
|
+
/**
|
|
152
|
+
* Disclosure behavior (open state, anchor ref, toggle handler) merged onto a
|
|
153
|
+
* button the caller owns: its own class, its own click counter, its own ref.
|
|
154
|
+
* Side-effect-free (no mount call), so `main.ts` and any test can import
|
|
155
|
+
* `App` directly.
|
|
156
|
+
*/
|
|
157
|
+
import { h } from "@weftui/core";
|
|
158
|
+
import { Props } from "@weftui/dom";
|
|
159
|
+
import { Effect, Option, Stream, SubscriptionRef } from "effect";
|
|
160
|
+
|
|
161
|
+
const makeDisclosure = () =>
|
|
162
|
+
Effect.gen(function* () {
|
|
163
|
+
const isOpen = yield* SubscriptionRef.make(false);
|
|
164
|
+
const anchor = yield* SubscriptionRef.make(Option.none<HTMLElement>());
|
|
165
|
+
|
|
166
|
+
const trigger = {
|
|
167
|
+
ref: anchor,
|
|
168
|
+
"aria-expanded": Stream.map(SubscriptionRef.changes(isOpen), (open) =>
|
|
169
|
+
open ? ("true" as const) : ("false" as const),
|
|
170
|
+
),
|
|
171
|
+
onclick: () => SubscriptionRef.update(isOpen, (open) => !open),
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
return { isOpen, trigger };
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
export const App = () =>
|
|
178
|
+
Effect.gen(function* () {
|
|
179
|
+
const disclosure = yield* makeDisclosure();
|
|
180
|
+
const measure = yield* SubscriptionRef.make(Option.none<HTMLElement>());
|
|
181
|
+
const clicks = yield* SubscriptionRef.make(0);
|
|
182
|
+
|
|
183
|
+
return yield* h.div({ id: "app" }, [
|
|
184
|
+
h.button(
|
|
185
|
+
Props.merge(disclosure.trigger, {
|
|
186
|
+
class: Props.cx("btn", { "btn--open": SubscriptionRef.changes(disclosure.isOpen) }),
|
|
187
|
+
onclick: () => SubscriptionRef.update(clicks, (n) => n + 1),
|
|
188
|
+
ref: measure,
|
|
189
|
+
}),
|
|
190
|
+
"Details",
|
|
191
|
+
),
|
|
192
|
+
h.p([
|
|
193
|
+
"clicked ",
|
|
194
|
+
Stream.map(SubscriptionRef.changes(clicks), String),
|
|
195
|
+
" times · ref fan-out: ",
|
|
196
|
+
Stream.map(SubscriptionRef.changes(measure), (captured) =>
|
|
197
|
+
Option.isSome(captured) ? "captured" : "pending",
|
|
198
|
+
),
|
|
199
|
+
]),
|
|
200
|
+
]);
|
|
201
|
+
});
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
```typescript
|
|
205
|
+
// src/main.ts
|
|
206
|
+
/**
|
|
207
|
+
* Browser entry: mounts the demo into #root.
|
|
208
|
+
*/
|
|
209
|
+
import { WeftApp } from "@weftui/dom/client";
|
|
210
|
+
import { Effect } from "effect";
|
|
211
|
+
import { App } from "./app";
|
|
212
|
+
|
|
213
|
+
const root = document.getElementById("root")!;
|
|
214
|
+
|
|
215
|
+
const app = WeftApp.make();
|
|
216
|
+
void Effect.runPromise(WeftApp.mount(app, App(), root));
|
|
217
|
+
```
|
|
122
218
|
|
|
123
219
|
## When to use
|
|
124
220
|
|
|
125
|
-
Reach for `Props.merge` when two parties contribute props to one element: a
|
|
126
|
-
shared behavior and a caller, or a base variant and a caller's override. For
|
|
127
|
-
a single bag you already control, write the object directly. Merge earns its
|
|
128
|
-
cost only when a key could collide.
|
|
221
|
+
Reach for `Props.merge` when two parties contribute props to one element: a shared behavior and a caller, or a base variant and a caller's override. For a single bag you already control, write the object directly. Merge only earns its cost when a key could collide.
|
|
129
222
|
|
|
130
|
-
`Props.merge` is pure: calling it has no side effects and subscribes
|
|
131
|
-
nothing. A merged `class` that turns out reactive is a `Stream` description.
|
|
132
|
-
The renderer subscribes it once the element mounts, the same as any other
|
|
133
|
-
reactive prop.
|
|
223
|
+
`Props.merge` is pure: calling it has no side effects and subscribes nothing. A merged `class` that turns out reactive is a `Stream` description, not a live subscription. The renderer subscribes it once the element mounts, the same as any other reactive prop.
|
|
134
224
|
|
|
135
225
|
## See also
|
|
136
226
|
|
|
137
|
-
- [Headless Menu example](https://github.com/stefvw93/weft/tree/main/examples/headless-menu): a full behavior
|
|
138
|
-
primitive (`Menu.trigger`/`popup`/`item`) merged onto consumer-owned markup,
|
|
139
|
-
with handler chaining, ref fan-out, and a service requirement flowing
|
|
140
|
-
through the merge into `Node<E, R>`.
|
|
227
|
+
- [Headless Menu example](https://github.com/stefvw93/weft/tree/main/examples/headless-menu): a full behavior primitive (`Menu.trigger`/`popup`/`item`) merged onto consumer-owned markup, with handler chaining, ref fan-out, and a service requirement flowing through the merge into `Node<E, R>`.
|
|
141
228
|
- [`@weftui/dom` reference](https://weftui.dev/docs/reference/dom): the full per-key rules and `cx` grammar
|
|
142
229
|
- [Use Element Refs](https://weftui.dev/docs/how-to/use-element-refs): the single-ref contract that fan-out builds on
|
|
143
230
|
- [Style Reactively](https://weftui.dev/docs/how-to/style-reactively): per-property style streams and `cx`
|