foldkit-primitives 0.2.1 → 0.4.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 +514 -43
- package/dist/device/index.d.mts +11 -11
- 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 -1
- package/dist/dom/index.d.mts.map +1 -1
- package/dist/dom/index.mjs +303 -5
- 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 +10 -1
- package/dist/events/index.d.mts.map +1 -1
- package/dist/events/index.mjs +34 -18
- package/dist/events/index.mjs.map +1 -1
- package/dist/follow-tab-stop-DpxwLXvU.mjs +800 -0
- package/dist/follow-tab-stop-DpxwLXvU.mjs.map +1 -0
- package/dist/index-Cppf-lBx.d.mts +130 -0
- package/dist/index-Cppf-lBx.d.mts.map +1 -0
- package/dist/index.d.mts +8 -6
- package/dist/index.mjs +8 -6
- package/dist/input-modality-DGOhAknO.d.mts +26 -0
- package/dist/input-modality-DGOhAknO.d.mts.map +1 -0
- package/dist/input-modality-m6v7__Xs.mjs +43 -0
- package/dist/input-modality-m6v7__Xs.mjs.map +1 -0
- package/dist/interaction/index.d.mts +1017 -0
- package/dist/interaction/index.d.mts.map +1 -0
- package/dist/interaction/index.mjs +1647 -0
- package/dist/interaction/index.mjs.map +1 -0
- 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.d.mts +2 -112
- package/dist/motion/index.mjs +2 -208
- package/dist/motion-DGGBZ0wt.mjs +240 -0
- package/dist/motion-DGGBZ0wt.mjs.map +1 -0
- package/dist/net/index.d.mts +68 -4
- 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/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/dist/state/index.d.mts +57 -15
- 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 +31 -8
- package/dist/motion/index.d.mts.map +0 -1
- package/dist/motion/index.mjs.map +0 -1
package/README.md
CHANGED
|
@@ -21,11 +21,15 @@ import { MediaQuery } from 'foldkit-primitives/media'
|
|
|
21
21
|
A primitive belongs here when it is **reused across applications** and
|
|
22
22
|
**stateful**: something observes it, something reacts to it, and replay sees
|
|
23
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).
|
|
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.
|
|
25
28
|
|
|
26
29
|
| Kind | Form | Example |
|
|
27
30
|
| --- | --- | --- |
|
|
28
31
|
| Stateful + effectful | bundle | MediaQuery, Timer, WebSocket, Pagination |
|
|
32
|
+
| Interaction state a view's slots must reflect | bundle + Behavior | RovingTabindex, Typeahead, ListNavigation, GridNavigation, Press |
|
|
29
33
|
| Keyed collections of stateful items | bundle per key | uploads, sockets, timers (later) |
|
|
30
34
|
| Stream source with a stored fact | bundle with one boolean/scalar slice | Online, Visibility, WindowSize |
|
|
31
35
|
| Stream source only | Subscription entry, not a bundle | keyboard, pointer, scroll, broadcast |
|
|
@@ -46,6 +50,12 @@ A bundle holds no state and performs no I/O by itself. The parent Model owns
|
|
|
46
50
|
the placed slice; the browser (or server, or clock) only reports facts as
|
|
47
51
|
Messages. The same rule as everywhere else: observation is not ownership.
|
|
48
52
|
|
|
53
|
+
A Message that changes nothing (a repeated resize, `Started` while running,
|
|
54
|
+
a total that is already held) returns the Model it was given, and a
|
|
55
|
+
placement then returns the parent unchanged. Foldkit renders only when the
|
|
56
|
+
root Model changes identity, so such a Message renders nothing, and a
|
|
57
|
+
`createLazy` view over a placed slice is skipped while the slice is the same.
|
|
58
|
+
|
|
49
59
|
Solid developers will notice missing plumbing: there is no event bus
|
|
50
60
|
because Messages are the bus, no memo because derivations are pure reads
|
|
51
61
|
over the Model, and no reactive map or store because the Model holds plain
|
|
@@ -57,14 +67,14 @@ accident — one state machine leaves nowhere for a second one to live.
|
|
|
57
67
|
```text
|
|
58
68
|
browser / clock / server ──facts as Messages──▶ update ──▶ Model slice
|
|
59
69
|
▲
|
|
60
|
-
|
|
70
|
+
The parent Model owns the recorded fact; subscriptions report environment changes.
|
|
61
71
|
```
|
|
62
72
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
73
|
+
Initialization creates the Model slice; subscriptions observe ongoing facts and
|
|
74
|
+
report Messages; `update` stores them. Defaults differ by primitive:
|
|
75
|
+
MediaQuery starts at `false`, while Online, Visibility, and Locale read an
|
|
76
|
+
available platform value during initialization. Missing browser APIs have
|
|
77
|
+
primitive-specific fallbacks; do not infer readiness from an SSR default.
|
|
68
78
|
|
|
69
79
|
## Install
|
|
70
80
|
|
|
@@ -87,51 +97,52 @@ Each subpath is one concern, one import:
|
|
|
87
97
|
- `time` — clock facts: Timer, Interval, Debounce, Throttle, relative time
|
|
88
98
|
- `state` — owned UI state: Pagination, History, Locale, SelectionSet, Virtual, range
|
|
89
99
|
- `motion` — animation state: Tween, Spring, Presence
|
|
100
|
+
- `interaction` — a Bundle (or Mount) and its `foldkit-mixins` Behavior: RovingTabindex, Typeahead, ListNavigation, GridNavigation, TreeNavigation, FocusScope, Press, LongPress, Move, Targets, PointerDrag, EditableText, FocusVisible, DismissLayer, ScrollLock, HideOutside, Selection, LiveAnnounce
|
|
90
101
|
- `device` — hardware: Geolocation, MediaDevices, MediaStream, Permissions, Fullscreen
|
|
91
|
-
- `events` — raw browser events: Visibility, WindowSize, Idle, keyboard, pointer, scroll, focus
|
|
102
|
+
- `events` — raw browser events: Visibility, WindowSize, Idle, InputModality, keyboard, pointer, scroll, focus
|
|
92
103
|
- `observers` — element Mounts: Resize, Intersection, Mutation, Bounds
|
|
93
|
-
- `dom` — element Mounts and one-shot Commands: Autofocus, InputMask, clipboard, share, script loading
|
|
104
|
+
- `dom` — element Mounts and one-shot Commands: Autofocus, FocusScope, KeepInView, Measure, Move, Targets, PointerDrag, EditableText, ScrollLock, HideOutside, InputMask, clipboard, share, script loading
|
|
94
105
|
|
|
95
106
|
## Sixty seconds: follow the color scheme
|
|
96
107
|
|
|
97
|
-
**
|
|
98
|
-
Message
|
|
108
|
+
**Place it in a parent.** `Bundle.compose` states the parent's own field and
|
|
109
|
+
Message, and places the primitive under `dark`, with the wrapper Message
|
|
110
|
+
`GotDarkMessage` by Foldkit's `Got<Field>Message` convention:
|
|
99
111
|
|
|
100
112
|
```ts
|
|
101
113
|
import { Schema } from 'effect'
|
|
102
|
-
import {
|
|
114
|
+
import type { HtmlBuilder } from 'foldkit/html'
|
|
103
115
|
import { Bundle } from 'foldkit-bundle'
|
|
104
116
|
import { MediaQuery } from 'foldkit-primitives/media'
|
|
105
117
|
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
type Message = typeof Message.Type
|
|
118
|
+
const Page = Bundle.compose({ theme: Schema.String }).pipe(
|
|
119
|
+
Bundle.withMessages({ ThemeSet: { theme: Schema.String } }),
|
|
120
|
+
Bundle.withChild('dark', MediaQuery, { args: { query: '(prefers-color-scheme: dark)' } }),
|
|
121
|
+
)
|
|
122
|
+
type Model = typeof Page.Model.Type
|
|
123
|
+
type Message = typeof Page.Message.Type
|
|
112
124
|
```
|
|
113
125
|
|
|
114
|
-
**
|
|
115
|
-
assembly derives the update and the Subscriptions:
|
|
126
|
+
**Run it.** The parent's assembly derives the update and the Subscriptions:
|
|
116
127
|
|
|
117
128
|
```ts
|
|
118
|
-
const
|
|
119
|
-
const placements = Page.assemble(Page.at(Dark, { args: { query: '(prefers-color-scheme: dark)' } }))
|
|
129
|
+
const { placements } = Page
|
|
120
130
|
|
|
121
131
|
const config = placements.complete({
|
|
122
132
|
init: () => placements.initial({ theme: 'light' }),
|
|
123
133
|
update: placements.update(model => ({ model })),
|
|
124
|
-
view,
|
|
134
|
+
view: (model: Model, h: HtmlBuilder<Message>) =>
|
|
135
|
+
h.div([], [model.dark.matches ? 'Dark mode' : 'Light mode']),
|
|
125
136
|
subscriptions: placements.subscriptions(),
|
|
126
137
|
})
|
|
127
138
|
```
|
|
128
139
|
|
|
129
140
|
`update` routes `GotDarkMessage` to the bundle; `subscriptions` runs the
|
|
130
141
|
`matchMedia` stream; `initial` starts `matches` at `false` and the stream
|
|
131
|
-
corrects it on subscribe. None of
|
|
132
|
-
`
|
|
133
|
-
folding and the streams
|
|
134
|
-
subscribes. The Solid equivalent this replaces:
|
|
142
|
+
corrects it on subscribe. None of these calls perform I/O: `compose` states
|
|
143
|
+
the parent, `withChild` places the primitive with its config, and the
|
|
144
|
+
assembly derives the folding and the streams. The browser is touched only
|
|
145
|
+
when the runtime subscribes. The Solid equivalent this replaces:
|
|
135
146
|
|
|
136
147
|
```ts
|
|
137
148
|
// solid-primitives: const dark = createMediaQuery('(prefers-color-scheme: dark)')
|
|
@@ -165,7 +176,7 @@ Bound presets place with no args:
|
|
|
165
176
|
```ts
|
|
166
177
|
import { PrefersDark, PrefersReducedMotion } from 'foldkit-primitives/media'
|
|
167
178
|
|
|
168
|
-
const
|
|
179
|
+
const Page = Bundle.compose({ theme: Schema.String }).pipe(Bundle.withChild('dark', PrefersDark))
|
|
169
180
|
```
|
|
170
181
|
|
|
171
182
|
`Breakpoints` derives names from one `resize` listener: args
|
|
@@ -202,8 +213,12 @@ SSE spec.
|
|
|
202
213
|
holds `{ url, status, lastError }` with `status` moving
|
|
203
214
|
closed → connecting → open. The resource owns the socket (one assembly holds
|
|
204
215
|
one); `send` is a placed helper whose command writes through the resource tag
|
|
205
|
-
and yields `Sent` on dispatch,
|
|
206
|
-
closed socket's `send` is a
|
|
216
|
+
and yields `Sent { data }` on dispatch, carrying what was sent, and
|
|
217
|
+
`SendFailed` when no connection is open — a closed socket's `send` is a
|
|
218
|
+
silent no-op per spec, so the bundle checks first. Args `{ url,
|
|
219
|
+
connectTimeoutMs? }`: with a timeout, a socket still connecting when it runs
|
|
220
|
+
out is closed and reported as `TimedOut` (status closed, `lastError` set);
|
|
221
|
+
without one, an attempt waits as long as the browser does.
|
|
207
222
|
Incoming `Received` notifies without storing: project the payload into your
|
|
208
223
|
own field to keep it. `createSocket` defaults to the platform WebSocket, read
|
|
209
224
|
lazily so tests substitute a double; the socket service rides the assembly
|
|
@@ -225,14 +240,41 @@ the Command fails, instead of throwing.
|
|
|
225
240
|
`Started`/`Stopped`/`Ticked`, args `{ intervalMs }` (positive and finite —
|
|
226
241
|
anything else is rejected at placement). The tick stream runs on
|
|
227
242
|
Effect's clock, so tests advance it with TestClock instead of waiting; while
|
|
228
|
-
stopped the stream is empty.
|
|
229
|
-
advances it.
|
|
243
|
+
stopped the stream is empty. The first tick comes one interval after
|
|
244
|
+
`Started`. Restarting keeps the count; only `Ticked` advances it.
|
|
230
245
|
|
|
231
246
|
`Interval` is the wall-clock sibling: Model `{ running, lastAt }`,
|
|
232
247
|
`Ticked { at }` stamped from Effect's clock. `Timer` counts ticks,
|
|
233
248
|
`Interval` records when — views render clocks and elapsed times from
|
|
234
249
|
`lastAt`. Same args, same TestClock story, same silence while stopped.
|
|
235
250
|
|
|
251
|
+
Both own their `running` flag, at an interval fixed where they are placed.
|
|
252
|
+
When the parent's Model already decides whether the clock runs and how fast
|
|
253
|
+
(a game's phase and its score), a second flag would be a second owner of that
|
|
254
|
+
fact. `ticks` is the entry for that case: `intervalMs` reads the interval from
|
|
255
|
+
the Model, `None` while stopped, and `onTick` makes the parent's Message with
|
|
256
|
+
the tick's time:
|
|
257
|
+
|
|
258
|
+
```ts
|
|
259
|
+
import { Option } from 'effect'
|
|
260
|
+
import * as Subscription from 'foldkit/subscription'
|
|
261
|
+
import { ticks } from 'foldkit-primitives/time'
|
|
262
|
+
|
|
263
|
+
const subscriptions = Subscription.make<Game, GameMessage>()(() => ({
|
|
264
|
+
clock: ticks({
|
|
265
|
+
intervalMs: (game: Game) =>
|
|
266
|
+
game.playing ? Option.some(Math.max(80, 150 - game.points)) : Option.none(),
|
|
267
|
+
onTick: () => GameMessage.TickedClock(),
|
|
268
|
+
}),
|
|
269
|
+
}))
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
A Model change that leaves the interval alone does nothing. A new interval
|
|
273
|
+
applies from the next tick, without restarting the clock, so a game that
|
|
274
|
+
speeds up keeps its rhythm instead of ticking at once; `None` stops it, and
|
|
275
|
+
`Some` again starts afresh, one interval before the first tick. `Timer` and
|
|
276
|
+
`Interval` are built on it.
|
|
277
|
+
|
|
236
278
|
`debounce({ name, value })` is a factory over any value Schema (like
|
|
237
279
|
`history`): `Changed` restarts a `{ delayMs }` timer, and only the latest
|
|
238
280
|
value settles — as an OutMessage the placement must handle with `onOut`, so
|
|
@@ -280,9 +322,11 @@ focus `{ tag, id }` — elements cross as tag and id, never as live nodes.
|
|
|
280
322
|
`matchHotkey("ctrl+shift+k", press)` answers whether a press is a shortcut,
|
|
281
323
|
so `update` stays a table of chords; matching is exact and auto-repeat never
|
|
282
324
|
matches. When the observed target itself depends on state, scope the entry
|
|
283
|
-
through subscription dependencies and it restreams on change.
|
|
284
|
-
|
|
285
|
-
|
|
325
|
+
through subscription dependencies and it restreams on change.
|
|
326
|
+
`keyboardEvents({ preventDefault: press => ... })` cancels the default of the
|
|
327
|
+
presses the predicate picks (the arrows scrolling a game's page,
|
|
328
|
+
`matchHotkey('ctrl+s', press)`), decided in the listener, because a default
|
|
329
|
+
can only be cancelled while the event is dispatching. Lift
|
|
286
330
|
with `Subscription.persistent`, mapping into the parent's Message; without
|
|
287
331
|
a window each stream is empty instead of throwing.
|
|
288
332
|
|
|
@@ -305,8 +349,7 @@ skipped. `Bounds()` re-measures `Measured { x, y, width, height }` on
|
|
|
305
349
|
observer, scroll, and resize, starting with the current rect; without a
|
|
306
350
|
ResizeObserver the window events still measure. Without the observer API
|
|
307
351
|
(SSR, old browser) they emit nothing instead of throwing; teardown
|
|
308
|
-
disconnects.
|
|
309
|
-
is same-valued and harmless.
|
|
352
|
+
disconnects. Treat repeated measurements as observations, not proof that a user action occurred.
|
|
310
353
|
|
|
311
354
|
## Device: `foldkit-primitives/device`
|
|
312
355
|
|
|
@@ -353,7 +396,10 @@ owns fullscreen state.
|
|
|
353
396
|
`copyText` copies text as a Command: use it in `update` beside any bundle.
|
|
354
397
|
It yields `Copied` on success and `CopyFailed` otherwise — denial, insecure
|
|
355
398
|
context, or no clipboard API (SSR) all become the failure Message instead of
|
|
356
|
-
throwing.
|
|
399
|
+
throwing. `readText()` reads it the same way, yielding a
|
|
400
|
+
`ClipboardReadMessage`: `Read { text }`, or `ReadFailed` when the browser
|
|
401
|
+
refuses. No Model involved: the clipboard is not
|
|
402
|
+
application state.
|
|
357
403
|
|
|
358
404
|
`share(data)` posts `{ title?, text?, url? }` to the platform sheet: `Shared`
|
|
359
405
|
on success, `Dismissed` on sheet cancel (its own outcome, not a failure),
|
|
@@ -369,6 +415,26 @@ rewrites the field with approximate caret restore, and emits
|
|
|
369
415
|
`Input { value, raw }` with masked and unmasked text. The parent owns the
|
|
370
416
|
state, like any controlled input.
|
|
371
417
|
|
|
418
|
+
`KeepInView({ selector })` keeps what is marked in view: whenever an element in
|
|
419
|
+
its subtree newly matches `selector` (the row just selected, the node just
|
|
420
|
+
inserted), it is scrolled into view the least amount that shows it. It sends no
|
|
421
|
+
Message and redraws nothing, so a focused row stays focused. Where there is no
|
|
422
|
+
layout (jsdom), it scrolls nothing.
|
|
423
|
+
|
|
424
|
+
`Measure({ targets: { selected: '[aria-selected="true"]' } })` measures, for
|
|
425
|
+
each named target, the first element in its subtree that matches, relative to
|
|
426
|
+
its scroll box, and writes `--fk-selected-x`, `-y`, `-w`, `-h` (pixels) and
|
|
427
|
+
`--fk-selected-display` (`block`, or `none` while nothing matches) on the
|
|
428
|
+
element (`measured('selected')` names them). A child placed absolutely from
|
|
429
|
+
them, such as an editor's selection outline, sits over the target and scrolls
|
|
430
|
+
with it. Where an element is, is presentation: it sends no Message and keeps
|
|
431
|
+
nothing in the Model. It measures again when the subtree changes, as the
|
|
432
|
+
records arrive, so the box moves in the frame the change is drawn in; and, at
|
|
433
|
+
most once a frame, when the element scrolls, when it or a target changes size,
|
|
434
|
+
when the window resizes, when an image or a font inside loads, and on each
|
|
435
|
+
frame of a transition or an animation inside. Anything else that moves a
|
|
436
|
+
target without resizing it (a stylesheet added) is seen at the next of these.
|
|
437
|
+
|
|
372
438
|
## State: `foldkit-primitives/state`
|
|
373
439
|
|
|
374
440
|
> Reference: [`./state/README.md`](./state/README.md)
|
|
@@ -383,9 +449,18 @@ item's index for a slice or a query. Loading data stays the application's job:
|
|
|
383
449
|
this bundle owns the page, not the items.
|
|
384
450
|
|
|
385
451
|
`history({ name, value, capacity })` makes an undo/redo bundle over any value
|
|
386
|
-
Schema. The Model holds `{ past, present, future }`; `Push` records and
|
|
387
|
-
the redo future
|
|
388
|
-
|
|
452
|
+
Schema. The Model holds `{ past, present, future, group }`; `Push` records and
|
|
453
|
+
drops the redo future (an empty future is kept as it was, so a view reading it
|
|
454
|
+
is not drawn again per push), `Undo`/`Redo` move one step, `GoTo { step }`
|
|
455
|
+
jumps to any kept value, counted from the oldest (0) so the present is at
|
|
456
|
+
`past.length`, and `Clear` empties both sides while keeping the present. A `Push` may name a `group`: consecutive pushes of
|
|
457
|
+
the same group are one step, so typing a word undoes as a whole, with no clock.
|
|
458
|
+
The steps are also pure functions, `History.start`, `push`, `undo`, `redo`,
|
|
459
|
+
`goTo` and `clear`, for a parent that records an edit in the same transition that makes it
|
|
460
|
+
(the page Builder keeps its page this way). Two more are only functions:
|
|
461
|
+
`History.close(model)` ends the group, so the same group pushed again is a step
|
|
462
|
+
of its own, and `History.revert(model, group)` takes back the step `group` is
|
|
463
|
+
making with nothing left to redo, which is what a cancelled edit leaves. The past holds at most `capacity` entries (default 100);
|
|
389
464
|
a negative or fractional capacity throws at the factory, naming it. The
|
|
390
465
|
factory attaches the Message union, so placements dispatch
|
|
391
466
|
`EditHistory.Message.Push(...)`. `canUndo`/`canRedo` read the edges:
|
|
@@ -401,7 +476,7 @@ const Doc = Bundle.declare(EditHistory, 'doc')
|
|
|
401
476
|
startup with the configured `default` as fallback; `SetLocale` switches it.
|
|
402
477
|
`SelectionSet` keeps string ids in first-selection order: `Select` (keeps
|
|
403
478
|
position), `Deselect`, `Toggle` (re-appends), `ReplaceAll` (deduped), and
|
|
404
|
-
`Clear`. `isSelected` reads membership.
|
|
479
|
+
`Clear`. `isSelected` reads membership. Neither subscribes to changes; Locale reads the initial browser language when available.
|
|
405
480
|
Keyed children — lists with stable identity — place through the bundle
|
|
406
481
|
mechanism's `each`.
|
|
407
482
|
`range(start, end, step?)` counts half-open numbers — the pagination page
|
|
@@ -421,6 +496,11 @@ the spacer height, `isAtEnd(model, keys, viewportHeight, threshold)` is the
|
|
|
421
496
|
infinite-scroll check (an empty list counts as ended), `distanceToEnd`
|
|
422
497
|
answers the pixels remaining for prefetch thresholds, and `offsetFor`
|
|
423
498
|
computes programmatic scroll targets the application actuates itself.
|
|
499
|
+
These share one table of row offsets, built once per `keys` array and
|
|
500
|
+
`heights` record and then searched, so keep the keys array between renders
|
|
501
|
+
(derive it where the list changes, not in the view) and a scroll costs
|
|
502
|
+
O(log n) instead of a pass over every row. Both are compared by identity:
|
|
503
|
+
never change a keys array in place.
|
|
424
504
|
`Prune` drops heights for departed keys — the bundle never sees key order.
|
|
425
505
|
Poisoned positions and heights are ignored, never stored. For window-
|
|
426
506
|
scrolled lists, map the scroll entry into `Scrolled`; for follow-bottom,
|
|
@@ -446,6 +526,16 @@ should survive reload, and keep this package's bundles for live facts.
|
|
|
446
526
|
|
|
447
527
|
> Reference: [`./motion/README.md`](./motion/README.md)
|
|
448
528
|
|
|
529
|
+
Whether motion should be reduced is a service, `Motion`, read when a
|
|
530
|
+
transition starts rather than sniffed once. `Presence` then exits at once,
|
|
531
|
+
and `Tween` and `Spring` jump to `to`, in the same Messages, so the Model sees
|
|
532
|
+
the same transitions. Provide `Motion.live` (the user's
|
|
533
|
+
`prefers-reduced-motion`) through the assembly's resources, or `Motion.reduced`
|
|
534
|
+
and `Motion.full` in a test or for a setting the application owns. With no
|
|
535
|
+
service provided, motion is full, so a placement that provides nothing behaves
|
|
536
|
+
as before. `Motion.reducedMotion` is the Effect the bundles read, for a
|
|
537
|
+
transition of your own.
|
|
538
|
+
|
|
449
539
|
`Tween` animates one number from `from` to `to` over `ms` milliseconds.
|
|
450
540
|
Model `{ value, running }`, Messages `Started`/`Ticked`/`Finished`, args
|
|
451
541
|
`{ from, to, ms }` (a non-positive or non-finite duration is rejected at
|
|
@@ -471,6 +561,387 @@ exact end value even when an underdamped spring overshoots on the way.
|
|
|
471
561
|
`Tween` (fixed duration, linear) versus `Spring` (physics, settles) — pick
|
|
472
562
|
the motion, not both.
|
|
473
563
|
|
|
564
|
+
## Interaction: `foldkit-primitives/interaction`
|
|
565
|
+
|
|
566
|
+
Interaction state that a view's slots must reflect: the Bundle holds it in the
|
|
567
|
+
Model, and a matching [`foldkit-mixins`](../mixins) Behavior writes the
|
|
568
|
+
attributes and handlers on the slots. Importing this subpath needs
|
|
569
|
+
`foldkit-mixins`; the other subpaths do not.
|
|
570
|
+
|
|
571
|
+
`RovingTabindex` is one tab stop for a set of items: arrows move focus between
|
|
572
|
+
them, the rest stay out of the tab order. The Model slice is `{ current }`, the
|
|
573
|
+
current item's **id**, so a reorder keeps the same item current and a resumed
|
|
574
|
+
page knows where focus was. Args: `orientation` (`'vertical' | 'horizontal' |
|
|
575
|
+
'both'`), `loop`, and `virtual` (focus stays on the container and
|
|
576
|
+
`aria-activedescendant` points at the current item).
|
|
577
|
+
|
|
578
|
+
```ts
|
|
579
|
+
import { Bundle } from 'foldkit-bundle'
|
|
580
|
+
import { Behavior, Behaviors, Capability, Slot, Slots, SlotView } from 'foldkit-mixins'
|
|
581
|
+
import { RovingTabindex } from 'foldkit-primitives/interaction'
|
|
582
|
+
|
|
583
|
+
const Roving = Bundle.declare(RovingTabindex.bundle, 'toolbarFocus')
|
|
584
|
+
const Model = Schema.Struct({ ...Roving.fields, tools: Schema.Array(Tool) })
|
|
585
|
+
const Message = defineMessageUnion({ ...Roving.cases })
|
|
586
|
+
const Page = Bundle.parent({ Model, Message })
|
|
587
|
+
const args = { orientation: 'horizontal', loop: true, virtual: false } as const
|
|
588
|
+
const placements = Page.assemble(Page.at(Roving, { args }))
|
|
589
|
+
|
|
590
|
+
const ToolbarSlots = Slots.define({
|
|
591
|
+
root: Slot.make({ capability: Capability.Container }),
|
|
592
|
+
tool: Slot.make({ capability: Capability.Focusable }),
|
|
593
|
+
})
|
|
594
|
+
const describeTools = (tools: ReadonlyArray<Tool>) =>
|
|
595
|
+
Behaviors.Collection.of(tools, { id: tool => tool.id, disabled: tool => tool.disabled })
|
|
596
|
+
|
|
597
|
+
// Ids on each item come from Collection; RovingTabindex reads them.
|
|
598
|
+
const Ids = Behaviors.Collection.behavior(ToolbarSlots)<Model, Message>({
|
|
599
|
+
item: 'tool',
|
|
600
|
+
items: model => describeTools(model.tools),
|
|
601
|
+
})
|
|
602
|
+
const Focus = RovingTabindex.behavior(Roving, args)(ToolbarSlots)<Model, Message>({
|
|
603
|
+
container: 'root',
|
|
604
|
+
item: 'tool',
|
|
605
|
+
items: model => describeTools(model.tools),
|
|
606
|
+
})
|
|
607
|
+
|
|
608
|
+
const Toolbar = SlotView.forMessages<Message>()
|
|
609
|
+
.define(ToolbarSlots, (model, slots, h) => {
|
|
610
|
+
const items = describeTools(model.tools)
|
|
611
|
+
return h.div(
|
|
612
|
+
slots.root.attrs([h.Role('toolbar')]),
|
|
613
|
+
model.tools.map((tool, index) =>
|
|
614
|
+
h.button(slots.tool.attrs([h.Key(tool.id)], items.slotItem(index)), [tool.label]),
|
|
615
|
+
),
|
|
616
|
+
)
|
|
617
|
+
})
|
|
618
|
+
.pipe(Behavior.attach(Ids), Behavior.attach(Focus))
|
|
619
|
+
```
|
|
620
|
+
|
|
621
|
+
What each half does: the container gets `OnKeyDownFocus`, which on an arrow,
|
|
622
|
+
Home or End focuses the next **enabled** item synchronously by its id, prevents
|
|
623
|
+
the default, and dispatches `Focused { id }`. Each item gets `tabindex` `0` when
|
|
624
|
+
it is the tab stop and `-1` otherwise, and `OnFocus` reporting `Focused`, so a
|
|
625
|
+
click makes an item current too. Before anything is current, or when the
|
|
626
|
+
current item is gone or disabled, the first enabled item is the tab stop. Keys
|
|
627
|
+
with ctrl, alt or meta held are left alone; `direction: model => 'rtl'` swaps
|
|
628
|
+
left and right. The pure `move(enabled, current, key, modifiers, options)` and
|
|
629
|
+
`tabStop(items, current)` are exported for a view that wires its own.
|
|
630
|
+
|
|
631
|
+
Under `virtual` the items get no `tabindex`, the container gets
|
|
632
|
+
`aria-activedescendant`, and a key keeps DOM focus where it is and only moves
|
|
633
|
+
the pointer. Nothing is written on dispose: the attributes are data, so a view
|
|
634
|
+
that no longer attaches the Behavior leaves no `tabindex` behind. PageUp and
|
|
635
|
+
PageDown are handled when `move` is given a `page`; `ListNavigation` below
|
|
636
|
+
does that.
|
|
637
|
+
|
|
638
|
+
`Typeahead` is type-to-find for a host that has no roving tab stop, or whose
|
|
639
|
+
focus is managed elsewhere. The Model slice is `{ query, generation }`:
|
|
640
|
+
printable keys extend the query, a timer of `timeoutMs` on Effect's clock
|
|
641
|
+
clears it (`generation` lets a superseded timer change nothing), and `Cleared`
|
|
642
|
+
drops it on purpose. Which item a query picks is the pure
|
|
643
|
+
`Typeahead.match(texts, enabled, query, current)`: one character, or one
|
|
644
|
+
character repeated, starts *after* the current item so repeated presses cycle;
|
|
645
|
+
a longer query starts *at* it, since the user is refining. Case and leading
|
|
646
|
+
whitespace are ignored and disabled items are skipped. The Behavior
|
|
647
|
+
(`Typeahead.behavior(Declared)(Slots)<Model, Message>({ host, items, text,
|
|
648
|
+
current })`) gives the host `OnKeyDownFocus`: a printable key with no ctrl, alt
|
|
649
|
+
or meta extends the query, focuses the match by id, and dispatches `Typed`;
|
|
650
|
+
with no match the key is still recorded and focus stays put; a space with an
|
|
651
|
+
empty query is left to the host.
|
|
652
|
+
|
|
653
|
+
`ListNavigation` is what a list host takes when it wants both: arrows, Home,
|
|
654
|
+
End, PageUp and PageDown by `page`, and typeahead, in **one placement**. It
|
|
655
|
+
exists for two reasons. The resolver allows one owner per event on a slot, so
|
|
656
|
+
`RovingTabindex` and `Typeahead` cannot both own the host's `OnKeyDownFocus`;
|
|
657
|
+
and under `virtual` a typed key must move the pointer and extend the query in
|
|
658
|
+
one transition, which two placements cannot do. Its Model slice is `{ current,
|
|
659
|
+
query, generation }`, its args are `RovingTabindex`'s plus `timeoutMs` and
|
|
660
|
+
`page`, and its Behavior takes `{ container, item, items, text, direction? }`.
|
|
661
|
+
`Typed { char, match }` carries the item the query now picks, so `update` sets
|
|
662
|
+
`current` and `query` together.
|
|
663
|
+
|
|
664
|
+
```ts
|
|
665
|
+
const Nav = Bundle.declare(ListNavigation.bundle, 'nav')
|
|
666
|
+
// place with { args: { orientation: 'vertical', loop: false, virtual: false, timeoutMs: 500, page: 10 } }
|
|
667
|
+
const Keys = ListNavigation.behavior(Nav, args)(ListSlots)<Model, Message>({
|
|
668
|
+
container: 'list',
|
|
669
|
+
item: 'option',
|
|
670
|
+
items: model => describeFruits(model.fruits),
|
|
671
|
+
text: (model, index) => model.fruits[index]?.label ?? '',
|
|
672
|
+
})
|
|
673
|
+
```
|
|
674
|
+
|
|
675
|
+
`GridNavigation` is the two-dimensional counterpart for cells laid out in rows
|
|
676
|
+
of `columns` (a calendar grid, a swatch picker, an emoji palette). Its Model
|
|
677
|
+
slice and item attributes are `RovingTabindex`'s, so the two are
|
|
678
|
+
interchangeable on a view; only the pure `move` differs. Left and right step
|
|
679
|
+
within the row and up and down within the column, skipping disabled cells;
|
|
680
|
+
under `wrap` a horizontal key continues into the next row and a vertical key
|
|
681
|
+
into the next column, otherwise the key is consumed at the edge. Home and End
|
|
682
|
+
are the row's first and last enabled cell, Ctrl+Home and Ctrl+End the grid's.
|
|
683
|
+
RTL swaps left and right, and `virtual` works as it does for `RovingTabindex`.
|
|
684
|
+
|
|
685
|
+
```ts
|
|
686
|
+
const Cells = Bundle.declare(GridNavigation.bundle, 'cells')
|
|
687
|
+
// place with { args: { columns: 7, wrap: false, virtual: false } }
|
|
688
|
+
const Keys = GridNavigation.behavior(Cells, args)(CalendarSlots)<Model, Message>({
|
|
689
|
+
container: 'grid',
|
|
690
|
+
item: 'day',
|
|
691
|
+
items: model => describeDays(model.days),
|
|
692
|
+
})
|
|
693
|
+
```
|
|
694
|
+
|
|
695
|
+
`TreeNavigation` is the tree counterpart, after the WAI-ARIA tree pattern,
|
|
696
|
+
for a file explorer, a page's layers, or a nested menu: Up and Down through
|
|
697
|
+
the rows that are showing, Right opens a row or steps into it, Left closes it or
|
|
698
|
+
steps out to its parent, Home and End go to the first and last row. The view
|
|
699
|
+
gives every row, open or not, in tree order, each with its `parent` and whether
|
|
700
|
+
it is a `branch`; `TreeNavigation.shown(rows, model, args)` is the rows that are
|
|
701
|
+
showing, with their level and place among their siblings. The Model slice is
|
|
702
|
+
`{ current, toggled }`: openness is stored as the rows toggled away from
|
|
703
|
+
`openByDefault`, so a layers panel that starts open and a file tree that starts
|
|
704
|
+
closed are one bundle with a different arg. The Behavior writes each showing
|
|
705
|
+
row's `id` (through `domId`, by default the row's own), `role="treeitem"`,
|
|
706
|
+
`aria-level`, `aria-posinset`, `aria-setsize`, `aria-expanded` on a branch,
|
|
707
|
+
`aria-disabled`, a roving `tabindex` and `OnFocus`; the container's keys move
|
|
708
|
+
focus by id, or open and close the current row in place. Focus in the tree
|
|
709
|
+
follows its stop when a transition it did not see moves it or removes the
|
|
710
|
+
focused row (the `FollowTabStop` Mount, in `foldkit-primitives/dom`, which
|
|
711
|
+
never takes focus from outside the container). The level is also the
|
|
712
|
+
custom property `--fk-tree-level`, 1 at the top, so one rule indents any depth:
|
|
713
|
+
`padding-inline-start: calc(var(--fk-tree-level) * 1rem)`.
|
|
714
|
+
|
|
715
|
+
```ts
|
|
716
|
+
const Layers = Bundle.declare(TreeNavigation.bundle, 'layers')
|
|
717
|
+
// place with { args: { openByDefault: true } }
|
|
718
|
+
const Keys = TreeNavigation.behavior(Layers, { openByDefault: true })(TreeSlots)<Model, Message>({
|
|
719
|
+
container: 'tree',
|
|
720
|
+
item: 'row',
|
|
721
|
+
rows: model => model.folders.map(folder => ({ id: folder.id, parent: folder.parent, branch: folder.hasChildren })),
|
|
722
|
+
})
|
|
723
|
+
```
|
|
724
|
+
|
|
725
|
+
The pure `move(shown, current, key, modifiers, model, args, direction?)` answers
|
|
726
|
+
with `Focus`, `Open` or `Close`, for a view that wires its own.
|
|
727
|
+
|
|
728
|
+
`FocusScope` is the one entry here that is a Mount, not a Bundle: which
|
|
729
|
+
element has focus is a DOM fact, so nothing crosses to the Model. The Mount
|
|
730
|
+
lives in `foldkit-primitives/dom`; `FocusScope.behavior(Slots)<Input,
|
|
731
|
+
Message>({ container, contain?, restore?, initialFocus? })` attaches it to a
|
|
732
|
+
container slot. On insert the container focuses `initialFocus`, else its first
|
|
733
|
+
tabbable descendant, else itself. With `contain` (default), Tab from the last
|
|
734
|
+
tabbable wraps to the first, Shift+Tab from the first wraps to the last, and
|
|
735
|
+
focus that lands outside comes straight back; Tab in the middle is the
|
|
736
|
+
browser's. On unmount, with `restore` (default), focus returns to the element
|
|
737
|
+
that had it, if it is still in the document. A native `<dialog>` does all of
|
|
738
|
+
this itself; this is for a custom overlay, a menu, or a command palette.
|
|
739
|
+
`tabbableWithin(element)` is exported: focusable, visible descendants with a
|
|
740
|
+
non-negative `tabindex`, in order.
|
|
741
|
+
|
|
742
|
+
`Press` turns pointer and keyboard activation of one element into one fact.
|
|
743
|
+
Foldkit's declarative pointer attributes carry no button, pointer id, or click
|
|
744
|
+
detail, so `Press.events` is a Mount that reports what the element saw
|
|
745
|
+
(`PointerDown`, `PointerUp`, `PointerCancelled`, `KeyDown`, `KeyUp`,
|
|
746
|
+
`Clicked`), and the Bundle's `update` decides: primary button only, one
|
|
747
|
+
pointer at a time, `pointerleave` and `pointercancel` cancel, Enter and Space
|
|
748
|
+
with a repeat ignored, a click with `detail` 0 (keyboard on a native control,
|
|
749
|
+
or assistive technology) counts, and the ghost click that follows a touch is
|
|
750
|
+
ignored inside a window of `clickSuppressionMs` that a Command on Effect's
|
|
751
|
+
clock closes. Enter and Space are default-prevented on the element, so a
|
|
752
|
+
native control does not also click and Space does not scroll. Activation is
|
|
753
|
+
the OutMessage `Pressed { pointerType, shiftKey }`, and the placement must handle it:
|
|
754
|
+
|
|
755
|
+
```ts
|
|
756
|
+
const Button = Bundle.declare(Press.bundle, 'saveButton')
|
|
757
|
+
const placements = Page.assemble(
|
|
758
|
+
Page.at(Button, {
|
|
759
|
+
args: { clickSuppressionMs: 50 },
|
|
760
|
+
onOut: () => model => ({ model, commands: [save(model)] }),
|
|
761
|
+
}),
|
|
762
|
+
)
|
|
763
|
+
const Activate = Press.behavior(Button)(CardSlots)<Model, Message>({
|
|
764
|
+
target: 'save',
|
|
765
|
+
disabled: model => model.saving,
|
|
766
|
+
})
|
|
767
|
+
```
|
|
768
|
+
|
|
769
|
+
The Behavior attaches the Mount to the target slot, writes `data-pressed`
|
|
770
|
+
while the element is down for styling, and marks a disabled target
|
|
771
|
+
`aria-disabled`, which the Mount reads at event time so nothing is reported
|
|
772
|
+
and no remount is needed. The Model slice is `{ pressed, pointerId, key,
|
|
773
|
+
suppressing, generation }`; only `pressed` is meant for a view.
|
|
774
|
+
|
|
775
|
+
`LongPress` is holding for `thresholdMs`. It reads the same facts
|
|
776
|
+
`Press.events` reports, so it needs no Mount of its own; the threshold is a
|
|
777
|
+
Command on Effect's clock carrying a generation, and a release before it fires
|
|
778
|
+
makes its `Elapsed` a no-op. `LongPressed { pointerType }` is the OutMessage,
|
|
779
|
+
required at placement. The Behavior writes `data-holding` while down. `Press`
|
|
780
|
+
and `LongPress` on one slot are refused by the resolver, since both would mount
|
|
781
|
+
`PressEvents`; a slot takes one of them.
|
|
782
|
+
|
|
783
|
+
`Move` is pointer movement as facts, a Mount in `foldkit-primitives/dom`: a
|
|
784
|
+
primary-button pointer down captures the pointer and reports `MoveStarted`,
|
|
785
|
+
each move reports `Moved { deltaX, deltaY }` from where it went down, and up,
|
|
786
|
+
cancel, or lost capture reports `MoveEnded { completed }`. A second pointer
|
|
787
|
+
and a secondary button are ignored; capture is released with the Mount.
|
|
788
|
+
`Move.behavior(Slots)<Input, Message>({ handle, toMessage })` attaches it to a
|
|
789
|
+
`Draggable` slot and maps each fact into the view's Messages; a drag's meaning
|
|
790
|
+
(a threshold, a snap, a reorder) is the parent's `update`.
|
|
791
|
+
|
|
792
|
+
`Targets` is which marked descendant of a container the pointer is over, and
|
|
793
|
+
which one was pressed, as one Mount on the container rather than one per item.
|
|
794
|
+
A descendant is marked by an attribute holding its id (a canvas's
|
|
795
|
+
`data-composition-node`, a table's `data-row`), and the nearest marked ancestor
|
|
796
|
+
of the event's target, inside the container, is the one reported:
|
|
797
|
+
`TargetHovered { id }` once per change, `null` when there is none or the
|
|
798
|
+
pointer leaves, and `TargetPressed { id, shiftKey, altKey, ctrlKey, metaKey }`
|
|
799
|
+
on a click. `preventDefault: true` stops a press's default, such as a link
|
|
800
|
+
navigating inside an editor's canvas. The Mount is `Targets({ attribute,
|
|
801
|
+
preventDefault })` in `foldkit-primitives/dom`; `Targets.behavior(Slots)<Input,
|
|
802
|
+
Message>({ container, attribute, preventDefault?, toMessage })` attaches it.
|
|
803
|
+
`targetOf(container, from, attribute)` is the pure lookup.
|
|
804
|
+
|
|
805
|
+
`EditableText` is text typed into a marked descendant of a container while it
|
|
806
|
+
is `contenteditable`, as one Mount on the container. The view decides which
|
|
807
|
+
field is editable; the Mount focuses it as it becomes so (made editable, or
|
|
808
|
+
drawn editable), with the caret at its end, and at no other time: a field
|
|
809
|
+
editable all along, or another going away, moves no focus. It reads what is
|
|
810
|
+
typed:
|
|
811
|
+
|
|
812
|
+
- **Text, never markup.** It reads `innerText`. A field is one line unless it
|
|
813
|
+
carries `aria-multiline="true"`, and in one line a line break becomes a
|
|
814
|
+
space. Where the browser lacks `contenteditable="plaintext-only"`, a paste is
|
|
815
|
+
inserted as its text.
|
|
816
|
+
- **`TextEdited { field, text }`** on each change, `field` being the marking
|
|
817
|
+
attribute's value. Nothing is reported while an input method composes; the
|
|
818
|
+
composed text arrives once.
|
|
819
|
+
- **`TextCommitted { field, text }`** on Enter (Shift+Enter breaks a line in a
|
|
820
|
+
multiline field) or on leaving the field; **`TextCancelled { field, initial }`**
|
|
821
|
+
on Escape, which also puts the text the field had when the edit began (on
|
|
822
|
+
focus, or on the first keystroke after an edit ended) back in the DOM, the
|
|
823
|
+
caret at its end. A view that stopped redrawing the field while it was edited
|
|
824
|
+
would not. An edit ends once: the blur after Enter commits nothing more.
|
|
825
|
+
- **Focus comes back to the container** when Enter or Escape ended an edit and
|
|
826
|
+
the view then removes the field or makes it no longer editable, which leaves
|
|
827
|
+
focus on nothing; give the container a `tabindex`, and the next key (an
|
|
828
|
+
undo) reaches it. A field left by the author keeps no such claim.
|
|
829
|
+
- **`EditAsked { field }`** on a double-click over a marked field that is not
|
|
830
|
+
editable yet: the view's cue to make it so.
|
|
831
|
+
|
|
832
|
+
The Mount is `EditableText({ attribute })` in `foldkit-primitives/dom`;
|
|
833
|
+
`EditableText.behavior(Slots)<Input, Message>({ container, attribute, toMessage })`
|
|
834
|
+
attaches it. What a change means (a prop set, an undo group) is the parent's
|
|
835
|
+
`update`.
|
|
836
|
+
|
|
837
|
+
`PointerDrag` is dragging one marked descendant onto another, marked the way
|
|
838
|
+
`Targets` marks them. A primary press on one that moves more than
|
|
839
|
+
`DRAG_THRESHOLD` (4px) starts a drag (`DragStarted { id }`); then, once per
|
|
840
|
+
change, `DraggedOver { over }` says which other marked descendant the pointer
|
|
841
|
+
is over and in which third of its box, `{ id, zone: 'before' | 'inside' |
|
|
842
|
+
'after' }`, or `null`. Releasing is `DragDropped { id, over }`, and Escape, a
|
|
843
|
+
cancelled pointer, or a button found released mid-drag is `DragCancelled { id }`.
|
|
844
|
+
An Escape that cancels is heard on the way down and goes no further, so the
|
|
845
|
+
focused element's own Escape (a canvas's deselect) does not also run.
|
|
846
|
+
It follows the pointer that pressed and ignores a second one; the element under
|
|
847
|
+
it is found by position, so a touch or a pen, which the browser captures to
|
|
848
|
+
where it went down, drags too, once the marked elements have `touch-action:
|
|
849
|
+
none` so a finger drags rather than scrolls. The click a drop ends with is
|
|
850
|
+
swallowed, so a `Targets` on the same container does not also press. A press
|
|
851
|
+
in `contenteditable` text selects text and starts no drag. It writes
|
|
852
|
+
no roles, `tabindex` or keys, so it sits beside a tree's or a listbox's own;
|
|
853
|
+
the keyboard's way to do what a drag does is yours to give. Boxes are measured
|
|
854
|
+
as the pointer moves and never kept; an element drawn as `display: contents`
|
|
855
|
+
is measured by its first child (`boxOf`). The Mount is
|
|
856
|
+
`PointerDrag({ attribute })` in `foldkit-primitives/dom`;
|
|
857
|
+
`PointerDrag.behavior(Slots)<Input, Message>({ container, attribute, toMessage })`
|
|
858
|
+
attaches it, and `zoneOf(box, y)` is the pure split.
|
|
859
|
+
|
|
860
|
+
A drag may also land somewhere other than among its own: with
|
|
861
|
+
`targets: { attribute: 'data-composition-node', within: '#page' }`, what is
|
|
862
|
+
dragged is still one of the container's marked descendants (a palette's
|
|
863
|
+
tiles), and `over` is an element marked by that attribute inside the one
|
|
864
|
+
`within` selects (the page's nodes). Its own tiles, and a matching element
|
|
865
|
+
outside that one, are then over nothing. `within` is looked for nearest
|
|
866
|
+
first, under the container's closest ancestor that holds a match, so two
|
|
867
|
+
editors on one page each drop onto their own page. Such a drag's
|
|
868
|
+
`DraggedOver` and `DragDropped` also carry `region`: whether the pointer is
|
|
869
|
+
inside that element, so `over: null` with `region: true` is its empty space
|
|
870
|
+
(an empty page, the space below its last node), and with `false`, elsewhere.
|
|
871
|
+
|
|
872
|
+
`FocusVisible` is the one entry whose Bundle lives elsewhere: `InputModality`
|
|
873
|
+
in `foldkit-primitives/events` keeps `{ modality }` (`'keyboard'`, `'pointer'`,
|
|
874
|
+
or `'unknown'` before any input), fed by the window's `keydown` (a modifier
|
|
875
|
+
alone says nothing) and `pointerdown`. `FocusVisible.behavior(Declared)(Slots)
|
|
876
|
+
<Model, Message>({ target })` writes `data-focus-visible` on the target while
|
|
877
|
+
the page is driven by keyboard, so a stylesheet shows a ring with
|
|
878
|
+
`[data-focus-visible]:focus`. CSS `:focus-visible` does this with no Model at
|
|
879
|
+
all; this is for a design system that must decide in the Model, or show the
|
|
880
|
+
same answer somewhere other than the focused element.
|
|
881
|
+
|
|
882
|
+
`DismissLayer` closes overlays that are not native `<dialog>` or `popover`
|
|
883
|
+
elements: Escape closes the topmost open layer, and a pointer press closes
|
|
884
|
+
the layers it is outside of. One Bundle, **placed once**, owns the document
|
|
885
|
+
listeners; each layer's Behavior marks its container with
|
|
886
|
+
`data-foldkit-plus-layer="<id>"` and its trigger with the matching trigger
|
|
887
|
+
attribute. The stack is the DOM order of the marked elements at the moment of
|
|
888
|
+
the event, so a layer takes part exactly while it is rendered and an `open`
|
|
889
|
+
flag in the parent Model is its whole lifecycle; nothing registers. The rules,
|
|
890
|
+
each a test: a press inside a parent layer is outside its children, so the
|
|
891
|
+
parent stays and the children go; a press on a layer's trigger counts as
|
|
892
|
+
inside it, so a click on the trigger never dismisses and reopens; a layer
|
|
893
|
+
placed with `outsidePress: false` or `escape: false` opts out of that path.
|
|
894
|
+
`Dismiss { ids }` is the OutMessage, and the placement's `onOut` closes them:
|
|
895
|
+
|
|
896
|
+
```ts
|
|
897
|
+
const Layers = Bundle.declare(DismissLayer.bundle, 'layers')
|
|
898
|
+
const placements = Page.assemble(
|
|
899
|
+
Page.at(Layers, {
|
|
900
|
+
onOut: ({ ids }) => model => ({ model: { ...model, menuOpen: ids.includes('menu') ? false : model.menuOpen } }),
|
|
901
|
+
}),
|
|
902
|
+
)
|
|
903
|
+
const Dismissable = DismissLayer.behavior(Layers)(MenuSlots)<Model, Message>({
|
|
904
|
+
layer: 'panel',
|
|
905
|
+
trigger: 'button',
|
|
906
|
+
id: () => 'menu',
|
|
907
|
+
})
|
|
908
|
+
```
|
|
909
|
+
|
|
910
|
+
The Model slice is `{ layers }`, the open layers as the last event saw them,
|
|
911
|
+
for DevTools and agents. `toDismiss(layers, inside)` is the pure rule.
|
|
912
|
+
|
|
913
|
+
`Layers.scrollLock(Slots)({ container })` and `Layers.hideOutside(Slots)({
|
|
914
|
+
container })` are Mounts over Foldkit's own `Dom.lockScroll` and
|
|
915
|
+
`Dom.inertOthers`: the first locks the document's scroll while the container is
|
|
916
|
+
mounted, refcounted so nested overlays release together, with Foldkit's iOS
|
|
917
|
+
handling; the second marks everything outside the container inert while it is
|
|
918
|
+
mounted, keyed by an id the Mount mints so two overlays restore independently.
|
|
919
|
+
Both live in `foldkit-primitives/dom` as `ScrollLock` and `HideOutside`. A
|
|
920
|
+
native `<dialog>` shown modally needs neither.
|
|
921
|
+
|
|
922
|
+
`Selection` is which items are selected, with `mode` `'single'` (a click
|
|
923
|
+
replaces; `allowEmpty` says whether clicking the selected item deselects it),
|
|
924
|
+
`'multiple'` (a click toggles), or `'none'`, and the `anchor` a range extends
|
|
925
|
+
from. The Model slice is `{ selected, anchor }`. A range needs the items'
|
|
926
|
+
order, which the view knows and the Bundle does not, so `Ranged { id, order }`
|
|
927
|
+
carries it; `Selection.between(order, from, to)` is the pure span. The Behavior
|
|
928
|
+
(`Selection.behavior(Declared, args)(Slots)<Model, Message>({ container?,
|
|
929
|
+
item, items, click? })`) writes `aria-selected` on each item and
|
|
930
|
+
`aria-multiselectable` on the container, and wires a plain click on each
|
|
931
|
+
enabled item to `Activated`; pass `click: false` when `Press` or the view owns
|
|
932
|
+
the click. For a Shift range, `Press`'s `Pressed { pointerType, shiftKey }`
|
|
933
|
+
says whether Shift was held, and the placement's `onOut` dispatches `Ranged`.
|
|
934
|
+
|
|
935
|
+
`LiveAnnounce` speaks to assistive technology: one Bundle, placed once, holds
|
|
936
|
+
the text of a polite and an assertive live region. `say(Declared)(text,
|
|
937
|
+
politeness?)` builds the Message to return from `update` or an `onOut`; an
|
|
938
|
+
announcement waits `debounceMs` so a burst reads once, then clears after
|
|
939
|
+
`clearAfterMs`, both on Effect's clock with a generation so a superseded timer
|
|
940
|
+
changes nothing; the same text twice gets a trailing no-break space toggled,
|
|
941
|
+
which is what makes a screen reader read it again. `LiveAnnounce.view(slice,
|
|
942
|
+
h)` renders the two regions: put it once in the page and hide them visually
|
|
943
|
+
with a rule on `[data-foldkit-plus-live]`, never `display: none`.
|
|
944
|
+
|
|
474
945
|
## Testing placements
|
|
475
946
|
|
|
476
947
|
Every primitive is testable without the platform, following one pattern
|
|
@@ -502,7 +973,7 @@ application uses.
|
|
|
502
973
|
| A Command without its platform API | failure Message (`CopyFailed`, `ShareFailed`, `BroadcastFailed`), never a throw |
|
|
503
974
|
| Listener removed (unmount, gate closed) | finalizer disconnects; resubscribing re-reads the current value |
|
|
504
975
|
| A Message for an unplaced child | never routes: wrappers only match placed variants |
|
|
505
|
-
|
|
|
976
|
+
| MediaStream acquisition fails | parks at `denied`/`idle` with requirements cleared; `Started` retries |
|
|
506
977
|
|
|
507
978
|
## Limits
|
|
508
979
|
|