foldkit-primitives 0.3.0 → 0.5.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/README.md +144 -723
- package/dist/device/index.d.mts.map +1 -1
- package/dist/device/index.mjs +95 -87
- package/dist/device/index.mjs.map +1 -1
- package/dist/dom/index.d.mts +71 -3
- package/dist/dom/index.d.mts.map +1 -1
- package/dist/dom/index.mjs +303 -6
- package/dist/dom/index.mjs.map +1 -1
- package/dist/editable-text-CKkS_mWt.d.mts +235 -0
- package/dist/editable-text-CKkS_mWt.d.mts.map +1 -0
- package/dist/events/index.d.mts +8 -1
- package/dist/events/index.d.mts.map +1 -1
- package/dist/events/index.mjs +32 -17
- package/dist/events/index.mjs.map +1 -1
- package/dist/follow-tab-stop-BSZsUrrk.mjs +805 -0
- package/dist/follow-tab-stop-BSZsUrrk.mjs.map +1 -0
- package/dist/index.d.mts +6 -6
- package/dist/index.mjs +7 -7
- package/dist/interaction/index.d.mts +247 -92
- package/dist/interaction/index.d.mts.map +1 -1
- package/dist/interaction/index.mjs +507 -224
- package/dist/interaction/index.mjs.map +1 -1
- package/dist/internal-DP-xLKbT.mjs +37 -0
- package/dist/internal-DP-xLKbT.mjs.map +1 -0
- package/dist/media/index.d.mts.map +1 -1
- package/dist/media/index.mjs +3 -3
- package/dist/media/index.mjs.map +1 -1
- package/dist/motion/index.mjs +1 -1
- package/dist/{motion-LfJYAMnP.mjs → motion-DGGBZ0wt.mjs} +14 -13
- package/dist/motion-DGGBZ0wt.mjs.map +1 -0
- package/dist/net/index.d.mts +69 -5
- package/dist/net/index.d.mts.map +1 -1
- package/dist/net/index.mjs +139 -56
- package/dist/net/index.mjs.map +1 -1
- package/dist/observers/index.d.mts +1 -1
- package/dist/observers/index.mjs +3 -0
- package/dist/observers/index.mjs.map +1 -1
- package/dist/state/index.d.mts +54 -12
- package/dist/state/index.d.mts.map +1 -1
- package/dist/state/index.mjs +198 -90
- package/dist/state/index.mjs.map +1 -1
- package/dist/time/index.d.mts +21 -1
- package/dist/time/index.d.mts.map +1 -1
- package/dist/time/index.mjs +48 -19
- package/dist/time/index.mjs.map +1 -1
- package/package.json +20 -9
- package/dist/motion-LfJYAMnP.mjs.map +0 -1
- package/dist/move-CZwHyTGk.mjs +0 -215
- package/dist/move-CZwHyTGk.mjs.map +0 -1
- package/dist/move-DNiMPcuf.d.mts +0 -58
- package/dist/move-DNiMPcuf.d.mts.map +0 -1
package/README.md
CHANGED
|
@@ -1,74 +1,40 @@
|
|
|
1
1
|
# `foldkit-primitives`
|
|
2
2
|
|
|
3
|
-
Ready-made
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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`).
|
|
3
|
+
Ready-made pieces of browser, clock, device, and interaction state for a
|
|
4
|
+
Foldkit application: a media query, a timer, a socket, an undo stack, a roving
|
|
5
|
+
tab stop, a resize observer, a clipboard write. Each comes in the shape Foldkit
|
|
6
|
+
already has, so adding one adds no store, no hook, and no second reducer. The
|
|
7
|
+
fact lives in your Model, arrives as a Message, and replays like everything
|
|
8
|
+
else.
|
|
14
9
|
|
|
15
10
|
```ts
|
|
16
11
|
import { MediaQuery } from 'foldkit-primitives/media'
|
|
17
12
|
```
|
|
18
13
|
|
|
19
|
-
|
|
14
|
+
> **The browser reports; the Model owns.** A primitive observes something and
|
|
15
|
+
> hands your `update` a Message. What your application keeps, and what it does
|
|
16
|
+
> about it, stays yours.
|
|
20
17
|
|
|
21
|
-
|
|
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). The one
|
|
25
|
-
exception is `interaction`, whose Bundles ship with a `foldkit-mixins` Behavior
|
|
26
|
-
that wires them to a view's slots; `foldkit-mixins` is an optional peer that
|
|
27
|
-
only that subpath needs.
|
|
18
|
+
## Which form a primitive takes
|
|
28
19
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
|
32
|
-
|
|
|
33
|
-
|
|
|
34
|
-
|
|
|
35
|
-
|
|
|
36
|
-
|
|
|
37
|
-
|
|
|
38
|
-
|
|
|
39
|
-
|
|
40
|
-
Choose by what you need to own. State that outlives the moment — a match,
|
|
41
|
-
a count, a page, a position — wants a bundle: the Model keeps it, replay
|
|
42
|
-
sees it. A stream you only react to wants an entry: map it into the
|
|
43
|
-
parent's Message and keep nothing. Work bound to one element wants a
|
|
44
|
-
Mount. Work that runs once and reports back wants a Command. A value
|
|
45
|
-
computed from data you already have wants a pure function. When in doubt,
|
|
46
|
-
start with the lighter form; promote to a bundle the day the state needs
|
|
47
|
-
a name in the Model.
|
|
48
|
-
|
|
49
|
-
A bundle holds no state and performs no I/O by itself. The parent Model owns
|
|
50
|
-
the placed slice; the browser (or server, or clock) only reports facts as
|
|
51
|
-
Messages. The same rule as everywhere else: observation is not ownership.
|
|
52
|
-
|
|
53
|
-
Solid developers will notice missing plumbing: there is no event bus
|
|
54
|
-
because Messages are the bus, no memo because derivations are pure reads
|
|
55
|
-
over the Model, and no reactive map or store because the Model holds plain
|
|
56
|
-
data (Effect collections where mutation matters). None of it is missing by
|
|
57
|
-
accident — one state machine leaves nowhere for a second one to live.
|
|
58
|
-
|
|
59
|
-
## The mental model
|
|
60
|
-
|
|
61
|
-
```text
|
|
62
|
-
browser / clock / server ──facts as Messages──▶ update ──▶ Model slice
|
|
63
|
-
▲
|
|
64
|
-
The parent Model owns the recorded fact; subscriptions report environment changes.
|
|
65
|
-
```
|
|
20
|
+
Every primitive is one of five things, chosen by what it has to own:
|
|
21
|
+
|
|
22
|
+
| It needs to own | Form | Joins the application by | Examples |
|
|
23
|
+
| --- | --- | --- | --- |
|
|
24
|
+
| A fact that outlives the moment: a match, a count, a page, a position | **bundle** | placing it in the parent, as a field and a wrapper Message | `MediaQuery`, `Timer`, `Pagination`, `websocket` |
|
|
25
|
+
| Interaction state a view's slots must reflect | **bundle + Behavior** | placing it, then attaching its Behavior to the view's slots | `RovingTabindex`, `Press`, `DismissLayer` |
|
|
26
|
+
| Nothing: a stream you react to | **entry** | lifting it into your Subscriptions, mapped to your Message | `keyboardEvents`, `ticks`, `broadcastMessages` |
|
|
27
|
+
| Nothing: observation bound to one element | **Mount** | attaching it in the view with `h.OnMount` | `Resize`, `Autofocus`, `KeepInView` |
|
|
28
|
+
| Nothing: work done once that reports back | **Command** | returning it from `update` | `copyText`, `share`, `enterFullscreen` |
|
|
29
|
+
| Nothing: a value computed from what you have | **function** | calling it | `range`, `matchHotkey`, `formatRelativeTime` |
|
|
66
30
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
31
|
+
The lighter form wins when in doubt. Promote to a bundle the day the state
|
|
32
|
+
needs a name in the Model.
|
|
33
|
+
|
|
34
|
+
The one dependency is [`foldkit-bundle`](../bundle), whose placement
|
|
35
|
+
mechanism every bundle here uses. `interaction` additionally needs
|
|
36
|
+
[`foldkit-mixins`](../mixins), an optional peer, because its Behaviors write to
|
|
37
|
+
a view's slots.
|
|
72
38
|
|
|
73
39
|
## Install
|
|
74
40
|
|
|
@@ -76,32 +42,14 @@ primitive-specific fallbacks; do not infer readiness from an SSR default.
|
|
|
76
42
|
pnpm add foldkit-primitives foldkit-bundle effect foldkit
|
|
77
43
|
```
|
|
78
44
|
|
|
79
|
-
`effect` and `foldkit` are peer dependencies. Import
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
`foldkit-primitives/events`, `foldkit-primitives/observers`, `foldkit-primitives/dom` —
|
|
83
|
-
so bundlers drop the primitives you never import.
|
|
84
|
-
|
|
85
|
-
## Map of the package
|
|
86
|
-
|
|
87
|
-
Each subpath is one concern, one import:
|
|
88
|
-
|
|
89
|
-
- `media` — environment facts: MediaQuery (+presets), Breakpoints, platform
|
|
90
|
-
- `net` — remote facts: Online, WebSocket, SSE, BroadcastChannel
|
|
91
|
-
- `time` — clock facts: Timer, Interval, Debounce, Throttle, relative time
|
|
92
|
-
- `state` — owned UI state: Pagination, History, Locale, SelectionSet, Virtual, range
|
|
93
|
-
- `motion` — animation state: Tween, Spring, Presence
|
|
94
|
-
- `interaction` — a Bundle (or Mount) and its `foldkit-mixins` Behavior: RovingTabindex, Typeahead, ListNavigation, GridNavigation, FocusScope, Press, LongPress, Move, FocusVisible, DismissLayer, ScrollLock, HideOutside, Selection, LiveAnnounce
|
|
95
|
-
- `device` — hardware: Geolocation, MediaDevices, MediaStream, Permissions, Fullscreen
|
|
96
|
-
- `events` — raw browser events: Visibility, WindowSize, Idle, InputModality, keyboard, pointer, scroll, focus
|
|
97
|
-
- `observers` — element Mounts: Resize, Intersection, Mutation, Bounds
|
|
98
|
-
- `dom` — element Mounts and one-shot Commands: Autofocus, FocusScope, Move, ScrollLock, HideOutside, InputMask, clipboard, share, script loading
|
|
45
|
+
`effect` and `foldkit` are peer dependencies. Import by subpath (`/media`,
|
|
46
|
+
`/net`, `/time`, `/state`, `/motion`, `/interaction`, `/device`, `/events`,
|
|
47
|
+
`/observers`, `/dom`) so a bundler drops what you never import.
|
|
99
48
|
|
|
100
49
|
## Sixty seconds: follow the color scheme
|
|
101
50
|
|
|
102
|
-
|
|
103
|
-
Message
|
|
104
|
-
`GotDarkMessage` by Foldkit's `Got<Field>Message` convention:
|
|
51
|
+
A bundle is placed in a parent, which gains a field for its state and a
|
|
52
|
+
wrapper Message for its transitions:
|
|
105
53
|
|
|
106
54
|
```ts
|
|
107
55
|
import { Schema } from 'effect'
|
|
@@ -115,11 +63,7 @@ const Page = Bundle.compose({ theme: Schema.String }).pipe(
|
|
|
115
63
|
)
|
|
116
64
|
type Model = typeof Page.Model.Type
|
|
117
65
|
type Message = typeof Page.Message.Type
|
|
118
|
-
```
|
|
119
|
-
|
|
120
|
-
**Run it.** The parent's assembly derives the update and the Subscriptions:
|
|
121
66
|
|
|
122
|
-
```ts
|
|
123
67
|
const { placements } = Page
|
|
124
68
|
|
|
125
69
|
const config = placements.complete({
|
|
@@ -131,665 +75,142 @@ const config = placements.complete({
|
|
|
131
75
|
})
|
|
132
76
|
```
|
|
133
77
|
|
|
134
|
-
|
|
135
|
-
`matchMedia` stream; `initial` starts `matches` at `false` and the stream
|
|
136
|
-
corrects it on subscribe. None of these calls perform I/O: `compose` states
|
|
137
|
-
the parent, `withChild` places the primitive with its config, and the
|
|
138
|
-
assembly derives the folding and the streams. The browser is touched only
|
|
139
|
-
when the runtime subscribes. The Solid equivalent this replaces:
|
|
140
|
-
|
|
141
|
-
```ts
|
|
142
|
-
// solid-primitives: const dark = createMediaQuery('(prefers-color-scheme: dark)')
|
|
143
|
-
// Here the fact lives in the Model: replay, DevTools, and time travel see it.
|
|
144
|
-
```
|
|
145
|
-
|
|
146
|
-
## Composing placements
|
|
78
|
+
What each line does, and does not do:
|
|
147
79
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
80
|
+
- **`Bundle.compose(...).pipe(withChild('dark', MediaQuery, …))`** states the
|
|
81
|
+
parent: its own field `theme`, its own Message `ThemeSet`, and `MediaQuery`
|
|
82
|
+
under `dark`. The Model is `{ theme, dark: { matches } }`; the Message union
|
|
83
|
+
gains `GotDarkMessage`. Nothing runs.
|
|
84
|
+
- **`placements.initial({ theme: 'light' })`** gives the fields no placement
|
|
85
|
+
owns; `dark` starts at `{ matches: false }`.
|
|
86
|
+
- **`placements.update(own)`** routes `GotDarkMessage` to the bundle and
|
|
87
|
+
everything else to your own update.
|
|
88
|
+
- **`placements.subscriptions()`** is the `matchMedia` stream, lifted. The
|
|
89
|
+
browser is touched only when the runtime subscribes, which then corrects
|
|
90
|
+
`matches` at once.
|
|
91
|
+
- **`config`** is an ordinary Foldkit application config. Hand it to the
|
|
92
|
+
runtime.
|
|
158
93
|
|
|
159
|
-
|
|
94
|
+
The Solid line this replaces is `createMediaQuery('(prefers-color-scheme: dark)')`.
|
|
95
|
+
The difference is where the answer lives: in the Model, where replay,
|
|
96
|
+
DevTools, an agent, and a server render all see it.
|
|
160
97
|
|
|
161
|
-
|
|
98
|
+
## The other three ways in
|
|
162
99
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
the current value on subscribe, then every change; without `matchMedia` it is
|
|
166
|
-
empty and the slice keeps its initial `false`.
|
|
167
|
-
|
|
168
|
-
Bound presets place with no args:
|
|
100
|
+
**An entry**, lifted into your Subscriptions and mapped to your Message. The
|
|
101
|
+
parent keeps whatever it wants of the stream:
|
|
169
102
|
|
|
170
103
|
```ts
|
|
171
|
-
import {
|
|
172
|
-
|
|
173
|
-
|
|
104
|
+
import { Stream } from 'effect'
|
|
105
|
+
import * as Subscription from 'foldkit/subscription'
|
|
106
|
+
import { keyboardEvents } from 'foldkit-primitives/events'
|
|
107
|
+
|
|
108
|
+
const subscriptions = Subscription.make<GameModel, GameMessage>()(() => ({
|
|
109
|
+
keys: Subscription.persistent(
|
|
110
|
+
keyboardEvents({ preventDefault: press => press.key.startsWith('Arrow') }).pipe(
|
|
111
|
+
Stream.filter(event => event._tag === 'Pressed'),
|
|
112
|
+
Stream.map(({ key }) => GameMessage.PressedKey({ key })),
|
|
113
|
+
),
|
|
114
|
+
),
|
|
115
|
+
}))
|
|
174
116
|
```
|
|
175
117
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
else is rejected at placement), Model
|
|
179
|
-
`{ width, breakpoint }`, one Message `Changed { width }`. The breakpoint is
|
|
180
|
-
the largest name at or below the width (ties break alphabetically); SSR
|
|
181
|
-
starts at width 0 with `null`. Placing both `Breakpoints` and `WindowSize`
|
|
182
|
-
doubles resize listeners — pick the one the view reads.
|
|
183
|
-
|
|
184
|
-
`platformFromUA(ua, hints?)` reads `mac | windows | linux | android | ios | unknown`
|
|
185
|
-
from a passed user-agent string (mobile checks first: Android contains
|
|
186
|
-
"Linux", iPhones mention "Mac"); Client Hints `platform` wins when
|
|
187
|
-
recognized, and a multi-touch Mac UA reads as iOS. `isBrowser()`/`isServer()` split SSR from
|
|
188
|
-
client for init defaults.
|
|
189
|
-
|
|
190
|
-
## Net: `foldkit-primitives/net`
|
|
191
|
-
|
|
192
|
-
> Reference: [`./net/README.md`](./net/README.md)
|
|
193
|
-
|
|
194
|
-
`Online` keeps `online: boolean` in the Model, read from `navigator.onLine`
|
|
195
|
-
at startup and kept current by the window's `online`/`offline` events. One
|
|
196
|
-
Message `Changed { online }`, no args, no OutMessage. Without a window the
|
|
197
|
-
stream is empty and the slice stays at its default, so SSR renders online.
|
|
198
|
-
|
|
199
|
-
`sse({ name, createSource? })` is the one-directional sibling: the Model
|
|
200
|
-
holds `{ url, status, lastError }`, the resource owns the EventSource, and a
|
|
201
|
-
subscription streams its messages. No `send` — the server speaks, the Model
|
|
202
|
-
listens. A `Failed` records the error but stays `connecting`: the browser
|
|
203
|
-
reconnects dropped streams itself. Event payloads are always text per the
|
|
204
|
-
SSE spec.
|
|
205
|
-
|
|
206
|
-
`websocket({ name, createSocket? })` makes a duplex socket bundle: the Model
|
|
207
|
-
holds `{ url, status, lastError }` with `status` moving
|
|
208
|
-
closed → connecting → open. The resource owns the socket (one assembly holds
|
|
209
|
-
one); `send` is a placed helper whose command writes through the resource tag
|
|
210
|
-
and yields `Sent` on dispatch, `SendFailed` when no connection is open — a
|
|
211
|
-
closed socket's `send` is a silent no-op per spec, so the bundle checks first.
|
|
212
|
-
Incoming `Received` notifies without storing: project the payload into your
|
|
213
|
-
own field to keep it. `createSocket` defaults to the platform WebSocket, read
|
|
214
|
-
lazily so tests substitute a double; the socket service rides the assembly
|
|
215
|
-
into the application's resources, as `RemoteClient` does for Remote.
|
|
216
|
-
|
|
217
|
-
`broadcastMessages(name)` is the entry form for cross-tab traffic: posts from
|
|
218
|
-
other instances arrive as `Received { data }` (notify without storing, like
|
|
219
|
-
SSE). No bundle — the channel owns no state, so the parent maps `Received`
|
|
220
|
-
into its own Message and keeps what it stores. `postBroadcast(name, data)` is
|
|
221
|
-
the one-shot Command, yielding `Posted` or `BroadcastFailed`. A post never
|
|
222
|
-
echoes to its own channel, per spec; without the API the entry is empty and
|
|
223
|
-
the Command fails, instead of throwing.
|
|
224
|
-
|
|
225
|
-
## Time: `foldkit-primitives/time`
|
|
226
|
-
|
|
227
|
-
> Reference: [`./time/README.md`](./time/README.md)
|
|
228
|
-
|
|
229
|
-
`Timer` counts ticks while running. Model `{ count, running }`, Messages
|
|
230
|
-
`Started`/`Stopped`/`Ticked`, args `{ intervalMs }` (positive and finite —
|
|
231
|
-
anything else is rejected at placement). The tick stream runs on
|
|
232
|
-
Effect's clock, so tests advance it with TestClock instead of waiting; while
|
|
233
|
-
stopped the stream is empty. Restarting keeps the count; only `Ticked`
|
|
234
|
-
advances it.
|
|
235
|
-
|
|
236
|
-
`Interval` is the wall-clock sibling: Model `{ running, lastAt }`,
|
|
237
|
-
`Ticked { at }` stamped from Effect's clock. `Timer` counts ticks,
|
|
238
|
-
`Interval` records when — views render clocks and elapsed times from
|
|
239
|
-
`lastAt`. Same args, same TestClock story, same silence while stopped.
|
|
240
|
-
|
|
241
|
-
`debounce({ name, value })` is a factory over any value Schema (like
|
|
242
|
-
`history`): `Changed` restarts a `{ delayMs }` timer, and only the latest
|
|
243
|
-
value settles — as an OutMessage the placement must handle with `onOut`, so
|
|
244
|
-
a settled query can never be dropped by omission. Each `Changed` bumps a
|
|
245
|
-
generation the scheduled `Settled` carries; a superseded timer emits nothing.
|
|
246
|
-
|
|
247
|
-
`Throttle` is the leading edge to Debounce's trailing one: the first
|
|
248
|
-
`Attempted` in an `{ intervalMs }` window surfaces a `Throttled` OutMessage
|
|
249
|
-
(`onOut`, likewise required), the rest are dropped. `Attempted` reads the
|
|
250
|
-
clock through a Command, so the check runs on Effect time and tests drive
|
|
251
|
-
it; the boundary counts as past (`>=`). Pair the two rather than adding a
|
|
252
|
-
second timer.
|
|
253
|
-
|
|
254
|
-
`formatRelativeTime(from, to, locale?)` picks the unit (seconds through
|
|
255
|
-
years) and lets `Intl.RelativeTimeFormat` word it — locales come from the
|
|
256
|
-
platform, not a phrase table. There is deliberately no `now` helper:
|
|
257
|
-
`Clock.currentTimeMillis` already is it.
|
|
258
|
-
|
|
259
|
-
## Events: `foldkit-primitives/events`
|
|
260
|
-
|
|
261
|
-
> Reference: [`./events/README.md`](./events/README.md)
|
|
262
|
-
|
|
263
|
-
`Visibility` keeps `visible: boolean` in the Model, read from the document
|
|
264
|
-
at startup (SSR assumes visible) and kept current by `visibilitychange`:
|
|
265
|
-
one Message `Changed { visible }`, no args. A hidden page is the
|
|
266
|
-
application's cue to pause polling and streams; the decision stays in
|
|
267
|
-
application `update`, not here.
|
|
268
|
-
|
|
269
|
-
`WindowSize` keeps raw `{ width, height }` in the Model, read from the
|
|
270
|
-
window on subscribe and kept current by one `resize` listener. SSR starts
|
|
271
|
-
at zero; teardown removes the listener.
|
|
272
|
-
|
|
273
|
-
`Idle` keeps `idle: boolean` in the Model, args `{ timeoutMs }` (positive
|
|
274
|
-
and finite). While active, activity (mouse, keys, pointer, scroll) debounced past the
|
|
275
|
-
timeout settles to `BecameIdle`; while idle, the first activity wakes to
|
|
276
|
-
`BecameActive` and the dependency flip restarts the watch. Starts active —
|
|
277
|
-
a lurker idles when the silence elapses, because subscribe time seeds the
|
|
278
|
-
debounce as last-known-alive.
|
|
279
|
-
|
|
280
|
-
`keyboardEvents()`, `pointerEvents()`, `scrollEvents()`, and
|
|
281
|
-
`activeElementEvents()` are entries, not bundles: the parent owns whatever
|
|
282
|
-
key, cursor, scroll, or focus state it keeps. They report presses (with
|
|
283
|
-
repeat and modifiers) and releases, moves `{ x, y }`, scroll positions, and
|
|
284
|
-
focus `{ tag, id }` — elements cross as tag and id, never as live nodes.
|
|
285
|
-
`matchHotkey("ctrl+shift+k", press)` answers whether a press is a shortcut,
|
|
286
|
-
so `update` stays a table of chords; matching is exact and auto-repeat never
|
|
287
|
-
matches. When the observed target itself depends on state, scope the entry
|
|
288
|
-
through subscription dependencies and it restreams on change. When the
|
|
289
|
-
handler must cancel the browser default, core
|
|
290
|
-
`Subscription.fromEventFilterMap` maps synchronously inside dispatch. Lift
|
|
291
|
-
with `Subscription.persistent`, mapping into the parent's Message; without
|
|
292
|
-
a window each stream is empty instead of throwing.
|
|
293
|
-
|
|
294
|
-
## Observers: `foldkit-primitives/observers`
|
|
295
|
-
|
|
296
|
-
> Reference: [`./observers/README.md`](./observers/README.md)
|
|
297
|
-
|
|
298
|
-
`Resize` and `Intersection` are Mounts, not bundles: element-scoped
|
|
299
|
-
observation attaches in views, not Model slots. Attach `Resize()` (or
|
|
300
|
-
`Intersection()`) with `h.OnMount` on the element. There are no ref objects
|
|
301
|
-
to thread: a Mount receives its element directly, and views take the rest
|
|
302
|
-
as plain arguments.
|
|
303
|
-
|
|
304
|
-
`Resize()` reports `Resized { width, height }` from the element's content
|
|
305
|
-
box; `Intersection()` reports `IntersectionChanged { isIntersecting, ratio }`
|
|
306
|
-
on viewport crossings; `Mutation()` reports `Mutated { type, added, removed,
|
|
307
|
-
attribute }` for child, attribute, and text changes across the whole subtree
|
|
308
|
-
— nodes cross as names, never as live objects, and unknown record types are
|
|
309
|
-
skipped. `Bounds()` re-measures `Measured { x, y, width, height }` on
|
|
310
|
-
observer, scroll, and resize, starting with the current rect; without a
|
|
311
|
-
ResizeObserver the window events still measure. Without the observer API
|
|
312
|
-
(SSR, old browser) they emit nothing instead of throwing; teardown
|
|
313
|
-
disconnects. Treat repeated measurements as observations, not proof that a user action occurred.
|
|
314
|
-
|
|
315
|
-
## Device: `foldkit-primitives/device`
|
|
316
|
-
|
|
317
|
-
> Reference: [`./device/README.md`](./device/README.md)
|
|
318
|
-
|
|
319
|
-
`Geolocation` watches the device position while placed. Model `{ status,
|
|
320
|
-
coords, lastError }` with `status` unknown → ready; denial is its own status
|
|
321
|
-
(actionable UI), transient failures keep the last fix and note the error.
|
|
322
|
-
Permission code 1 maps to `Denied`, anything else to `Failed`. Without a
|
|
323
|
-
geolocation API the stream is empty instead of throwing.
|
|
324
|
-
|
|
325
|
-
`mediaDevices({ name, create? })` scans the device list on placement and
|
|
326
|
-
re-scans on `Scan`, `DevicesChanged` (wired to `devicechange`), and every
|
|
327
|
-
placement: `Refreshed { devices }` with `{ deviceId, groupId, kind, label }`.
|
|
328
|
-
Denial lands as `denied` while keeping the last list; other failures keep the last list and
|
|
329
|
-
note the error; an unknown `kind` fails the scan at the boundary instead of
|
|
330
|
-
entering the Model.
|
|
331
|
-
|
|
332
|
-
`mediaStream({ name, request? })` holds one live camera/mic stream in a
|
|
333
|
-
Managed Resource while the Model asks for it (`requesting` or `live`).
|
|
334
|
-
`Started` requests with the placed `{ audio, video }` constraints, `Stopped`
|
|
335
|
-
and `Ended` release; denial parks at `denied`, any other failure parks at
|
|
336
|
-
`idle` with the error — both clear requirements, so a failing device never
|
|
337
|
-
spins an acquire loop. Release stops every track. The `LiveStream` tag and
|
|
338
|
-
service are exported for commands that attach the stream to an element; one
|
|
339
|
-
assembly holds one stream.
|
|
340
|
-
|
|
341
|
-
`permissions({ name, create? })` queries `{ names }` on acquire and watches
|
|
342
|
-
each status object's `onchange` through an ordered queue into a persistent
|
|
343
|
-
subscription. `Snapshot` replaces the states map, `Changed` merges one,
|
|
344
|
-
`Cleared` empties on release (which also detaches every handler). Unknown
|
|
345
|
-
names and unknown state strings fail the acquire — never Model facts.
|
|
346
|
-
|
|
347
|
-
`enterFullscreen(element)` / `exitFullscreen()` are Commands yielding
|
|
348
|
-
`Entered`/`Exited` or `Failed` (rejected request, missing capability, with a
|
|
349
|
-
legacy `webkit` fallback); `fullscreenChanges()` starts with the current
|
|
350
|
-
answer then follows flips as `Changed { active }`. No Model: the document
|
|
351
|
-
owns fullscreen state.
|
|
352
|
-
|
|
353
|
-
## DOM: `foldkit-primitives/dom`
|
|
354
|
-
|
|
355
|
-
> Reference: [`./dom/README.md`](./dom/README.md)
|
|
356
|
-
|
|
357
|
-
`copyText` copies text as a Command: use it in `update` beside any bundle.
|
|
358
|
-
It yields `Copied` on success and `CopyFailed` otherwise — denial, insecure
|
|
359
|
-
context, or no clipboard API (SSR) all become the failure Message instead of
|
|
360
|
-
throwing. No Model involved: the clipboard is not application state.
|
|
361
|
-
|
|
362
|
-
`share(data)` posts `{ title?, text?, url? }` to the platform sheet: `Shared`
|
|
363
|
-
on success, `Dismissed` on sheet cancel (its own outcome, not a failure),
|
|
364
|
-
`ShareFailed` otherwise. `loadScript(src)` appends a head script unless one
|
|
365
|
-
carries the URL already — idempotent by URL, so concurrent placements
|
|
366
|
-
collapse onto the first tag and share its fate — yielding `Loaded` or
|
|
367
|
-
`LoadFailed`. A dead tag is removed, so a retry fetches afresh.
|
|
368
|
-
|
|
369
|
-
`Autofocus()` focuses the element on insert, then emits `Focused` (requested,
|
|
370
|
-
not landed: a non-focusable element may decline). `InputMask({ pattern })`
|
|
371
|
-
masks a field against `#`/`A`/`*` placeholders with literal separators,
|
|
372
|
-
rewrites the field with approximate caret restore, and emits
|
|
373
|
-
`Input { value, raw }` with masked and unmasked text. The parent owns the
|
|
374
|
-
state, like any controlled input.
|
|
375
|
-
|
|
376
|
-
## State: `foldkit-primitives/state`
|
|
377
|
-
|
|
378
|
-
> Reference: [`./state/README.md`](./state/README.md)
|
|
379
|
-
|
|
380
|
-
`Pagination` keeps `{ page, perPage, total }` in the Model, with `total: null`
|
|
381
|
-
while unknown. Every transition clamps into range: past the last page lands on
|
|
382
|
-
it, below one lands on one, and a smaller total pulls the page back. New sizes
|
|
383
|
-
and totals arrive as Messages (`SetPerPage` ignores a non-positive size, and
|
|
384
|
-
`SetTotal` ignores a negative total).
|
|
385
|
-
`pageCount` returns null while the total is unknown; `offset` gives the first
|
|
386
|
-
item's index for a slice or a query. Loading data stays the application's job:
|
|
387
|
-
this bundle owns the page, not the items.
|
|
388
|
-
|
|
389
|
-
`history({ name, value, capacity })` makes an undo/redo bundle over any value
|
|
390
|
-
Schema. The Model holds `{ past, present, future }`; `Push` records and drops
|
|
391
|
-
the redo future, `Undo`/`Redo` move one step, `Clear` empties both sides while
|
|
392
|
-
keeping the present. The past holds at most `capacity` entries (default 100);
|
|
393
|
-
a negative or fractional capacity throws at the factory, naming it. The
|
|
394
|
-
factory attaches the Message union, so placements dispatch
|
|
395
|
-
`EditHistory.Message.Push(...)`. `canUndo`/`canRedo` read the edges:
|
|
118
|
+
**A Mount**, attached to the element it observes. No ref to thread: the Mount
|
|
119
|
+
receives its element.
|
|
396
120
|
|
|
397
121
|
```ts
|
|
398
|
-
import {
|
|
122
|
+
import { Resize } from 'foldkit-primitives/observers'
|
|
399
123
|
|
|
400
|
-
const
|
|
401
|
-
|
|
124
|
+
const panel = (model: PanelModel, h: HtmlBuilder<PanelMessage>) =>
|
|
125
|
+
h.div([h.OnMount(Resize())], [`${model.width} × ${model.height}`])
|
|
402
126
|
```
|
|
403
127
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
`SelectionSet` keeps string ids in first-selection order: `Select` (keeps
|
|
407
|
-
position), `Deselect`, `Toggle` (re-appends), `ReplaceAll` (deduped), and
|
|
408
|
-
`Clear`. `isSelected` reads membership. Neither subscribes to changes; Locale reads the initial browser language when available.
|
|
409
|
-
Keyed children — lists with stable identity — place through the bundle
|
|
410
|
-
mechanism's `each`.
|
|
411
|
-
`range(start, end, step?)` counts half-open numbers — the pagination page
|
|
412
|
-
list is `range(1, (pageCount(model) ?? 0) + 1)`; a zero or non-finite step
|
|
413
|
-
throws, naming it.
|
|
414
|
-
|
|
415
|
-
`Virtual` owns a virtualized list's scroll position, measured heights, and
|
|
416
|
-
layout: Model `{ scrollTop, heights, scrolling, generation, estimatedHeight,
|
|
417
|
-
overscan, gap, paddingStart, paddingEnd }`, Messages
|
|
418
|
-
`Scrolled`/`Measured`/`Prune`/`Settled`, args for the layout plus optional
|
|
419
|
-
`initialScrollTop`/`initialHeights` restores (measurements sanitized like
|
|
420
|
-
live ones) and a `settleMs` silence (default 150). Every scroll marks
|
|
421
|
-
`scrolling` until the silence settles — suspend loaders and parallax on it.
|
|
422
|
-
`Viewport` reports the container's own scrolls and `MeasureRow({ key })` reports row heights, both as Mounts;
|
|
423
|
-
`windowFor(model, keys, viewportHeight)` answers which rows to render plus
|
|
424
|
-
the spacer height, `isAtEnd(model, keys, viewportHeight, threshold)` is the
|
|
425
|
-
infinite-scroll check (an empty list counts as ended), `distanceToEnd`
|
|
426
|
-
answers the pixels remaining for prefetch thresholds, and `offsetFor`
|
|
427
|
-
computes programmatic scroll targets the application actuates itself.
|
|
428
|
-
`Prune` drops heights for departed keys — the bundle never sees key order.
|
|
429
|
-
Poisoned positions and heights are ignored, never stored. For window-
|
|
430
|
-
scrolled lists, map the scroll entry into `Scrolled`; for follow-bottom,
|
|
431
|
-
hold the end while `isAtEnd` and scroll on extend; to anchor a prepend,
|
|
432
|
-
re-`Scrolled` by the totals' delta. Render each row keyed (with
|
|
433
|
-
`aria-rowcount`/`posinset` from the window) so per-row placements keep
|
|
434
|
-
identity. `stickyHeader(sections, start)` answers which section header
|
|
435
|
-
sticks — CSS `position: sticky` does the sticking. `masonry(keys, heights,
|
|
436
|
-
options)` packs fixed-width columns shortest-first into `{ placements,
|
|
437
|
-
totalHeight }`: layout only, every placed item renders, so it fits hundreds
|
|
438
|
-
of images rather than hundred-thousands. The sums never name an
|
|
439
|
-
axis: pass column widths as heights and a horizontal offset as scroll
|
|
440
|
-
position to window a carousel the same way — no parallel horizontal
|
|
441
|
-
bundle. Windowed grids and per-index estimates stay out by design.
|
|
442
|
-
|
|
443
|
-
Persisted state lives one package over: `Mirror.kv(App, { key, fields })`
|
|
444
|
-
keeps a Model slice in Effect's `KeyValueStore` (localStorage in the
|
|
445
|
-
browser), restored through a `MirrorRestored` Message the application
|
|
446
|
-
reduces. Nothing here duplicates it — reach for the mirror when a slice
|
|
447
|
-
should survive reload, and keep this package's bundles for live facts.
|
|
448
|
-
|
|
449
|
-
## Motion: `foldkit-primitives/motion`
|
|
450
|
-
|
|
451
|
-
> Reference: [`./motion/README.md`](./motion/README.md)
|
|
452
|
-
|
|
453
|
-
Whether motion should be reduced is a service, `Motion`, read when a
|
|
454
|
-
transition starts rather than sniffed once. `Presence` then exits at once,
|
|
455
|
-
and `Tween` and `Spring` jump to `to`, in the same Messages, so the Model sees
|
|
456
|
-
the same transitions. Provide `Motion.live` (the user's
|
|
457
|
-
`prefers-reduced-motion`) through the assembly's resources, or `Motion.reduced`
|
|
458
|
-
and `Motion.full` in a test or for a setting the application owns. With no
|
|
459
|
-
service provided, motion is full, so a placement that provides nothing behaves
|
|
460
|
-
as before. `Motion.reducedMotion` is the Effect the bundles read, for a
|
|
461
|
-
transition of your own.
|
|
462
|
-
|
|
463
|
-
`Tween` animates one number from `from` to `to` over `ms` milliseconds.
|
|
464
|
-
Model `{ value, running }`, Messages `Started`/`Ticked`/`Finished`, args
|
|
465
|
-
`{ from, to, ms }` (a non-positive or non-finite duration is rejected at
|
|
466
|
-
placement). Progress comes from Effect's clock, so tests advance it with TestClock; the
|
|
467
|
-
stream ends with `Finished` carrying the exact end value, and the value rests
|
|
468
|
-
at `to` either way. Linear interpolation only: easing curves stay the
|
|
469
|
-
application's job.
|
|
470
|
-
|
|
471
|
-
`Presence` holds mount-transition state for exit animations. Model
|
|
472
|
-
`{ phase, generation }` with `phase` moving shown → hiding → hidden:
|
|
473
|
-
`Hide` starts the timed `hiding` phase, and the `Hidden` fact it yields
|
|
474
|
-
carries its generation, so a `Show` in between wins and the late fact is
|
|
475
|
-
ignored. Args `{ durationMs }` (positive and finite). `isVisible` reads
|
|
476
|
-
whether content renders (shown or mid-exit). The timeout Command is the default owner; a
|
|
477
|
-
`transitionend` Mount stays a future opt-in, not a second timer.
|
|
478
|
-
|
|
479
|
-
`Spring` pulls one number toward `to` with `{ stiffness, damping }` physics
|
|
480
|
-
(positive and finite): Model `{ value, velocity, running }`, Messages
|
|
481
|
-
`Started`/`Stopped`/`Ticked`/`Finished`, args `{ from, to, stiffness,
|
|
482
|
-
damping }`. Fixed 16ms semi-implicit Euler makes the trajectory identical on
|
|
483
|
-
the live clock and TestClock; the stream ends with `Finished` carrying the
|
|
484
|
-
exact end value even when an underdamped spring overshoots on the way.
|
|
485
|
-
`Tween` (fixed duration, linear) versus `Spring` (physics, settles) — pick
|
|
486
|
-
the motion, not both.
|
|
487
|
-
|
|
488
|
-
## Interaction: `foldkit-primitives/interaction`
|
|
489
|
-
|
|
490
|
-
Interaction state that a view's slots must reflect: the Bundle holds it in the
|
|
491
|
-
Model, and a matching [`foldkit-mixins`](../mixins) Behavior writes the
|
|
492
|
-
attributes and handlers on the slots. Importing this subpath needs
|
|
493
|
-
`foldkit-mixins`; the other subpaths do not.
|
|
494
|
-
|
|
495
|
-
`RovingTabindex` is one tab stop for a set of items: arrows move focus between
|
|
496
|
-
them, the rest stay out of the tab order. The Model slice is `{ current }`, the
|
|
497
|
-
current item's **id**, so a reorder keeps the same item current and a resumed
|
|
498
|
-
page knows where focus was. Args: `orientation` (`'vertical' | 'horizontal' |
|
|
499
|
-
'both'`), `loop`, and `virtual` (focus stays on the container and
|
|
500
|
-
`aria-activedescendant` points at the current item).
|
|
128
|
+
**A Command**, returned from `update`, whose result comes back as a Message
|
|
129
|
+
your union wraps:
|
|
501
130
|
|
|
502
131
|
```ts
|
|
503
|
-
import {
|
|
504
|
-
import {
|
|
505
|
-
import { RovingTabindex } from 'foldkit-primitives/interaction'
|
|
506
|
-
|
|
507
|
-
const Roving = Bundle.declare(RovingTabindex.bundle, 'toolbarFocus')
|
|
508
|
-
const Model = Schema.Struct({ ...Roving.fields, tools: Schema.Array(Tool) })
|
|
509
|
-
const Message = defineMessageUnion({ ...Roving.cases })
|
|
510
|
-
const Page = Bundle.parent({ Model, Message })
|
|
511
|
-
const args = { orientation: 'horizontal', loop: true, virtual: false } as const
|
|
512
|
-
const placements = Page.assemble(Page.at(Roving, { args }))
|
|
513
|
-
|
|
514
|
-
const ToolbarSlots = Slots.define({
|
|
515
|
-
root: Slot.make({ capability: Capability.Container }),
|
|
516
|
-
tool: Slot.make({ capability: Capability.Focusable }),
|
|
517
|
-
})
|
|
518
|
-
const describeTools = (tools: ReadonlyArray<Tool>) =>
|
|
519
|
-
Behaviors.Collection.of(tools, { id: tool => tool.id, disabled: tool => tool.disabled })
|
|
132
|
+
import { mapMessage } from 'foldkit/command'
|
|
133
|
+
import { copyText } from 'foldkit-primitives/dom'
|
|
520
134
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
items: model => describeTools(model.tools),
|
|
525
|
-
})
|
|
526
|
-
const Focus = RovingTabindex.behavior(Roving, args)(ToolbarSlots)<Model, Message>({
|
|
527
|
-
container: 'root',
|
|
528
|
-
item: 'tool',
|
|
529
|
-
items: model => describeTools(model.tools),
|
|
135
|
+
const copy = (model: NoteModel): Update.Return<NoteModel, NoteMessage> => ({
|
|
136
|
+
model,
|
|
137
|
+
commands: [mapMessage(copyText(model.text), message => NoteMessage.Clipboard({ message }))],
|
|
530
138
|
})
|
|
531
|
-
|
|
532
|
-
const Toolbar = SlotView.forMessages<Message>()
|
|
533
|
-
.define(ToolbarSlots, (model, slots, h) => {
|
|
534
|
-
const items = describeTools(model.tools)
|
|
535
|
-
return h.div(
|
|
536
|
-
slots.root.attrs([h.Role('toolbar')]),
|
|
537
|
-
model.tools.map((tool, index) =>
|
|
538
|
-
h.button(slots.tool.attrs([h.Key(tool.id)], items.slotItem(index)), [tool.label]),
|
|
539
|
-
),
|
|
540
|
-
)
|
|
541
|
-
})
|
|
542
|
-
.pipe(Behavior.attach(Ids), Behavior.attach(Focus))
|
|
543
139
|
```
|
|
544
140
|
|
|
545
|
-
|
|
546
|
-
Home or End focuses the next **enabled** item synchronously by its id, prevents
|
|
547
|
-
the default, and dispatches `Focused { id }`. Each item gets `tabindex` `0` when
|
|
548
|
-
it is the tab stop and `-1` otherwise, and `OnFocus` reporting `Focused`, so a
|
|
549
|
-
click makes an item current too. Before anything is current, or when the
|
|
550
|
-
current item is gone or disabled, the first enabled item is the tab stop. Keys
|
|
551
|
-
with ctrl, alt or meta held are left alone; `direction: model => 'rtl'` swaps
|
|
552
|
-
left and right. The pure `move(enabled, current, key, modifiers, options)` and
|
|
553
|
-
`tabStop(items, current)` are exported for a view that wires its own.
|
|
554
|
-
|
|
555
|
-
Under `virtual` the items get no `tabindex`, the container gets
|
|
556
|
-
`aria-activedescendant`, and a key keeps DOM focus where it is and only moves
|
|
557
|
-
the pointer. Nothing is written on dispose: the attributes are data, so a view
|
|
558
|
-
that no longer attaches the Behavior leaves no `tabindex` behind. PageUp and
|
|
559
|
-
PageDown are handled when `move` is given a `page`; `ListNavigation` below
|
|
560
|
-
does that.
|
|
561
|
-
|
|
562
|
-
`Typeahead` is type-to-find for a host that has no roving tab stop, or whose
|
|
563
|
-
focus is managed elsewhere. The Model slice is `{ query, generation }`:
|
|
564
|
-
printable keys extend the query, a timer of `timeoutMs` on Effect's clock
|
|
565
|
-
clears it (`generation` lets a superseded timer change nothing), and `Cleared`
|
|
566
|
-
drops it on purpose. Which item a query picks is the pure
|
|
567
|
-
`Typeahead.match(texts, enabled, query, current)`: one character, or one
|
|
568
|
-
character repeated, starts *after* the current item so repeated presses cycle;
|
|
569
|
-
a longer query starts *at* it, since the user is refining. Case and leading
|
|
570
|
-
whitespace are ignored and disabled items are skipped. The Behavior
|
|
571
|
-
(`Typeahead.behavior(Declared)(Slots)<Model, Message>({ host, items, text,
|
|
572
|
-
current })`) gives the host `OnKeyDownFocus`: a printable key with no ctrl, alt
|
|
573
|
-
or meta extends the query, focuses the match by id, and dispatches `Typed`;
|
|
574
|
-
with no match the key is still recorded and focus stays put; a space with an
|
|
575
|
-
empty query is left to the host.
|
|
576
|
-
|
|
577
|
-
`ListNavigation` is what a list host takes when it wants both: arrows, Home,
|
|
578
|
-
End, PageUp and PageDown by `page`, and typeahead, in **one placement**. It
|
|
579
|
-
exists for two reasons. The resolver allows one owner per event on a slot, so
|
|
580
|
-
`RovingTabindex` and `Typeahead` cannot both own the host's `OnKeyDownFocus`;
|
|
581
|
-
and under `virtual` a typed key must move the pointer and extend the query in
|
|
582
|
-
one transition, which two placements cannot do. Its Model slice is `{ current,
|
|
583
|
-
query, generation }`, its args are `RovingTabindex`'s plus `timeoutMs` and
|
|
584
|
-
`page`, and its Behavior takes `{ container, item, items, text, direction? }`.
|
|
585
|
-
`Typed { char, match }` carries the item the query now picks, so `update` sets
|
|
586
|
-
`current` and `query` together.
|
|
141
|
+
## The map
|
|
587
142
|
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
// place with { args: { orientation: 'vertical', loop: false, virtual: false, timeoutMs: 500, page: 10 } }
|
|
591
|
-
const Keys = ListNavigation.behavior(Nav, args)(ListSlots)<Model, Message>({
|
|
592
|
-
container: 'list',
|
|
593
|
-
item: 'option',
|
|
594
|
-
items: model => describeFruits(model.fruits),
|
|
595
|
-
text: (model, index) => model.fruits[index]?.label ?? '',
|
|
596
|
-
})
|
|
597
|
-
```
|
|
598
|
-
|
|
599
|
-
`GridNavigation` is the two-dimensional counterpart for cells laid out in rows
|
|
600
|
-
of `columns` (a calendar grid, a swatch picker, an emoji palette). Its Model
|
|
601
|
-
slice and item attributes are `RovingTabindex`'s, so the two are
|
|
602
|
-
interchangeable on a view; only the pure `move` differs. Left and right step
|
|
603
|
-
within the row and up and down within the column, skipping disabled cells;
|
|
604
|
-
under `wrap` a horizontal key continues into the next row and a vertical key
|
|
605
|
-
into the next column, otherwise the key is consumed at the edge. Home and End
|
|
606
|
-
are the row's first and last enabled cell, Ctrl+Home and Ctrl+End the grid's.
|
|
607
|
-
RTL swaps left and right, and `virtual` works as it does for `RovingTabindex`.
|
|
608
|
-
|
|
609
|
-
```ts
|
|
610
|
-
const Cells = Bundle.declare(GridNavigation.bundle, 'cells')
|
|
611
|
-
// place with { args: { columns: 7, wrap: false, virtual: false } }
|
|
612
|
-
const Keys = GridNavigation.behavior(Cells, args)(CalendarSlots)<Model, Message>({
|
|
613
|
-
container: 'grid',
|
|
614
|
-
item: 'day',
|
|
615
|
-
items: model => describeDays(model.days),
|
|
616
|
-
})
|
|
617
|
-
```
|
|
143
|
+
Each subpath is one concern and one import. Its README has the full
|
|
144
|
+
reference: every Model shape, Message, argument, and failure rule.
|
|
618
145
|
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
focus
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
`
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
`
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
primary-button pointer down captures the pointer and reports `MoveStarted`,
|
|
676
|
-
each move reports `Moved { deltaX, deltaY }` from where it went down, and up,
|
|
677
|
-
cancel, or lost capture reports `MoveEnded { completed }`. A second pointer
|
|
678
|
-
and a secondary button are ignored; capture is released with the Mount.
|
|
679
|
-
`Move.behavior(Slots)<Input, Message>({ handle, toMessage })` attaches it to a
|
|
680
|
-
`Draggable` slot and maps each fact into the view's Messages; a drag's meaning
|
|
681
|
-
(a threshold, a snap, a reorder) is the parent's `update`.
|
|
682
|
-
|
|
683
|
-
`FocusVisible` is the one entry whose Bundle lives elsewhere: `InputModality`
|
|
684
|
-
in `foldkit-primitives/events` keeps `{ modality }` (`'keyboard'`, `'pointer'`,
|
|
685
|
-
or `'unknown'` before any input), fed by the window's `keydown` (a modifier
|
|
686
|
-
alone says nothing) and `pointerdown`. `FocusVisible.behavior(Declared)(Slots)
|
|
687
|
-
<Model, Message>({ target })` writes `data-focus-visible` on the target while
|
|
688
|
-
the page is driven by keyboard, so a stylesheet shows a ring with
|
|
689
|
-
`[data-focus-visible]:focus`. CSS `:focus-visible` does this with no Model at
|
|
690
|
-
all; this is for a design system that must decide in the Model, or show the
|
|
691
|
-
same answer somewhere other than the focused element.
|
|
692
|
-
|
|
693
|
-
`DismissLayer` closes overlays that are not native `<dialog>` or `popover`
|
|
694
|
-
elements: Escape closes the topmost open layer, and a pointer press closes
|
|
695
|
-
the layers it is outside of. One Bundle, **placed once**, owns the document
|
|
696
|
-
listeners; each layer's Behavior marks its container with
|
|
697
|
-
`data-foldkit-plus-layer="<id>"` and its trigger with the matching trigger
|
|
698
|
-
attribute. The stack is the DOM order of the marked elements at the moment of
|
|
699
|
-
the event, so a layer takes part exactly while it is rendered and an `open`
|
|
700
|
-
flag in the parent Model is its whole lifecycle; nothing registers. The rules,
|
|
701
|
-
each a test: a press inside a parent layer is outside its children, so the
|
|
702
|
-
parent stays and the children go; a press on a layer's trigger counts as
|
|
703
|
-
inside it, so a click on the trigger never dismisses and reopens; a layer
|
|
704
|
-
placed with `outsidePress: false` or `escape: false` opts out of that path.
|
|
705
|
-
`Dismiss { ids }` is the OutMessage, and the placement's `onOut` closes them:
|
|
706
|
-
|
|
707
|
-
```ts
|
|
708
|
-
const Layers = Bundle.declare(DismissLayer.bundle, 'layers')
|
|
709
|
-
const placements = Page.assemble(
|
|
710
|
-
Page.at(Layers, {
|
|
711
|
-
onOut: ({ ids }) => model => ({ model: { ...model, menuOpen: ids.includes('menu') ? false : model.menuOpen } }),
|
|
712
|
-
}),
|
|
713
|
-
)
|
|
714
|
-
const Dismissable = DismissLayer.behavior(Layers)(MenuSlots)<Model, Message>({
|
|
715
|
-
layer: 'panel',
|
|
716
|
-
trigger: 'button',
|
|
717
|
-
id: () => 'menu',
|
|
718
|
-
})
|
|
719
|
-
```
|
|
720
|
-
|
|
721
|
-
The Model slice is `{ layers }`, the open layers as the last event saw them,
|
|
722
|
-
for DevTools and agents. `toDismiss(layers, inside)` is the pure rule.
|
|
723
|
-
|
|
724
|
-
`Layers.scrollLock(Slots)({ container })` and `Layers.hideOutside(Slots)({
|
|
725
|
-
container })` are Mounts over Foldkit's own `Dom.lockScroll` and
|
|
726
|
-
`Dom.inertOthers`: the first locks the document's scroll while the container is
|
|
727
|
-
mounted, refcounted so nested overlays release together, with Foldkit's iOS
|
|
728
|
-
handling; the second marks everything outside the container inert while it is
|
|
729
|
-
mounted, keyed by an id the Mount mints so two overlays restore independently.
|
|
730
|
-
Both live in `foldkit-primitives/dom` as `ScrollLock` and `HideOutside`. A
|
|
731
|
-
native `<dialog>` shown modally needs neither.
|
|
732
|
-
|
|
733
|
-
`Selection` is which items are selected, with `mode` `'single'` (a click
|
|
734
|
-
replaces; `allowEmpty` says whether clicking the selected item deselects it),
|
|
735
|
-
`'multiple'` (a click toggles), or `'none'`, and the `anchor` a range extends
|
|
736
|
-
from. The Model slice is `{ selected, anchor }`. A range needs the items'
|
|
737
|
-
order, which the view knows and the Bundle does not, so `Ranged { id, order }`
|
|
738
|
-
carries it; `Selection.between(order, from, to)` is the pure span. The Behavior
|
|
739
|
-
(`Selection.behavior(Declared, args)(Slots)<Model, Message>({ container?,
|
|
740
|
-
item, items, click? })`) writes `aria-selected` on each item and
|
|
741
|
-
`aria-multiselectable` on the container, and wires a plain click on each
|
|
742
|
-
enabled item to `Activated`; pass `click: false` when `Press` or the view owns
|
|
743
|
-
the click. For a Shift range, `Press`'s `Pressed { pointerType, shiftKey }`
|
|
744
|
-
says whether Shift was held, and the placement's `onOut` dispatches `Ranged`.
|
|
745
|
-
|
|
746
|
-
`LiveAnnounce` speaks to assistive technology: one Bundle, placed once, holds
|
|
747
|
-
the text of a polite and an assertive live region. `say(Declared)(text,
|
|
748
|
-
politeness?)` builds the Message to return from `update` or an `onOut`; an
|
|
749
|
-
announcement waits `debounceMs` so a burst reads once, then clears after
|
|
750
|
-
`clearAfterMs`, both on Effect's clock with a generation so a superseded timer
|
|
751
|
-
changes nothing; the same text twice gets a trailing no-break space toggled,
|
|
752
|
-
which is what makes a screen reader read it again. `LiveAnnounce.view(slice,
|
|
753
|
-
h)` renders the two regions: put it once in the page and hide them visually
|
|
754
|
-
with a rule on `[data-foldkit-plus-live]`, never `display: none`.
|
|
755
|
-
|
|
756
|
-
## Testing placements
|
|
757
|
-
|
|
758
|
-
Every primitive is testable without the platform, following one pattern
|
|
759
|
-
with three ingredients. First, substitute the environment: factories take
|
|
760
|
-
`create` (streams, devices, permissions) or `request` (camera) doubles, so
|
|
761
|
-
tests pass fakes instead of stubbing globals. Second, advance time instead
|
|
762
|
-
of waiting it: anything on Effect's clock (debounce, throttle, presence,
|
|
763
|
-
idle, timers, tweens) runs under `TestClock.adjust`. Third, never hang on a
|
|
764
|
-
quiet stream: collect with a bounded `takeMessages`, which fails fast naming
|
|
765
|
-
the stall. The settle-before-adjust rule applies throughout — yield after
|
|
766
|
-
forking before the first `TestClock.adjust`, or dispatched events hit
|
|
767
|
-
unregistered listeners. Each ingredient is demonstrated in this package's
|
|
768
|
-
`test/` directory, named after its primitive.
|
|
146
|
+
| Subpath | What it covers | Primitives |
|
|
147
|
+
| --- | --- | --- |
|
|
148
|
+
| [`media`](./media/README.md) | the environment | `MediaQuery` (+ `PrefersDark`, `PrefersReducedMotion`), `Breakpoints`, `platformFromUA`, `isBrowser` |
|
|
149
|
+
| [`net`](./net/README.md) | the network | `Online`, `websocket`, `sse`, `broadcastMessages`, `postBroadcast` |
|
|
150
|
+
| [`time`](./time/README.md) | the clock | `Timer`, `Interval`, `ticks`, `debounce`, `Throttle`, `formatRelativeTime` |
|
|
151
|
+
| [`state`](./state/README.md) | UI state with nowhere else to live | `Pagination`, `history`, `Locale`, `SelectionSet`, `Virtual`, `range`, layout math |
|
|
152
|
+
| [`motion`](./motion/README.md) | animation | `Tween`, `Spring`, `Presence`, the `Motion` service |
|
|
153
|
+
| [`interaction`](./interaction/README.md) | keyboard, pointer, and focus patterns, with their Behaviors | `RovingTabindex`, `Typeahead`, `ListNavigation`, `GridNavigation`, `TreeNavigation`, `FocusScope`, `Press`, `LongPress`, `Move`, `Targets`, `PointerDrag`, `EditableText`, `FocusVisible`, `DismissLayer`, `Layers`, `Selection`, `LiveAnnounce` |
|
|
154
|
+
| [`device`](./device/README.md) | hardware | `Geolocation`, `mediaDevices`, `mediaStream`, `permissions`, fullscreen |
|
|
155
|
+
| [`events`](./events/README.md) | raw window events | `Visibility`, `WindowSize`, `Idle`, `InputModality`, `keyboardEvents`, `pointerEvents`, `scrollEvents`, `activeElementEvents`, `matchHotkey` |
|
|
156
|
+
| [`observers`](./observers/README.md) | element observers | `Resize`, `Intersection`, `Mutation`, `Bounds` |
|
|
157
|
+
| [`dom`](./dom/README.md) | element Mounts and one-shot Commands | `Autofocus`, `InputMask`, `KeepInView`, `Measure`, `FocusScope`, `FollowTabStop`, `ScrollLock`, `HideOutside`, `copyText`, `readText`, `share`, `loadScript`, `keepScroll` |
|
|
158
|
+
|
|
159
|
+
## Rules every primitive keeps
|
|
160
|
+
|
|
161
|
+
These hold across the package, so the subpath pages do not repeat them.
|
|
162
|
+
|
|
163
|
+
- **A fact that changed nothing returns the Model it was given.** A repeated
|
|
164
|
+
resize, `Started` while running, a total already held: the placement then
|
|
165
|
+
returns the parent unchanged, Foldkit renders nothing, and a `createLazy` view
|
|
166
|
+
over the slice is skipped.
|
|
167
|
+
- **Init is a safe default, not a read.** `matches: false`, `online: true`, a
|
|
168
|
+
count of zero. A server render shows these; the subscription corrects them on
|
|
169
|
+
the client. Do not infer readiness from the default.
|
|
170
|
+
- **Without the platform API, nothing throws.** A stream is empty and the
|
|
171
|
+
slice keeps its default; a Command yields its failure Message (`CopyFailed`,
|
|
172
|
+
`ShareFailed`); a Mount emits nothing.
|
|
173
|
+
- **One placement observes once.** Two placements of one query open two
|
|
174
|
+
listeners; share the field. One assembly holds one socket, stream, camera,
|
|
175
|
+
or permission watch: their resource tags are per module, and `assemble`
|
|
176
|
+
refuses the second.
|
|
177
|
+
- **An OutMessage cannot be dropped by omission.** `debounce`, `Throttle`,
|
|
178
|
+
`Press`, `DismissLayer`, and the rest that surface a settled value require
|
|
179
|
+
`onOut` at placement. `Bundle.ignore` drops one on purpose.
|
|
180
|
+
- **Everything timed runs on Effect's clock.** Timers, tweens, debounces,
|
|
181
|
+
presence, idle, typeahead. Tests advance them with `TestClock.adjust`
|
|
182
|
+
instead of waiting.
|
|
183
|
+
- **Reduced motion is a service.** Provide `Motion.live` through the
|
|
184
|
+
assembly's resources, or `Motion.reduced` and `Motion.full` in a test;
|
|
185
|
+
absent, motion is full.
|
|
186
|
+
- **Elements never cross into the Model.** A focus report is `{ tag, id }`, a
|
|
187
|
+
mutation names nodes, a target is its marked id.
|
|
188
|
+
|
|
189
|
+
## Testing a placement
|
|
190
|
+
|
|
191
|
+
Three ingredients, each shown in this package's `test/` directory under the
|
|
192
|
+
primitive's name:
|
|
193
|
+
|
|
194
|
+
1. **Substitute the environment.** Factories take a `create` (streams,
|
|
195
|
+
devices, permissions, sockets) or `request` (camera) double, so a test
|
|
196
|
+
passes a fake instead of stubbing a global.
|
|
197
|
+
2. **Advance time instead of waiting.** Anything on Effect's clock runs under
|
|
198
|
+
`TestClock.adjust`. Yield after forking before the first adjust, or
|
|
199
|
+
dispatched events reach listeners not yet registered.
|
|
200
|
+
3. **Never hang on a quiet stream.** Collect with a bounded take that fails
|
|
201
|
+
fast naming the stall.
|
|
769
202
|
|
|
770
203
|
## With Surface and Mirror
|
|
771
204
|
|
|
772
|
-
Placed state is ordinary Model, so
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
application uses.
|
|
778
|
-
|
|
779
|
-
## Failure and recovery
|
|
780
|
-
|
|
781
|
-
| Failure | Behaviour |
|
|
782
|
-
| --- | --- |
|
|
783
|
-
| No `window` (SSR) or no API (old browser, minimal DOM) | stream is empty; the slice keeps its default |
|
|
784
|
-
| A Command without its platform API | failure Message (`CopyFailed`, `ShareFailed`, `BroadcastFailed`), never a throw |
|
|
785
|
-
| Listener removed (unmount, gate closed) | finalizer disconnects; resubscribing re-reads the current value |
|
|
786
|
-
| A Message for an unplaced child | never routes: wrappers only match placed variants |
|
|
787
|
-
| MediaStream acquisition fails | parks at `denied`/`idle` with requirements cleared; `Started` retries |
|
|
205
|
+
Placed state is ordinary Model, so nothing here has a Surface or Mirror API of
|
|
206
|
+
its own. Declare a Surface over the placed fields to render them or expose them
|
|
207
|
+
to an agent; point `Mirror.url` at them to link them; spread the bundle's cases
|
|
208
|
+
into the same unions. A slice that must survive a reload persists through
|
|
209
|
+
`Mirror.kv`; this package owns live facts only.
|
|
788
210
|
|
|
789
211
|
## Limits
|
|
790
212
|
|
|
791
|
-
-
|
|
792
|
-
|
|
793
|
-
-
|
|
794
|
-
-
|
|
795
|
-
the application for now.
|
|
213
|
+
- Presets cover the common media queries; anything else passes `args`.
|
|
214
|
+
- `Virtual` windows one axis of a list; windowed grids stay out by design.
|
|
215
|
+
- `Sync.lww`-style merging, collaborative text, and other replicated state are
|
|
216
|
+
[`foldkit-sync`](../sync)'s, not a primitive.
|