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.
Files changed (57) hide show
  1. package/README.md +514 -43
  2. package/dist/device/index.d.mts +11 -11
  3. package/dist/device/index.d.mts.map +1 -1
  4. package/dist/device/index.mjs +95 -87
  5. package/dist/device/index.mjs.map +1 -1
  6. package/dist/dom/index.d.mts +71 -1
  7. package/dist/dom/index.d.mts.map +1 -1
  8. package/dist/dom/index.mjs +303 -5
  9. package/dist/dom/index.mjs.map +1 -1
  10. package/dist/editable-text-CKkS_mWt.d.mts +235 -0
  11. package/dist/editable-text-CKkS_mWt.d.mts.map +1 -0
  12. package/dist/events/index.d.mts +10 -1
  13. package/dist/events/index.d.mts.map +1 -1
  14. package/dist/events/index.mjs +34 -18
  15. package/dist/events/index.mjs.map +1 -1
  16. package/dist/follow-tab-stop-DpxwLXvU.mjs +800 -0
  17. package/dist/follow-tab-stop-DpxwLXvU.mjs.map +1 -0
  18. package/dist/index-Cppf-lBx.d.mts +130 -0
  19. package/dist/index-Cppf-lBx.d.mts.map +1 -0
  20. package/dist/index.d.mts +8 -6
  21. package/dist/index.mjs +8 -6
  22. package/dist/input-modality-DGOhAknO.d.mts +26 -0
  23. package/dist/input-modality-DGOhAknO.d.mts.map +1 -0
  24. package/dist/input-modality-m6v7__Xs.mjs +43 -0
  25. package/dist/input-modality-m6v7__Xs.mjs.map +1 -0
  26. package/dist/interaction/index.d.mts +1017 -0
  27. package/dist/interaction/index.d.mts.map +1 -0
  28. package/dist/interaction/index.mjs +1647 -0
  29. package/dist/interaction/index.mjs.map +1 -0
  30. package/dist/internal-DP-xLKbT.mjs +37 -0
  31. package/dist/internal-DP-xLKbT.mjs.map +1 -0
  32. package/dist/media/index.d.mts.map +1 -1
  33. package/dist/media/index.mjs +3 -3
  34. package/dist/media/index.mjs.map +1 -1
  35. package/dist/motion/index.d.mts +2 -112
  36. package/dist/motion/index.mjs +2 -208
  37. package/dist/motion-DGGBZ0wt.mjs +240 -0
  38. package/dist/motion-DGGBZ0wt.mjs.map +1 -0
  39. package/dist/net/index.d.mts +68 -4
  40. package/dist/net/index.d.mts.map +1 -1
  41. package/dist/net/index.mjs +139 -56
  42. package/dist/net/index.mjs.map +1 -1
  43. package/dist/observers/index.d.mts +1 -1
  44. package/dist/observers/index.mjs +3 -0
  45. package/dist/observers/index.mjs.map +1 -1
  46. package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
  47. package/dist/state/index.d.mts +57 -15
  48. package/dist/state/index.d.mts.map +1 -1
  49. package/dist/state/index.mjs +198 -90
  50. package/dist/state/index.mjs.map +1 -1
  51. package/dist/time/index.d.mts +21 -1
  52. package/dist/time/index.d.mts.map +1 -1
  53. package/dist/time/index.mjs +48 -19
  54. package/dist/time/index.mjs.map +1 -1
  55. package/package.json +31 -8
  56. package/dist/motion/index.d.mts.map +0 -1
  57. 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
- Note the direction: init is pure, so SSR renders the default; the stream corrects it live.
70
+ The parent Model owns the recorded fact; subscriptions report environment changes.
61
71
  ```
62
72
 
63
- Every primitive follows one lifecycle: `init` returns a safe default without
64
- touching the environment (SSR-safe by construction). Streams report live
65
- facts — MediaQuery reads the current match first, then changes; Timer ticks
66
- while running. A primitive that cannot observe (no `window`, no API) yields
67
- an empty stream instead of throwing, so the slice keeps its default.
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
- **Declare where it lives.** A declaration names the Model field and the
98
- Message variant, by Foldkit's `Got<Field>Message` convention:
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 { defineMessageUnion } from 'foldkit/message'
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 Dark = Bundle.declare(MediaQuery, 'dark')
107
-
108
- const Model = Schema.Struct({ ...Dark.fields, theme: Schema.String })
109
- type Model = typeof Model.Type
110
- const Message = defineMessageUnion({ ...Dark.cases, ThemeSet: { theme: Schema.String } })
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
- **Place it.** The scope types everything from the parent's Schemas; the
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 Page = Bundle.parent({ Model, Message })
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 the placement calls perform I/O:
132
- `declare` names a slot, `at` binds config to it, `assemble` derives the
133
- folding and the streams — the browser is touched only when the runtime
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 placements = Page.assemble(Page.place(PrefersDark, 'dark'))
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, `SendFailed` when no connection is open — a
206
- closed socket's `send` is a silent no-op per spec, so the bundle checks first.
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. Restarting keeps the count; only `Ticked`
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. When the
284
- handler must cancel the browser default, core
285
- `Subscription.fromEventFilterMap` maps synchronously inside dispatch. Lift
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. They keep observing across time-travel pause — replay traffic
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. No Model involved: the clipboard is not application state.
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 drops
387
- the redo future, `Undo`/`Redo` move one step, `Clear` empties both sides while
388
- keeping the present. The past holds at most `capacity` entries (default 100);
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. Both are pure logic, no streams.
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
- | A failing device (denied camera, unknown permission) | parks at `denied`/`idle` with requirements cleared: no acquire loop |
976
+ | MediaStream acquisition fails | parks at `denied`/`idle` with requirements cleared; `Started` retries |
506
977
 
507
978
  ## Limits
508
979