foldkit-primitives 0.1.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 +21 -0
- package/README.md +454 -0
- package/dist/device/index.d.mts +312 -0
- package/dist/device/index.d.mts.map +1 -0
- package/dist/device/index.mjs +490 -0
- package/dist/device/index.mjs.map +1 -0
- package/dist/dom/index.d.mts +73 -0
- package/dist/dom/index.d.mts.map +1 -0
- package/dist/dom/index.mjs +218 -0
- package/dist/dom/index.mjs.map +1 -0
- package/dist/events/index.d.mts +154 -0
- package/dist/events/index.d.mts.map +1 -0
- package/dist/events/index.mjs +287 -0
- package/dist/events/index.mjs.map +1 -0
- package/dist/index.d.mts +10 -0
- package/dist/index.mjs +10 -0
- package/dist/media/index.d.mts +79 -0
- package/dist/media/index.d.mts.map +1 -0
- package/dist/media/index.mjs +127 -0
- package/dist/media/index.mjs.map +1 -0
- package/dist/motion/index.d.mts +112 -0
- package/dist/motion/index.d.mts.map +1 -0
- package/dist/motion/index.mjs +208 -0
- package/dist/motion/index.mjs.map +1 -0
- package/dist/net/index.d.mts +199 -0
- package/dist/net/index.d.mts.map +1 -0
- package/dist/net/index.mjs +329 -0
- package/dist/net/index.mjs.map +1 -0
- package/dist/observers/index.d.mts +59 -0
- package/dist/observers/index.d.mts.map +1 -0
- package/dist/observers/index.mjs +156 -0
- package/dist/observers/index.mjs.map +1 -0
- package/dist/state/index.d.mts +342 -0
- package/dist/state/index.d.mts.map +1 -0
- package/dist/state/index.mjs +504 -0
- package/dist/state/index.mjs.map +1 -0
- package/dist/time/index.d.mts +129 -0
- package/dist/time/index.d.mts.map +1 -0
- package/dist/time/index.mjs +213 -0
- package/dist/time/index.mjs.map +1 -0
- package/package.json +96 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Patrick Glenn
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
# `foldkit-primitives`
|
|
2
|
+
|
|
3
|
+
Ready-made [`foldkit-bundle`](./bundle) primitives: media queries, breakpoints,
|
|
4
|
+
presence, timers, intervals, debounce, tweens, springs, pagination, history,
|
|
5
|
+
locales, selections, ranges, geolocation, cameras, permissions, sockets,
|
|
6
|
+
broadcasts, observers, and clipboard. Most are ordinary bundles — Model,
|
|
7
|
+
Message, init, update, and Subscriptions collected in one value — published
|
|
8
|
+
under a tree-shakeable subpath, so an application pays only for the primitives
|
|
9
|
+
it places. The exceptions keep their own form: entries are Subscription
|
|
10
|
+
streams mapped to the parent's Message (keyboard, pointer, scroll); Mounts
|
|
11
|
+
are element-scoped observation attached with `h.OnMount` (`Resize`,
|
|
12
|
+
`Autofocus`); Commands are one-shot (`copyText`, `share`); pure helpers are
|
|
13
|
+
functions (`range`, `formatRelativeTime`).
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { MediaQuery } from 'foldkit-primitives/media'
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Which state belongs here?
|
|
20
|
+
|
|
21
|
+
A primitive belongs here when it is **reused across applications** and
|
|
22
|
+
**stateful**: something observes it, something reacts to it, and replay sees
|
|
23
|
+
the same transitions. That is exactly the bundle shape, which is why this
|
|
24
|
+
package depends on `foldkit-bundle` and nothing else (besides peers).
|
|
25
|
+
|
|
26
|
+
| Kind | Form | Example |
|
|
27
|
+
| --- | --- | --- |
|
|
28
|
+
| Stateful + effectful | bundle | MediaQuery, Timer, WebSocket, Pagination |
|
|
29
|
+
| Keyed collections of stateful items | bundle per key | uploads, sockets, timers (later) |
|
|
30
|
+
| Stream source with a stored fact | bundle with one boolean/scalar slice | Online, Visibility, WindowSize |
|
|
31
|
+
| Stream source only | Subscription entry, not a bundle | keyboard, pointer, scroll, broadcast |
|
|
32
|
+
| Element-scoped observation | Mount, not a bundle | Resize, Intersection, Mutation, Autofocus |
|
|
33
|
+
| One-shot actions | Command, not a bundle | clipboard copy, share, script load |
|
|
34
|
+
| Derived data | pure function, not a bundle | range, relative time, platform |
|
|
35
|
+
|
|
36
|
+
A bundle holds no state and performs no I/O by itself. The parent Model owns
|
|
37
|
+
the placed slice; the browser (or server, or clock) only reports facts as
|
|
38
|
+
Messages. The same rule as everywhere else: observation is not ownership.
|
|
39
|
+
|
|
40
|
+
Solid developers will notice missing plumbing: there is no event bus
|
|
41
|
+
because Messages are the bus, no memo because derivations are pure reads
|
|
42
|
+
over the Model, and no reactive map or store because the Model holds plain
|
|
43
|
+
data (Effect collections where mutation matters). None of it is missing by
|
|
44
|
+
accident — one state machine leaves nowhere for a second one to live.
|
|
45
|
+
|
|
46
|
+
## The mental model
|
|
47
|
+
|
|
48
|
+
```text
|
|
49
|
+
browser / clock / server ──facts as Messages──▶ update ──▶ Model slice
|
|
50
|
+
▲
|
|
51
|
+
Note the direction: init is pure, so SSR renders the default; the stream corrects it live.
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Every primitive follows one lifecycle: `init` returns a safe default without
|
|
55
|
+
touching the environment (SSR-safe by construction). Streams report live
|
|
56
|
+
facts — MediaQuery reads the current match first, then changes; Timer ticks
|
|
57
|
+
while running. A primitive that cannot observe (no `window`, no API) yields
|
|
58
|
+
an empty stream instead of throwing, so the slice keeps its default.
|
|
59
|
+
|
|
60
|
+
## Install
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pnpm add foldkit-primitives foldkit-bundle effect foldkit
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`effect` and `foldkit` are peer dependencies. Import per subpath —
|
|
67
|
+
`foldkit-primitives/media`, `foldkit-primitives/net`, `foldkit-primitives/time`,
|
|
68
|
+
`foldkit-primitives/state`, `foldkit-primitives/motion`, `foldkit-primitives/device`,
|
|
69
|
+
`foldkit-primitives/events`, `foldkit-primitives/observers`, `foldkit-primitives/dom` —
|
|
70
|
+
so bundlers drop the primitives you never import.
|
|
71
|
+
|
|
72
|
+
## Sixty seconds: follow the color scheme
|
|
73
|
+
|
|
74
|
+
**Declare where it lives.** A declaration names the Model field and the
|
|
75
|
+
Message variant, by Foldkit's `Got<Field>Message` convention:
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
import { Schema } from 'effect'
|
|
79
|
+
import { defineMessageUnion } from 'foldkit/message'
|
|
80
|
+
import { Bundle } from 'foldkit-bundle'
|
|
81
|
+
import { MediaQuery } from 'foldkit-primitives/media'
|
|
82
|
+
|
|
83
|
+
const Dark = Bundle.declare(MediaQuery, 'dark')
|
|
84
|
+
|
|
85
|
+
const Model = Schema.Struct({ ...Dark.fields, theme: Schema.String })
|
|
86
|
+
type Model = typeof Model.Type
|
|
87
|
+
const Message = defineMessageUnion({ ...Dark.cases, ThemeSet: { theme: Schema.String } })
|
|
88
|
+
type Message = typeof Message.Type
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**Place it.** The scope types everything from the parent's Schemas; the
|
|
92
|
+
assembly derives the update and the Subscriptions:
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
const Page = Bundle.parent({ Model, Message })
|
|
96
|
+
const placements = Page.assemble(Page.at(Dark, { args: { query: '(prefers-color-scheme: dark)' } }))
|
|
97
|
+
|
|
98
|
+
const config = placements.complete({
|
|
99
|
+
init: () => placements.initial({ theme: 'light' }),
|
|
100
|
+
update: placements.update(model => ({ model })),
|
|
101
|
+
view,
|
|
102
|
+
subscriptions: placements.subscriptions(),
|
|
103
|
+
})
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`update` routes `GotDarkMessage` to the bundle; `subscriptions` runs the
|
|
107
|
+
`matchMedia` stream; `initial` starts `matches` at `false` and the stream
|
|
108
|
+
corrects it on subscribe. The Solid equivalent this replaces:
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
// solid-primitives: const dark = createMediaQuery('(prefers-color-scheme: dark)')
|
|
112
|
+
// Here the fact lives in the Model: replay, DevTools, and time travel see it.
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Media: `foldkit-primitives/media`
|
|
116
|
+
|
|
117
|
+
`MediaQuery` follows one CSS media query. Model `{ matches: boolean }`,
|
|
118
|
+
one Message `Changed { matches }`, args `{ query: string }`. The stream emits
|
|
119
|
+
the current value on subscribe, then every change; without `matchMedia` it is
|
|
120
|
+
empty and the slice keeps its initial `false`.
|
|
121
|
+
|
|
122
|
+
Bound presets place with no args:
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
import { PrefersDark, PrefersReducedMotion } from 'foldkit-primitives/media'
|
|
126
|
+
|
|
127
|
+
const placements = Page.assemble(Page.place(PrefersDark, 'dark'))
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`Breakpoints` derives names from one `resize` listener: args
|
|
131
|
+
`{ breakpoints }` map names to mobile-first min-widths (finite — anything
|
|
132
|
+
else is rejected at placement), Model
|
|
133
|
+
`{ width, breakpoint }`, one Message `Changed { width }`. The breakpoint is
|
|
134
|
+
the largest name at or below the width (ties break alphabetically); SSR
|
|
135
|
+
starts at width 0 with `null`. Placing both `Breakpoints` and `WindowSize`
|
|
136
|
+
doubles resize listeners — pick the one the view reads.
|
|
137
|
+
|
|
138
|
+
`platformFromUA(ua)` reads `mac | windows | linux | android | ios | unknown`
|
|
139
|
+
from a passed user-agent string (mobile checks first: Android contains
|
|
140
|
+
"Linux", iPhones mention "Mac"); `isBrowser()`/`isServer()` split SSR from
|
|
141
|
+
client for init defaults.
|
|
142
|
+
|
|
143
|
+
## Net: `foldkit-primitives/net`
|
|
144
|
+
|
|
145
|
+
`Online` keeps `online: boolean` in the Model, read from `navigator.onLine`
|
|
146
|
+
at startup and kept current by the window's `online`/`offline` events. One
|
|
147
|
+
Message `Changed { online }`, no args, no OutMessage. Without a window the
|
|
148
|
+
stream is empty and the slice stays at its default, so SSR renders online.
|
|
149
|
+
|
|
150
|
+
`sse({ name, createSource? })` is the one-directional sibling: the Model
|
|
151
|
+
holds `{ url, status, lastError }`, the resource owns the EventSource, and a
|
|
152
|
+
subscription streams its messages. No `send` — the server speaks, the Model
|
|
153
|
+
listens. A `Failed` records the error but stays `connecting`: the browser
|
|
154
|
+
reconnects dropped streams itself. Event payloads are always text per the
|
|
155
|
+
SSE spec.
|
|
156
|
+
|
|
157
|
+
`websocket({ name, createSocket? })` makes a duplex socket bundle: the Model
|
|
158
|
+
holds `{ url, status, lastError }` with `status` moving
|
|
159
|
+
closed → connecting → open. The resource owns the socket (one assembly holds
|
|
160
|
+
one); `send` is a placed helper whose command writes through the resource tag
|
|
161
|
+
and yields `Sent` on dispatch, `SendFailed` when no connection is open — a
|
|
162
|
+
closed socket's `send` is a silent no-op per spec, so the bundle checks first.
|
|
163
|
+
Incoming `Received` notifies without storing: project the payload into your
|
|
164
|
+
own field to keep it. `createSocket` defaults to the platform WebSocket, read
|
|
165
|
+
lazily so tests substitute a double; the socket service rides the assembly
|
|
166
|
+
into the application's resources, as `RemoteClient` does for Remote.
|
|
167
|
+
|
|
168
|
+
`broadcastMessages(name)` is the entry form for cross-tab traffic: posts from
|
|
169
|
+
other instances arrive as `Received { data }` (notify without storing, like
|
|
170
|
+
SSE). No bundle — the channel owns no state, so the parent maps `Received`
|
|
171
|
+
into its own Message and keeps what it stores. `postBroadcast(name, data)` is
|
|
172
|
+
the one-shot Command, yielding `Posted` or `BroadcastFailed`. A post never
|
|
173
|
+
echoes to its own channel, per spec; without the API the entry is empty and
|
|
174
|
+
the Command fails, instead of throwing.
|
|
175
|
+
|
|
176
|
+
## Time: `foldkit-primitives/time`
|
|
177
|
+
|
|
178
|
+
`Timer` counts ticks while running. Model `{ count, running }`, Messages
|
|
179
|
+
`Started`/`Stopped`/`Ticked`, args `{ intervalMs }` (positive and finite —
|
|
180
|
+
anything else is rejected at placement). The tick stream runs on
|
|
181
|
+
Effect's clock, so tests advance it with TestClock instead of waiting; while
|
|
182
|
+
stopped the stream is empty. Restarting keeps the count; only `Ticked`
|
|
183
|
+
advances it.
|
|
184
|
+
|
|
185
|
+
`Interval` is the wall-clock sibling: Model `{ running, lastAt }`,
|
|
186
|
+
`Ticked { at }` stamped from Effect's clock. `Timer` counts ticks,
|
|
187
|
+
`Interval` records when — views render clocks and elapsed times from
|
|
188
|
+
`lastAt`. Same args, same TestClock story, same silence while stopped.
|
|
189
|
+
|
|
190
|
+
`debounce({ name, value })` is a factory over any value Schema (like
|
|
191
|
+
`history`): `Changed` restarts a `{ delayMs }` timer, and only the latest
|
|
192
|
+
value settles — as an OutMessage the placement must handle with `onOut`, so
|
|
193
|
+
a settled query can never be dropped by omission. Each `Changed` bumps a
|
|
194
|
+
generation the scheduled `Settled` carries; a superseded timer emits nothing.
|
|
195
|
+
|
|
196
|
+
`Throttle` is the leading edge to Debounce's trailing one: the first
|
|
197
|
+
`Attempted` in an `{ intervalMs }` window surfaces a `Throttled` OutMessage
|
|
198
|
+
(`onOut`, likewise required), the rest are dropped. `Attempted` reads the
|
|
199
|
+
clock through a Command, so the check runs on Effect time and tests drive
|
|
200
|
+
it; the boundary counts as past (`>=`). Pair the two rather than adding a
|
|
201
|
+
second timer.
|
|
202
|
+
|
|
203
|
+
`formatRelativeTime(from, to, locale?)` picks the unit (seconds through
|
|
204
|
+
years) and lets `Intl.RelativeTimeFormat` word it — locales come from the
|
|
205
|
+
platform, not a phrase table. There is deliberately no `now` helper:
|
|
206
|
+
`Clock.currentTimeMillis` already is it.
|
|
207
|
+
|
|
208
|
+
## Events: `foldkit-primitives/events`
|
|
209
|
+
|
|
210
|
+
`Visibility` keeps `visible: boolean` in the Model, read from the document
|
|
211
|
+
at startup (SSR assumes visible) and kept current by `visibilitychange`:
|
|
212
|
+
one Message `Changed { visible }`, no args. A hidden page is the
|
|
213
|
+
application's cue to pause polling and streams; the decision stays in
|
|
214
|
+
application `update`, not here.
|
|
215
|
+
|
|
216
|
+
`WindowSize` keeps raw `{ width, height }` in the Model, read from the
|
|
217
|
+
window on subscribe and kept current by one `resize` listener. SSR starts
|
|
218
|
+
at zero; teardown removes the listener.
|
|
219
|
+
|
|
220
|
+
`Idle` keeps `idle: boolean` in the Model, args `{ timeoutMs }` (positive
|
|
221
|
+
and finite). While active, activity (mouse, keys, pointer, scroll) debounced past the
|
|
222
|
+
timeout settles to `BecameIdle`; while idle, the first activity wakes to
|
|
223
|
+
`BecameActive` and the dependency flip restarts the watch. Starts active —
|
|
224
|
+
a lurker idles when the silence elapses, because subscribe time seeds the
|
|
225
|
+
debounce as last-known-alive.
|
|
226
|
+
|
|
227
|
+
`keyboardEvents()`, `pointerEvents()`, `scrollEvents()`, and
|
|
228
|
+
`activeElementEvents()` are entries, not bundles: the parent owns whatever
|
|
229
|
+
key, cursor, scroll, or focus state it keeps. They report presses (with
|
|
230
|
+
repeat and modifiers) and releases, moves `{ x, y }`, scroll positions, and
|
|
231
|
+
focus `{ tag, id }` — elements cross as tag and id, never as live nodes.
|
|
232
|
+
`matchHotkey("ctrl+shift+k", press)` answers whether a press is a shortcut,
|
|
233
|
+
so `update` stays a table of chords; matching is exact and auto-repeat never
|
|
234
|
+
matches. When the observed target itself depends on state, scope the entry
|
|
235
|
+
through subscription dependencies and it restreams on change. When the
|
|
236
|
+
handler must cancel the browser default, core
|
|
237
|
+
`Subscription.fromEventFilterMap` maps synchronously inside dispatch. Lift
|
|
238
|
+
with `Subscription.persistent`, mapping into the parent's Message; without
|
|
239
|
+
a window each stream is empty instead of throwing.
|
|
240
|
+
|
|
241
|
+
## Observers: `foldkit-primitives/observers`
|
|
242
|
+
|
|
243
|
+
`Resize` and `Intersection` are Mounts, not bundles: element-scoped
|
|
244
|
+
observation attaches in views, not Model slots. Attach `Resize()` (or
|
|
245
|
+
`Intersection()`) with `h.OnMount` on the element. There are no ref objects
|
|
246
|
+
to thread: a Mount receives its element directly, and views take the rest
|
|
247
|
+
as plain arguments.
|
|
248
|
+
|
|
249
|
+
`Resize()` reports `Resized { width, height }` from the element's content
|
|
250
|
+
box; `Intersection()` reports `IntersectionChanged { isIntersecting, ratio }`
|
|
251
|
+
on viewport crossings; `Mutation()` reports `Mutated { type, added, removed,
|
|
252
|
+
attribute }` for child, attribute, and text changes across the whole subtree
|
|
253
|
+
— nodes cross as names, never as live objects, and unknown record types are
|
|
254
|
+
skipped. `Bounds()` re-measures `Measured { x, y, width, height }` on
|
|
255
|
+
observer, scroll, and resize, starting with the current rect; without a
|
|
256
|
+
ResizeObserver the window events still measure. Without the observer API
|
|
257
|
+
(SSR, old browser) they emit nothing instead of throwing; teardown
|
|
258
|
+
disconnects. They keep observing across time-travel pause — replay traffic
|
|
259
|
+
is same-valued and harmless.
|
|
260
|
+
|
|
261
|
+
## Device: `foldkit-primitives/device`
|
|
262
|
+
|
|
263
|
+
`Geolocation` watches the device position while placed. Model `{ status,
|
|
264
|
+
coords, lastError }` with `status` unknown → ready; denial is its own status
|
|
265
|
+
(actionable UI), transient failures keep the last fix and note the error.
|
|
266
|
+
Permission code 1 maps to `Denied`, anything else to `Failed`. Without a
|
|
267
|
+
geolocation API the stream is empty instead of throwing.
|
|
268
|
+
|
|
269
|
+
`mediaDevices({ name, create? })` scans the device list on placement and
|
|
270
|
+
re-scans on `Scan`, `DevicesChanged` (wired to `devicechange`), and every
|
|
271
|
+
placement: `Refreshed { devices }` with `{ deviceId, groupId, kind, label }`.
|
|
272
|
+
Denial empties with status `denied`; other failures keep the last list and
|
|
273
|
+
note the error; an unknown `kind` fails the scan at the boundary instead of
|
|
274
|
+
entering the Model.
|
|
275
|
+
|
|
276
|
+
`mediaStream({ name, request? })` holds one live camera/mic stream in a
|
|
277
|
+
Managed Resource while the Model asks for it (`requesting` or `live`).
|
|
278
|
+
`Started` requests with the placed `{ audio, video }` constraints, `Stopped`
|
|
279
|
+
and `Ended` release; denial parks at `denied`, any other failure parks at
|
|
280
|
+
`idle` with the error — both clear requirements, so a failing device never
|
|
281
|
+
spins an acquire loop. Release stops every track. The `LiveStream` tag and
|
|
282
|
+
service are exported for commands that attach the stream to an element; one
|
|
283
|
+
assembly holds one stream.
|
|
284
|
+
|
|
285
|
+
`permissions({ name, create? })` queries `{ names }` on acquire and watches
|
|
286
|
+
each status object's `onchange` through an ordered queue into a persistent
|
|
287
|
+
subscription. `Snapshot` replaces the states map, `Changed` merges one,
|
|
288
|
+
`Cleared` empties on release (which also detaches every handler). Unknown
|
|
289
|
+
names and unknown state strings fail the acquire — never Model facts.
|
|
290
|
+
|
|
291
|
+
`enterFullscreen(element)` / `exitFullscreen()` are Commands yielding
|
|
292
|
+
`Entered`/`Exited` or `Failed` (rejected request, missing capability, with a
|
|
293
|
+
legacy `webkit` fallback); `fullscreenChanges()` starts with the current
|
|
294
|
+
answer then follows flips as `Changed { active }`. No Model: the document
|
|
295
|
+
owns fullscreen state.
|
|
296
|
+
|
|
297
|
+
## DOM: `foldkit-primitives/dom`
|
|
298
|
+
|
|
299
|
+
`copyText` copies text as a Command: use it in `update` beside any bundle.
|
|
300
|
+
It yields `Copied` on success and `CopyFailed` otherwise — denial, insecure
|
|
301
|
+
context, or no clipboard API (SSR) all become the failure Message instead of
|
|
302
|
+
throwing. No Model involved: the clipboard is not application state.
|
|
303
|
+
|
|
304
|
+
`share(data)` posts `{ title?, text?, url? }` to the platform sheet: `Shared`
|
|
305
|
+
on success, `Dismissed` on sheet cancel (its own outcome, not a failure),
|
|
306
|
+
`ShareFailed` otherwise. `loadScript(src)` appends a head script unless one
|
|
307
|
+
carries the URL already — idempotent by URL, so concurrent placements
|
|
308
|
+
collapse onto the first tag and share its fate — yielding `Loaded` or
|
|
309
|
+
`LoadFailed`. A dead tag is removed, so a retry fetches afresh.
|
|
310
|
+
|
|
311
|
+
`Autofocus()` focuses the element on insert, then emits `Focused` (requested,
|
|
312
|
+
not landed: a non-focusable element may decline). `InputMask({ pattern })`
|
|
313
|
+
masks a field against `#`/`A`/`*` placeholders with literal separators,
|
|
314
|
+
rewrites the field with approximate caret restore, and emits
|
|
315
|
+
`Input { value, raw }` with masked and unmasked text. The parent owns the
|
|
316
|
+
state, like any controlled input.
|
|
317
|
+
|
|
318
|
+
## State: `foldkit-primitives/state`
|
|
319
|
+
|
|
320
|
+
`Pagination` keeps `{ page, perPage, total }` in the Model, with `total: null`
|
|
321
|
+
while unknown. Every transition clamps into range: past the last page lands on
|
|
322
|
+
it, below one lands on one, and a smaller total pulls the page back. New sizes
|
|
323
|
+
and totals arrive as Messages (`SetPerPage` ignores a non-positive size, and
|
|
324
|
+
`SetTotal` ignores a negative total).
|
|
325
|
+
`pageCount` returns null while the total is unknown; `offset` gives the first
|
|
326
|
+
item's index for a slice or a query. Loading data stays the application's job:
|
|
327
|
+
this bundle owns the page, not the items.
|
|
328
|
+
|
|
329
|
+
`history({ name, value, capacity })` makes an undo/redo bundle over any value
|
|
330
|
+
Schema. The Model holds `{ past, present, future }`; `Push` records and drops
|
|
331
|
+
the redo future, `Undo`/`Redo` move one step, `Clear` empties both sides while
|
|
332
|
+
keeping the present. The past holds at most `capacity` entries (default 100);
|
|
333
|
+
a negative or fractional capacity throws at the factory, naming it. The
|
|
334
|
+
factory attaches the Message union, so placements dispatch
|
|
335
|
+
`EditHistory.Message.Push(...)`. `canUndo`/`canRedo` read the edges:
|
|
336
|
+
|
|
337
|
+
```ts
|
|
338
|
+
import { history } from 'foldkit-primitives/state'
|
|
339
|
+
|
|
340
|
+
const EditHistory = history({ name: 'EditHistory', value: Schema.String, capacity: 50 })
|
|
341
|
+
const Doc = Bundle.declare(EditHistory, 'doc')
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
`Locale` keeps one string in the Model, read from `navigator.language` at
|
|
345
|
+
startup with the configured `default` as fallback; `SetLocale` switches it.
|
|
346
|
+
`SelectionSet` keeps string ids in first-selection order: `Select` (keeps
|
|
347
|
+
position), `Deselect`, `Toggle` (re-appends), `ReplaceAll` (deduped), and
|
|
348
|
+
`Clear`. `isSelected` reads membership. Both are pure logic, no streams.
|
|
349
|
+
Keyed children — lists with stable identity — place through the bundle
|
|
350
|
+
mechanism's `each`.
|
|
351
|
+
`range(start, end, step?)` counts half-open numbers — the pagination page
|
|
352
|
+
list is `range(1, (pageCount(model) ?? 0) + 1)`; a zero or non-finite step
|
|
353
|
+
throws, naming it.
|
|
354
|
+
|
|
355
|
+
`Virtual` owns a virtualized list's scroll position, measured heights, and
|
|
356
|
+
layout: Model `{ scrollTop, heights, scrolling, generation, estimatedHeight,
|
|
357
|
+
overscan, gap, paddingStart, paddingEnd }`, Messages
|
|
358
|
+
`Scrolled`/`Measured`/`Prune`/`Settled`, args for the layout plus optional
|
|
359
|
+
`initialScrollTop`/`initialHeights` restores (measurements sanitized like
|
|
360
|
+
live ones) and a `settleMs` silence (default 150). Every scroll marks
|
|
361
|
+
`scrolling` until the silence settles — suspend loaders and parallax on it.
|
|
362
|
+
`Viewport` reports the container's own scrolls and `MeasureRow({ key })` reports row heights, both as Mounts;
|
|
363
|
+
`windowFor(model, keys, viewportHeight)` answers which rows to render plus
|
|
364
|
+
the spacer height, `isAtEnd(model, keys, viewportHeight, threshold)` is the
|
|
365
|
+
infinite-scroll check (an empty list counts as ended), and `offsetFor`
|
|
366
|
+
computes programmatic scroll targets the application actuates itself.
|
|
367
|
+
`Prune` drops heights for departed keys — the bundle never sees key order.
|
|
368
|
+
Poisoned positions and heights are ignored, never stored. For window-
|
|
369
|
+
scrolled lists, map the scroll entry into `Scrolled`; for follow-bottom,
|
|
370
|
+
hold the end while `isAtEnd` and scroll on extend; to anchor a prepend,
|
|
371
|
+
re-`Scrolled` by the totals' delta. Render each row keyed (with
|
|
372
|
+
`aria-rowcount`/`posinset` from the window) so per-row placements keep
|
|
373
|
+
identity. `stickyHeader(sections, start)` answers which section header
|
|
374
|
+
sticks — CSS `position: sticky` does the sticking. `masonry(keys, heights,
|
|
375
|
+
options)` packs fixed-width columns shortest-first into `{ placements,
|
|
376
|
+
totalHeight }`: layout only, every placed item renders, so it fits hundreds
|
|
377
|
+
of images rather than hundred-thousands. The sums never name an
|
|
378
|
+
axis: pass column widths as heights and a horizontal offset as scroll
|
|
379
|
+
position to window a carousel the same way — no parallel horizontal
|
|
380
|
+
bundle. Windowed grids and per-index estimates stay out by design.
|
|
381
|
+
|
|
382
|
+
Persisted state lives one package over: `Mirror.kv(App, { key, fields })`
|
|
383
|
+
keeps a Model slice in Effect's `KeyValueStore` (localStorage in the
|
|
384
|
+
browser), restored through a `MirrorRestored` Message the application
|
|
385
|
+
reduces. Nothing here duplicates it — reach for the mirror when a slice
|
|
386
|
+
should survive reload, and keep this package's bundles for live facts.
|
|
387
|
+
|
|
388
|
+
## With Surface and Mirror
|
|
389
|
+
|
|
390
|
+
Placed state is ordinary Model, so the surrounding tools apply unchanged —
|
|
391
|
+
no bundle-specific Surface or Mirror API exists, by design. Declare a Surface
|
|
392
|
+
over the placed fields to render them or expose them to an agent; point
|
|
393
|
+
`Mirror.url` at them to link them; spread the bundle's cases into the same
|
|
394
|
+
unions. The field refs and wrapper Messages are the same ones the rest of the
|
|
395
|
+
application uses.
|
|
396
|
+
|
|
397
|
+
## Motion: `foldkit-primitives/motion`
|
|
398
|
+
|
|
399
|
+
`Tween` animates one number from `from` to `to` over `ms` milliseconds.
|
|
400
|
+
Model `{ value, running }`, Messages `Started`/`Ticked`/`Finished`, args
|
|
401
|
+
`{ from, to, ms }` (a non-positive or non-finite duration is rejected at
|
|
402
|
+
placement). Progress comes from Effect's clock, so tests advance it with TestClock; the
|
|
403
|
+
stream ends with `Finished` carrying the exact end value, and the value rests
|
|
404
|
+
at `to` either way. Linear interpolation only: easing curves stay the
|
|
405
|
+
application's job.
|
|
406
|
+
|
|
407
|
+
`Presence` holds mount-transition state for exit animations. Model
|
|
408
|
+
`{ phase, generation }` with `phase` moving shown → hiding → hidden:
|
|
409
|
+
`Hide` starts the timed `hiding` phase, and the `Hidden` fact it yields
|
|
410
|
+
carries its generation, so a `Show` in between wins and the late fact is
|
|
411
|
+
ignored. Args `{ durationMs }` (positive and finite). `isVisible` reads
|
|
412
|
+
whether content renders (shown or mid-exit). The timeout Command is the default owner; a
|
|
413
|
+
`transitionend` Mount stays a future opt-in, not a second timer.
|
|
414
|
+
|
|
415
|
+
`Spring` pulls one number toward `to` with `{ stiffness, damping }` physics
|
|
416
|
+
(positive and finite): Model `{ value, velocity, running }`, Messages
|
|
417
|
+
`Started`/`Stopped`/`Ticked`/`Finished`, args `{ from, to, stiffness,
|
|
418
|
+
damping }`. Fixed 16ms semi-implicit Euler makes the trajectory identical on
|
|
419
|
+
the live clock and TestClock; the stream ends with `Finished` carrying the
|
|
420
|
+
exact end value even when an underdamped spring overshoots on the way.
|
|
421
|
+
`Tween` (fixed duration, linear) versus `Spring` (physics, settles) — pick
|
|
422
|
+
the motion, not both.
|
|
423
|
+
|
|
424
|
+
## Testing placements
|
|
425
|
+
|
|
426
|
+
Every primitive is testable without the platform, following one pattern
|
|
427
|
+
with three ingredients. First, substitute the environment: factories take
|
|
428
|
+
`create` (streams, devices, permissions) or `request` (camera) doubles, so
|
|
429
|
+
tests pass fakes instead of stubbing globals. Second, advance time instead
|
|
430
|
+
of waiting it: anything on Effect's clock (debounce, throttle, presence,
|
|
431
|
+
idle, timers, tweens) runs under `TestClock.adjust`. Third, never hang on a
|
|
432
|
+
quiet stream: collect with a bounded `takeMessages`, which fails fast naming
|
|
433
|
+
the stall. The settle-before-adjust rule applies throughout — yield after
|
|
434
|
+
forking before the first `TestClock.adjust`, or dispatched events hit
|
|
435
|
+
unregistered listeners. Each ingredient is demonstrated in this package's
|
|
436
|
+
`test/` directory, named after its primitive.
|
|
437
|
+
|
|
438
|
+
## Failure and recovery
|
|
439
|
+
|
|
440
|
+
| Failure | Behaviour |
|
|
441
|
+
| --- | --- |
|
|
442
|
+
| No `window` (SSR) or no API (old browser, minimal DOM) | stream is empty; the slice keeps its default |
|
|
443
|
+
| A Command without its platform API | failure Message (`CopyFailed`, `ShareFailed`, `BroadcastFailed`), never a throw |
|
|
444
|
+
| Listener removed (unmount, gate closed) | finalizer disconnects; resubscribing re-reads the current value |
|
|
445
|
+
| A Message for an unplaced child | never routes: wrappers only match placed variants |
|
|
446
|
+
| A failing device (denied camera, unknown permission) | parks at `denied`/`idle` with requirements cleared: no acquire loop |
|
|
447
|
+
|
|
448
|
+
## Limits
|
|
449
|
+
|
|
450
|
+
- One placement observes one query. Two placements of `MediaQuery` with the
|
|
451
|
+
same query open two listeners; share the field instead.
|
|
452
|
+
- Presets cover the common queries. Anything else passes `args` explicitly.
|
|
453
|
+
- `/plus` Surfaces and Mirrors per primitive are future work; declare them in
|
|
454
|
+
the application for now.
|