@mlola-ui/behavior 1.0.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/LICENSE +21 -0
- package/README.md +49 -0
- package/package.json +39 -0
- package/src/document.d.ts +6 -0
- package/src/document.js +67 -0
- package/src/index.js +620 -0
- package/src/logic.d.ts +113 -0
- package/src/logic.js +298 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mlola
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# @mlola-ui/behavior
|
|
2
|
+
|
|
3
|
+
Framework-free interaction behavior for Mlola UI markup. Standard DOM only: no
|
|
4
|
+
framework, no build step, no runtime peers, and no renderer of its own. Mark a
|
|
5
|
+
root with `data-ml="<behaviour>"` and enhance it, or call `observe()` once and
|
|
6
|
+
let new markup enhance itself.
|
|
7
|
+
|
|
8
|
+
The decisions themselves — which index a key moves to, where focus goes, what a
|
|
9
|
+
value snaps to — live in `@mlola-ui/behavior/logic` as pure functions, so React
|
|
10
|
+
components and this runtime cannot drift apart.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install @mlola-ui/behavior
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Use
|
|
19
|
+
|
|
20
|
+
```html
|
|
21
|
+
<link rel="stylesheet" href="@mlola-ui/engine" />
|
|
22
|
+
|
|
23
|
+
<div data-theme="atelier" data-mode="light">
|
|
24
|
+
<div data-ml="tabs">…</div>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<script type="module">
|
|
28
|
+
import { observe } from "@mlola-ui/behavior";
|
|
29
|
+
observe();
|
|
30
|
+
</script>
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
It is idempotent, so it is safe to call after a framework re-render, a turbo
|
|
34
|
+
navigation, or an htmx swap.
|
|
35
|
+
|
|
36
|
+
## Implemented behaviours
|
|
37
|
+
|
|
38
|
+
`accordion`, `tabs`, `dropdown-menu`, `select`, `modal`, `sheet`, `tooltip`,
|
|
39
|
+
`toast`, `switch`, `slider` — the same list the engine's `behavior-spec.mjs`
|
|
40
|
+
specifies, checked by the contract audit.
|
|
41
|
+
|
|
42
|
+
## Exports
|
|
43
|
+
|
|
44
|
+
| Path | What it is |
|
|
45
|
+
| --- | --- |
|
|
46
|
+
| `@mlola-ui/behavior` | `enhance`, `observe`, `destroy`, `behaviors` |
|
|
47
|
+
| `@mlola-ui/behavior/logic` | pure decisions shared with React |
|
|
48
|
+
|
|
49
|
+
MIT licensed. Part of [Mlola UI](https://ui.mlola.com).
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mlola-ui/behavior",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Framework-free behavior for Mlola UI markup: standard DOM, no runtime peers",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"files": [
|
|
9
|
+
"src"
|
|
10
|
+
],
|
|
11
|
+
"exports": {
|
|
12
|
+
".": "./src/index.js",
|
|
13
|
+
"./logic": "./src/logic.js",
|
|
14
|
+
"./document": "./src/document.js"
|
|
15
|
+
},
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"author": "Mlola",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/mlolahq/mlola-ui.git",
|
|
21
|
+
"directory": "packages/behavior"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://ui.mlola.com",
|
|
24
|
+
"bugs": {
|
|
25
|
+
"url": "https://github.com/mlolahq/mlola-ui/issues"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"mlola",
|
|
29
|
+
"ui",
|
|
30
|
+
"behavior",
|
|
31
|
+
"framework-free",
|
|
32
|
+
"dom",
|
|
33
|
+
"vanilla",
|
|
34
|
+
"accessibility"
|
|
35
|
+
],
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/document.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Document-level effects that both runtimes need, exactly once.
|
|
3
|
+
*
|
|
4
|
+
* A React modal and a framework-free sheet can be open at the same time, and
|
|
5
|
+
* a page can load two copies of this package. They must still agree on one
|
|
6
|
+
* scroll lock, so its state lives on <html> (under a registered symbol), not
|
|
7
|
+
* in a module variable that each copy would have its own of.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const STATE = Symbol.for("mlola.scrollLock");
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Stop the page from scrolling behind an overlay, without the page moving.
|
|
14
|
+
*
|
|
15
|
+
* Hiding overflow removes a classic scrollbar, which widens the viewport and
|
|
16
|
+
* shifts everything sideways. The lock keeps the scrollbar's space instead:
|
|
17
|
+
* `scrollbar-gutter: stable`, or padding where the page still widened. While locked,
|
|
18
|
+
* <html> carries `data-ml-scroll-locked` and `--ml-scroll-lock-offset` (the
|
|
19
|
+
* padding added, 0px with a gutter) for any fixed element that must follow.
|
|
20
|
+
*
|
|
21
|
+
* Locks nest. Returns a release function; calling it twice is harmless.
|
|
22
|
+
*/
|
|
23
|
+
export function lockScroll(doc = globalThis.document) {
|
|
24
|
+
if (!doc?.documentElement) return () => {};
|
|
25
|
+
const root = doc.documentElement;
|
|
26
|
+
const state = root[STATE] ?? (root[STATE] = { count: 0, saved: null });
|
|
27
|
+
|
|
28
|
+
if (state.count === 0) {
|
|
29
|
+
const view = doc.defaultView;
|
|
30
|
+
const width = root.clientWidth;
|
|
31
|
+
state.saved = {
|
|
32
|
+
overflow: root.style.overflow,
|
|
33
|
+
scrollbarGutter: root.style.scrollbarGutter,
|
|
34
|
+
paddingInlineEnd: root.style.paddingInlineEnd,
|
|
35
|
+
};
|
|
36
|
+
root.style.scrollbarGutter = "stable";
|
|
37
|
+
root.style.overflow = "hidden";
|
|
38
|
+
// Not every engine keeps the gutter for every scrollbar (custom
|
|
39
|
+
// scrollbars, older engines), so measure instead of trusting it: if the
|
|
40
|
+
// page still widened, give the lost width back as padding.
|
|
41
|
+
const widened = root.clientWidth - width;
|
|
42
|
+
let offset = 0;
|
|
43
|
+
if (widened > 0 && view) {
|
|
44
|
+
root.style.scrollbarGutter = state.saved.scrollbarGutter;
|
|
45
|
+
const current = Number.parseFloat(view.getComputedStyle(root).paddingInlineEnd) || 0;
|
|
46
|
+
root.style.paddingInlineEnd = `${current + widened}px`;
|
|
47
|
+
offset = widened;
|
|
48
|
+
}
|
|
49
|
+
root.style.setProperty("--ml-scroll-lock-offset", `${offset}px`);
|
|
50
|
+
root.setAttribute("data-ml-scroll-locked", "");
|
|
51
|
+
}
|
|
52
|
+
state.count += 1;
|
|
53
|
+
|
|
54
|
+
let released = false;
|
|
55
|
+
return () => {
|
|
56
|
+
if (released) return;
|
|
57
|
+
released = true;
|
|
58
|
+
state.count = Math.max(0, state.count - 1);
|
|
59
|
+
if (state.count > 0 || !state.saved) return;
|
|
60
|
+
root.style.overflow = state.saved.overflow;
|
|
61
|
+
root.style.scrollbarGutter = state.saved.scrollbarGutter;
|
|
62
|
+
root.style.paddingInlineEnd = state.saved.paddingInlineEnd;
|
|
63
|
+
root.style.removeProperty("--ml-scroll-lock-offset");
|
|
64
|
+
root.removeAttribute("data-ml-scroll-locked");
|
|
65
|
+
state.saved = null;
|
|
66
|
+
};
|
|
67
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mlola behaviour: the library's interactions without a framework.
|
|
3
|
+
*
|
|
4
|
+
* Built to still work in ten years, which drove every choice here:
|
|
5
|
+
*
|
|
6
|
+
* - Standard DOM only. No framework, no bundler, no transpiler. This file is
|
|
7
|
+
* a plain ES module and runs from a <script type="module"> as it is.
|
|
8
|
+
* - Progressive enhancement over ownership. Markup comes first, from React,
|
|
9
|
+
* Svelte, Rails, a Go template or a static file, and behaviour attaches to
|
|
10
|
+
* it. Nothing here renders HTML, so nothing here can disagree with it.
|
|
11
|
+
* - Attribute driven, matching the contract the stylesheet already reads.
|
|
12
|
+
* Behaviour sets the same data-* and aria-* the CSS reacts to.
|
|
13
|
+
* - Idempotent. Enhancing twice is a no-op, so it is safe to call after a
|
|
14
|
+
* framework re-render, a turbo navigation or an htmx swap.
|
|
15
|
+
*
|
|
16
|
+
* Mark a root with data-ml="<behaviour>" and call enhance(), or call observe()
|
|
17
|
+
* once and let new markup enhance itself.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
clampToStep,
|
|
22
|
+
focusTrapIndex,
|
|
23
|
+
percentOf,
|
|
24
|
+
rovingIndex,
|
|
25
|
+
sliderValueForKey,
|
|
26
|
+
valueFromRatio,
|
|
27
|
+
} from "./logic.js";
|
|
28
|
+
import { lockScroll } from "./document.js";
|
|
29
|
+
|
|
30
|
+
const ENHANCED = "__mlolaEnhanced";
|
|
31
|
+
|
|
32
|
+
const FOCUSABLE = [
|
|
33
|
+
"a[href]",
|
|
34
|
+
"button:not([disabled])",
|
|
35
|
+
"input:not([disabled]):not([type='hidden'])",
|
|
36
|
+
"select:not([disabled])",
|
|
37
|
+
"textarea:not([disabled])",
|
|
38
|
+
"[tabindex]:not([tabindex='-1'])",
|
|
39
|
+
].join(",");
|
|
40
|
+
|
|
41
|
+
function focusable(root) {
|
|
42
|
+
return [...root.querySelectorAll(FOCUSABLE)].filter(
|
|
43
|
+
(element) => element.offsetWidth > 0 || element.offsetHeight > 0 || element === document.activeElement,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function on(target, type, handler, options) {
|
|
48
|
+
target.addEventListener(type, handler, options);
|
|
49
|
+
return () => target.removeEventListener(type, handler, options);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Element-shaped wrapper over the shared rovingIndex decision. */
|
|
53
|
+
function roving(items, index, key, options = {}) {
|
|
54
|
+
return rovingIndex(items.length, index, key, {
|
|
55
|
+
...options,
|
|
56
|
+
enabled: (at) =>
|
|
57
|
+
!items[at].disabled && items[at].getAttribute("aria-disabled") !== "true",
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const behaviors = {
|
|
62
|
+
accordion(root) {
|
|
63
|
+
const multiple = root.dataset.mlMultiple === "true";
|
|
64
|
+
const collapsible = root.dataset.mlCollapsible !== "false";
|
|
65
|
+
const items = () => [...root.querySelectorAll(".ml-accordion-item")];
|
|
66
|
+
const triggers = () => [...root.querySelectorAll(".ml-accordion-trigger")];
|
|
67
|
+
|
|
68
|
+
const setOpen = (item, open) => {
|
|
69
|
+
const trigger = item.querySelector(".ml-accordion-trigger");
|
|
70
|
+
const panel = item.querySelector(".ml-accordion-panel");
|
|
71
|
+
const state = open ? "open" : "closed";
|
|
72
|
+
item.dataset.state = state;
|
|
73
|
+
if (trigger) {
|
|
74
|
+
trigger.dataset.state = state;
|
|
75
|
+
trigger.setAttribute("aria-expanded", String(open));
|
|
76
|
+
}
|
|
77
|
+
if (panel) {
|
|
78
|
+
panel.dataset.state = state;
|
|
79
|
+
panel.hidden = !open;
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const toggle = (item) => {
|
|
84
|
+
const open = item.dataset.state === "open";
|
|
85
|
+
if (open && !collapsible) return;
|
|
86
|
+
if (!multiple && !open) {
|
|
87
|
+
for (const other of items()) if (other !== item) setOpen(other, false);
|
|
88
|
+
}
|
|
89
|
+
setOpen(item, !open);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
return [
|
|
93
|
+
on(root, "click", (event) => {
|
|
94
|
+
const trigger = event.target.closest(".ml-accordion-trigger");
|
|
95
|
+
if (trigger && root.contains(trigger)) toggle(trigger.closest(".ml-accordion-item"));
|
|
96
|
+
}),
|
|
97
|
+
on(root, "keydown", (event) => {
|
|
98
|
+
const trigger = event.target.closest(".ml-accordion-trigger");
|
|
99
|
+
if (!trigger) return;
|
|
100
|
+
const all = triggers();
|
|
101
|
+
const index = all.indexOf(trigger);
|
|
102
|
+
const target = roving(all, index, event.key, { horizontal: false });
|
|
103
|
+
if (target >= 0) {
|
|
104
|
+
event.preventDefault();
|
|
105
|
+
all[target].focus();
|
|
106
|
+
}
|
|
107
|
+
}),
|
|
108
|
+
];
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
tabs(root) {
|
|
112
|
+
const horizontal = root.dataset.orientation !== "vertical";
|
|
113
|
+
const triggers = () => [...root.querySelectorAll(".ml-tabs-trigger")];
|
|
114
|
+
|
|
115
|
+
const select = (trigger) => {
|
|
116
|
+
for (const other of triggers()) {
|
|
117
|
+
const active = other === trigger;
|
|
118
|
+
other.dataset.state = active ? "active" : "inactive";
|
|
119
|
+
other.setAttribute("aria-selected", String(active));
|
|
120
|
+
other.tabIndex = active ? 0 : -1;
|
|
121
|
+
const panel = document.getElementById(other.getAttribute("aria-controls") ?? "");
|
|
122
|
+
if (panel) {
|
|
123
|
+
panel.dataset.state = active ? "active" : "inactive";
|
|
124
|
+
panel.hidden = !active;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
return [
|
|
130
|
+
on(root, "click", (event) => {
|
|
131
|
+
const trigger = event.target.closest(".ml-tabs-trigger");
|
|
132
|
+
if (trigger && root.contains(trigger) && !trigger.disabled) select(trigger);
|
|
133
|
+
}),
|
|
134
|
+
on(root, "keydown", (event) => {
|
|
135
|
+
const trigger = event.target.closest(".ml-tabs-trigger");
|
|
136
|
+
if (!trigger) return;
|
|
137
|
+
const all = triggers();
|
|
138
|
+
const target = roving(all, all.indexOf(trigger), event.key, { horizontal });
|
|
139
|
+
if (target < 0) return;
|
|
140
|
+
event.preventDefault();
|
|
141
|
+
all[target].focus();
|
|
142
|
+
select(all[target]);
|
|
143
|
+
}),
|
|
144
|
+
];
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
"dropdown-menu": (root) => {
|
|
148
|
+
const trigger = root.querySelector(".ml-dropdown-trigger");
|
|
149
|
+
const menu = root.querySelector(".ml-dropdown-menu");
|
|
150
|
+
if (!trigger || !menu) return [];
|
|
151
|
+
const items = () =>
|
|
152
|
+
[...menu.querySelectorAll(".ml-dropdown-item")].filter(
|
|
153
|
+
(item) => !item.disabled && item.getAttribute("aria-disabled") !== "true",
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const setOpen = (open) => {
|
|
157
|
+
root.dataset.state = open ? "open" : "closed";
|
|
158
|
+
menu.dataset.state = open ? "open" : "closed";
|
|
159
|
+
menu.hidden = !open;
|
|
160
|
+
trigger.setAttribute("aria-expanded", String(open));
|
|
161
|
+
if (!open) for (const item of items()) delete item.dataset.highlighted;
|
|
162
|
+
};
|
|
163
|
+
const isOpen = () => root.dataset.state === "open";
|
|
164
|
+
|
|
165
|
+
const highlight = (item) => {
|
|
166
|
+
for (const other of items()) delete other.dataset.highlighted;
|
|
167
|
+
if (item) {
|
|
168
|
+
item.dataset.highlighted = "";
|
|
169
|
+
item.focus();
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
setOpen(false);
|
|
174
|
+
|
|
175
|
+
return [
|
|
176
|
+
on(trigger, "click", () => setOpen(!isOpen())),
|
|
177
|
+
on(root, "keydown", (event) => {
|
|
178
|
+
const all = items();
|
|
179
|
+
if (event.key === "Escape") {
|
|
180
|
+
setOpen(false);
|
|
181
|
+
trigger.focus();
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (event.key === "Tab") {
|
|
185
|
+
setOpen(false);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
189
|
+
event.preventDefault();
|
|
190
|
+
if (!isOpen()) {
|
|
191
|
+
setOpen(true);
|
|
192
|
+
highlight(all[event.key === "ArrowDown" ? 0 : all.length - 1]);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const current = all.findIndex((item) => "highlighted" in item.dataset);
|
|
196
|
+
const target = roving(all, current, event.key, { horizontal: false });
|
|
197
|
+
if (target >= 0) highlight(all[target]);
|
|
198
|
+
}
|
|
199
|
+
}),
|
|
200
|
+
on(menu, "pointermove", (event) => {
|
|
201
|
+
const item = event.target.closest(".ml-dropdown-item");
|
|
202
|
+
if (item && items().includes(item)) highlight(item);
|
|
203
|
+
}),
|
|
204
|
+
on(document, "pointerdown", (event) => {
|
|
205
|
+
if (isOpen() && !root.contains(event.target)) setOpen(false);
|
|
206
|
+
}),
|
|
207
|
+
];
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
switch: (root) => {
|
|
211
|
+
const set = (on_) => {
|
|
212
|
+
root.setAttribute("aria-checked", String(on_));
|
|
213
|
+
root.dataset.state = on_ ? "checked" : "unchecked";
|
|
214
|
+
};
|
|
215
|
+
if (!root.hasAttribute("aria-checked")) set(false);
|
|
216
|
+
return [
|
|
217
|
+
on(root, "click", () => set(root.getAttribute("aria-checked") !== "true")),
|
|
218
|
+
on(root, "keydown", (event) => {
|
|
219
|
+
if (event.key !== "Enter" && event.key !== " ") return;
|
|
220
|
+
event.preventDefault();
|
|
221
|
+
set(root.getAttribute("aria-checked") !== "true");
|
|
222
|
+
}),
|
|
223
|
+
];
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
/** Shared by modal and sheet: they differ only in which edge they sit on. */
|
|
227
|
+
modal(root) {
|
|
228
|
+
const closeOnEscape = root.dataset.mlEscape !== "false";
|
|
229
|
+
const closeOnOverlay = root.dataset.mlOverlay !== "false";
|
|
230
|
+
let opener = null;
|
|
231
|
+
let releaseScroll = () => {};
|
|
232
|
+
|
|
233
|
+
const overlay = () =>
|
|
234
|
+
document.querySelector(`.ml-modal-overlay[data-ml-for="${root.id}"], .ml-sheet-overlay[data-ml-for="${root.id}"]`);
|
|
235
|
+
|
|
236
|
+
const close = () => {
|
|
237
|
+
root.dataset.state = "closed";
|
|
238
|
+
root.hidden = true;
|
|
239
|
+
const layer = overlay();
|
|
240
|
+
if (layer) layer.hidden = true;
|
|
241
|
+
releaseScroll();
|
|
242
|
+
releaseScroll = () => {};
|
|
243
|
+
if (opener && document.contains(opener)) opener.focus();
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const open = (from) => {
|
|
247
|
+
opener = from ?? document.activeElement;
|
|
248
|
+
root.dataset.state = "open";
|
|
249
|
+
root.hidden = false;
|
|
250
|
+
const layer = overlay();
|
|
251
|
+
if (layer) layer.hidden = false;
|
|
252
|
+
releaseScroll();
|
|
253
|
+
releaseScroll = lockScroll();
|
|
254
|
+
(focusable(root)[0] ?? root).focus();
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
root.mlolaDialog = { open, close };
|
|
258
|
+
|
|
259
|
+
return [
|
|
260
|
+
on(document, "click", (event) => {
|
|
261
|
+
const opener_ = event.target.closest(`[data-ml-opens="${root.id}"]`);
|
|
262
|
+
if (opener_) {
|
|
263
|
+
event.preventDefault();
|
|
264
|
+
open(opener_);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (root.querySelector(".ml-modal-close, .ml-sheet-close")?.contains(event.target)) close();
|
|
268
|
+
if (closeOnOverlay && event.target === overlay()) close();
|
|
269
|
+
}),
|
|
270
|
+
on(document, "keydown", (event) => {
|
|
271
|
+
if (root.hidden) return;
|
|
272
|
+
if (event.key === "Escape" && closeOnEscape) {
|
|
273
|
+
event.preventDefault();
|
|
274
|
+
close();
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (event.key !== "Tab") return;
|
|
278
|
+
const items = focusable(root);
|
|
279
|
+
const target = focusTrapIndex(
|
|
280
|
+
items.length,
|
|
281
|
+
items.indexOf(document.activeElement),
|
|
282
|
+
event.shiftKey,
|
|
283
|
+
);
|
|
284
|
+
if (target < 0) return;
|
|
285
|
+
event.preventDefault();
|
|
286
|
+
items[target].focus();
|
|
287
|
+
}),
|
|
288
|
+
];
|
|
289
|
+
},
|
|
290
|
+
|
|
291
|
+
select(root) {
|
|
292
|
+
const trigger = root.querySelector(".ml-select");
|
|
293
|
+
const popover = root.querySelector(".ml-select-popover");
|
|
294
|
+
const list = root.querySelector(".ml-select-list");
|
|
295
|
+
if (!trigger || !popover || !list) return [];
|
|
296
|
+
const multiple = list.getAttribute("aria-multiselectable") === "true";
|
|
297
|
+
const options = () =>
|
|
298
|
+
[...list.querySelectorAll(".ml-select-option")].filter(
|
|
299
|
+
(option) => option.getAttribute("aria-disabled") !== "true",
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
const setOpen = (open) => {
|
|
303
|
+
trigger.dataset.state = open ? "open" : "closed";
|
|
304
|
+
popover.hidden = !open;
|
|
305
|
+
trigger.setAttribute("aria-expanded", String(open));
|
|
306
|
+
if (!open) {
|
|
307
|
+
for (const option of options()) delete option.dataset.highlighted;
|
|
308
|
+
trigger.removeAttribute("aria-activedescendant");
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
const isOpen = () => trigger.dataset.state === "open";
|
|
312
|
+
|
|
313
|
+
const highlight = (option) => {
|
|
314
|
+
for (const other of options()) delete other.dataset.highlighted;
|
|
315
|
+
if (!option) return;
|
|
316
|
+
option.dataset.highlighted = "";
|
|
317
|
+
if (option.id) trigger.setAttribute("aria-activedescendant", option.id);
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const choose = (option) => {
|
|
321
|
+
if (!option) return;
|
|
322
|
+
const chosen = option.getAttribute("aria-selected") === "true";
|
|
323
|
+
if (!multiple) {
|
|
324
|
+
for (const other of options()) {
|
|
325
|
+
other.setAttribute("aria-selected", "false");
|
|
326
|
+
other.dataset.state = "unchecked";
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
const next = multiple ? !chosen : true;
|
|
330
|
+
option.setAttribute("aria-selected", String(next));
|
|
331
|
+
option.dataset.state = next ? "checked" : "unchecked";
|
|
332
|
+
const value = root.querySelector(".ml-select-value");
|
|
333
|
+
if (value && !multiple) value.textContent = option.textContent.trim();
|
|
334
|
+
if (!multiple) {
|
|
335
|
+
setOpen(false);
|
|
336
|
+
trigger.focus();
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
setOpen(false);
|
|
341
|
+
|
|
342
|
+
return [
|
|
343
|
+
on(trigger, "click", () => setOpen(!isOpen())),
|
|
344
|
+
on(list, "click", (event) => choose(event.target.closest(".ml-select-option"))),
|
|
345
|
+
on(list, "pointermove", (event) => {
|
|
346
|
+
const option = event.target.closest(".ml-select-option");
|
|
347
|
+
if (option && options().includes(option)) highlight(option);
|
|
348
|
+
}),
|
|
349
|
+
on(root, "keydown", (event) => {
|
|
350
|
+
const all = options();
|
|
351
|
+
if (event.key === "Escape") {
|
|
352
|
+
setOpen(false);
|
|
353
|
+
trigger.focus();
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
if (event.key === "Tab") {
|
|
357
|
+
setOpen(false);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
361
|
+
event.preventDefault();
|
|
362
|
+
if (!isOpen()) {
|
|
363
|
+
setOpen(true);
|
|
364
|
+
highlight(all[event.key === "ArrowDown" ? 0 : all.length - 1]);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
const current = all.findIndex((option) => "highlighted" in option.dataset);
|
|
368
|
+
const target = roving(all, current, event.key, { horizontal: false });
|
|
369
|
+
if (target >= 0) highlight(all[target]);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (event.key === "Home" || event.key === "End") {
|
|
373
|
+
if (!isOpen()) return;
|
|
374
|
+
event.preventDefault();
|
|
375
|
+
highlight(event.key === "Home" ? all[0] : all[all.length - 1]);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
if (event.key === "Enter" || (event.key === " " && event.target !== root.querySelector(".ml-select-search"))) {
|
|
379
|
+
event.preventDefault();
|
|
380
|
+
if (!isOpen()) {
|
|
381
|
+
setOpen(true);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
choose(all.find((option) => "highlighted" in option.dataset));
|
|
385
|
+
}
|
|
386
|
+
}),
|
|
387
|
+
on(document, "pointerdown", (event) => {
|
|
388
|
+
if (isOpen() && !root.contains(event.target)) setOpen(false);
|
|
389
|
+
}),
|
|
390
|
+
];
|
|
391
|
+
},
|
|
392
|
+
|
|
393
|
+
slider(root) {
|
|
394
|
+
const track = root.querySelector(".ml-slider-track");
|
|
395
|
+
const range = root.querySelector(".ml-slider-range");
|
|
396
|
+
const thumb = root.querySelector(".ml-slider-thumb");
|
|
397
|
+
// The value lives on whichever element carries role="slider"; the React
|
|
398
|
+
// build puts it on the control, hand-written markup often uses the thumb.
|
|
399
|
+
const valued = root.querySelector('[role="slider"]');
|
|
400
|
+
if (!track || !valued) return [];
|
|
401
|
+
|
|
402
|
+
const bounds = () => ({
|
|
403
|
+
min: Number(valued.getAttribute("aria-valuemin") ?? 0),
|
|
404
|
+
max: Number(valued.getAttribute("aria-valuemax") ?? 100),
|
|
405
|
+
step: Number(root.dataset.mlStep ?? 1),
|
|
406
|
+
});
|
|
407
|
+
const disabled = root.dataset.disabled !== undefined || valued.getAttribute("aria-disabled") === "true";
|
|
408
|
+
const output = root.querySelector(".ml-slider-output");
|
|
409
|
+
const current = () => Number(valued.getAttribute("aria-valuenow") ?? bounds().min);
|
|
410
|
+
|
|
411
|
+
const set = (raw) => {
|
|
412
|
+
const box = bounds();
|
|
413
|
+
const next = clampToStep(raw, box);
|
|
414
|
+
valued.setAttribute("aria-valuenow", String(next));
|
|
415
|
+
const percent = percentOf(next, box);
|
|
416
|
+
if (range) range.style.width = `${percent}%`;
|
|
417
|
+
if (thumb) thumb.style.left = `${percent}%`;
|
|
418
|
+
if (output) output.textContent = String(next);
|
|
419
|
+
root.dispatchEvent(new CustomEvent("ml-change", { detail: { value: next }, bubbles: true }));
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
const fromPointer = (event) => {
|
|
423
|
+
const box = track.getBoundingClientRect();
|
|
424
|
+
if (!box.width) return current();
|
|
425
|
+
return valueFromRatio((event.clientX - box.left) / box.width, bounds());
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
let dragging = false;
|
|
429
|
+
|
|
430
|
+
return [
|
|
431
|
+
on(track, "pointerdown", (event) => {
|
|
432
|
+
if (disabled) return;
|
|
433
|
+
dragging = true;
|
|
434
|
+
track.setPointerCapture?.(event.pointerId);
|
|
435
|
+
set(fromPointer(event));
|
|
436
|
+
valued.focus();
|
|
437
|
+
}),
|
|
438
|
+
on(track, "pointermove", (event) => {
|
|
439
|
+
if (dragging && !disabled) set(fromPointer(event));
|
|
440
|
+
}),
|
|
441
|
+
on(track, "pointerup", (event) => {
|
|
442
|
+
dragging = false;
|
|
443
|
+
track.releasePointerCapture?.(event.pointerId);
|
|
444
|
+
}),
|
|
445
|
+
on(valued, "keydown", (event) => {
|
|
446
|
+
if (disabled) return;
|
|
447
|
+
const next = sliderValueForKey(event.key, current(), bounds());
|
|
448
|
+
if (next === undefined) return;
|
|
449
|
+
event.preventDefault();
|
|
450
|
+
set(next);
|
|
451
|
+
}),
|
|
452
|
+
];
|
|
453
|
+
},
|
|
454
|
+
|
|
455
|
+
tooltip(root) {
|
|
456
|
+
const tip = root.querySelector(".ml-tooltip");
|
|
457
|
+
const trigger = root.firstElementChild;
|
|
458
|
+
if (!tip || !trigger) return [];
|
|
459
|
+
const delay = Number(root.dataset.mlDelay ?? 200);
|
|
460
|
+
let timer = null;
|
|
461
|
+
|
|
462
|
+
const show = () => {
|
|
463
|
+
clearTimeout(timer);
|
|
464
|
+
timer = setTimeout(() => {
|
|
465
|
+
tip.hidden = false;
|
|
466
|
+
tip.dataset.state = "open";
|
|
467
|
+
if (tip.id) trigger.setAttribute("aria-describedby", tip.id);
|
|
468
|
+
}, Math.max(0, delay));
|
|
469
|
+
};
|
|
470
|
+
const hide = () => {
|
|
471
|
+
clearTimeout(timer);
|
|
472
|
+
tip.hidden = true;
|
|
473
|
+
delete tip.dataset.state;
|
|
474
|
+
trigger.removeAttribute("aria-describedby");
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
hide();
|
|
478
|
+
|
|
479
|
+
return [
|
|
480
|
+
on(root, "pointerenter", show),
|
|
481
|
+
on(root, "pointerleave", hide),
|
|
482
|
+
on(root, "focusin", show),
|
|
483
|
+
on(root, "focusout", hide),
|
|
484
|
+
on(document, "keydown", (event) => {
|
|
485
|
+
if (event.key === "Escape") hide();
|
|
486
|
+
}),
|
|
487
|
+
];
|
|
488
|
+
},
|
|
489
|
+
|
|
490
|
+
toast(root) {
|
|
491
|
+
const timers = new Map();
|
|
492
|
+
const durationOf = (toast) =>
|
|
493
|
+
Number(toast.dataset.mlDuration ?? root.dataset.mlDuration ?? 5000);
|
|
494
|
+
|
|
495
|
+
const dismiss = (toast) => {
|
|
496
|
+
const timer = timers.get(toast);
|
|
497
|
+
if (timer) clearTimeout(timer);
|
|
498
|
+
timers.delete(toast);
|
|
499
|
+
toast.remove();
|
|
500
|
+
if (!root.querySelector(".ml-toast")) {
|
|
501
|
+
root.dispatchEvent(new CustomEvent("ml-empty", { bubbles: true }));
|
|
502
|
+
}
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
const schedule = (toast) => {
|
|
506
|
+
if ("mlPaused" in toast.dataset) return;
|
|
507
|
+
const duration = durationOf(toast);
|
|
508
|
+
const existing = timers.get(toast);
|
|
509
|
+
if (existing) clearTimeout(existing);
|
|
510
|
+
if (!(duration > 0)) return;
|
|
511
|
+
timers.set(
|
|
512
|
+
toast,
|
|
513
|
+
setTimeout(() => dismiss(toast), duration),
|
|
514
|
+
);
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
const register = (toast) => {
|
|
518
|
+
if (toast.dataset.mlEnhanced !== undefined) return;
|
|
519
|
+
toast.dataset.mlEnhanced = "";
|
|
520
|
+
schedule(toast);
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
const existing = () => [...root.querySelectorAll(".ml-toast")];
|
|
524
|
+
if (!root.hasAttribute("role")) root.setAttribute("role", "region");
|
|
525
|
+
for (const toast of existing()) register(toast);
|
|
526
|
+
|
|
527
|
+
const observer = new MutationObserver((records) => {
|
|
528
|
+
for (const record of records) {
|
|
529
|
+
for (const node of record.addedNodes) {
|
|
530
|
+
if (node.nodeType !== 1) continue;
|
|
531
|
+
const added = node.matches?.(".ml-toast")
|
|
532
|
+
? [node, ...node.querySelectorAll(".ml-toast")]
|
|
533
|
+
: [...node.querySelectorAll(".ml-toast")];
|
|
534
|
+
for (const toast of added) register(toast);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
observer.observe(root, { childList: true, subtree: true });
|
|
539
|
+
|
|
540
|
+
return [
|
|
541
|
+
on(root, "click", (event) => {
|
|
542
|
+
const toast = event.target.closest(".ml-toast");
|
|
543
|
+
if (!toast || !root.contains(toast)) return;
|
|
544
|
+
if (event.target.closest(".ml-toast-close")) {
|
|
545
|
+
event.preventDefault();
|
|
546
|
+
dismiss(toast);
|
|
547
|
+
}
|
|
548
|
+
}),
|
|
549
|
+
on(root, "pointerenter", (event) => {
|
|
550
|
+
const toast = event.target.closest(".ml-toast");
|
|
551
|
+
if (toast) { toast.dataset.mlPaused = ""; schedule(toast); }
|
|
552
|
+
}, true),
|
|
553
|
+
on(root, "pointerleave", (event) => {
|
|
554
|
+
const toast = event.target.closest(".ml-toast");
|
|
555
|
+
if (toast) { delete toast.dataset.mlPaused; schedule(toast); }
|
|
556
|
+
}, true),
|
|
557
|
+
on(root, "focusin", (event) => {
|
|
558
|
+
const toast = event.target.closest(".ml-toast");
|
|
559
|
+
if (toast) { toast.dataset.mlPaused = ""; schedule(toast); }
|
|
560
|
+
}),
|
|
561
|
+
on(root, "focusout", (event) => {
|
|
562
|
+
const toast = event.target.closest(".ml-toast");
|
|
563
|
+
if (toast) { delete toast.dataset.mlPaused; schedule(toast); }
|
|
564
|
+
}),
|
|
565
|
+
() => {
|
|
566
|
+
observer.disconnect();
|
|
567
|
+
for (const timer of timers.values()) clearTimeout(timer);
|
|
568
|
+
},
|
|
569
|
+
];
|
|
570
|
+
},
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
// A modal and a sheet differ only in which edge the panel sits on, so they
|
|
574
|
+
// share one implementation rather than drifting apart.
|
|
575
|
+
behaviors.sheet = behaviors.modal;
|
|
576
|
+
|
|
577
|
+
/** Attach behaviour to every marked root inside `scope`. Safe to call again. */
|
|
578
|
+
export function enhance(scope = document) {
|
|
579
|
+
const roots = [
|
|
580
|
+
...(scope.matches?.("[data-ml]") ? [scope] : []),
|
|
581
|
+
...scope.querySelectorAll("[data-ml]"),
|
|
582
|
+
];
|
|
583
|
+
let count = 0;
|
|
584
|
+
for (const root of roots) {
|
|
585
|
+
if (root[ENHANCED]) continue;
|
|
586
|
+
const behavior = behaviors[root.dataset.ml];
|
|
587
|
+
if (!behavior) continue;
|
|
588
|
+
const teardown = behavior(root) ?? [];
|
|
589
|
+
root[ENHANCED] = () => {
|
|
590
|
+
for (const off of teardown) off();
|
|
591
|
+
delete root[ENHANCED];
|
|
592
|
+
};
|
|
593
|
+
count += 1;
|
|
594
|
+
}
|
|
595
|
+
return count;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** Undo enhancement, for hot reloads and tests. */
|
|
599
|
+
export function destroy(scope = document) {
|
|
600
|
+
for (const root of scope.querySelectorAll("[data-ml]")) root[ENHANCED]?.();
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** Enhance now and keep enhancing markup added later. Returns a stop function. */
|
|
604
|
+
export function observe(scope = document) {
|
|
605
|
+
enhance(scope);
|
|
606
|
+
const observer = new MutationObserver((records) => {
|
|
607
|
+
for (const record of records) {
|
|
608
|
+
for (const node of record.addedNodes) {
|
|
609
|
+
if (node.nodeType === 1) enhance(node);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
observer.observe(scope === document ? document.documentElement : scope, {
|
|
614
|
+
childList: true,
|
|
615
|
+
subtree: true,
|
|
616
|
+
});
|
|
617
|
+
return () => observer.disconnect();
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
export { behaviors };
|
package/src/logic.d.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types for the shared decision functions.
|
|
3
|
+
*
|
|
4
|
+
* The implementation stays plain JavaScript on purpose: it must run from a
|
|
5
|
+
* script tag with no build step, in ten years, without a compiler. This
|
|
6
|
+
* declaration file gives TypeScript callers the same guarantees without
|
|
7
|
+
* making the runtime depend on a toolchain.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface Bounds {
|
|
11
|
+
min?: number;
|
|
12
|
+
max?: number;
|
|
13
|
+
step?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface RovingOptions {
|
|
17
|
+
horizontal?: boolean;
|
|
18
|
+
wrap?: boolean;
|
|
19
|
+
enabled?: (index: number) => boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface DisclosureOptions {
|
|
23
|
+
multiple?: boolean;
|
|
24
|
+
collapsible?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function clampToStep(raw: number, bounds?: Bounds): number;
|
|
28
|
+
export function valueFromRatio(ratio: number, bounds?: Bounds): number;
|
|
29
|
+
export function percentOf(value: number, bounds?: Pick<Bounds, "min" | "max">): number;
|
|
30
|
+
export function sliderValueForKey(
|
|
31
|
+
key: string,
|
|
32
|
+
current: number,
|
|
33
|
+
bounds?: Bounds,
|
|
34
|
+
): number | undefined;
|
|
35
|
+
export function rovingIndex(
|
|
36
|
+
count: number,
|
|
37
|
+
index: number,
|
|
38
|
+
key: string,
|
|
39
|
+
options?: RovingOptions,
|
|
40
|
+
): number;
|
|
41
|
+
export function focusTrapIndex(count: number, activeIndex: number, shiftKey: boolean): number;
|
|
42
|
+
export function resolveDisclosure(
|
|
43
|
+
open: number[],
|
|
44
|
+
index: number,
|
|
45
|
+
options?: DisclosureOptions,
|
|
46
|
+
): number[];
|
|
47
|
+
|
|
48
|
+
export interface PasswordStrengthOptions {
|
|
49
|
+
/** Below this length the result is always "weak". */
|
|
50
|
+
min?: number;
|
|
51
|
+
/** At or above this length (with enough variety) the result is "strong". */
|
|
52
|
+
strong?: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type PasswordStrength = "empty" | "weak" | "medium" | "strong";
|
|
56
|
+
export function passwordStrength(
|
|
57
|
+
value: string,
|
|
58
|
+
options?: PasswordStrengthOptions,
|
|
59
|
+
): PasswordStrength;
|
|
60
|
+
|
|
61
|
+
/** Deliberately simple, non-authoritative shape check shared by every form. */
|
|
62
|
+
export function isEmail(value: string): boolean;
|
|
63
|
+
|
|
64
|
+
export interface RevealOptions {
|
|
65
|
+
/** Steady reading rate. */
|
|
66
|
+
charsPerSecond?: number;
|
|
67
|
+
/** The longest a backlog may take to clear, in milliseconds. */
|
|
68
|
+
catchUpMs?: number;
|
|
69
|
+
}
|
|
70
|
+
/** How much of a streamed text to show after `elapsedMs`. Fractional. */
|
|
71
|
+
export function revealLength(shown: number, target: number, elapsedMs: number, options?: RevealOptions): number;
|
|
72
|
+
|
|
73
|
+
export function isPinnedToEnd(scrollTop: number, scrollHeight: number, clientHeight: number, threshold?: number): boolean;
|
|
74
|
+
|
|
75
|
+
export interface ComposerKey {
|
|
76
|
+
key: string;
|
|
77
|
+
shiftKey?: boolean;
|
|
78
|
+
metaKey?: boolean;
|
|
79
|
+
ctrlKey?: boolean;
|
|
80
|
+
altKey?: boolean;
|
|
81
|
+
isComposing?: boolean;
|
|
82
|
+
keyCode?: number;
|
|
83
|
+
}
|
|
84
|
+
export type ComposerSubmitOn = "enter" | "mod-enter";
|
|
85
|
+
export function composerKeyAction(event: ComposerKey, options?: { submitOn?: ComposerSubmitOn }): "submit" | "newline" | null;
|
|
86
|
+
|
|
87
|
+
export function smoothLevel(previous: number, next: number, options?: { attack?: number; release?: number }): number;
|
|
88
|
+
export function spectrumToLevels(bins: ArrayLike<number>, count: number, options?: { floor?: number; gain?: number }): number[];
|
|
89
|
+
|
|
90
|
+
export function formatDuration(ms: number): string;
|
|
91
|
+
export function formatTokens(count: number): string;
|
|
92
|
+
|
|
93
|
+
export type ConfidenceBand = "low" | "medium" | "high";
|
|
94
|
+
export function confidenceBand(value: number, options?: { low?: number; high?: number }): ConfidenceBand;
|
|
95
|
+
|
|
96
|
+
export interface ContextSegment {
|
|
97
|
+
id: string;
|
|
98
|
+
label: string;
|
|
99
|
+
tokens: number;
|
|
100
|
+
}
|
|
101
|
+
export interface ContextUsage<Segment extends ContextSegment = ContextSegment> {
|
|
102
|
+
used: number;
|
|
103
|
+
limit: number;
|
|
104
|
+
remaining: number;
|
|
105
|
+
ratio: number;
|
|
106
|
+
level: "ok" | "warning" | "critical";
|
|
107
|
+
segments: Array<Segment & { ratio: number }>;
|
|
108
|
+
}
|
|
109
|
+
export function contextUsage<Segment extends ContextSegment>(
|
|
110
|
+
segments: Segment[],
|
|
111
|
+
limit: number,
|
|
112
|
+
options?: { warning?: number; critical?: number },
|
|
113
|
+
): ContextUsage<Segment>;
|
package/src/logic.js
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The decisions, with no DOM and no framework.
|
|
3
|
+
*
|
|
4
|
+
* A framework-free runtime mutates the DOM; React renders it from state. Those
|
|
5
|
+
* two cannot share mutations without fighting over who owns the document. What
|
|
6
|
+
* they can share — and must, or they drift — is the reasoning: which index a
|
|
7
|
+
* key moves to, what a value snaps to, where focus goes next.
|
|
8
|
+
*
|
|
9
|
+
* Everything here is a pure function over numbers and plain data. It runs in a
|
|
10
|
+
* browser, in Node, in a test, and it will keep running long after any
|
|
11
|
+
* particular framework stops being fashionable.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Round to the precision the step implies, so 0.1 steps do not drift. */
|
|
15
|
+
function toStepPrecision(value, step) {
|
|
16
|
+
const decimals = (String(step).split(".")[1] ?? "").length;
|
|
17
|
+
return Number(value.toFixed(Math.min(decimals, 10)));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Clamp into range and snap onto the step grid measured from `min`. */
|
|
21
|
+
export function clampToStep(raw, { min = 0, max = 100, step = 1 } = {}) {
|
|
22
|
+
const low = Number.isFinite(min) ? min : 0;
|
|
23
|
+
const high = Number.isFinite(max) && max > low ? max : low + 100;
|
|
24
|
+
const increment = Number.isFinite(step) && step > 0 ? step : 1;
|
|
25
|
+
const value = Number.isFinite(raw) ? raw : low;
|
|
26
|
+
const clamped = Math.min(high, Math.max(low, value));
|
|
27
|
+
const snapped = low + Math.round((clamped - low) / increment) * increment;
|
|
28
|
+
return toStepPrecision(Math.min(high, Math.max(low, snapped)), increment);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Turn a 0..1 position along a track into a value in range. */
|
|
32
|
+
export function valueFromRatio(ratio, { min = 0, max = 100, step = 1 } = {}) {
|
|
33
|
+
const low = Number.isFinite(min) ? min : 0;
|
|
34
|
+
const high = Number.isFinite(max) && max > low ? max : low + 100;
|
|
35
|
+
const safe = Number.isFinite(ratio) ? Math.min(1, Math.max(0, ratio)) : 0;
|
|
36
|
+
return clampToStep(low + safe * (high - low), { min: low, max: high, step });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Percentage along the track, for positioning a range fill and a thumb. */
|
|
40
|
+
export function percentOf(value, { min = 0, max = 100 } = {}) {
|
|
41
|
+
const low = Number.isFinite(min) ? min : 0;
|
|
42
|
+
const high = Number.isFinite(max) && max > low ? max : low + 100;
|
|
43
|
+
if (high === low) return 0;
|
|
44
|
+
return ((Math.min(high, Math.max(low, value)) - low) / (high - low)) * 100;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The value a key produces, or undefined when the key means nothing here.
|
|
49
|
+
* Returning undefined rather than the current value lets a caller know whether
|
|
50
|
+
* to call preventDefault.
|
|
51
|
+
*/
|
|
52
|
+
export function sliderValueForKey(key, current, { min = 0, max = 100, step = 1 } = {}) {
|
|
53
|
+
const low = Number.isFinite(min) ? min : 0;
|
|
54
|
+
const high = Number.isFinite(max) && max > low ? max : low + 100;
|
|
55
|
+
const increment = Number.isFinite(step) && step > 0 ? step : 1;
|
|
56
|
+
const page = Math.max(increment, (high - low) / 10);
|
|
57
|
+
const moves = {
|
|
58
|
+
ArrowLeft: -increment,
|
|
59
|
+
ArrowDown: -increment,
|
|
60
|
+
ArrowRight: increment,
|
|
61
|
+
ArrowUp: increment,
|
|
62
|
+
PageDown: -page,
|
|
63
|
+
PageUp: page,
|
|
64
|
+
};
|
|
65
|
+
if (key === "Home") return clampToStep(low, { min: low, max: high, step: increment });
|
|
66
|
+
if (key === "End") return clampToStep(high, { min: low, max: high, step: increment });
|
|
67
|
+
const delta = moves[key];
|
|
68
|
+
if (delta === undefined) return undefined;
|
|
69
|
+
return clampToStep(current + delta, { min: low, max: high, step: increment });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Where a roving-focus key lands. `enabled` reports whether an index may take
|
|
74
|
+
* focus, so disabled entries are stepped over rather than landed on.
|
|
75
|
+
* Returns -1 when the key does not move focus.
|
|
76
|
+
*/
|
|
77
|
+
export function rovingIndex(
|
|
78
|
+
count,
|
|
79
|
+
index,
|
|
80
|
+
key,
|
|
81
|
+
{ horizontal = true, wrap = true, enabled = () => true } = {},
|
|
82
|
+
) {
|
|
83
|
+
if (count <= 0) return -1;
|
|
84
|
+
const firstEnabled = (from, direction) => {
|
|
85
|
+
let candidate = from;
|
|
86
|
+
for (let steps = 0; steps < count; steps += 1) {
|
|
87
|
+
if (enabled(candidate)) return candidate;
|
|
88
|
+
candidate += direction;
|
|
89
|
+
if (candidate < 0) candidate = count - 1;
|
|
90
|
+
if (candidate >= count) candidate = 0;
|
|
91
|
+
}
|
|
92
|
+
return -1;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
if (key === "Home") return firstEnabled(0, 1);
|
|
96
|
+
if (key === "End") return firstEnabled(count - 1, -1);
|
|
97
|
+
|
|
98
|
+
const forward = horizontal ? "ArrowRight" : "ArrowDown";
|
|
99
|
+
const backward = horizontal ? "ArrowLeft" : "ArrowUp";
|
|
100
|
+
const step = key === forward ? 1 : key === backward ? -1 : 0;
|
|
101
|
+
if (!step) return -1;
|
|
102
|
+
|
|
103
|
+
let candidate = index;
|
|
104
|
+
for (let steps = 0; steps < count; steps += 1) {
|
|
105
|
+
candidate += step;
|
|
106
|
+
if (candidate < 0) {
|
|
107
|
+
if (!wrap) return -1;
|
|
108
|
+
candidate = count - 1;
|
|
109
|
+
}
|
|
110
|
+
if (candidate >= count) {
|
|
111
|
+
if (!wrap) return -1;
|
|
112
|
+
candidate = 0;
|
|
113
|
+
}
|
|
114
|
+
if (enabled(candidate)) return candidate;
|
|
115
|
+
}
|
|
116
|
+
return -1;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The index Tab should move to inside a trap, or -1 to let the browser handle
|
|
121
|
+
* it. Only the edges need intercepting; everything between them is natural.
|
|
122
|
+
*/
|
|
123
|
+
export function focusTrapIndex(count, activeIndex, shiftKey) {
|
|
124
|
+
if (count <= 0) return -1;
|
|
125
|
+
if (shiftKey && activeIndex <= 0) return count - 1;
|
|
126
|
+
if (!shiftKey && activeIndex === count - 1) return 0;
|
|
127
|
+
return -1;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Which items stay open after toggling `index`. */
|
|
131
|
+
export function resolveDisclosure(open, index, { multiple = false, collapsible = true } = {}) {
|
|
132
|
+
const isOpen = open.includes(index);
|
|
133
|
+
if (isOpen && !collapsible && !multiple) return open;
|
|
134
|
+
if (multiple) {
|
|
135
|
+
return isOpen ? open.filter((entry) => entry !== index) : [...open, index].sort((a, b) => a - b);
|
|
136
|
+
}
|
|
137
|
+
if (isOpen) return collapsible ? [] : open;
|
|
138
|
+
return [index];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* A coarse password-strength hint, deliberately not authoritative. It exists so
|
|
143
|
+
* React and framework-free markup show the same label for the same input, not
|
|
144
|
+
* to grade a real password. Length dominates; character variety nudges it.
|
|
145
|
+
*/
|
|
146
|
+
export function passwordStrength(value, { min = 8, strong = 12 } = {}) {
|
|
147
|
+
const text = typeof value === "string" ? value : "";
|
|
148
|
+
if (!text) return "empty";
|
|
149
|
+
const variety =
|
|
150
|
+
(/[a-z]/.test(text) ? 1 : 0) +
|
|
151
|
+
(/[A-Z]/.test(text) ? 1 : 0) +
|
|
152
|
+
(/\d/.test(text) ? 1 : 0) +
|
|
153
|
+
(/[^A-Za-z0-9]/.test(text) ? 1 : 0);
|
|
154
|
+
const score = text.length + variety * 2;
|
|
155
|
+
if (text.length < min) return "weak";
|
|
156
|
+
if (text.length < strong || score < strong + 2) return "medium";
|
|
157
|
+
return "strong";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* A deliberately simple email shape check, shared by every form so the rule is
|
|
162
|
+
* one rule. It is not a validator: the server is the authority on deliverable
|
|
163
|
+
* addresses, and rejecting a valid-but-unusual address is worse than sending.
|
|
164
|
+
*/
|
|
165
|
+
export function isEmail(value) {
|
|
166
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(typeof value === "string" ? value.trim() : "");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/* ── AI interfaces ────────────────────────────────────────────────────── */
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* How much of a streamed text to show after `elapsedMs`.
|
|
173
|
+
*
|
|
174
|
+
* Models deliver text in uneven bursts: nothing for 300 ms, then forty words.
|
|
175
|
+
* Rendering bursts as they land reads as stutter. The reveal instead moves at
|
|
176
|
+
* a steady reading rate, and speeds up only as far as needed to clear the
|
|
177
|
+
* backlog within `catchUpMs`, so a fast model is never made to look slow.
|
|
178
|
+
* Returns a fractional length; callers floor it when slicing.
|
|
179
|
+
*/
|
|
180
|
+
export function revealLength(shown, target, elapsedMs, { charsPerSecond = 80, catchUpMs = 1200 } = {}) {
|
|
181
|
+
const from = Math.max(0, Number.isFinite(shown) ? shown : 0);
|
|
182
|
+
const to = Math.max(0, Number.isFinite(target) ? target : 0);
|
|
183
|
+
if (from >= to) return to;
|
|
184
|
+
const seconds = Math.max(0, elapsedMs) / 1000;
|
|
185
|
+
const backlog = to - from;
|
|
186
|
+
const rate = Math.max(charsPerSecond, backlog / Math.max(0.05, catchUpMs / 1000));
|
|
187
|
+
return Math.min(to, from + rate * seconds);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Whether a scroller is close enough to its end to keep following new content. */
|
|
191
|
+
export function isPinnedToEnd(scrollTop, scrollHeight, clientHeight, threshold = 48) {
|
|
192
|
+
return scrollHeight - clientHeight - scrollTop <= threshold;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* What a key press in a prompt composer means.
|
|
197
|
+
*
|
|
198
|
+
* Enter must never send while an input method is composing: the Enter that
|
|
199
|
+
* commits a Japanese or Chinese word would otherwise send half a sentence.
|
|
200
|
+
* `submitOn: "mod-enter"` is for composers where Enter writes a new line.
|
|
201
|
+
*/
|
|
202
|
+
export function composerKeyAction(
|
|
203
|
+
{ key, shiftKey = false, metaKey = false, ctrlKey = false, altKey = false, isComposing = false, keyCode } = {},
|
|
204
|
+
{ submitOn = "enter" } = {},
|
|
205
|
+
) {
|
|
206
|
+
if (key !== "Enter" || isComposing || keyCode === 229) return null;
|
|
207
|
+
const modifier = metaKey || ctrlKey;
|
|
208
|
+
if (submitOn === "mod-enter") return modifier ? "submit" : null;
|
|
209
|
+
if (shiftKey || altKey) return "newline";
|
|
210
|
+
return "submit";
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* An envelope follower for audio levels: rises fast, falls slowly, the way a
|
|
215
|
+
* VU meter does, so bars look alive instead of flickering on every frame.
|
|
216
|
+
*/
|
|
217
|
+
export function smoothLevel(previous, next, { attack = 0.55, release = 0.12 } = {}) {
|
|
218
|
+
const from = Number.isFinite(previous) ? previous : 0;
|
|
219
|
+
const to = Math.min(1, Math.max(0, Number.isFinite(next) ? next : 0));
|
|
220
|
+
const factor = to > from ? attack : release;
|
|
221
|
+
return from + (to - from) * factor;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Fold an FFT spectrum (0..255 per bin, as AnalyserNode gives) into `count`
|
|
226
|
+
* bars. Bands widen with frequency, as hearing does, so speech fills the
|
|
227
|
+
* middle of the wave instead of crowding into the first few bars.
|
|
228
|
+
*/
|
|
229
|
+
export function spectrumToLevels(bins, count, { floor = 0.02, gain = 1.4 } = {}) {
|
|
230
|
+
const size = bins?.length ?? 0;
|
|
231
|
+
const bars = Math.max(1, Math.floor(count));
|
|
232
|
+
if (!size) return Array.from({ length: bars }, () => 0);
|
|
233
|
+
// Speech lives in the lower half of the spectrum; the top is mostly hiss.
|
|
234
|
+
const usable = Math.max(bars, Math.floor(size * 0.5));
|
|
235
|
+
const levels = [];
|
|
236
|
+
for (let bar = 0; bar < bars; bar += 1) {
|
|
237
|
+
const start = Math.floor(usable * ((2 ** (bar / bars) - 1)));
|
|
238
|
+
const end = Math.max(start + 1, Math.floor(usable * ((2 ** ((bar + 1) / bars) - 1))));
|
|
239
|
+
let sum = 0;
|
|
240
|
+
for (let index = start; index < end && index < size; index += 1) sum += bins[index];
|
|
241
|
+
const average = sum / (end - start) / 255;
|
|
242
|
+
levels.push(Math.min(1, Math.max(0, (average - floor) * gain)));
|
|
243
|
+
}
|
|
244
|
+
return levels;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** A duration for people: "0.8s", "12s", "1m 04s". */
|
|
248
|
+
export function formatDuration(ms) {
|
|
249
|
+
const value = Math.max(0, Number.isFinite(ms) ? ms : 0);
|
|
250
|
+
if (value < 1000) return `${(value / 1000).toFixed(1)}s`;
|
|
251
|
+
const seconds = Math.round(value / 1000);
|
|
252
|
+
if (seconds < 60) return `${seconds}s`;
|
|
253
|
+
const minutes = Math.floor(seconds / 60);
|
|
254
|
+
return `${minutes}m ${String(seconds % 60).padStart(2, "0")}s`;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** A token count for people: 812, 1.2k, 128k, 1M. */
|
|
258
|
+
export function formatTokens(count) {
|
|
259
|
+
const value = Math.max(0, Math.round(Number.isFinite(count) ? count : 0));
|
|
260
|
+
if (value < 1000) return String(value);
|
|
261
|
+
if (value < 1_000_000) {
|
|
262
|
+
const thousands = value / 1000;
|
|
263
|
+
return `${thousands < 10 ? Number(thousands.toFixed(1)) : Math.round(thousands)}k`;
|
|
264
|
+
}
|
|
265
|
+
const millions = value / 1_000_000;
|
|
266
|
+
return `${millions < 10 ? Number(millions.toFixed(1)) : Math.round(millions)}M`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Which band a model confidence (0..1) falls in. */
|
|
270
|
+
export function confidenceBand(value, { low = 0.5, high = 0.8 } = {}) {
|
|
271
|
+
const confidence = Number.isFinite(value) ? value : 0;
|
|
272
|
+
if (confidence < low) return "low";
|
|
273
|
+
if (confidence < high) return "medium";
|
|
274
|
+
return "high";
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* How full a context window is, by segment. Levels: "ok" below `warning`,
|
|
279
|
+
* "warning" below `critical`, then "critical" — the point where the next
|
|
280
|
+
* long answer may not fit.
|
|
281
|
+
*/
|
|
282
|
+
export function contextUsage(segments, limit, { warning = 0.7, critical = 0.9 } = {}) {
|
|
283
|
+
const capacity = Math.max(1, Number.isFinite(limit) ? limit : 1);
|
|
284
|
+
const parts = (segments ?? []).map((segment) => ({
|
|
285
|
+
...segment,
|
|
286
|
+
tokens: Math.max(0, Number.isFinite(segment.tokens) ? segment.tokens : 0),
|
|
287
|
+
}));
|
|
288
|
+
const used = parts.reduce((total, part) => total + part.tokens, 0);
|
|
289
|
+
const ratio = Math.min(1, used / capacity);
|
|
290
|
+
return {
|
|
291
|
+
used,
|
|
292
|
+
limit: capacity,
|
|
293
|
+
remaining: Math.max(0, capacity - used),
|
|
294
|
+
ratio,
|
|
295
|
+
level: ratio >= critical ? "critical" : ratio >= warning ? "warning" : "ok",
|
|
296
|
+
segments: parts.map((part) => ({ ...part, ratio: part.tokens / capacity })),
|
|
297
|
+
};
|
|
298
|
+
}
|