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
|
@@ -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
|