foldkit-primitives 0.2.1 → 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.
@@ -0,0 +1,1364 @@
1
+ import { t as __exportAll } from "../rolldown-runtime-D7D4PA-g.mjs";
2
+ 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";
3
+ import { a as modalityOfKey, i as Modality, t as InputModality } from "../input-modality-m6v7__Xs.mjs";
4
+ import { Effect, Option, Queue, Schema, Stream } from "effect";
5
+ import * as Mount from "foldkit/mount";
6
+ import { defineMessageUnion } from "foldkit/message";
7
+ import * as Subscription from "foldkit/subscription";
8
+ import { Bundle } from "foldkit-bundle";
9
+ import { Behavior, Capability } from "foldkit-mixins";
10
+ //#region src/interaction/dismiss-layer.ts
11
+ /**
12
+ * Dismissable layers: Escape closes the topmost open layer, a pointer press
13
+ * outside closes the layers it is outside of. One Bundle, placed once, owns
14
+ * the document listeners; each layer's Behavior marks its container (and its
15
+ * trigger) with data attributes. The stack is the DOM order of the marked
16
+ * elements at the moment of the event, so a layer opening or closing needs no
17
+ * registration. A press inside a parent layer is outside its children, so the
18
+ * parent stays and the children go; a press on a layer's trigger counts as
19
+ * inside it, so a trigger never dismisses and reopens in one click. What a
20
+ * dismissal does is the placement's `onOut`.
21
+ */
22
+ var dismiss_layer_exports = /* @__PURE__ */ __exportAll({
23
+ Dismiss: () => Dismiss,
24
+ LAYER_ATTRIBUTE: () => LAYER_ATTRIBUTE,
25
+ Layer: () => Layer$1,
26
+ Message: () => Message$8,
27
+ Model: () => Model$8,
28
+ TRIGGER_ATTRIBUTE: () => TRIGGER_ATTRIBUTE,
29
+ behavior: () => behavior$10,
30
+ bundle: () => bundle$8,
31
+ layersOnPath: () => layersOnPath,
32
+ toDismiss: () => toDismiss
33
+ });
34
+ const LAYER_ATTRIBUTE = "data-foldkit-plus-layer";
35
+ const TRIGGER_ATTRIBUTE = "data-foldkit-plus-layer-trigger";
36
+ const OUTSIDE_ATTRIBUTE = "data-foldkit-plus-layer-outside";
37
+ const ESCAPE_ATTRIBUTE = "data-foldkit-plus-layer-escape";
38
+ /** An open layer as the document showed it: bottom to top is DOM order. */
39
+ const Layer$1 = Schema.Struct({
40
+ id: Schema.String,
41
+ /** Dismiss on a press outside it. */
42
+ outside: Schema.Boolean,
43
+ /** Dismiss on Escape when topmost. */
44
+ escape: Schema.Boolean
45
+ });
46
+ const Model$8 = Schema.Struct({
47
+ /** The open layers at the last event, bottom to top. */
48
+ layers: Schema.Array(Layer$1) });
49
+ const Message$8 = defineMessageUnion({
50
+ /** A pointer went down; `inside` are the layers whose element or trigger was on the event's path. */
51
+ PressedAt: {
52
+ layers: Schema.Array(Layer$1),
53
+ inside: Schema.Array(Schema.String)
54
+ },
55
+ PressedEscape: { layers: Schema.Array(Layer$1) }
56
+ });
57
+ const Dismiss = Schema.TaggedStruct("Dismiss", { ids: Schema.Array(Schema.String) });
58
+ /** Which layers a press inside `inside` dismisses: every layer above the topmost one hit, or all when none was. */
59
+ const toDismiss = (layers, inside) => {
60
+ let topmostHit = -1;
61
+ layers.forEach((layer, index) => {
62
+ if (inside.includes(layer.id)) topmostHit = index;
63
+ });
64
+ return layers.slice(topmostHit + 1).filter((layer) => layer.outside).map((layer) => layer.id);
65
+ };
66
+ const bundle$8 = Bundle.make("DismissLayer", {
67
+ Model: Model$8,
68
+ Message: Message$8,
69
+ init: () => ({ model: { layers: [] } }),
70
+ update: (_model, message) => Message$8.match(message, {
71
+ PressedAt: ({ layers, inside }) => {
72
+ const ids = toDismiss(layers, inside);
73
+ return ids.length === 0 ? { model: { layers } } : {
74
+ model: { layers },
75
+ outMessage: Dismiss.make({ ids })
76
+ };
77
+ },
78
+ PressedEscape: ({ layers }) => {
79
+ const top = layers[layers.length - 1];
80
+ return top !== void 0 && top.escape ? {
81
+ model: { layers },
82
+ outMessage: Dismiss.make({ ids: [top.id] })
83
+ } : { model: { layers } };
84
+ }
85
+ }),
86
+ subscriptions: () => Subscription.make()(() => ({ document: Subscription.persistent(documentEvents()) }))
87
+ });
88
+ const openLayers = (root) => Array.from(root.querySelectorAll(`[${LAYER_ATTRIBUTE}]`)).map((element) => ({
89
+ id: element.getAttribute("data-foldkit-plus-layer") ?? "",
90
+ outside: element.getAttribute(OUTSIDE_ATTRIBUTE) !== "false",
91
+ escape: element.getAttribute(ESCAPE_ATTRIBUTE) !== "false"
92
+ }));
93
+ /** The layer ids whose element or trigger is on the event's composed path. */
94
+ const layersOnPath = (path) => {
95
+ const ids = [];
96
+ for (const target of path) {
97
+ if (!(target instanceof Element)) continue;
98
+ const id = target.getAttribute("data-foldkit-plus-layer") ?? target.getAttribute("data-foldkit-plus-layer-trigger");
99
+ if (id !== null && !ids.includes(id)) ids.push(id);
100
+ }
101
+ return ids;
102
+ };
103
+ /**
104
+ * The facts are read inside the listener, not after: `composedPath()` is
105
+ * empty once dispatch has finished, and the open layers must be the ones the
106
+ * press saw, not the ones a later render left.
107
+ */
108
+ const documentEvents = () => {
109
+ if (typeof document === "undefined") return Stream.empty;
110
+ return Stream.callback((queue) => Effect.acquireRelease(Effect.sync(() => {
111
+ const onPointerDown = (event) => Queue.offerUnsafe(queue, Message$8.PressedAt({
112
+ layers: openLayers(document),
113
+ inside: layersOnPath(event.composedPath())
114
+ }));
115
+ const onKeyDown = (event) => {
116
+ if (event.key !== "Escape") return;
117
+ Queue.offerUnsafe(queue, Message$8.PressedEscape({ layers: openLayers(document) }));
118
+ };
119
+ document.addEventListener("pointerdown", onPointerDown, { capture: true });
120
+ document.addEventListener("keydown", onKeyDown, { capture: true });
121
+ return {
122
+ onPointerDown,
123
+ onKeyDown
124
+ };
125
+ }), ({ onPointerDown, onKeyDown }) => Effect.sync(() => {
126
+ document.removeEventListener("pointerdown", onPointerDown, { capture: true });
127
+ document.removeEventListener("keydown", onKeyDown, { capture: true });
128
+ })));
129
+ };
130
+ /**
131
+ * Marks the layer's container and trigger for the placed stack. The layer
132
+ * takes part only while its element is rendered, so an `open` flag in the
133
+ * parent Model is the whole lifecycle. Not for an element with `popover`,
134
+ * which the browser dismisses itself.
135
+ */
136
+ const behavior$10 = (_declared) => (slots) => (options) => Behavior.forSlots(slots)({
137
+ [options.layer]: Behavior.slot({
138
+ requires: { capability: Capability.Container },
139
+ attributes: ({ input, h }) => [
140
+ h.Attribute(LAYER_ATTRIBUTE, options.id(input)),
141
+ ...options.outsidePress === false ? [h.Attribute(OUTSIDE_ATTRIBUTE, "false")] : [],
142
+ ...options.escape === false ? [h.Attribute(ESCAPE_ATTRIBUTE, "false")] : []
143
+ ]
144
+ }),
145
+ ...options.trigger === void 0 ? {} : { [options.trigger]: Behavior.slot({
146
+ requires: { capability: Capability.Interactive },
147
+ attributes: ({ input, h }) => [h.Attribute(TRIGGER_ATTRIBUTE, options.id(input))]
148
+ }) }
149
+ }, { name: "DismissLayer" });
150
+ //#endregion
151
+ //#region src/interaction/focus-scope.ts
152
+ /**
153
+ * `FocusScope` as a Behavior: attaches the `foldkit-primitives/dom` Mount to a
154
+ * container slot. No Bundle, because which element has focus is a DOM fact.
155
+ */
156
+ var focus_scope_exports = /* @__PURE__ */ __exportAll({
157
+ behavior: () => behavior$9,
158
+ mount: () => FocusScope,
159
+ tabbableWithin: () => tabbableWithin
160
+ });
161
+ /**
162
+ * On insert the container focuses `initialFocus`, else its first tabbable
163
+ * descendant, else itself; with `contain`, Tab and Shift+Tab wrap inside and
164
+ * focus that lands outside comes back to the first tabbable; on unmount, with
165
+ * `restore`, focus returns to the element that had it. A dialog that is not
166
+ * a native `<dialog>`, a menu, or a command palette takes this.
167
+ */
168
+ const behavior$9 = (slots) => (options) => Behavior.forSlots(slots)({ [options.container]: Behavior.slot({
169
+ requires: { capability: Capability.Container },
170
+ mount: () => FocusScope({
171
+ contain: options.contain ?? true,
172
+ restore: options.restore ?? true,
173
+ initialFocus: options.initialFocus ?? null
174
+ })
175
+ }) }, { name: "FocusScope" });
176
+ //#endregion
177
+ //#region src/interaction/focus-visible.ts
178
+ var focus_visible_exports = /* @__PURE__ */ __exportAll({
179
+ Modality: () => Modality,
180
+ behavior: () => behavior$8,
181
+ bundle: () => InputModality,
182
+ modalityOfKey: () => modalityOfKey
183
+ });
184
+ const behavior$8 = (declared) => (slots) => (options) => Behavior.forSlots(slots)({ [options.target]: Behavior.slot({
185
+ requires: { capability: Capability.Focusable },
186
+ attributes: ({ input, h }) => input[declared.field].modality === "keyboard" ? [h.DataAttribute("focus-visible", "true")] : []
187
+ }) }, { name: "FocusVisible" });
188
+ //#endregion
189
+ //#region src/interaction/roving-tabindex.ts
190
+ /**
191
+ * One tab stop for a set of items (a toolbar, a tab list, a listbox): arrows
192
+ * move focus between them, the rest stay out of the tab order. The current
193
+ * item's id is the Model slice, so a reorder keeps the same item current and a
194
+ * resumed page knows where focus was. Which item comes next is a pure
195
+ * function of the items and the key; the Behavior wires it to the slots.
196
+ */
197
+ var roving_tabindex_exports = /* @__PURE__ */ __exportAll({
198
+ Args: () => Args$7,
199
+ Message: () => Message$7,
200
+ Model: () => Model$7,
201
+ Orientation: () => Orientation,
202
+ behavior: () => behavior$7,
203
+ bundle: () => bundle$7,
204
+ idSelector: () => idSelector,
205
+ itemAttributes: () => itemAttributes,
206
+ move: () => move$1,
207
+ tabStop: () => tabStop
208
+ });
209
+ const Orientation = Schema.Literals([
210
+ "vertical",
211
+ "horizontal",
212
+ "both"
213
+ ]);
214
+ const Model$7 = Schema.Struct({
215
+ /** The current item's id, or `null` before any item has been focused. */
216
+ current: Schema.NullOr(Schema.String) });
217
+ const Message$7 = defineMessageUnion({
218
+ /** An item became current: the user focused it, or a key moved there. */
219
+ Focused: { id: Schema.String } });
220
+ const Args$7 = Schema.Struct({
221
+ orientation: Orientation,
222
+ /** Wrap from the last enabled item to the first, and back. */
223
+ loop: Schema.Boolean,
224
+ /** Keep DOM focus on the container and point at the current item with
225
+ * `aria-activedescendant` instead of moving focus. */
226
+ virtual: Schema.Boolean
227
+ });
228
+ const bundle$7 = Bundle.make("RovingTabindex", {
229
+ Model: Model$7,
230
+ Message: Message$7,
231
+ args: Args$7,
232
+ init: () => ({ model: { current: null } }),
233
+ update: (_model, message) => ({ model: { current: message.id } })
234
+ });
235
+ const isPlain = (modifiers) => !modifiers.ctrlKey && !modifiers.altKey && !modifiers.metaKey;
236
+ /**
237
+ * The index the key moves to from `current`, over the enabled indices only, or
238
+ * `undefined` when the key is not a navigation key here. `current` may be
239
+ * `-1` (nothing current yet): arrows then land on the first or last enabled
240
+ * item. Under `rtl`, left and right swap. PageUp and PageDown move by `page`
241
+ * and clamp at the ends, never wrapping.
242
+ */
243
+ const move$1 = (enabled, current, key, modifiers, options) => {
244
+ if (enabled.length === 0 || !isPlain(modifiers)) return void 0;
245
+ const first = enabled[0];
246
+ const last = enabled[enabled.length - 1];
247
+ if (key === "Home") return first;
248
+ if (key === "End") return last;
249
+ if (options.page !== void 0 && (key === "PageUp" || key === "PageDown")) {
250
+ const position = enabled.indexOf(current);
251
+ if (position === -1) return key === "PageDown" ? first : last;
252
+ const jumped = position + (key === "PageDown" ? options.page : -options.page);
253
+ return enabled[Math.min(enabled.length - 1, Math.max(0, jumped))];
254
+ }
255
+ const vertical = options.orientation !== "horizontal";
256
+ const horizontal = options.orientation !== "vertical";
257
+ const forwardKey = options.direction === "rtl" ? "ArrowLeft" : "ArrowRight";
258
+ const backwardKey = options.direction === "rtl" ? "ArrowRight" : "ArrowLeft";
259
+ const step = vertical && key === "ArrowDown" || horizontal && key === forwardKey ? 1 : vertical && key === "ArrowUp" || horizontal && key === backwardKey ? -1 : 0;
260
+ if (step === 0) return void 0;
261
+ const position = enabled.indexOf(current);
262
+ if (position === -1) return step === 1 ? first : last;
263
+ const next = position + step;
264
+ if (next < 0) return options.loop ? last : first;
265
+ if (next >= enabled.length) return options.loop ? first : last;
266
+ return enabled[next];
267
+ };
268
+ /** The item that holds the tab stop: the current one, else the first enabled. */
269
+ const tabStop = (items, current) => {
270
+ const index = current === null ? -1 : items.indexOf(current);
271
+ if (index !== -1 && !items.isDisabled(index)) return index;
272
+ return items.enabled[0] ?? -1;
273
+ };
274
+ /** A selector for an element by id, safe for any id. */
275
+ const idSelector = (id) => `[id="${id.replace(/["\\]/g, "\\$&")}"]`;
276
+ /**
277
+ * The attributes one item gets: `tabindex` 0 when it holds the tab stop and
278
+ * -1 otherwise (none under `virtual`), and `OnFocus` reporting it current.
279
+ * Shared with `ListNavigation`.
280
+ */
281
+ const itemAttributes = (h, items, current, item, virtual, focused) => {
282
+ const id = item.id ?? items.ids[item.index];
283
+ if (id === void 0) return [];
284
+ const isStop = tabStop(items, current) === item.index;
285
+ return [...virtual ? [] : [h.Tabindex(isStop ? 0 : -1)], h.OnFocus(focused(id))];
286
+ };
287
+ /**
288
+ * Wires a placed `RovingTabindex` to the slots: the container's arrow, Home
289
+ * and End keys focus the next item (synchronously, then dispatch), each item
290
+ * gets `tabindex` 0 or -1 and reports `Focused` when focused. Under `virtual`
291
+ * DOM focus never leaves the container: the key only dispatches, the
292
+ * container carries `aria-activedescendant`, and items keep no `tabindex`.
293
+ *
294
+ * Every handled key is default-prevented. Nothing is written on dispose: the
295
+ * attributes are data, so a view that no longer attaches this leaves no
296
+ * `tabindex` behind.
297
+ */
298
+ const behavior$7 = (declared, args) => (slots) => (options) => {
299
+ const wrap = (id) => declared.wrapper.make(Message$7.Focused({ id }));
300
+ const slice = (input) => input[declared.field];
301
+ return Behavior.forSlots(slots)({
302
+ [options.container]: Behavior.slot({
303
+ requires: { capability: Capability.Interactive },
304
+ attributes: ({ input, h }) => {
305
+ const items = options.items(input);
306
+ const current = slice(input).current;
307
+ const from = current === null ? -1 : items.indexOf(current);
308
+ const direction = options.direction?.(input) ?? "ltr";
309
+ const stop = tabStop(items, current);
310
+ const stopId = stop === -1 ? void 0 : items.ids[stop];
311
+ const target = (key, modifiers) => {
312
+ const next = move$1(items.enabled, from, key, modifiers, {
313
+ ...args,
314
+ direction
315
+ });
316
+ return next === void 0 ? void 0 : items.ids[next];
317
+ };
318
+ if (args.virtual) return [h.OnKeyDownPreventDefault((key, modifiers) => Option.map(Option.fromNullishOr(target(key, modifiers)), wrap)), ...stopId === void 0 ? [] : [h.AriaActiveDescendant(stopId)]];
319
+ return [h.OnKeyDownFocus((key, modifiers) => Option.map(Option.fromNullishOr(target(key, modifiers)), (id) => ({
320
+ focusSelector: idSelector(id),
321
+ message: wrap(id)
322
+ })))];
323
+ }
324
+ }),
325
+ [options.item]: Behavior.slot({
326
+ requires: { capability: Capability.Focusable },
327
+ attributes: ({ input, h, item }) => {
328
+ if (item === void 0) return [];
329
+ return itemAttributes(h, options.items(input), slice(input).current, item, args.virtual, wrap);
330
+ }
331
+ })
332
+ }, { name: "RovingTabindex" });
333
+ };
334
+ //#endregion
335
+ //#region src/interaction/grid-navigation.ts
336
+ /**
337
+ * Two-dimensional arrow keys over a set of items laid out in rows of
338
+ * `columns` (a calendar grid, a color swatch picker, an emoji palette). The
339
+ * Model slice is the current item's id, as with `RovingTabindex`, so a
340
+ * reorder keeps the same item current. Which cell a key lands on is the pure
341
+ * `move`; the Behavior wires it to a container slot and a per-item slot and
342
+ * shares `RovingTabindex`'s item attributes, so the two are interchangeable
343
+ * on a view.
344
+ */
345
+ var grid_navigation_exports = /* @__PURE__ */ __exportAll({
346
+ Args: () => Args$6,
347
+ Message: () => Message$6,
348
+ Model: () => Model$6,
349
+ behavior: () => behavior$6,
350
+ bundle: () => bundle$6,
351
+ move: () => move
352
+ });
353
+ const Model$6 = Schema.Struct({
354
+ /** The current cell's id, or `null` before any cell has been focused. */
355
+ current: Schema.NullOr(Schema.String) });
356
+ const Message$6 = defineMessageUnion({
357
+ /** A cell became current: the user focused it, or a key moved there. */
358
+ Focused: { id: Schema.String } });
359
+ const Args$6 = Schema.Struct({
360
+ /** Cells per row; the last row may be short. */
361
+ columns: Schema.Number.pipe(Schema.check(Schema.isGreaterThanOrEqualTo(1))),
362
+ /** Left and right continue into the previous and next row; up and down
363
+ * continue into the previous and next column. Off, they stop at the edge. */
364
+ wrap: Schema.Boolean,
365
+ /** Keep DOM focus on the container and point at the current cell with
366
+ * `aria-activedescendant` instead of moving focus. */
367
+ virtual: Schema.Boolean
368
+ });
369
+ const bundle$6 = Bundle.make("GridNavigation", {
370
+ Model: Model$6,
371
+ Message: Message$6,
372
+ args: Args$6,
373
+ init: () => ({ model: { current: null } }),
374
+ update: (_model, message) => ({ model: { current: message.id } })
375
+ });
376
+ /**
377
+ * The index the key moves to from `current` over a grid of `count` cells in
378
+ * rows of `columns`, skipping cells not in `enabled`, or `undefined` when the
379
+ * key is not a grid key. Left and right step within the row, and across rows
380
+ * under `wrap`; up and down step within the column, and across columns under
381
+ * `wrap`. Home and End are the row's first and last enabled cell; with Ctrl
382
+ * they are the grid's. At an edge without `wrap` the key is consumed and
383
+ * `current` is returned. From `-1` (nothing current) a forward key lands on
384
+ * the first enabled cell and a backward key on the last. Under `rtl`, left
385
+ * and right swap.
386
+ */
387
+ const move = (enabled, count, current, key, modifiers, options) => {
388
+ if (enabled.length === 0 || modifiers.altKey || modifiers.metaKey) return void 0;
389
+ const first = enabled[0];
390
+ const last = enabled[enabled.length - 1];
391
+ const isEnabled = (index) => enabled.includes(index);
392
+ const { columns } = options;
393
+ const row = (index) => Math.floor(index / columns);
394
+ if (key === "Home" || key === "End") {
395
+ if (modifiers.ctrlKey || current === -1) return key === "Home" ? first : last;
396
+ const inRow = enabled.filter((index) => row(index) === row(current));
397
+ return key === "Home" ? inRow[0] ?? current : inRow[inRow.length - 1] ?? current;
398
+ }
399
+ if (modifiers.ctrlKey) return void 0;
400
+ const forwardKey = options.direction === "rtl" ? "ArrowLeft" : "ArrowRight";
401
+ const backwardKey = options.direction === "rtl" ? "ArrowRight" : "ArrowLeft";
402
+ const axis = key === forwardKey || key === backwardKey ? "row" : key === "ArrowDown" || key === "ArrowUp" ? "column" : void 0;
403
+ if (axis === void 0) return void 0;
404
+ const step = key === forwardKey || key === "ArrowDown" ? 1 : -1;
405
+ if (current === -1) return step === 1 ? first : last;
406
+ if (axis === "row") {
407
+ for (let index = current + step; index >= 0 && index < count; index += step) {
408
+ if (!options.wrap && row(index) !== row(current)) return current;
409
+ if (isEnabled(index)) return index;
410
+ }
411
+ return current;
412
+ }
413
+ const column = current % columns;
414
+ for (let index = current + step * columns;; index += step * columns) {
415
+ if (index < 0 || index >= count) {
416
+ if (!options.wrap) return current;
417
+ const nextColumn = column + step;
418
+ if (nextColumn < 0 || nextColumn >= columns) return current;
419
+ const start = step === 1 ? nextColumn : nextColumn + columns * Math.floor((count - 1 - nextColumn) / columns);
420
+ for (let next = start; next >= 0 && next < count; next += step * columns) if (isEnabled(next)) return next;
421
+ return current;
422
+ }
423
+ if (isEnabled(index)) return index;
424
+ }
425
+ };
426
+ /**
427
+ * Wires a placed `GridNavigation` to the slots exactly as `RovingTabindex`
428
+ * does, with `move` above deciding the cell: the container's arrows, Home and
429
+ * End focus the next cell (synchronously, then dispatch) or, under `virtual`,
430
+ * only dispatch and point with `aria-activedescendant`; each cell gets
431
+ * `tabindex` 0 or -1 and reports `Focused` when focused.
432
+ */
433
+ const behavior$6 = (declared, args) => (slots) => (options) => {
434
+ const wrap = (id) => declared.wrapper.make(Message$6.Focused({ id }));
435
+ const slice = (input) => input[declared.field];
436
+ return Behavior.forSlots(slots)({
437
+ [options.container]: Behavior.slot({
438
+ requires: { capability: Capability.Interactive },
439
+ attributes: ({ input, h }) => {
440
+ const items = options.items(input);
441
+ const current = slice(input).current;
442
+ const from = current === null ? -1 : items.indexOf(current);
443
+ const direction = options.direction?.(input) ?? "ltr";
444
+ const stop = tabStop(items, current);
445
+ const stopId = stop === -1 ? void 0 : items.ids[stop];
446
+ const target = (key, modifiers) => {
447
+ const next = move(items.enabled, items.size, from, key, modifiers, {
448
+ columns: args.columns,
449
+ wrap: args.wrap,
450
+ direction
451
+ });
452
+ return next === void 0 ? void 0 : items.ids[next];
453
+ };
454
+ if (args.virtual) return [h.OnKeyDownPreventDefault((key, modifiers) => Option.map(Option.fromNullishOr(target(key, modifiers)), wrap)), ...stopId === void 0 ? [] : [h.AriaActiveDescendant(stopId)]];
455
+ return [h.OnKeyDownFocus((key, modifiers) => Option.map(Option.fromNullishOr(target(key, modifiers)), (id) => ({
456
+ focusSelector: idSelector(id),
457
+ message: wrap(id)
458
+ })))];
459
+ }
460
+ }),
461
+ [options.item]: Behavior.slot({
462
+ requires: { capability: Capability.Focusable },
463
+ attributes: ({ input, h, item }) => {
464
+ if (item === void 0) return [];
465
+ return itemAttributes(h, options.items(input), slice(input).current, item, args.virtual, wrap);
466
+ }
467
+ })
468
+ }, { name: "GridNavigation" });
469
+ };
470
+ //#endregion
471
+ //#region src/interaction/layers.ts
472
+ var layers_exports = /* @__PURE__ */ __exportAll({
473
+ hideOutside: () => hideOutside,
474
+ hideOutsideMount: () => HideOutside,
475
+ scrollLock: () => scrollLock,
476
+ scrollLockMount: () => ScrollLock
477
+ });
478
+ const mountOn = (name, mount) => (slots) => (options) => Behavior.forSlots(slots)({ [options.container]: Behavior.slot({
479
+ requires: { capability: Capability.Container },
480
+ mount
481
+ }) }, { name });
482
+ /** Locks the document's scroll while the container is mounted; nested locks release together. */
483
+ const scrollLock = mountOn("ScrollLock", () => ScrollLock());
484
+ /** Marks everything outside the container inert while it is mounted. */
485
+ const hideOutside = mountOn("HideOutside", () => HideOutside());
486
+ //#endregion
487
+ //#region src/interaction/live-announce.ts
488
+ /**
489
+ * Announcements for assistive technology: one Bundle, placed once, holds the
490
+ * text of a polite and an assertive live region. An announcement waits
491
+ * `debounceMs` so a burst reads once, then clears after `clearAfterMs`, both
492
+ * on Effect's clock with a generation so a superseded timer changes nothing.
493
+ * The same text twice gets a trailing no-break space toggled, which is what
494
+ * makes a screen reader read it again. `view` renders the two regions; put it
495
+ * once in the page and style them visually hidden.
496
+ */
497
+ var live_announce_exports = /* @__PURE__ */ __exportAll({
498
+ Args: () => Args$5,
499
+ Message: () => Message$5,
500
+ Model: () => Model$5,
501
+ Politeness: () => Politeness,
502
+ REGION_ATTRIBUTE: () => REGION_ATTRIBUTE,
503
+ bundle: () => bundle$5,
504
+ reread: () => reread,
505
+ say: () => say,
506
+ view: () => view
507
+ });
508
+ const Politeness = Schema.Literals(["polite", "assertive"]);
509
+ const Model$5 = Schema.Struct({
510
+ polite: Schema.String,
511
+ assertive: Schema.String,
512
+ /** Waiting for the debounce to read. */
513
+ pending: Schema.NullOr(Schema.Struct({
514
+ message: Schema.String,
515
+ politeness: Politeness
516
+ })),
517
+ generation: Schema.Number
518
+ });
519
+ const Message$5 = defineMessageUnion({
520
+ Announced: {
521
+ message: Schema.String,
522
+ politeness: Politeness
523
+ },
524
+ Read: { generation: Schema.Number },
525
+ Cleared: { generation: Schema.Number }
526
+ });
527
+ const positive = Schema.Number.pipe(Schema.check(Schema.isGreaterThanOrEqualTo(0)), Schema.check(Schema.isFinite()));
528
+ const Args$5 = Schema.Struct({
529
+ /** A burst of announcements inside this window reads once, the last one. */
530
+ debounceMs: positive,
531
+ /** How long the text stays in the region. */
532
+ clearAfterMs: positive
533
+ });
534
+ const NBSP = "\xA0";
535
+ /** The text to put in the region so that `next` is read even when it equals `current`. */
536
+ const reread = (current, next) => current === next ? `${next}${NBSP}` : current === `${next}${NBSP}` ? next : next;
537
+ const after = (ms, message, name, generation) => ({
538
+ name,
539
+ args: { generation },
540
+ effect: Effect.as(Effect.sleep(ms), message)
541
+ });
542
+ const bundle$5 = Bundle.make("LiveAnnounce", {
543
+ Model: Model$5,
544
+ Message: Message$5,
545
+ args: Args$5,
546
+ init: () => ({ model: {
547
+ polite: "",
548
+ assertive: "",
549
+ pending: null,
550
+ generation: 0
551
+ } }),
552
+ update: (model, message, args) => Message$5.match(message, {
553
+ Announced: ({ message: text, politeness }) => {
554
+ const generation = model.generation + 1;
555
+ return {
556
+ model: {
557
+ ...model,
558
+ pending: {
559
+ message: text,
560
+ politeness
561
+ },
562
+ generation
563
+ },
564
+ commands: [after(args.debounceMs, Message$5.Read({ generation }), "LiveAnnounce.read", generation)]
565
+ };
566
+ },
567
+ Read: ({ generation }) => {
568
+ if (generation !== model.generation || model.pending === null) return { model };
569
+ const { message: text, politeness } = model.pending;
570
+ const region = politeness === "polite" ? "polite" : "assertive";
571
+ return {
572
+ model: {
573
+ ...model,
574
+ pending: null,
575
+ [region]: reread(model[region], text)
576
+ },
577
+ commands: [after(args.clearAfterMs, Message$5.Cleared({ generation }), "LiveAnnounce.clear", generation)]
578
+ };
579
+ },
580
+ Cleared: ({ generation }) => generation === model.generation ? { model: {
581
+ ...model,
582
+ polite: "",
583
+ assertive: ""
584
+ } } : { model }
585
+ })
586
+ });
587
+ /** The parent Message that announces `text`, to return from `update` or `onOut`. */
588
+ const say = (declared) => (text, politeness = "polite") => declared.wrapper.make(Message$5.Announced({
589
+ message: text,
590
+ politeness
591
+ }));
592
+ const REGION_ATTRIBUTE = "data-foldkit-plus-live";
593
+ /**
594
+ * The two live regions. Render once, anywhere in the page, and hide them
595
+ * visually with a rule on `[data-foldkit-plus-live]`; they must stay in the
596
+ * accessibility tree, so no `display: none`.
597
+ */
598
+ const view = (model, h) => h.div([h.Attribute(REGION_ATTRIBUTE, "regions")], [h.div([
599
+ h.Attribute(REGION_ATTRIBUTE, "polite"),
600
+ h.AriaLive("polite"),
601
+ h.AriaAtomic(true)
602
+ ], [model.polite]), h.div([
603
+ h.Attribute(REGION_ATTRIBUTE, "assertive"),
604
+ h.AriaLive("assertive"),
605
+ h.AriaAtomic(true)
606
+ ], [model.assertive])]);
607
+ //#endregion
608
+ //#region src/interaction/typeahead.ts
609
+ /**
610
+ * Typeahead over a set of items: printable keys build a query that a timer
611
+ * clears, and the query picks the item to move to. The query lives in the
612
+ * Model so a replay sees the same picks; the timer runs on Effect's clock so
613
+ * TestClock drives it. Which item matches is a pure function.
614
+ */
615
+ var typeahead_exports = /* @__PURE__ */ __exportAll({
616
+ Args: () => Args$4,
617
+ Message: () => Message$4,
618
+ Model: () => Model$4,
619
+ behavior: () => behavior$5,
620
+ bundle: () => bundle$4,
621
+ isPrintable: () => isPrintable,
622
+ match: () => match
623
+ });
624
+ const Model$4 = Schema.Struct({
625
+ query: Schema.String,
626
+ /** Bumped per keystroke, carried by the clear it schedules, so a superseded
627
+ * timer's `Expired` changes nothing. */
628
+ generation: Schema.Number
629
+ });
630
+ const Message$4 = defineMessageUnion({
631
+ /** A printable key was pressed with no ctrl, alt or meta. */
632
+ Typed: { char: Schema.String },
633
+ /** The clear timer fired for `generation`. */
634
+ Expired: { generation: Schema.Number },
635
+ /** The query was dropped on purpose (a blur, a selection). */
636
+ Cleared: {}
637
+ });
638
+ const Args$4 = Schema.Struct({
639
+ /** How long the query survives after the last key. */
640
+ timeoutMs: Schema.Number.pipe(Schema.check(Schema.isGreaterThan(0)), Schema.check(Schema.isFinite())) });
641
+ const bundle$4 = Bundle.make("Typeahead", {
642
+ Model: Model$4,
643
+ Message: Message$4,
644
+ args: Args$4,
645
+ init: () => ({ model: {
646
+ query: "",
647
+ generation: 0
648
+ } }),
649
+ update: (model, message, args) => Message$4.match(message, {
650
+ Typed: ({ char }) => {
651
+ const generation = model.generation + 1;
652
+ return {
653
+ model: {
654
+ query: model.query + char,
655
+ generation
656
+ },
657
+ commands: [{
658
+ name: "Typeahead.expire",
659
+ args: { generation },
660
+ effect: Effect.as(Effect.sleep(args.timeoutMs), Message$4.Expired({ generation }))
661
+ }]
662
+ };
663
+ },
664
+ Expired: ({ generation }) => generation === model.generation ? { model: {
665
+ ...model,
666
+ query: ""
667
+ } } : { model },
668
+ Cleared: () => ({ model: {
669
+ ...model,
670
+ query: ""
671
+ } })
672
+ })
673
+ });
674
+ const normalize = (text) => text.trimStart().toLocaleLowerCase();
675
+ const isRepeated = (query) => query.length > 1 && [...query].every((char) => char === query[0]);
676
+ /**
677
+ * The enabled index whose text starts with `query`, searching forward from
678
+ * `current` and wrapping, or `undefined`. A query of one character, or one
679
+ * character repeated (`aaa`), starts *after* the current item so repeated
680
+ * presses cycle through the items that begin with it; a longer query starts
681
+ * *at* the current item, since the user is refining a match. Case and leading
682
+ * whitespace are ignored.
683
+ */
684
+ const match = (texts, enabled, query, current) => {
685
+ if (query === "" || enabled.length === 0) return void 0;
686
+ const needle = normalize(isRepeated(query) ? query[0] : query);
687
+ const position = enabled.indexOf(current);
688
+ const startAfter = needle.length === 1 || isRepeated(query);
689
+ const start = position === -1 ? 0 : startAfter ? position + 1 : position;
690
+ for (let offset = 0; offset < enabled.length; offset += 1) {
691
+ const index = enabled[(start + offset) % enabled.length];
692
+ if (normalize(texts[index] ?? "").startsWith(needle)) return index;
693
+ }
694
+ };
695
+ /** A single character with no ctrl, alt or meta: a typeahead key. */
696
+ const isPrintable = (key, modifiers) => key.length === 1 && !modifiers.ctrlKey && !modifiers.altKey && !modifiers.metaKey;
697
+ /**
698
+ * Wires a placed `Typeahead` to the host slot: a printable key extends the
699
+ * query, focuses the matching item by its id (which then reports itself
700
+ * current, through `RovingTabindex`'s `OnFocus`), and dispatches `Typed`.
701
+ * With no match the key still extends the query, so the next one can match;
702
+ * a space with an empty query is left to the host (it activates).
703
+ */
704
+ const behavior$5 = (declared) => (slots) => (options) => {
705
+ const wrap = (char) => declared.wrapper.make(Message$4.Typed({ char }));
706
+ return Behavior.forSlots(slots)({ [options.host]: Behavior.slot({
707
+ requires: { capability: Capability.Interactive },
708
+ attributes: ({ input, h }) => {
709
+ const items = options.items(input);
710
+ const query = input[declared.field].query;
711
+ const currentId = options.current(input);
712
+ const current = currentId === null ? -1 : items.indexOf(currentId);
713
+ const texts = items.ids.map((_, index) => options.text(input, index));
714
+ return [h.OnKeyDownFocus((key, modifiers) => {
715
+ if (!isPrintable(key, modifiers)) return Option.none();
716
+ if (key === " " && query === "") return Option.none();
717
+ const found = match(texts, items.enabled, query + key, current);
718
+ const target = found === void 0 ? currentId : items.ids[found];
719
+ return Option.some({
720
+ focusSelector: target === null || target === void 0 ? ":not(*)" : idSelector(target),
721
+ message: wrap(key)
722
+ });
723
+ })];
724
+ }
725
+ }) }, { name: "Typeahead" });
726
+ };
727
+ //#endregion
728
+ //#region src/interaction/list-navigation.ts
729
+ /**
730
+ * Keyboard navigation of a list in one placement: a roving tab stop, type to
731
+ * find, and PageUp and PageDown. One Bundle holds the current item's id and
732
+ * the typeahead query, so a printable key can move the pointer and extend the
733
+ * query in one transition, which two placements cannot do under `virtual`.
734
+ * The resolver allows one owner per event on a slot, so a host that needs
735
+ * both arrows and typeahead takes this, not `RovingTabindex` plus `Typeahead`.
736
+ */
737
+ var list_navigation_exports = /* @__PURE__ */ __exportAll({
738
+ Args: () => Args$3,
739
+ Message: () => Message$3,
740
+ Model: () => Model$3,
741
+ behavior: () => behavior$4,
742
+ bundle: () => bundle$3,
743
+ keyOutcome: () => keyOutcome
744
+ });
745
+ const Model$3 = Schema.Struct({
746
+ current: Schema.NullOr(Schema.String),
747
+ query: Schema.String,
748
+ generation: Schema.Number
749
+ });
750
+ const Message$3 = defineMessageUnion({
751
+ /** An item became current: focused, or reached by a key. */
752
+ Focused: { id: Schema.String },
753
+ /** A printable key extended the query; `match` is the item it now picks, if any. */
754
+ Typed: {
755
+ char: Schema.String,
756
+ match: Schema.NullOr(Schema.String)
757
+ },
758
+ Expired: { generation: Schema.Number },
759
+ Cleared: {}
760
+ });
761
+ const Args$3 = Schema.Struct({
762
+ orientation: Orientation,
763
+ loop: Schema.Boolean,
764
+ virtual: Schema.Boolean,
765
+ /** How long the typeahead query survives after the last key. */
766
+ timeoutMs: Schema.Number.pipe(Schema.check(Schema.isGreaterThan(0)), Schema.check(Schema.isFinite())),
767
+ /** How many enabled items PageUp and PageDown move. */
768
+ page: Schema.Number.pipe(Schema.check(Schema.isInt()), Schema.check(Schema.isGreaterThan(0)))
769
+ });
770
+ const bundle$3 = Bundle.make("ListNavigation", {
771
+ Model: Model$3,
772
+ Message: Message$3,
773
+ args: Args$3,
774
+ init: () => ({ model: {
775
+ current: null,
776
+ query: "",
777
+ generation: 0
778
+ } }),
779
+ update: (model, message, args) => Message$3.match(message, {
780
+ Focused: ({ id }) => ({ model: {
781
+ ...model,
782
+ current: id
783
+ } }),
784
+ Typed: ({ char, match }) => {
785
+ const generation = model.generation + 1;
786
+ return {
787
+ model: {
788
+ current: match ?? model.current,
789
+ query: model.query + char,
790
+ generation
791
+ },
792
+ commands: [{
793
+ name: "ListNavigation.expire",
794
+ args: { generation },
795
+ effect: Effect.as(Effect.sleep(args.timeoutMs), Message$3.Expired({ generation }))
796
+ }]
797
+ };
798
+ },
799
+ Expired: ({ generation }) => generation === model.generation ? { model: {
800
+ ...model,
801
+ query: ""
802
+ } } : { model },
803
+ Cleared: () => ({ model: {
804
+ ...model,
805
+ query: ""
806
+ } })
807
+ })
808
+ });
809
+ const keyOutcome = (items, texts, state, key, modifiers, options) => {
810
+ const from = state.current === null ? -1 : items.indexOf(state.current);
811
+ const moved = move$1(items.enabled, from, key, modifiers, options);
812
+ const movedId = moved === void 0 ? void 0 : items.ids[moved];
813
+ if (movedId !== void 0) return {
814
+ _tag: "Navigate",
815
+ id: movedId
816
+ };
817
+ if (!isPrintable(key, modifiers)) return void 0;
818
+ if (key === " " && state.query === "") return void 0;
819
+ const found = match(texts, items.enabled, state.query + key, from);
820
+ return {
821
+ _tag: "Type",
822
+ char: key,
823
+ match: found === void 0 ? null : items.ids[found] ?? null
824
+ };
825
+ };
826
+ /**
827
+ * Wires a placed `ListNavigation` to the slots. The container gets one key
828
+ * handler: arrows, Home, End, PageUp and PageDown move by id; a printable key
829
+ * extends the query and moves to its match. Each item gets `tabindex` and
830
+ * `OnFocus` as under `RovingTabindex`. Under `virtual` nothing takes DOM focus
831
+ * and the container carries `aria-activedescendant`.
832
+ */
833
+ const behavior$4 = (declared, args) => (slots) => (options) => {
834
+ const wrap = (message) => declared.wrapper.make(message);
835
+ const focused = (id) => wrap(Message$3.Focused({ id }));
836
+ const slice = (input) => input[declared.field];
837
+ const outcomeMessage = (outcome) => outcome._tag === "Navigate" ? focused(outcome.id) : wrap(Message$3.Typed({
838
+ char: outcome.char,
839
+ match: outcome.match
840
+ }));
841
+ return Behavior.forSlots(slots)({
842
+ [options.container]: Behavior.slot({
843
+ requires: { capability: Capability.Interactive },
844
+ attributes: ({ input, h }) => {
845
+ const items = options.items(input);
846
+ const state = slice(input);
847
+ const texts = items.ids.map((_, index) => options.text(input, index));
848
+ const moveOptions = {
849
+ orientation: args.orientation,
850
+ loop: args.loop,
851
+ direction: options.direction?.(input) ?? "ltr",
852
+ page: args.page
853
+ };
854
+ const outcome = (key, modifiers) => Option.fromNullishOr(keyOutcome(items, texts, state, key, modifiers, moveOptions));
855
+ const stop = tabStop(items, state.current);
856
+ const stopId = stop === -1 ? void 0 : items.ids[stop];
857
+ if (args.virtual) return [h.OnKeyDownPreventDefault((key, modifiers) => Option.map(outcome(key, modifiers), outcomeMessage)), ...stopId === void 0 ? [] : [h.AriaActiveDescendant(stopId)]];
858
+ return [h.OnKeyDownFocus((key, modifiers) => Option.map(outcome(key, modifiers), (result) => {
859
+ const target = result._tag === "Navigate" ? result.id : result.match ?? state.current;
860
+ return {
861
+ focusSelector: target === null ? ":not(*)" : idSelector(target),
862
+ message: outcomeMessage(result)
863
+ };
864
+ }))];
865
+ }
866
+ }),
867
+ [options.item]: Behavior.slot({
868
+ requires: { capability: Capability.Focusable },
869
+ attributes: ({ input, h, item }) => item === void 0 ? [] : itemAttributes(h, options.items(input), slice(input).current, item, args.virtual, focused)
870
+ })
871
+ }, { name: "ListNavigation" });
872
+ };
873
+ //#endregion
874
+ //#region src/interaction/press.ts
875
+ /**
876
+ * Press: pointer and keyboard activation of one element as one fact. A Mount
877
+ * reports what the element saw (pointer down, up, cancel, key down, up,
878
+ * click) with the detail Foldkit's declarative attributes do not carry; the
879
+ * Bundle's `update` decides what counts as a press; the ghost click that
880
+ * follows a touch is suppressed by a timed Command on Effect's clock, never
881
+ * by a clock read in a handler. Activation is an OutMessage, `Pressed`, so
882
+ * the placement must say what a press does.
883
+ */
884
+ var press_exports = /* @__PURE__ */ __exportAll({
885
+ Args: () => Args$2,
886
+ Message: () => Message$2,
887
+ Model: () => Model$2,
888
+ PointerType: () => PointerType,
889
+ Pressed: () => Pressed,
890
+ behavior: () => behavior$3,
891
+ bundle: () => bundle$2,
892
+ events: () => events
893
+ });
894
+ const PointerType = Schema.Literals([
895
+ "mouse",
896
+ "touch",
897
+ "pen",
898
+ "keyboard",
899
+ "virtual"
900
+ ]);
901
+ const Model$2 = Schema.Struct({
902
+ /** Down and not yet released or cancelled. For styling. */
903
+ pressed: Schema.Boolean,
904
+ /** The pointer that pressed, so another pointer's up cannot release it. */
905
+ pointerId: Schema.NullOr(Schema.Number),
906
+ /** Enter or Space is down. */
907
+ key: Schema.NullOr(Schema.String),
908
+ /** A click arriving before this generation's window ends is the ghost of a pointer press. */
909
+ suppressing: Schema.NullOr(Schema.Number),
910
+ generation: Schema.Number
911
+ });
912
+ const Message$2 = defineMessageUnion({
913
+ PointerDown: {
914
+ pointerId: Schema.Number,
915
+ button: Schema.Number,
916
+ pointerType: Schema.String
917
+ },
918
+ PointerUp: {
919
+ pointerId: Schema.Number,
920
+ pointerType: Schema.String,
921
+ shiftKey: Schema.Boolean
922
+ },
923
+ PointerCancelled: { pointerId: Schema.Number },
924
+ KeyDown: {
925
+ key: Schema.String,
926
+ repeat: Schema.Boolean
927
+ },
928
+ KeyUp: {
929
+ key: Schema.String,
930
+ shiftKey: Schema.Boolean
931
+ },
932
+ /** `detail` is 0 for a click no pointer made: keyboard on a native control, or assistive technology. */
933
+ Clicked: {
934
+ detail: Schema.Number,
935
+ shiftKey: Schema.Boolean
936
+ },
937
+ Unsuppressed: { generation: Schema.Number }
938
+ });
939
+ const Pressed = Schema.TaggedStruct("Pressed", {
940
+ pointerType: PointerType,
941
+ /** Shift was held, for a range selection. */
942
+ shiftKey: Schema.Boolean
943
+ });
944
+ const Args$2 = Schema.Struct({
945
+ /** How long after a pointer press a `click` is taken for its ghost. */
946
+ clickSuppressionMs: Schema.Number.pipe(Schema.check(Schema.isGreaterThanOrEqualTo(0)), Schema.check(Schema.isFinite())) });
947
+ const idle = (model) => ({
948
+ ...model,
949
+ pressed: false,
950
+ pointerId: null,
951
+ key: null
952
+ });
953
+ const isActivationKey$1 = (key) => key === "Enter" || key === " ";
954
+ const asPointerType = (raw) => raw === "touch" || raw === "pen" ? raw : "mouse";
955
+ const bundle$2 = Bundle.make("Press", {
956
+ Model: Model$2,
957
+ Message: Message$2,
958
+ args: Args$2,
959
+ init: () => ({ model: {
960
+ pressed: false,
961
+ pointerId: null,
962
+ key: null,
963
+ suppressing: null,
964
+ generation: 0
965
+ } }),
966
+ update: (model, message, args) => Message$2.match(message, {
967
+ PointerDown: ({ pointerId, button }) => button !== 0 || model.pointerId !== null ? { model } : { model: {
968
+ ...model,
969
+ pressed: true,
970
+ pointerId
971
+ } },
972
+ PointerUp: ({ pointerId, pointerType, shiftKey }) => {
973
+ if (model.pointerId !== pointerId) return { model };
974
+ const generation = model.generation + 1;
975
+ return {
976
+ model: {
977
+ ...idle(model),
978
+ suppressing: generation,
979
+ generation
980
+ },
981
+ commands: [{
982
+ name: "Press.unsuppress",
983
+ args: { generation },
984
+ effect: Effect.as(Effect.sleep(args.clickSuppressionMs), Message$2.Unsuppressed({ generation }))
985
+ }],
986
+ outMessage: Pressed.make({
987
+ pointerType: asPointerType(pointerType),
988
+ shiftKey
989
+ })
990
+ };
991
+ },
992
+ PointerCancelled: ({ pointerId }) => model.pointerId === pointerId ? { model: idle(model) } : { model },
993
+ KeyDown: ({ key, repeat }) => !isActivationKey$1(key) || repeat || model.key !== null || model.pointerId !== null ? { model } : { model: {
994
+ ...model,
995
+ pressed: true,
996
+ key
997
+ } },
998
+ KeyUp: ({ key, shiftKey }) => model.key === key ? {
999
+ model: idle(model),
1000
+ outMessage: Pressed.make({
1001
+ pointerType: "keyboard",
1002
+ shiftKey
1003
+ })
1004
+ } : { model },
1005
+ Clicked: ({ detail, shiftKey }) => detail === 0 ? {
1006
+ model,
1007
+ outMessage: Pressed.make({
1008
+ pointerType: "virtual",
1009
+ shiftKey
1010
+ })
1011
+ } : model.suppressing !== null ? { model } : {
1012
+ model,
1013
+ outMessage: Pressed.make({
1014
+ pointerType: "mouse",
1015
+ shiftKey
1016
+ })
1017
+ },
1018
+ Unsuppressed: ({ generation }) => model.suppressing === generation ? { model: {
1019
+ ...model,
1020
+ suppressing: null
1021
+ } } : { model }
1022
+ })
1023
+ });
1024
+ /**
1025
+ * The element's press-relevant events as facts. Enter and Space key downs
1026
+ * are default-prevented so a native control does not also click, and Space
1027
+ * does not scroll; the Bundle owns activation. An element marked
1028
+ * `aria-disabled="true"` (which the Behavior writes) reports nothing, read
1029
+ * at event time so a toggle needs no remount. Nothing else is decided here.
1030
+ */
1031
+ const events = Mount.defineStream("PressEvents", {
1032
+ messages: [
1033
+ Message$2.PointerDown,
1034
+ Message$2.PointerUp,
1035
+ Message$2.PointerCancelled,
1036
+ Message$2.KeyDown,
1037
+ Message$2.KeyUp,
1038
+ Message$2.Clicked
1039
+ ],
1040
+ execute: ({ element }) => Stream.callback((queue) => Effect.acquireRelease(Effect.sync(() => {
1041
+ const offer = (message) => {
1042
+ if (element.getAttribute("aria-disabled") === "true") return;
1043
+ Queue.offerUnsafe(queue, message);
1044
+ };
1045
+ const listeners = [
1046
+ ["pointerdown", (event) => {
1047
+ const pointer = event;
1048
+ offer(Message$2.PointerDown({
1049
+ pointerId: pointer.pointerId ?? 0,
1050
+ button: pointer.button ?? 0,
1051
+ pointerType: pointer.pointerType ?? "mouse"
1052
+ }));
1053
+ }],
1054
+ ["pointerup", (event) => {
1055
+ const pointer = event;
1056
+ offer(Message$2.PointerUp({
1057
+ pointerId: pointer.pointerId ?? 0,
1058
+ pointerType: pointer.pointerType ?? "mouse",
1059
+ shiftKey: pointer.shiftKey ?? false
1060
+ }));
1061
+ }],
1062
+ ["pointerleave", (event) => offer(Message$2.PointerCancelled({ pointerId: event.pointerId ?? 0 }))],
1063
+ ["pointercancel", (event) => offer(Message$2.PointerCancelled({ pointerId: event.pointerId ?? 0 }))],
1064
+ ["keydown", (event) => {
1065
+ const key = event;
1066
+ if (isActivationKey$1(key.key)) key.preventDefault();
1067
+ offer(Message$2.KeyDown({
1068
+ key: key.key,
1069
+ repeat: key.repeat
1070
+ }));
1071
+ }],
1072
+ ["keyup", (event) => {
1073
+ const key = event;
1074
+ offer(Message$2.KeyUp({
1075
+ key: key.key,
1076
+ shiftKey: key.shiftKey
1077
+ }));
1078
+ }],
1079
+ ["click", (event) => {
1080
+ const click = event;
1081
+ offer(Message$2.Clicked({
1082
+ detail: click.detail ?? 0,
1083
+ shiftKey: click.shiftKey ?? false
1084
+ }));
1085
+ }]
1086
+ ];
1087
+ for (const [type, listener] of listeners) element.addEventListener(type, listener);
1088
+ return listeners;
1089
+ }), (listeners) => Effect.sync(() => {
1090
+ for (const [type, listener] of listeners) element.removeEventListener(type, listener);
1091
+ })))
1092
+ });
1093
+ /**
1094
+ * Attaches the events Mount to the target slot, mapped into the placement's
1095
+ * Messages, and writes `data-pressed` while down for styling. The element
1096
+ * decides nothing; a click, a touch, Enter, Space, or an assistive
1097
+ * technology's click all reach `update` and come out as one `Pressed`.
1098
+ */
1099
+ const behavior$3 = (declared) => (slots) => (options) => Behavior.forSlots(slots)({ [options.target]: Behavior.slot({
1100
+ requires: { capability: Capability.Interactive },
1101
+ attributes: ({ input, h }) => [...input[declared.field].pressed ? [h.DataAttribute("pressed", "true")] : [], ...options.disabled?.(input) === true ? [h.AriaDisabled(true)] : []],
1102
+ mount: () => Mount.mapMessage(events(), (message) => declared.wrapper.make(message))
1103
+ }) }, { name: "Press" });
1104
+ //#endregion
1105
+ //#region src/interaction/long-press.ts
1106
+ /**
1107
+ * LongPress: holding an element down for `thresholdMs` is one fact. It reads
1108
+ * the same facts `Press.events` reports; the threshold is a Command on
1109
+ * Effect's clock carrying a generation, so a release before it fires makes
1110
+ * its `Elapsed` a no-op. `LongPressed` is an OutMessage the placement must
1111
+ * handle. A short press is `Press`'s business, not this Bundle's.
1112
+ */
1113
+ var long_press_exports = /* @__PURE__ */ __exportAll({
1114
+ Args: () => Args$1,
1115
+ LongPressed: () => LongPressed,
1116
+ Message: () => Message$1,
1117
+ Model: () => Model$1,
1118
+ behavior: () => behavior$2,
1119
+ bundle: () => bundle$1
1120
+ });
1121
+ const Model$1 = Schema.Struct({
1122
+ /** Down, and the threshold has not passed. */
1123
+ holding: Schema.Boolean,
1124
+ pointerId: Schema.NullOr(Schema.Number),
1125
+ key: Schema.NullOr(Schema.String),
1126
+ generation: Schema.Number
1127
+ });
1128
+ const Message$1 = defineMessageUnion({
1129
+ PointerDown: {
1130
+ pointerId: Schema.Number,
1131
+ button: Schema.Number,
1132
+ pointerType: Schema.String
1133
+ },
1134
+ PointerUp: {
1135
+ pointerId: Schema.Number,
1136
+ pointerType: Schema.String,
1137
+ shiftKey: Schema.Boolean
1138
+ },
1139
+ PointerCancelled: { pointerId: Schema.Number },
1140
+ KeyDown: {
1141
+ key: Schema.String,
1142
+ repeat: Schema.Boolean
1143
+ },
1144
+ KeyUp: {
1145
+ key: Schema.String,
1146
+ shiftKey: Schema.Boolean
1147
+ },
1148
+ Clicked: {
1149
+ detail: Schema.Number,
1150
+ shiftKey: Schema.Boolean
1151
+ },
1152
+ Elapsed: {
1153
+ generation: Schema.Number,
1154
+ pointerType: PointerType
1155
+ }
1156
+ });
1157
+ const LongPressed = Schema.TaggedStruct("LongPressed", { pointerType: PointerType });
1158
+ const Args$1 = Schema.Struct({ thresholdMs: Schema.Number.pipe(Schema.check(Schema.isGreaterThan(0)), Schema.check(Schema.isFinite())) });
1159
+ const isActivationKey = (key) => key === "Enter" || key === " ";
1160
+ const released = (model) => ({
1161
+ ...model,
1162
+ holding: false,
1163
+ pointerId: null,
1164
+ key: null
1165
+ });
1166
+ const start = (model, args, by, pointerType) => {
1167
+ const generation = model.generation + 1;
1168
+ return {
1169
+ model: {
1170
+ holding: true,
1171
+ pointerId: "pointerId" in by ? by.pointerId : null,
1172
+ key: "key" in by ? by.key : null,
1173
+ generation
1174
+ },
1175
+ commands: [{
1176
+ name: "LongPress.elapse",
1177
+ args: { generation },
1178
+ effect: Effect.as(Effect.sleep(args.thresholdMs), Message$1.Elapsed({
1179
+ generation,
1180
+ pointerType
1181
+ }))
1182
+ }]
1183
+ };
1184
+ };
1185
+ const bundle$1 = Bundle.make("LongPress", {
1186
+ Model: Model$1,
1187
+ Message: Message$1,
1188
+ args: Args$1,
1189
+ init: () => ({ model: {
1190
+ holding: false,
1191
+ pointerId: null,
1192
+ key: null,
1193
+ generation: 0
1194
+ } }),
1195
+ update: (model, message, args) => Message$1.match(message, {
1196
+ PointerDown: ({ pointerId, button, pointerType }) => button !== 0 || model.holding ? { model } : start(model, args, { pointerId }, pointerType === "touch" || pointerType === "pen" ? pointerType : "mouse"),
1197
+ PointerUp: ({ pointerId }) => model.pointerId === pointerId ? { model: released(model) } : { model },
1198
+ PointerCancelled: ({ pointerId }) => model.pointerId === pointerId ? { model: released(model) } : { model },
1199
+ KeyDown: ({ key, repeat }) => !isActivationKey(key) || repeat || model.holding ? { model } : start(model, args, { key }, "keyboard"),
1200
+ KeyUp: ({ key }) => model.key === key ? { model: released(model) } : { model },
1201
+ Clicked: () => ({ model }),
1202
+ Elapsed: ({ generation, pointerType }) => model.holding && model.generation === generation ? {
1203
+ model: released(model),
1204
+ outMessage: LongPressed.make({ pointerType })
1205
+ } : { model }
1206
+ })
1207
+ });
1208
+ /**
1209
+ * Attaches `Press.events` to the target, mapped into this placement's
1210
+ * Messages, and writes `data-holding` while the element is down. `Press` and
1211
+ * `LongPress` on one slot are refused by the resolver (two Mounts of one
1212
+ * name), so today a slot takes one of them.
1213
+ */
1214
+ const behavior$2 = (declared) => (slots) => (options) => Behavior.forSlots(slots)({ [options.target]: Behavior.slot({
1215
+ requires: { capability: Capability.Interactive },
1216
+ attributes: ({ input, h }) => input[declared.field].holding ? [h.DataAttribute("holding", "true")] : [],
1217
+ mount: () => Mount.mapMessage(events(), (fact) => declared.wrapper.make(fact))
1218
+ }) }, { name: "LongPress" });
1219
+ //#endregion
1220
+ //#region src/interaction/move.ts
1221
+ /**
1222
+ * `Move` as a Behavior: attaches the `foldkit-primitives/dom` Mount to a
1223
+ * draggable slot and maps its facts into the view's Messages. No Bundle: a
1224
+ * drag's meaning (a threshold, a snap, a reorder) is the parent's `update`,
1225
+ * and the deltas are already facts.
1226
+ */
1227
+ var move_exports = /* @__PURE__ */ __exportAll({
1228
+ MoveEnded: () => MoveEnded,
1229
+ MoveStarted: () => MoveStarted,
1230
+ Moved: () => Moved,
1231
+ behavior: () => behavior$1,
1232
+ mount: () => Move
1233
+ });
1234
+ const behavior$1 = (slots) => (options) => Behavior.forSlots(slots)({ [options.handle]: Behavior.slot({
1235
+ requires: { capability: Capability.Draggable },
1236
+ mount: () => Mount.mapMessage(Move(), options.toMessage)
1237
+ }) }, { name: "Move" });
1238
+ //#endregion
1239
+ //#region src/interaction/selection.ts
1240
+ /**
1241
+ * Selection over a set of items: which ids are selected, and the anchor a
1242
+ * range extends from. `mode` is `'single'` (a click replaces), `'multiple'`
1243
+ * (a click toggles), or `'none'`. Single mode keeps one item selected unless
1244
+ * `allowEmpty`. A range needs the items' order, which the view knows and the
1245
+ * Bundle does not, so `Ranged` carries it. The Behavior says the state in
1246
+ * ARIA and wires a plain click; a Shift range comes from `Press`, whose
1247
+ * `Pressed` carries `shiftKey`.
1248
+ */
1249
+ var selection_exports = /* @__PURE__ */ __exportAll({
1250
+ Args: () => Args,
1251
+ Message: () => Message,
1252
+ Mode: () => Mode,
1253
+ Model: () => Model,
1254
+ behavior: () => behavior,
1255
+ between: () => between,
1256
+ bundle: () => bundle
1257
+ });
1258
+ const Mode = Schema.Literals([
1259
+ "single",
1260
+ "multiple",
1261
+ "none"
1262
+ ]);
1263
+ const Model = Schema.Struct({
1264
+ /** In selection order. */
1265
+ selected: Schema.Array(Schema.String),
1266
+ /** Where the last plain selection happened; a range extends from it. */
1267
+ anchor: Schema.NullOr(Schema.String)
1268
+ });
1269
+ const Message = defineMessageUnion({
1270
+ /** A plain activation: replaces in single mode, toggles in multiple mode. */
1271
+ Activated: { id: Schema.String },
1272
+ /** A Shift activation: selects everything between the anchor and `id` in `order`. */
1273
+ Ranged: {
1274
+ id: Schema.String,
1275
+ order: Schema.Array(Schema.String)
1276
+ },
1277
+ Replaced: { ids: Schema.Array(Schema.String) },
1278
+ Cleared: {}
1279
+ });
1280
+ const Args = Schema.Struct({
1281
+ mode: Mode,
1282
+ /** In single mode, whether activating the selected item deselects it. */
1283
+ allowEmpty: Schema.Boolean
1284
+ });
1285
+ const without = (ids, id) => ids.filter((other) => other !== id);
1286
+ /** The ids between `from` and `to` in `order`, inclusive, in `order`'s order. */
1287
+ const between = (order, from, to) => {
1288
+ const end = order.indexOf(to);
1289
+ if (end === -1) return [];
1290
+ const start = from === null ? -1 : order.indexOf(from);
1291
+ if (start === -1) return [to];
1292
+ return order.slice(Math.min(start, end), Math.max(start, end) + 1);
1293
+ };
1294
+ const bundle = Bundle.make("Selection", {
1295
+ Model,
1296
+ Message,
1297
+ args: Args,
1298
+ init: () => ({ model: {
1299
+ selected: [],
1300
+ anchor: null
1301
+ } }),
1302
+ update: (model, message, args) => Message.match(message, {
1303
+ Activated: ({ id }) => {
1304
+ if (args.mode === "none") return { model };
1305
+ const isSelected = model.selected.includes(id);
1306
+ if (args.mode === "single") return isSelected ? args.allowEmpty ? { model: {
1307
+ selected: [],
1308
+ anchor: id
1309
+ } } : { model } : { model: {
1310
+ selected: [id],
1311
+ anchor: id
1312
+ } };
1313
+ return { model: {
1314
+ selected: isSelected ? without(model.selected, id) : [...model.selected, id],
1315
+ anchor: id
1316
+ } };
1317
+ },
1318
+ Ranged: ({ id, order }) => {
1319
+ if (args.mode !== "multiple") return { model };
1320
+ const range = between(order, model.anchor, id);
1321
+ return { model: {
1322
+ selected: [...model.selected.filter((other) => !range.includes(other)), ...range],
1323
+ anchor: model.anchor ?? id
1324
+ } };
1325
+ },
1326
+ Replaced: ({ ids }) => ({ model: {
1327
+ selected: args.mode === "none" ? [] : [...new Set(ids)],
1328
+ anchor: model.anchor
1329
+ } }),
1330
+ Cleared: () => ({ model: {
1331
+ selected: [],
1332
+ anchor: model.anchor
1333
+ } })
1334
+ })
1335
+ });
1336
+ /**
1337
+ * Writes `aria-selected` on each item from the placed slice, and
1338
+ * `aria-multiselectable` on the container in multiple mode; wires `OnClick`
1339
+ * to `Activated` unless told not to. A disabled item gets no click.
1340
+ */
1341
+ const behavior = (declared, args) => (slots) => (options) => {
1342
+ const wrap = (message) => declared.wrapper.make(message);
1343
+ return Behavior.forSlots(slots)({
1344
+ ...options.container === void 0 ? {} : { [options.container]: Behavior.slot({
1345
+ requires: { capability: Capability.Container },
1346
+ attributes: ({ h }) => [h.AriaMultiSelectable(args.mode === "multiple")]
1347
+ }) },
1348
+ [options.item]: Behavior.slot({
1349
+ requires: { capability: Capability.Interactive },
1350
+ attributes: ({ input, h, item }) => {
1351
+ if (item === void 0) return [];
1352
+ const items = options.items(input);
1353
+ const id = item.id ?? items.ids[item.index];
1354
+ if (id === void 0) return [];
1355
+ const clickable = options.click !== false && args.mode !== "none" && !items.isDisabled(item.index);
1356
+ return [h.AriaSelected(input[declared.field].selected.includes(id)), ...clickable ? [h.OnClick(wrap(Message.Activated({ id })))] : []];
1357
+ }
1358
+ })
1359
+ }, { name: "Selection" });
1360
+ };
1361
+ //#endregion
1362
+ export { dismiss_layer_exports as DismissLayer, focus_scope_exports as FocusScope, focus_visible_exports as FocusVisible, grid_navigation_exports as GridNavigation, layers_exports as Layers, list_navigation_exports as ListNavigation, live_announce_exports as LiveAnnounce, long_press_exports as LongPress, move_exports as Move, press_exports as Press, roving_tabindex_exports as RovingTabindex, selection_exports as Selection, typeahead_exports as Typeahead };
1363
+
1364
+ //# sourceMappingURL=index.mjs.map