foldkit-primitives 0.2.0 → 0.3.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 +358 -37
- package/dist/device/index.d.mts +2 -2
- package/dist/dom/index.d.mts +2 -0
- package/dist/dom/index.d.mts.map +1 -1
- package/dist/dom/index.mjs +2 -1
- package/dist/dom/index.mjs.map +1 -1
- package/dist/events/index.d.mts +2 -0
- package/dist/events/index.d.mts.map +1 -1
- package/dist/events/index.mjs +2 -1
- package/dist/events/index.mjs.map +1 -1
- package/dist/index-Cppf-lBx.d.mts +130 -0
- package/dist/index-Cppf-lBx.d.mts.map +1 -0
- package/dist/index.d.mts +4 -2
- package/dist/index.mjs +4 -2
- 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 +862 -0
- package/dist/interaction/index.d.mts.map +1 -0
- package/dist/interaction/index.mjs +1364 -0
- package/dist/interaction/index.mjs.map +1 -0
- package/dist/motion/index.d.mts +2 -112
- package/dist/motion/index.mjs +2 -208
- package/dist/motion-LfJYAMnP.mjs +239 -0
- package/dist/motion-LfJYAMnP.mjs.map +1 -0
- package/dist/move-CZwHyTGk.mjs +215 -0
- package/dist/move-CZwHyTGk.mjs.map +1 -0
- package/dist/move-DNiMPcuf.d.mts +58 -0
- package/dist/move-DNiMPcuf.d.mts.map +1 -0
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/package.json +20 -8
- package/dist/motion/index.d.mts.map +0 -1
- package/dist/motion/index.mjs.map +0 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# `foldkit-primitives`
|
|
2
2
|
|
|
3
|
-
Ready-made [`foldkit-bundle`](
|
|
3
|
+
Ready-made [`foldkit-bundle`](../bundle) primitives: media queries, breakpoints,
|
|
4
4
|
presence, timers, intervals, debounce, tweens, springs, pagination, history,
|
|
5
5
|
locales, selections, ranges, geolocation, cameras, permissions, sockets,
|
|
6
6
|
broadcasts, observers, and clipboard. Most are ordinary bundles — Model,
|
|
@@ -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 |
|
|
@@ -33,6 +37,15 @@ package depends on `foldkit-bundle` and nothing else (besides peers).
|
|
|
33
37
|
| One-shot actions | Command, not a bundle | clipboard copy, share, script load |
|
|
34
38
|
| Derived data | pure function, not a bundle | range, relative time, platform |
|
|
35
39
|
|
|
40
|
+
Choose by what you need to own. State that outlives the moment — a match,
|
|
41
|
+
a count, a page, a position — wants a bundle: the Model keeps it, replay
|
|
42
|
+
sees it. A stream you only react to wants an entry: map it into the
|
|
43
|
+
parent's Message and keep nothing. Work bound to one element wants a
|
|
44
|
+
Mount. Work that runs once and reports back wants a Command. A value
|
|
45
|
+
computed from data you already have wants a pure function. When in doubt,
|
|
46
|
+
start with the lighter form; promote to a bundle the day the state needs
|
|
47
|
+
a name in the Model.
|
|
48
|
+
|
|
36
49
|
A bundle holds no state and performs no I/O by itself. The parent Model owns
|
|
37
50
|
the placed slice; the browser (or server, or clock) only reports facts as
|
|
38
51
|
Messages. The same rule as everywhere else: observation is not ownership.
|
|
@@ -48,14 +61,14 @@ accident — one state machine leaves nowhere for a second one to live.
|
|
|
48
61
|
```text
|
|
49
62
|
browser / clock / server ──facts as Messages──▶ update ──▶ Model slice
|
|
50
63
|
▲
|
|
51
|
-
|
|
64
|
+
The parent Model owns the recorded fact; subscriptions report environment changes.
|
|
52
65
|
```
|
|
53
66
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
67
|
+
Initialization creates the Model slice; subscriptions observe ongoing facts and
|
|
68
|
+
report Messages; `update` stores them. Defaults differ by primitive:
|
|
69
|
+
MediaQuery starts at `false`, while Online, Visibility, and Locale read an
|
|
70
|
+
available platform value during initialization. Missing browser APIs have
|
|
71
|
+
primitive-specific fallbacks; do not infer readiness from an SSR default.
|
|
59
72
|
|
|
60
73
|
## Install
|
|
61
74
|
|
|
@@ -69,49 +82,80 @@ pnpm add foldkit-primitives foldkit-bundle effect foldkit
|
|
|
69
82
|
`foldkit-primitives/events`, `foldkit-primitives/observers`, `foldkit-primitives/dom` —
|
|
70
83
|
so bundlers drop the primitives you never import.
|
|
71
84
|
|
|
85
|
+
## Map of the package
|
|
86
|
+
|
|
87
|
+
Each subpath is one concern, one import:
|
|
88
|
+
|
|
89
|
+
- `media` — environment facts: MediaQuery (+presets), Breakpoints, platform
|
|
90
|
+
- `net` — remote facts: Online, WebSocket, SSE, BroadcastChannel
|
|
91
|
+
- `time` — clock facts: Timer, Interval, Debounce, Throttle, relative time
|
|
92
|
+
- `state` — owned UI state: Pagination, History, Locale, SelectionSet, Virtual, range
|
|
93
|
+
- `motion` — animation state: Tween, Spring, Presence
|
|
94
|
+
- `interaction` — a Bundle (or Mount) and its `foldkit-mixins` Behavior: RovingTabindex, Typeahead, ListNavigation, GridNavigation, FocusScope, Press, LongPress, Move, FocusVisible, DismissLayer, ScrollLock, HideOutside, Selection, LiveAnnounce
|
|
95
|
+
- `device` — hardware: Geolocation, MediaDevices, MediaStream, Permissions, Fullscreen
|
|
96
|
+
- `events` — raw browser events: Visibility, WindowSize, Idle, InputModality, keyboard, pointer, scroll, focus
|
|
97
|
+
- `observers` — element Mounts: Resize, Intersection, Mutation, Bounds
|
|
98
|
+
- `dom` — element Mounts and one-shot Commands: Autofocus, FocusScope, Move, ScrollLock, HideOutside, InputMask, clipboard, share, script loading
|
|
99
|
+
|
|
72
100
|
## Sixty seconds: follow the color scheme
|
|
73
101
|
|
|
74
|
-
**
|
|
75
|
-
Message
|
|
102
|
+
**Place it in a parent.** `Bundle.compose` states the parent's own field and
|
|
103
|
+
Message, and places the primitive under `dark`, with the wrapper Message
|
|
104
|
+
`GotDarkMessage` by Foldkit's `Got<Field>Message` convention:
|
|
76
105
|
|
|
77
106
|
```ts
|
|
78
107
|
import { Schema } from 'effect'
|
|
79
|
-
import {
|
|
108
|
+
import type { HtmlBuilder } from 'foldkit/html'
|
|
80
109
|
import { Bundle } from 'foldkit-bundle'
|
|
81
110
|
import { MediaQuery } from 'foldkit-primitives/media'
|
|
82
111
|
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
type Message = typeof Message.Type
|
|
112
|
+
const Page = Bundle.compose({ theme: Schema.String }).pipe(
|
|
113
|
+
Bundle.withMessages({ ThemeSet: { theme: Schema.String } }),
|
|
114
|
+
Bundle.withChild('dark', MediaQuery, { args: { query: '(prefers-color-scheme: dark)' } }),
|
|
115
|
+
)
|
|
116
|
+
type Model = typeof Page.Model.Type
|
|
117
|
+
type Message = typeof Page.Message.Type
|
|
89
118
|
```
|
|
90
119
|
|
|
91
|
-
**
|
|
92
|
-
assembly derives the update and the Subscriptions:
|
|
120
|
+
**Run it.** The parent's assembly derives the update and the Subscriptions:
|
|
93
121
|
|
|
94
122
|
```ts
|
|
95
|
-
const
|
|
96
|
-
const placements = Page.assemble(Page.at(Dark, { args: { query: '(prefers-color-scheme: dark)' } }))
|
|
123
|
+
const { placements } = Page
|
|
97
124
|
|
|
98
125
|
const config = placements.complete({
|
|
99
126
|
init: () => placements.initial({ theme: 'light' }),
|
|
100
127
|
update: placements.update(model => ({ model })),
|
|
101
|
-
view,
|
|
128
|
+
view: (model: Model, h: HtmlBuilder<Message>) =>
|
|
129
|
+
h.div([], [model.dark.matches ? 'Dark mode' : 'Light mode']),
|
|
102
130
|
subscriptions: placements.subscriptions(),
|
|
103
131
|
})
|
|
104
132
|
```
|
|
105
133
|
|
|
106
134
|
`update` routes `GotDarkMessage` to the bundle; `subscriptions` runs the
|
|
107
135
|
`matchMedia` stream; `initial` starts `matches` at `false` and the stream
|
|
108
|
-
corrects it on subscribe.
|
|
136
|
+
corrects it on subscribe. None of these calls perform I/O: `compose` states
|
|
137
|
+
the parent, `withChild` places the primitive with its config, and the
|
|
138
|
+
assembly derives the folding and the streams. The browser is touched only
|
|
139
|
+
when the runtime subscribes. The Solid equivalent this replaces:
|
|
109
140
|
|
|
110
141
|
```ts
|
|
111
142
|
// solid-primitives: const dark = createMediaQuery('(prefers-color-scheme: dark)')
|
|
112
143
|
// Here the fact lives in the Model: replay, DevTools, and time travel see it.
|
|
113
144
|
```
|
|
114
145
|
|
|
146
|
+
## Composing placements
|
|
147
|
+
|
|
148
|
+
One assembly holds every placement: spread its update, init,
|
|
149
|
+
subscriptions, and resources once per application, as above. Two
|
|
150
|
+
placements of one bundle observe twice — share the field instead, so one
|
|
151
|
+
stream feeds every reader. One assembly holds one socket, stream, or
|
|
152
|
+
watch: resource tags are per module, and `assemble` refuses the second.
|
|
153
|
+
Bundles whose settled value is the whole point (`Debounce`, `Throttle`)
|
|
154
|
+
surface it as an OutMessage the placement handles with `onOut`, so the
|
|
155
|
+
signal can never be dropped by omission. Entries lift with
|
|
156
|
+
`Subscription.persistent`, mapping into the parent's Message; Mounts
|
|
157
|
+
attach in views with `h.OnMount`.
|
|
158
|
+
|
|
115
159
|
## Media: `foldkit-primitives/media`
|
|
116
160
|
|
|
117
161
|
> Reference: [`./media/README.md`](./media/README.md)
|
|
@@ -126,7 +170,7 @@ Bound presets place with no args:
|
|
|
126
170
|
```ts
|
|
127
171
|
import { PrefersDark, PrefersReducedMotion } from 'foldkit-primitives/media'
|
|
128
172
|
|
|
129
|
-
const
|
|
173
|
+
const Page = Bundle.compose({ theme: Schema.String }).pipe(Bundle.withChild('dark', PrefersDark))
|
|
130
174
|
```
|
|
131
175
|
|
|
132
176
|
`Breakpoints` derives names from one `resize` listener: args
|
|
@@ -266,8 +310,7 @@ skipped. `Bounds()` re-measures `Measured { x, y, width, height }` on
|
|
|
266
310
|
observer, scroll, and resize, starting with the current rect; without a
|
|
267
311
|
ResizeObserver the window events still measure. Without the observer API
|
|
268
312
|
(SSR, old browser) they emit nothing instead of throwing; teardown
|
|
269
|
-
disconnects.
|
|
270
|
-
is same-valued and harmless.
|
|
313
|
+
disconnects. Treat repeated measurements as observations, not proof that a user action occurred.
|
|
271
314
|
|
|
272
315
|
## Device: `foldkit-primitives/device`
|
|
273
316
|
|
|
@@ -362,7 +405,7 @@ const Doc = Bundle.declare(EditHistory, 'doc')
|
|
|
362
405
|
startup with the configured `default` as fallback; `SetLocale` switches it.
|
|
363
406
|
`SelectionSet` keeps string ids in first-selection order: `Select` (keeps
|
|
364
407
|
position), `Deselect`, `Toggle` (re-appends), `ReplaceAll` (deduped), and
|
|
365
|
-
`Clear`. `isSelected` reads membership.
|
|
408
|
+
`Clear`. `isSelected` reads membership. Neither subscribes to changes; Locale reads the initial browser language when available.
|
|
366
409
|
Keyed children — lists with stable identity — place through the bundle
|
|
367
410
|
mechanism's `each`.
|
|
368
411
|
`range(start, end, step?)` counts half-open numbers — the pagination page
|
|
@@ -403,19 +446,20 @@ browser), restored through a `MirrorRestored` Message the application
|
|
|
403
446
|
reduces. Nothing here duplicates it — reach for the mirror when a slice
|
|
404
447
|
should survive reload, and keep this package's bundles for live facts.
|
|
405
448
|
|
|
406
|
-
## With Surface and Mirror
|
|
407
|
-
|
|
408
|
-
Placed state is ordinary Model, so the surrounding tools apply unchanged —
|
|
409
|
-
no bundle-specific Surface or Mirror API exists, by design. Declare a Surface
|
|
410
|
-
over the placed fields to render them or expose them to an agent; point
|
|
411
|
-
`Mirror.url` at them to link them; spread the bundle's cases into the same
|
|
412
|
-
unions. The field refs and wrapper Messages are the same ones the rest of the
|
|
413
|
-
application uses.
|
|
414
|
-
|
|
415
449
|
## Motion: `foldkit-primitives/motion`
|
|
416
450
|
|
|
417
451
|
> Reference: [`./motion/README.md`](./motion/README.md)
|
|
418
452
|
|
|
453
|
+
Whether motion should be reduced is a service, `Motion`, read when a
|
|
454
|
+
transition starts rather than sniffed once. `Presence` then exits at once,
|
|
455
|
+
and `Tween` and `Spring` jump to `to`, in the same Messages, so the Model sees
|
|
456
|
+
the same transitions. Provide `Motion.live` (the user's
|
|
457
|
+
`prefers-reduced-motion`) through the assembly's resources, or `Motion.reduced`
|
|
458
|
+
and `Motion.full` in a test or for a setting the application owns. With no
|
|
459
|
+
service provided, motion is full, so a placement that provides nothing behaves
|
|
460
|
+
as before. `Motion.reducedMotion` is the Effect the bundles read, for a
|
|
461
|
+
transition of your own.
|
|
462
|
+
|
|
419
463
|
`Tween` animates one number from `from` to `to` over `ms` milliseconds.
|
|
420
464
|
Model `{ value, running }`, Messages `Started`/`Ticked`/`Finished`, args
|
|
421
465
|
`{ from, to, ms }` (a non-positive or non-finite duration is rejected at
|
|
@@ -441,6 +485,274 @@ exact end value even when an underdamped spring overshoots on the way.
|
|
|
441
485
|
`Tween` (fixed duration, linear) versus `Spring` (physics, settles) — pick
|
|
442
486
|
the motion, not both.
|
|
443
487
|
|
|
488
|
+
## Interaction: `foldkit-primitives/interaction`
|
|
489
|
+
|
|
490
|
+
Interaction state that a view's slots must reflect: the Bundle holds it in the
|
|
491
|
+
Model, and a matching [`foldkit-mixins`](../mixins) Behavior writes the
|
|
492
|
+
attributes and handlers on the slots. Importing this subpath needs
|
|
493
|
+
`foldkit-mixins`; the other subpaths do not.
|
|
494
|
+
|
|
495
|
+
`RovingTabindex` is one tab stop for a set of items: arrows move focus between
|
|
496
|
+
them, the rest stay out of the tab order. The Model slice is `{ current }`, the
|
|
497
|
+
current item's **id**, so a reorder keeps the same item current and a resumed
|
|
498
|
+
page knows where focus was. Args: `orientation` (`'vertical' | 'horizontal' |
|
|
499
|
+
'both'`), `loop`, and `virtual` (focus stays on the container and
|
|
500
|
+
`aria-activedescendant` points at the current item).
|
|
501
|
+
|
|
502
|
+
```ts
|
|
503
|
+
import { Bundle } from 'foldkit-bundle'
|
|
504
|
+
import { Behavior, Behaviors, Capability, Slot, Slots, SlotView } from 'foldkit-mixins'
|
|
505
|
+
import { RovingTabindex } from 'foldkit-primitives/interaction'
|
|
506
|
+
|
|
507
|
+
const Roving = Bundle.declare(RovingTabindex.bundle, 'toolbarFocus')
|
|
508
|
+
const Model = Schema.Struct({ ...Roving.fields, tools: Schema.Array(Tool) })
|
|
509
|
+
const Message = defineMessageUnion({ ...Roving.cases })
|
|
510
|
+
const Page = Bundle.parent({ Model, Message })
|
|
511
|
+
const args = { orientation: 'horizontal', loop: true, virtual: false } as const
|
|
512
|
+
const placements = Page.assemble(Page.at(Roving, { args }))
|
|
513
|
+
|
|
514
|
+
const ToolbarSlots = Slots.define({
|
|
515
|
+
root: Slot.make({ capability: Capability.Container }),
|
|
516
|
+
tool: Slot.make({ capability: Capability.Focusable }),
|
|
517
|
+
})
|
|
518
|
+
const describeTools = (tools: ReadonlyArray<Tool>) =>
|
|
519
|
+
Behaviors.Collection.of(tools, { id: tool => tool.id, disabled: tool => tool.disabled })
|
|
520
|
+
|
|
521
|
+
// Ids on each item come from Collection; RovingTabindex reads them.
|
|
522
|
+
const Ids = Behaviors.Collection.behavior(ToolbarSlots)<Model, Message>({
|
|
523
|
+
item: 'tool',
|
|
524
|
+
items: model => describeTools(model.tools),
|
|
525
|
+
})
|
|
526
|
+
const Focus = RovingTabindex.behavior(Roving, args)(ToolbarSlots)<Model, Message>({
|
|
527
|
+
container: 'root',
|
|
528
|
+
item: 'tool',
|
|
529
|
+
items: model => describeTools(model.tools),
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
const Toolbar = SlotView.forMessages<Message>()
|
|
533
|
+
.define(ToolbarSlots, (model, slots, h) => {
|
|
534
|
+
const items = describeTools(model.tools)
|
|
535
|
+
return h.div(
|
|
536
|
+
slots.root.attrs([h.Role('toolbar')]),
|
|
537
|
+
model.tools.map((tool, index) =>
|
|
538
|
+
h.button(slots.tool.attrs([h.Key(tool.id)], items.slotItem(index)), [tool.label]),
|
|
539
|
+
),
|
|
540
|
+
)
|
|
541
|
+
})
|
|
542
|
+
.pipe(Behavior.attach(Ids), Behavior.attach(Focus))
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
What each half does: the container gets `OnKeyDownFocus`, which on an arrow,
|
|
546
|
+
Home or End focuses the next **enabled** item synchronously by its id, prevents
|
|
547
|
+
the default, and dispatches `Focused { id }`. Each item gets `tabindex` `0` when
|
|
548
|
+
it is the tab stop and `-1` otherwise, and `OnFocus` reporting `Focused`, so a
|
|
549
|
+
click makes an item current too. Before anything is current, or when the
|
|
550
|
+
current item is gone or disabled, the first enabled item is the tab stop. Keys
|
|
551
|
+
with ctrl, alt or meta held are left alone; `direction: model => 'rtl'` swaps
|
|
552
|
+
left and right. The pure `move(enabled, current, key, modifiers, options)` and
|
|
553
|
+
`tabStop(items, current)` are exported for a view that wires its own.
|
|
554
|
+
|
|
555
|
+
Under `virtual` the items get no `tabindex`, the container gets
|
|
556
|
+
`aria-activedescendant`, and a key keeps DOM focus where it is and only moves
|
|
557
|
+
the pointer. Nothing is written on dispose: the attributes are data, so a view
|
|
558
|
+
that no longer attaches the Behavior leaves no `tabindex` behind. PageUp and
|
|
559
|
+
PageDown are handled when `move` is given a `page`; `ListNavigation` below
|
|
560
|
+
does that.
|
|
561
|
+
|
|
562
|
+
`Typeahead` is type-to-find for a host that has no roving tab stop, or whose
|
|
563
|
+
focus is managed elsewhere. The Model slice is `{ query, generation }`:
|
|
564
|
+
printable keys extend the query, a timer of `timeoutMs` on Effect's clock
|
|
565
|
+
clears it (`generation` lets a superseded timer change nothing), and `Cleared`
|
|
566
|
+
drops it on purpose. Which item a query picks is the pure
|
|
567
|
+
`Typeahead.match(texts, enabled, query, current)`: one character, or one
|
|
568
|
+
character repeated, starts *after* the current item so repeated presses cycle;
|
|
569
|
+
a longer query starts *at* it, since the user is refining. Case and leading
|
|
570
|
+
whitespace are ignored and disabled items are skipped. The Behavior
|
|
571
|
+
(`Typeahead.behavior(Declared)(Slots)<Model, Message>({ host, items, text,
|
|
572
|
+
current })`) gives the host `OnKeyDownFocus`: a printable key with no ctrl, alt
|
|
573
|
+
or meta extends the query, focuses the match by id, and dispatches `Typed`;
|
|
574
|
+
with no match the key is still recorded and focus stays put; a space with an
|
|
575
|
+
empty query is left to the host.
|
|
576
|
+
|
|
577
|
+
`ListNavigation` is what a list host takes when it wants both: arrows, Home,
|
|
578
|
+
End, PageUp and PageDown by `page`, and typeahead, in **one placement**. It
|
|
579
|
+
exists for two reasons. The resolver allows one owner per event on a slot, so
|
|
580
|
+
`RovingTabindex` and `Typeahead` cannot both own the host's `OnKeyDownFocus`;
|
|
581
|
+
and under `virtual` a typed key must move the pointer and extend the query in
|
|
582
|
+
one transition, which two placements cannot do. Its Model slice is `{ current,
|
|
583
|
+
query, generation }`, its args are `RovingTabindex`'s plus `timeoutMs` and
|
|
584
|
+
`page`, and its Behavior takes `{ container, item, items, text, direction? }`.
|
|
585
|
+
`Typed { char, match }` carries the item the query now picks, so `update` sets
|
|
586
|
+
`current` and `query` together.
|
|
587
|
+
|
|
588
|
+
```ts
|
|
589
|
+
const Nav = Bundle.declare(ListNavigation.bundle, 'nav')
|
|
590
|
+
// place with { args: { orientation: 'vertical', loop: false, virtual: false, timeoutMs: 500, page: 10 } }
|
|
591
|
+
const Keys = ListNavigation.behavior(Nav, args)(ListSlots)<Model, Message>({
|
|
592
|
+
container: 'list',
|
|
593
|
+
item: 'option',
|
|
594
|
+
items: model => describeFruits(model.fruits),
|
|
595
|
+
text: (model, index) => model.fruits[index]?.label ?? '',
|
|
596
|
+
})
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
`GridNavigation` is the two-dimensional counterpart for cells laid out in rows
|
|
600
|
+
of `columns` (a calendar grid, a swatch picker, an emoji palette). Its Model
|
|
601
|
+
slice and item attributes are `RovingTabindex`'s, so the two are
|
|
602
|
+
interchangeable on a view; only the pure `move` differs. Left and right step
|
|
603
|
+
within the row and up and down within the column, skipping disabled cells;
|
|
604
|
+
under `wrap` a horizontal key continues into the next row and a vertical key
|
|
605
|
+
into the next column, otherwise the key is consumed at the edge. Home and End
|
|
606
|
+
are the row's first and last enabled cell, Ctrl+Home and Ctrl+End the grid's.
|
|
607
|
+
RTL swaps left and right, and `virtual` works as it does for `RovingTabindex`.
|
|
608
|
+
|
|
609
|
+
```ts
|
|
610
|
+
const Cells = Bundle.declare(GridNavigation.bundle, 'cells')
|
|
611
|
+
// place with { args: { columns: 7, wrap: false, virtual: false } }
|
|
612
|
+
const Keys = GridNavigation.behavior(Cells, args)(CalendarSlots)<Model, Message>({
|
|
613
|
+
container: 'grid',
|
|
614
|
+
item: 'day',
|
|
615
|
+
items: model => describeDays(model.days),
|
|
616
|
+
})
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
`FocusScope` is the one entry here that is a Mount, not a Bundle: which
|
|
620
|
+
element has focus is a DOM fact, so nothing crosses to the Model. The Mount
|
|
621
|
+
lives in `foldkit-primitives/dom`; `FocusScope.behavior(Slots)<Input,
|
|
622
|
+
Message>({ container, contain?, restore?, initialFocus? })` attaches it to a
|
|
623
|
+
container slot. On insert the container focuses `initialFocus`, else its first
|
|
624
|
+
tabbable descendant, else itself. With `contain` (default), Tab from the last
|
|
625
|
+
tabbable wraps to the first, Shift+Tab from the first wraps to the last, and
|
|
626
|
+
focus that lands outside comes straight back; Tab in the middle is the
|
|
627
|
+
browser's. On unmount, with `restore` (default), focus returns to the element
|
|
628
|
+
that had it, if it is still in the document. A native `<dialog>` does all of
|
|
629
|
+
this itself; this is for a custom overlay, a menu, or a command palette.
|
|
630
|
+
`tabbableWithin(element)` is exported: focusable, visible descendants with a
|
|
631
|
+
non-negative `tabindex`, in order.
|
|
632
|
+
|
|
633
|
+
`Press` turns pointer and keyboard activation of one element into one fact.
|
|
634
|
+
Foldkit's declarative pointer attributes carry no button, pointer id, or click
|
|
635
|
+
detail, so `Press.events` is a Mount that reports what the element saw
|
|
636
|
+
(`PointerDown`, `PointerUp`, `PointerCancelled`, `KeyDown`, `KeyUp`,
|
|
637
|
+
`Clicked`), and the Bundle's `update` decides: primary button only, one
|
|
638
|
+
pointer at a time, `pointerleave` and `pointercancel` cancel, Enter and Space
|
|
639
|
+
with a repeat ignored, a click with `detail` 0 (keyboard on a native control,
|
|
640
|
+
or assistive technology) counts, and the ghost click that follows a touch is
|
|
641
|
+
ignored inside a window of `clickSuppressionMs` that a Command on Effect's
|
|
642
|
+
clock closes. Enter and Space are default-prevented on the element, so a
|
|
643
|
+
native control does not also click and Space does not scroll. Activation is
|
|
644
|
+
the OutMessage `Pressed { pointerType, shiftKey }`, and the placement must handle it:
|
|
645
|
+
|
|
646
|
+
```ts
|
|
647
|
+
const Button = Bundle.declare(Press.bundle, 'saveButton')
|
|
648
|
+
const placements = Page.assemble(
|
|
649
|
+
Page.at(Button, {
|
|
650
|
+
args: { clickSuppressionMs: 50 },
|
|
651
|
+
onOut: () => model => ({ model, commands: [save(model)] }),
|
|
652
|
+
}),
|
|
653
|
+
)
|
|
654
|
+
const Activate = Press.behavior(Button)(CardSlots)<Model, Message>({
|
|
655
|
+
target: 'save',
|
|
656
|
+
disabled: model => model.saving,
|
|
657
|
+
})
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
The Behavior attaches the Mount to the target slot, writes `data-pressed`
|
|
661
|
+
while the element is down for styling, and marks a disabled target
|
|
662
|
+
`aria-disabled`, which the Mount reads at event time so nothing is reported
|
|
663
|
+
and no remount is needed. The Model slice is `{ pressed, pointerId, key,
|
|
664
|
+
suppressing, generation }`; only `pressed` is meant for a view.
|
|
665
|
+
|
|
666
|
+
`LongPress` is holding for `thresholdMs`. It reads the same facts
|
|
667
|
+
`Press.events` reports, so it needs no Mount of its own; the threshold is a
|
|
668
|
+
Command on Effect's clock carrying a generation, and a release before it fires
|
|
669
|
+
makes its `Elapsed` a no-op. `LongPressed { pointerType }` is the OutMessage,
|
|
670
|
+
required at placement. The Behavior writes `data-holding` while down. `Press`
|
|
671
|
+
and `LongPress` on one slot are refused by the resolver, since both would mount
|
|
672
|
+
`PressEvents`; a slot takes one of them.
|
|
673
|
+
|
|
674
|
+
`Move` is pointer movement as facts, a Mount in `foldkit-primitives/dom`: a
|
|
675
|
+
primary-button pointer down captures the pointer and reports `MoveStarted`,
|
|
676
|
+
each move reports `Moved { deltaX, deltaY }` from where it went down, and up,
|
|
677
|
+
cancel, or lost capture reports `MoveEnded { completed }`. A second pointer
|
|
678
|
+
and a secondary button are ignored; capture is released with the Mount.
|
|
679
|
+
`Move.behavior(Slots)<Input, Message>({ handle, toMessage })` attaches it to a
|
|
680
|
+
`Draggable` slot and maps each fact into the view's Messages; a drag's meaning
|
|
681
|
+
(a threshold, a snap, a reorder) is the parent's `update`.
|
|
682
|
+
|
|
683
|
+
`FocusVisible` is the one entry whose Bundle lives elsewhere: `InputModality`
|
|
684
|
+
in `foldkit-primitives/events` keeps `{ modality }` (`'keyboard'`, `'pointer'`,
|
|
685
|
+
or `'unknown'` before any input), fed by the window's `keydown` (a modifier
|
|
686
|
+
alone says nothing) and `pointerdown`. `FocusVisible.behavior(Declared)(Slots)
|
|
687
|
+
<Model, Message>({ target })` writes `data-focus-visible` on the target while
|
|
688
|
+
the page is driven by keyboard, so a stylesheet shows a ring with
|
|
689
|
+
`[data-focus-visible]:focus`. CSS `:focus-visible` does this with no Model at
|
|
690
|
+
all; this is for a design system that must decide in the Model, or show the
|
|
691
|
+
same answer somewhere other than the focused element.
|
|
692
|
+
|
|
693
|
+
`DismissLayer` closes overlays that are not native `<dialog>` or `popover`
|
|
694
|
+
elements: Escape closes the topmost open layer, and a pointer press closes
|
|
695
|
+
the layers it is outside of. One Bundle, **placed once**, owns the document
|
|
696
|
+
listeners; each layer's Behavior marks its container with
|
|
697
|
+
`data-foldkit-plus-layer="<id>"` and its trigger with the matching trigger
|
|
698
|
+
attribute. The stack is the DOM order of the marked elements at the moment of
|
|
699
|
+
the event, so a layer takes part exactly while it is rendered and an `open`
|
|
700
|
+
flag in the parent Model is its whole lifecycle; nothing registers. The rules,
|
|
701
|
+
each a test: a press inside a parent layer is outside its children, so the
|
|
702
|
+
parent stays and the children go; a press on a layer's trigger counts as
|
|
703
|
+
inside it, so a click on the trigger never dismisses and reopens; a layer
|
|
704
|
+
placed with `outsidePress: false` or `escape: false` opts out of that path.
|
|
705
|
+
`Dismiss { ids }` is the OutMessage, and the placement's `onOut` closes them:
|
|
706
|
+
|
|
707
|
+
```ts
|
|
708
|
+
const Layers = Bundle.declare(DismissLayer.bundle, 'layers')
|
|
709
|
+
const placements = Page.assemble(
|
|
710
|
+
Page.at(Layers, {
|
|
711
|
+
onOut: ({ ids }) => model => ({ model: { ...model, menuOpen: ids.includes('menu') ? false : model.menuOpen } }),
|
|
712
|
+
}),
|
|
713
|
+
)
|
|
714
|
+
const Dismissable = DismissLayer.behavior(Layers)(MenuSlots)<Model, Message>({
|
|
715
|
+
layer: 'panel',
|
|
716
|
+
trigger: 'button',
|
|
717
|
+
id: () => 'menu',
|
|
718
|
+
})
|
|
719
|
+
```
|
|
720
|
+
|
|
721
|
+
The Model slice is `{ layers }`, the open layers as the last event saw them,
|
|
722
|
+
for DevTools and agents. `toDismiss(layers, inside)` is the pure rule.
|
|
723
|
+
|
|
724
|
+
`Layers.scrollLock(Slots)({ container })` and `Layers.hideOutside(Slots)({
|
|
725
|
+
container })` are Mounts over Foldkit's own `Dom.lockScroll` and
|
|
726
|
+
`Dom.inertOthers`: the first locks the document's scroll while the container is
|
|
727
|
+
mounted, refcounted so nested overlays release together, with Foldkit's iOS
|
|
728
|
+
handling; the second marks everything outside the container inert while it is
|
|
729
|
+
mounted, keyed by an id the Mount mints so two overlays restore independently.
|
|
730
|
+
Both live in `foldkit-primitives/dom` as `ScrollLock` and `HideOutside`. A
|
|
731
|
+
native `<dialog>` shown modally needs neither.
|
|
732
|
+
|
|
733
|
+
`Selection` is which items are selected, with `mode` `'single'` (a click
|
|
734
|
+
replaces; `allowEmpty` says whether clicking the selected item deselects it),
|
|
735
|
+
`'multiple'` (a click toggles), or `'none'`, and the `anchor` a range extends
|
|
736
|
+
from. The Model slice is `{ selected, anchor }`. A range needs the items'
|
|
737
|
+
order, which the view knows and the Bundle does not, so `Ranged { id, order }`
|
|
738
|
+
carries it; `Selection.between(order, from, to)` is the pure span. The Behavior
|
|
739
|
+
(`Selection.behavior(Declared, args)(Slots)<Model, Message>({ container?,
|
|
740
|
+
item, items, click? })`) writes `aria-selected` on each item and
|
|
741
|
+
`aria-multiselectable` on the container, and wires a plain click on each
|
|
742
|
+
enabled item to `Activated`; pass `click: false` when `Press` or the view owns
|
|
743
|
+
the click. For a Shift range, `Press`'s `Pressed { pointerType, shiftKey }`
|
|
744
|
+
says whether Shift was held, and the placement's `onOut` dispatches `Ranged`.
|
|
745
|
+
|
|
746
|
+
`LiveAnnounce` speaks to assistive technology: one Bundle, placed once, holds
|
|
747
|
+
the text of a polite and an assertive live region. `say(Declared)(text,
|
|
748
|
+
politeness?)` builds the Message to return from `update` or an `onOut`; an
|
|
749
|
+
announcement waits `debounceMs` so a burst reads once, then clears after
|
|
750
|
+
`clearAfterMs`, both on Effect's clock with a generation so a superseded timer
|
|
751
|
+
changes nothing; the same text twice gets a trailing no-break space toggled,
|
|
752
|
+
which is what makes a screen reader read it again. `LiveAnnounce.view(slice,
|
|
753
|
+
h)` renders the two regions: put it once in the page and hide them visually
|
|
754
|
+
with a rule on `[data-foldkit-plus-live]`, never `display: none`.
|
|
755
|
+
|
|
444
756
|
## Testing placements
|
|
445
757
|
|
|
446
758
|
Every primitive is testable without the platform, following one pattern
|
|
@@ -455,6 +767,15 @@ forking before the first `TestClock.adjust`, or dispatched events hit
|
|
|
455
767
|
unregistered listeners. Each ingredient is demonstrated in this package's
|
|
456
768
|
`test/` directory, named after its primitive.
|
|
457
769
|
|
|
770
|
+
## With Surface and Mirror
|
|
771
|
+
|
|
772
|
+
Placed state is ordinary Model, so the surrounding tools apply unchanged —
|
|
773
|
+
no bundle-specific Surface or Mirror API exists, by design. Declare a Surface
|
|
774
|
+
over the placed fields to render them or expose them to an agent; point
|
|
775
|
+
`Mirror.url` at them to link them; spread the bundle's cases into the same
|
|
776
|
+
unions. The field refs and wrapper Messages are the same ones the rest of the
|
|
777
|
+
application uses.
|
|
778
|
+
|
|
458
779
|
## Failure and recovery
|
|
459
780
|
|
|
460
781
|
| Failure | Behaviour |
|
|
@@ -463,7 +784,7 @@ unregistered listeners. Each ingredient is demonstrated in this package's
|
|
|
463
784
|
| A Command without its platform API | failure Message (`CopyFailed`, `ShareFailed`, `BroadcastFailed`), never a throw |
|
|
464
785
|
| Listener removed (unmount, gate closed) | finalizer disconnects; resubscribing re-reads the current value |
|
|
465
786
|
| A Message for an unplaced child | never routes: wrappers only match placed variants |
|
|
466
|
-
|
|
|
787
|
+
| MediaStream acquisition fails | parks at `denied`/`idle` with requirements cleared; `Started` retries |
|
|
467
788
|
|
|
468
789
|
## Limits
|
|
469
790
|
|
package/dist/device/index.d.mts
CHANGED
|
@@ -292,6 +292,8 @@ export declare const permissions: <const Name extends string>(config: {
|
|
|
292
292
|
};
|
|
293
293
|
readonly lastError: string | null;
|
|
294
294
|
}, {
|
|
295
|
+
readonly _tag: "Cleared";
|
|
296
|
+
} | {
|
|
295
297
|
readonly _tag: "Changed";
|
|
296
298
|
readonly name: string;
|
|
297
299
|
readonly state: "granted" | "denied" | "prompt";
|
|
@@ -303,8 +305,6 @@ export declare const permissions: <const Name extends string>(config: {
|
|
|
303
305
|
readonly states: {
|
|
304
306
|
readonly [x: string]: "granted" | "denied" | "prompt";
|
|
305
307
|
};
|
|
306
|
-
} | {
|
|
307
|
-
readonly _tag: "Cleared";
|
|
308
308
|
}, never, never, ManagedResource.ManagedResourceService<"permissions">, void, Readonly<{
|
|
309
309
|
watch: ManagedResource.Entry<PermissionsModel, PermissionsMessage, Option.Option<ReadonlyArray<string>>, Acquired, WatchService, (acquired: Acquired) => PermissionsMessage>;
|
|
310
310
|
}>, {}>;
|
package/dist/dom/index.d.mts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { a as Moved, c as FocusScope, i as MoveStarted, l as tabbableWithin, n as Move, o as HideOutside, r as MoveEnded, s as ScrollLock, t as Fact } from "../move-DNiMPcuf.mjs";
|
|
1
2
|
import { Schema } from "effect";
|
|
2
3
|
import * as Mount from "foldkit/mount";
|
|
3
4
|
import { Command } from "foldkit/command";
|
|
@@ -70,4 +71,5 @@ export type ShareMessage = typeof ShareMessage.Type;
|
|
|
70
71
|
/** Shares data through the platform sheet. */
|
|
71
72
|
export declare const share: (data: ShareData) => Command<ShareMessage, never, never>;
|
|
72
73
|
//#endregion
|
|
74
|
+
export { Fact, FocusScope, HideOutside, Move, MoveEnded, MoveStarted, Moved, ScrollLock, tabbableWithin };
|
|
73
75
|
//# sourceMappingURL=index.d.mts.map
|
package/dist/dom/index.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/dom/autofocus.ts","../../src/dom/clipboard.ts","../../src/dom/input-mask.ts","../../src/dom/script-loader.ts","../../src/dom/share.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/dom/autofocus.ts","../../src/dom/clipboard.ts","../../src/dom/input-mask.ts","../../src/dom/script-loader.ts","../../src/dom/share.ts"],"mappings":";;;;;qBAUa,SAAO,OAAA;YACR,iBAAiB,QAAQ;qBAExB,WAAS,MAAA;;;;;qBCJT,4CAAgB;;;sBAAhB,OAAA;;;YAID,0BAA0B,iBAAiB;;;;;;qBAU1C,WAAY,iBAAe,QAAQ;;;qBCXnC,OAAK,OAAA;;;;YAIN,eAAe,MAAM;qBA2BpB,YACX,eACA;WACY;WAAyB;;qBAoC1B,WAAS,MAAA;;;;;;;;;qBCvET,yCAAa;;;sBAAb,OAAA;;;YAID,uBAAuB,cAAc;;qBASpC,aAAc,gBAAc,QAAQ;;;qBCbpC,WAAS,OAAA;;;;;YAKV,mBAAmB,UAAU;qBAE5B,wCAAY;;;;sBAAZ,OAAA;;;YAKD,sBAAsB,aAAa;;qBAUlC,QAAS,MAAM,cAAY,QAAQ"}
|
package/dist/dom/index.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { a as HideOutside, c as tabbableWithin, i as Moved, n as MoveEnded, o as ScrollLock, r as MoveStarted, s as FocusScope, t as Move } from "../move-CZwHyTGk.mjs";
|
|
1
2
|
import { Effect, Queue, Schema, Stream } from "effect";
|
|
2
3
|
import * as Mount from "foldkit/mount";
|
|
3
4
|
import { defineMessageUnion } from "foldkit/message";
|
|
@@ -213,6 +214,6 @@ const share = (data) => ({
|
|
|
213
214
|
}) : Effect.succeed(ShareMessage.ShareFailed({ message: "share is unavailable" }))
|
|
214
215
|
});
|
|
215
216
|
//#endregion
|
|
216
|
-
export { Autofocus, ClipboardMessage, Focused, Input, InputMask, ScriptMessage, ShareData, ShareMessage, applyMask, copyText, loadScript, share };
|
|
217
|
+
export { Autofocus, ClipboardMessage, FocusScope, Focused, HideOutside, Input, InputMask, Move, MoveEnded, MoveStarted, Moved, ScriptMessage, ScrollLock, ShareData, ShareMessage, applyMask, copyText, loadScript, share, tabbableWithin };
|
|
217
218
|
|
|
218
219
|
//# sourceMappingURL=index.mjs.map
|
package/dist/dom/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":["failMessage","failMessage"],"sources":["../../src/dom/autofocus.ts","../../src/dom/clipboard.ts","../../src/dom/input-mask.ts","../../src/dom/script-loader.ts","../../src/dom/share.ts"],"sourcesContent":["/**\n * Autofocus as a Mount: focuses the element on insert, then emits `Focused`.\n * The message means focus was requested — a non-focusable element or a\n * hidden tab may decline, and the browser does not report that. Plain\n * `focus()`, no scroll options: scroll behavior stays the application's\n * choice.\n */\nimport { Effect, Schema } from 'effect'\nimport * as Mount from 'foldkit/mount'\n\nexport const Focused = Schema.TaggedStruct('Focused', {})\nexport type Focused = typeof Focused.Type\n\nexport const Autofocus = Mount.define('Autofocus', {\n messages: [Focused],\n execute: ({ element }) =>\n Effect.sync(() => {\n const focusable = element as unknown as { readonly focus?: unknown }\n if (typeof focusable.focus === 'function') {\n ;(focusable.focus as () => void).call(element)\n }\n return Focused.make({})\n }),\n})\n","/**\n * Clipboard copy as a Command: runs in `update` beside any bundle, yields\n * `Copied` on success and `CopyFailed` otherwise. No Model involved: the\n * clipboard is not application state.\n */\nimport { Effect, Schema } from 'effect'\nimport type { Command } from 'foldkit/command'\nimport { defineMessageUnion } from 'foldkit/message'\n\nexport const ClipboardMessage = defineMessageUnion({\n Copied: {},\n CopyFailed: { message: Schema.String },\n})\nexport type ClipboardMessage = typeof ClipboardMessage.Type\n\nconst failMessage = (error: unknown): string =>\n error instanceof Error ? error.message : String(error)\n\n/**\n * Copies text to the clipboard. Without a clipboard API (SSR, insecure\n * context, denied permission) the command yields `CopyFailed` instead of\n * throwing, so `update` handles both outcomes as Messages.\n */\nexport const copyText = (text: string): Command<ClipboardMessage, never, never> => ({\n name: 'Clipboard.copy',\n args: { text },\n effect:\n typeof navigator !== 'undefined' && typeof navigator.clipboard?.writeText === 'function'\n ? Effect.matchEffect(\n Effect.tryPromise({\n try: () => navigator.clipboard.writeText(text),\n catch: failMessage,\n }),\n {\n onFailure: message => Effect.succeed(ClipboardMessage.CopyFailed({ message })),\n onSuccess: () => Effect.succeed(ClipboardMessage.Copied()),\n },\n )\n : Effect.succeed(ClipboardMessage.CopyFailed({ message: 'clipboard is unavailable' })),\n})\n","/**\n * An input mask as a Mount plus a pure mask function. The pattern language\n * is three placeholders — `#` digit, `A` letter, `*` either, all ASCII —\n * with every other character literal. Literals appear only once a later placeholder\n * consumes input, so partial values never dangle a separator. The Mount\n * rewrites the field, restores the caret approximately, and emits `Input`\n * with both the masked and raw values; the parent owns the state, like any\n * controlled input.\n */\nimport { Effect, Queue, Schema, Stream } from 'effect'\nimport * as Mount from 'foldkit/mount'\n\nexport const Input = Schema.TaggedStruct('Input', {\n value: Schema.String,\n raw: Schema.String,\n})\nexport type Input = typeof Input.Type\n\nconst isDigit = (char: string): boolean => char >= '0' && char <= '9'\nconst isLetter = (char: string): boolean =>\n (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z')\n\nconst matches = (placeholder: string, char: string): boolean =>\n placeholder === '#'\n ? isDigit(char)\n : placeholder === 'A'\n ? isLetter(char)\n : isDigit(char) || isLetter(char)\n\nconst isPlaceholder = (char: string): boolean => char === '#' || char === 'A' || char === '*'\n\n/** Whether any placeholder at or after `from` consumes any input at or after `at`. */\nconst fillable = (pattern: string, from: number, value: string, at: number): boolean => {\n for (let i = from; i < pattern.length; i++) {\n const placeholder = pattern[i]!\n if (!isPlaceholder(placeholder)) continue\n for (let j = at; j < value.length; j++) {\n if (matches(placeholder, value[j]!)) return true\n }\n }\n return false\n}\n\nexport const applyMask = (\n value: string,\n pattern: string,\n): { readonly masked: string; readonly raw: string } => {\n let masked = ''\n let raw = ''\n let at = 0\n for (let i = 0; i < pattern.length; i++) {\n const slot = pattern[i]!\n if (isPlaceholder(slot)) {\n while (at < value.length && !matches(slot, value[at]!)) at++\n if (at >= value.length) break\n masked += value[at]!\n raw += value[at]!\n at++\n } else if (fillable(pattern, i + 1, value, at)) {\n masked += slot\n } else {\n break\n }\n }\n return { masked, raw }\n}\n\ninterface MaskableField {\n value: string\n selectionStart: number | null\n addEventListener(type: string, listener: () => void): void\n removeEventListener(type: string, listener: () => void): void\n setSelectionRange?(start: number, end: number): void\n}\n\nconst asField = (element: Element): MaskableField | null => {\n const field = element as unknown as Partial<MaskableField>\n return typeof field.value === 'string' && typeof field.addEventListener === 'function'\n ? (field as MaskableField)\n : null\n}\n\nexport const InputMask = Mount.defineStream('InputMask', {\n messages: [Input],\n args: { pattern: Schema.String },\n execute: ({ element, pattern }) =>\n Stream.callback<typeof Input.Type>(queue =>\n Effect.gen(function* () {\n const field = asField(element)\n if (field === null) return\n const onInput = () => {\n const { masked, raw } = applyMask(field.value, pattern)\n if (masked !== field.value) {\n const caret = field.selectionStart ?? masked.length\n field.value = masked\n const at = Math.min(caret, masked.length)\n try {\n field.setSelectionRange?.(at, at)\n } catch {\n // Select-all style inputs reject ranges; the value still stands.\n }\n }\n Queue.offerUnsafe(queue, Input.make({ value: masked, raw }))\n }\n yield* Effect.acquireRelease(\n Effect.sync(() => {\n field.addEventListener('input', onInput)\n return field\n }),\n current => Effect.sync(() => current.removeEventListener('input', onInput)),\n )\n }),\n ),\n})\n","/**\n * Script loading as a Command: appends a `src` script to the head unless one\n * is already there. Idempotent by URL — concurrent placements collapse onto\n * the first tag instead of fetching twice. Yields `Loaded` on success and\n * `LoadFailed` otherwise (network error, no document), so `update` handles\n * both outcomes as Messages.\n */\nimport { Effect, Schema } from 'effect'\nimport type { Command } from 'foldkit/command'\nimport { defineMessageUnion } from 'foldkit/message'\n\nexport const ScriptMessage = defineMessageUnion({\n Loaded: {},\n LoadFailed: { message: Schema.String },\n})\nexport type ScriptMessage = typeof ScriptMessage.Type\n\nconst failMessage = (error: unknown): string =>\n error instanceof Error ? error.message : String(error)\n\n// Tags still fetching, by URL: concurrent loads share one tag and its fate.\nconst inflight = new Map<string, Promise<void>>()\n\n/** Loads a script by URL, once per URL. */\nexport const loadScript = (src: string): Command<ScriptMessage, never, never> => ({\n name: 'Script.load',\n args: { src },\n effect: Effect.matchEffect(\n Effect.tryPromise({\n try: () => {\n if (typeof document === 'undefined') {\n return Promise.reject(new Error('script loading is unavailable'))\n }\n // Concurrent loads share one tag and its fate, instead of racing\n // past the presence check and fetching twice: the in-flight entry\n // is checked before presence, in the same synchronous block that\n // appends, so a second load cannot slip between them.\n const running = inflight.get(src)\n if (running !== undefined) return running\n // getAttribute, not .src: the property resolves to an absolute URL\n // and would never equal the given string.\n const present = Array.from(document.getElementsByTagName('script')).some(\n element => element.getAttribute('src') === src,\n )\n if (present) return Promise.resolve()\n const completion = new Promise<void>((resolve, reject) => {\n const element = document.createElement('script')\n element.src = src\n element.async = true\n element.onload = () => resolve()\n element.onerror = () => {\n // A dead tag must not satisfy later presence checks.\n element.remove()\n reject(new Error(`failed to load script: ${src}`))\n }\n document.head.appendChild(element)\n })\n const tracked = completion.then(\n () => {\n inflight.delete(src)\n },\n (error: unknown) => {\n inflight.delete(src)\n throw error\n },\n )\n inflight.set(src, tracked)\n return tracked\n },\n catch: (error: unknown) => error,\n }),\n {\n onFailure: error => Effect.succeed(ScriptMessage.LoadFailed({ message: failMessage(error) })),\n onSuccess: () => Effect.succeed(ScriptMessage.Loaded()),\n },\n ),\n})\n","/**\n * Web Share as a Command: runs in `update` beside any bundle, yields\n * `Shared` on success. Dismissal is its own outcome, not a failure — the\n * application tells a cancelled sheet apart from a broken one. Without a\n * share API (desktop, insecure context) it yields `ShareFailed` instead of\n * throwing, so `update` handles every outcome as a Message.\n */\nimport { Effect, Schema } from 'effect'\nimport type { Command } from 'foldkit/command'\nimport { defineMessageUnion } from 'foldkit/message'\n\nexport const ShareData = Schema.Struct({\n title: Schema.optional(Schema.String),\n text: Schema.optional(Schema.String),\n url: Schema.optional(Schema.String),\n})\nexport type ShareData = typeof ShareData.Type\n\nexport const ShareMessage = defineMessageUnion({\n Shared: {},\n Dismissed: {},\n ShareFailed: { message: Schema.String },\n})\nexport type ShareMessage = typeof ShareMessage.Type\n\nconst failMessage = (error: unknown): string =>\n error instanceof Error ? error.message : String(error)\n\nconst isDismissal = (error: unknown): boolean =>\n typeof (error as { readonly name?: unknown } | null)?.name === 'string' &&\n (error as { readonly name: string }).name === 'AbortError'\n\n/** Shares data through the platform sheet. */\nexport const share = (data: ShareData): Command<ShareMessage, never, never> => ({\n name: 'Share.share',\n args: { data },\n effect:\n typeof navigator !== 'undefined' && typeof navigator.share === 'function'\n ? Effect.matchEffect(\n Effect.tryPromise({\n // Omit absent fields: the platform type rejects explicit\n // undefined under exactOptionalPropertyTypes.\n try: () =>\n navigator.share({\n ...(data.title !== undefined ? { title: data.title } : {}),\n ...(data.text !== undefined ? { text: data.text } : {}),\n ...(data.url !== undefined ? { url: data.url } : {}),\n }),\n catch: (error: unknown) => error,\n }),\n {\n onFailure: error =>\n Effect.succeed(\n isDismissal(error)\n ? ShareMessage.Dismissed()\n : ShareMessage.ShareFailed({ message: failMessage(error) }),\n ),\n onSuccess: () => Effect.succeed(ShareMessage.Shared()),\n },\n )\n : Effect.succeed(ShareMessage.ShareFailed({ message: 'share is unavailable' })),\n})\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":["failMessage","failMessage"],"sources":["../../src/dom/autofocus.ts","../../src/dom/clipboard.ts","../../src/dom/input-mask.ts","../../src/dom/script-loader.ts","../../src/dom/share.ts"],"sourcesContent":["/**\n * Autofocus as a Mount: focuses the element on insert, then emits `Focused`.\n * The message means focus was requested — a non-focusable element or a\n * hidden tab may decline, and the browser does not report that. Plain\n * `focus()`, no scroll options: scroll behavior stays the application's\n * choice.\n */\nimport { Effect, Schema } from 'effect'\nimport * as Mount from 'foldkit/mount'\n\nexport const Focused = Schema.TaggedStruct('Focused', {})\nexport type Focused = typeof Focused.Type\n\nexport const Autofocus = Mount.define('Autofocus', {\n messages: [Focused],\n execute: ({ element }) =>\n Effect.sync(() => {\n const focusable = element as unknown as { readonly focus?: unknown }\n if (typeof focusable.focus === 'function') {\n ;(focusable.focus as () => void).call(element)\n }\n return Focused.make({})\n }),\n})\n","/**\n * Clipboard copy as a Command: runs in `update` beside any bundle, yields\n * `Copied` on success and `CopyFailed` otherwise. No Model involved: the\n * clipboard is not application state.\n */\nimport { Effect, Schema } from 'effect'\nimport type { Command } from 'foldkit/command'\nimport { defineMessageUnion } from 'foldkit/message'\n\nexport const ClipboardMessage = defineMessageUnion({\n Copied: {},\n CopyFailed: { message: Schema.String },\n})\nexport type ClipboardMessage = typeof ClipboardMessage.Type\n\nconst failMessage = (error: unknown): string =>\n error instanceof Error ? error.message : String(error)\n\n/**\n * Copies text to the clipboard. Without a clipboard API (SSR, insecure\n * context, denied permission) the command yields `CopyFailed` instead of\n * throwing, so `update` handles both outcomes as Messages.\n */\nexport const copyText = (text: string): Command<ClipboardMessage, never, never> => ({\n name: 'Clipboard.copy',\n args: { text },\n effect:\n typeof navigator !== 'undefined' && typeof navigator.clipboard?.writeText === 'function'\n ? Effect.matchEffect(\n Effect.tryPromise({\n try: () => navigator.clipboard.writeText(text),\n catch: failMessage,\n }),\n {\n onFailure: message => Effect.succeed(ClipboardMessage.CopyFailed({ message })),\n onSuccess: () => Effect.succeed(ClipboardMessage.Copied()),\n },\n )\n : Effect.succeed(ClipboardMessage.CopyFailed({ message: 'clipboard is unavailable' })),\n})\n","/**\n * An input mask as a Mount plus a pure mask function. The pattern language\n * is three placeholders — `#` digit, `A` letter, `*` either, all ASCII —\n * with every other character literal. Literals appear only once a later placeholder\n * consumes input, so partial values never dangle a separator. The Mount\n * rewrites the field, restores the caret approximately, and emits `Input`\n * with both the masked and raw values; the parent owns the state, like any\n * controlled input.\n */\nimport { Effect, Queue, Schema, Stream } from 'effect'\nimport * as Mount from 'foldkit/mount'\n\nexport const Input = Schema.TaggedStruct('Input', {\n value: Schema.String,\n raw: Schema.String,\n})\nexport type Input = typeof Input.Type\n\nconst isDigit = (char: string): boolean => char >= '0' && char <= '9'\nconst isLetter = (char: string): boolean =>\n (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z')\n\nconst matches = (placeholder: string, char: string): boolean =>\n placeholder === '#'\n ? isDigit(char)\n : placeholder === 'A'\n ? isLetter(char)\n : isDigit(char) || isLetter(char)\n\nconst isPlaceholder = (char: string): boolean => char === '#' || char === 'A' || char === '*'\n\n/** Whether any placeholder at or after `from` consumes any input at or after `at`. */\nconst fillable = (pattern: string, from: number, value: string, at: number): boolean => {\n for (let i = from; i < pattern.length; i++) {\n const placeholder = pattern[i]!\n if (!isPlaceholder(placeholder)) continue\n for (let j = at; j < value.length; j++) {\n if (matches(placeholder, value[j]!)) return true\n }\n }\n return false\n}\n\nexport const applyMask = (\n value: string,\n pattern: string,\n): { readonly masked: string; readonly raw: string } => {\n let masked = ''\n let raw = ''\n let at = 0\n for (let i = 0; i < pattern.length; i++) {\n const slot = pattern[i]!\n if (isPlaceholder(slot)) {\n while (at < value.length && !matches(slot, value[at]!)) at++\n if (at >= value.length) break\n masked += value[at]!\n raw += value[at]!\n at++\n } else if (fillable(pattern, i + 1, value, at)) {\n masked += slot\n } else {\n break\n }\n }\n return { masked, raw }\n}\n\ninterface MaskableField {\n value: string\n selectionStart: number | null\n addEventListener(type: string, listener: () => void): void\n removeEventListener(type: string, listener: () => void): void\n setSelectionRange?(start: number, end: number): void\n}\n\nconst asField = (element: Element): MaskableField | null => {\n const field = element as unknown as Partial<MaskableField>\n return typeof field.value === 'string' && typeof field.addEventListener === 'function'\n ? (field as MaskableField)\n : null\n}\n\nexport const InputMask = Mount.defineStream('InputMask', {\n messages: [Input],\n args: { pattern: Schema.String },\n execute: ({ element, pattern }) =>\n Stream.callback<typeof Input.Type>(queue =>\n Effect.gen(function* () {\n const field = asField(element)\n if (field === null) return\n const onInput = () => {\n const { masked, raw } = applyMask(field.value, pattern)\n if (masked !== field.value) {\n const caret = field.selectionStart ?? masked.length\n field.value = masked\n const at = Math.min(caret, masked.length)\n try {\n field.setSelectionRange?.(at, at)\n } catch {\n // Select-all style inputs reject ranges; the value still stands.\n }\n }\n Queue.offerUnsafe(queue, Input.make({ value: masked, raw }))\n }\n yield* Effect.acquireRelease(\n Effect.sync(() => {\n field.addEventListener('input', onInput)\n return field\n }),\n current => Effect.sync(() => current.removeEventListener('input', onInput)),\n )\n }),\n ),\n})\n","/**\n * Script loading as a Command: appends a `src` script to the head unless one\n * is already there. Idempotent by URL — concurrent placements collapse onto\n * the first tag instead of fetching twice. Yields `Loaded` on success and\n * `LoadFailed` otherwise (network error, no document), so `update` handles\n * both outcomes as Messages.\n */\nimport { Effect, Schema } from 'effect'\nimport type { Command } from 'foldkit/command'\nimport { defineMessageUnion } from 'foldkit/message'\n\nexport const ScriptMessage = defineMessageUnion({\n Loaded: {},\n LoadFailed: { message: Schema.String },\n})\nexport type ScriptMessage = typeof ScriptMessage.Type\n\nconst failMessage = (error: unknown): string =>\n error instanceof Error ? error.message : String(error)\n\n// Tags still fetching, by URL: concurrent loads share one tag and its fate.\nconst inflight = new Map<string, Promise<void>>()\n\n/** Loads a script by URL, once per URL. */\nexport const loadScript = (src: string): Command<ScriptMessage, never, never> => ({\n name: 'Script.load',\n args: { src },\n effect: Effect.matchEffect(\n Effect.tryPromise({\n try: () => {\n if (typeof document === 'undefined') {\n return Promise.reject(new Error('script loading is unavailable'))\n }\n // Concurrent loads share one tag and its fate, instead of racing\n // past the presence check and fetching twice: the in-flight entry\n // is checked before presence, in the same synchronous block that\n // appends, so a second load cannot slip between them.\n const running = inflight.get(src)\n if (running !== undefined) return running\n // getAttribute, not .src: the property resolves to an absolute URL\n // and would never equal the given string.\n const present = Array.from(document.getElementsByTagName('script')).some(\n element => element.getAttribute('src') === src,\n )\n if (present) return Promise.resolve()\n const completion = new Promise<void>((resolve, reject) => {\n const element = document.createElement('script')\n element.src = src\n element.async = true\n element.onload = () => resolve()\n element.onerror = () => {\n // A dead tag must not satisfy later presence checks.\n element.remove()\n reject(new Error(`failed to load script: ${src}`))\n }\n document.head.appendChild(element)\n })\n const tracked = completion.then(\n () => {\n inflight.delete(src)\n },\n (error: unknown) => {\n inflight.delete(src)\n throw error\n },\n )\n inflight.set(src, tracked)\n return tracked\n },\n catch: (error: unknown) => error,\n }),\n {\n onFailure: error => Effect.succeed(ScriptMessage.LoadFailed({ message: failMessage(error) })),\n onSuccess: () => Effect.succeed(ScriptMessage.Loaded()),\n },\n ),\n})\n","/**\n * Web Share as a Command: runs in `update` beside any bundle, yields\n * `Shared` on success. Dismissal is its own outcome, not a failure — the\n * application tells a cancelled sheet apart from a broken one. Without a\n * share API (desktop, insecure context) it yields `ShareFailed` instead of\n * throwing, so `update` handles every outcome as a Message.\n */\nimport { Effect, Schema } from 'effect'\nimport type { Command } from 'foldkit/command'\nimport { defineMessageUnion } from 'foldkit/message'\n\nexport const ShareData = Schema.Struct({\n title: Schema.optional(Schema.String),\n text: Schema.optional(Schema.String),\n url: Schema.optional(Schema.String),\n})\nexport type ShareData = typeof ShareData.Type\n\nexport const ShareMessage = defineMessageUnion({\n Shared: {},\n Dismissed: {},\n ShareFailed: { message: Schema.String },\n})\nexport type ShareMessage = typeof ShareMessage.Type\n\nconst failMessage = (error: unknown): string =>\n error instanceof Error ? error.message : String(error)\n\nconst isDismissal = (error: unknown): boolean =>\n typeof (error as { readonly name?: unknown } | null)?.name === 'string' &&\n (error as { readonly name: string }).name === 'AbortError'\n\n/** Shares data through the platform sheet. */\nexport const share = (data: ShareData): Command<ShareMessage, never, never> => ({\n name: 'Share.share',\n args: { data },\n effect:\n typeof navigator !== 'undefined' && typeof navigator.share === 'function'\n ? Effect.matchEffect(\n Effect.tryPromise({\n // Omit absent fields: the platform type rejects explicit\n // undefined under exactOptionalPropertyTypes.\n try: () =>\n navigator.share({\n ...(data.title !== undefined ? { title: data.title } : {}),\n ...(data.text !== undefined ? { text: data.text } : {}),\n ...(data.url !== undefined ? { url: data.url } : {}),\n }),\n catch: (error: unknown) => error,\n }),\n {\n onFailure: error =>\n Effect.succeed(\n isDismissal(error)\n ? ShareMessage.Dismissed()\n : ShareMessage.ShareFailed({ message: failMessage(error) }),\n ),\n onSuccess: () => Effect.succeed(ShareMessage.Shared()),\n },\n )\n : Effect.succeed(ShareMessage.ShareFailed({ message: 'share is unavailable' })),\n})\n"],"mappings":"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"}
|