@mlola-ui/behavior 1.0.4 → 1.0.5
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/package.json +1 -1
- package/src/index.js +29 -1
- package/src/interaction.js +168 -0
- package/src/logic.d.ts +23 -0
- package/src/logic.js +1 -126
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -21,10 +21,11 @@ import {
|
|
|
21
21
|
clampToStep,
|
|
22
22
|
focusTrapIndex,
|
|
23
23
|
percentOf,
|
|
24
|
+
placeFloating,
|
|
24
25
|
rovingIndex,
|
|
25
26
|
sliderValueForKey,
|
|
26
27
|
valueFromRatio,
|
|
27
|
-
} from "./
|
|
28
|
+
} from "./interaction.js";
|
|
28
29
|
import { lockScroll } from "./document.js";
|
|
29
30
|
|
|
30
31
|
const ENHANCED = "__mlolaEnhanced";
|
|
@@ -299,10 +300,34 @@ const behaviors = {
|
|
|
299
300
|
(option) => option.getAttribute("aria-disabled") !== "true",
|
|
300
301
|
);
|
|
301
302
|
|
|
303
|
+
// The listbox is fixed to the viewport so no overflow clips it, which
|
|
304
|
+
// means it is placed beside the trigger here, and follows it while open.
|
|
305
|
+
const place = () => {
|
|
306
|
+
const rect = trigger.getBoundingClientRect();
|
|
307
|
+
popover.style.minWidth = `${trigger.offsetWidth}px`;
|
|
308
|
+
const at = placeFloating(
|
|
309
|
+
{ x: rect.left, y: rect.top, width: rect.width, height: rect.height },
|
|
310
|
+
{ width: popover.offsetWidth, height: popover.offsetHeight },
|
|
311
|
+
{ width: window.innerWidth, height: window.innerHeight },
|
|
312
|
+
{ side: "bottom", align: "start", offset: 6 },
|
|
313
|
+
);
|
|
314
|
+
popover.style.left = `${at.x}px`;
|
|
315
|
+
popover.style.top = `${at.y}px`;
|
|
316
|
+
popover.dataset.side = at.side;
|
|
317
|
+
};
|
|
318
|
+
let frame = 0;
|
|
319
|
+
const follow = () => {
|
|
320
|
+
cancelAnimationFrame(frame);
|
|
321
|
+
frame = requestAnimationFrame(() => {
|
|
322
|
+
if (isOpen()) place();
|
|
323
|
+
});
|
|
324
|
+
};
|
|
325
|
+
|
|
302
326
|
const setOpen = (open) => {
|
|
303
327
|
trigger.dataset.state = open ? "open" : "closed";
|
|
304
328
|
popover.hidden = !open;
|
|
305
329
|
trigger.setAttribute("aria-expanded", String(open));
|
|
330
|
+
if (open) place();
|
|
306
331
|
if (!open) {
|
|
307
332
|
for (const option of options()) delete option.dataset.highlighted;
|
|
308
333
|
trigger.removeAttribute("aria-activedescendant");
|
|
@@ -387,6 +412,9 @@ const behaviors = {
|
|
|
387
412
|
on(document, "pointerdown", (event) => {
|
|
388
413
|
if (isOpen() && !root.contains(event.target)) setOpen(false);
|
|
389
414
|
}),
|
|
415
|
+
on(window, "scroll", follow, true),
|
|
416
|
+
on(window, "resize", follow),
|
|
417
|
+
() => cancelAnimationFrame(frame),
|
|
390
418
|
];
|
|
391
419
|
},
|
|
392
420
|
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The interaction decisions: where a value snaps, which index a key moves
|
|
3
|
+
* to, where focus goes next, where a floating layer sits. The framework-free
|
|
4
|
+
* runtime imports only these, so a plain page downloads nothing else;
|
|
5
|
+
* everything is also exported from ./logic.js, the public entry.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Round to the precision the step implies, so 0.1 steps do not drift. */
|
|
9
|
+
function toStepPrecision(value, step) {
|
|
10
|
+
const decimals = (String(step).split(".")[1] ?? "").length;
|
|
11
|
+
return Number(value.toFixed(Math.min(decimals, 10)));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Clamp into range and snap onto the step grid measured from `min`. */
|
|
15
|
+
export function clampToStep(raw, { min = 0, max = 100, step = 1 } = {}) {
|
|
16
|
+
const low = Number.isFinite(min) ? min : 0;
|
|
17
|
+
const high = Number.isFinite(max) && max > low ? max : low + 100;
|
|
18
|
+
const increment = Number.isFinite(step) && step > 0 ? step : 1;
|
|
19
|
+
const value = Number.isFinite(raw) ? raw : low;
|
|
20
|
+
const clamped = Math.min(high, Math.max(low, value));
|
|
21
|
+
const snapped = low + Math.round((clamped - low) / increment) * increment;
|
|
22
|
+
return toStepPrecision(Math.min(high, Math.max(low, snapped)), increment);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Turn a 0..1 position along a track into a value in range. */
|
|
26
|
+
export function valueFromRatio(ratio, { min = 0, max = 100, step = 1 } = {}) {
|
|
27
|
+
const low = Number.isFinite(min) ? min : 0;
|
|
28
|
+
const high = Number.isFinite(max) && max > low ? max : low + 100;
|
|
29
|
+
const safe = Number.isFinite(ratio) ? Math.min(1, Math.max(0, ratio)) : 0;
|
|
30
|
+
return clampToStep(low + safe * (high - low), { min: low, max: high, step });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Percentage along the track, for positioning a range fill and a thumb. */
|
|
34
|
+
export function percentOf(value, { min = 0, max = 100 } = {}) {
|
|
35
|
+
const low = Number.isFinite(min) ? min : 0;
|
|
36
|
+
const high = Number.isFinite(max) && max > low ? max : low + 100;
|
|
37
|
+
if (high === low) return 0;
|
|
38
|
+
return ((Math.min(high, Math.max(low, value)) - low) / (high - low)) * 100;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The value a key produces, or undefined when the key means nothing here.
|
|
43
|
+
* Returning undefined rather than the current value lets a caller know whether
|
|
44
|
+
* to call preventDefault.
|
|
45
|
+
*/
|
|
46
|
+
export function sliderValueForKey(key, current, { min = 0, max = 100, step = 1 } = {}) {
|
|
47
|
+
const low = Number.isFinite(min) ? min : 0;
|
|
48
|
+
const high = Number.isFinite(max) && max > low ? max : low + 100;
|
|
49
|
+
const increment = Number.isFinite(step) && step > 0 ? step : 1;
|
|
50
|
+
const page = Math.max(increment, (high - low) / 10);
|
|
51
|
+
const moves = {
|
|
52
|
+
ArrowLeft: -increment,
|
|
53
|
+
ArrowDown: -increment,
|
|
54
|
+
ArrowRight: increment,
|
|
55
|
+
ArrowUp: increment,
|
|
56
|
+
PageDown: -page,
|
|
57
|
+
PageUp: page,
|
|
58
|
+
};
|
|
59
|
+
if (key === "Home") return clampToStep(low, { min: low, max: high, step: increment });
|
|
60
|
+
if (key === "End") return clampToStep(high, { min: low, max: high, step: increment });
|
|
61
|
+
const delta = moves[key];
|
|
62
|
+
if (delta === undefined) return undefined;
|
|
63
|
+
return clampToStep(current + delta, { min: low, max: high, step: increment });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Where a roving-focus key lands. `enabled` reports whether an index may take
|
|
68
|
+
* focus, so disabled entries are stepped over rather than landed on.
|
|
69
|
+
* Returns -1 when the key does not move focus.
|
|
70
|
+
*/
|
|
71
|
+
export function rovingIndex(
|
|
72
|
+
count,
|
|
73
|
+
index,
|
|
74
|
+
key,
|
|
75
|
+
{ horizontal = true, wrap = true, enabled = () => true } = {},
|
|
76
|
+
) {
|
|
77
|
+
if (count <= 0) return -1;
|
|
78
|
+
const firstEnabled = (from, direction) => {
|
|
79
|
+
let candidate = from;
|
|
80
|
+
for (let steps = 0; steps < count; steps += 1) {
|
|
81
|
+
if (enabled(candidate)) return candidate;
|
|
82
|
+
candidate += direction;
|
|
83
|
+
if (candidate < 0) candidate = count - 1;
|
|
84
|
+
if (candidate >= count) candidate = 0;
|
|
85
|
+
}
|
|
86
|
+
return -1;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
if (key === "Home") return firstEnabled(0, 1);
|
|
90
|
+
if (key === "End") return firstEnabled(count - 1, -1);
|
|
91
|
+
|
|
92
|
+
const forward = horizontal ? "ArrowRight" : "ArrowDown";
|
|
93
|
+
const backward = horizontal ? "ArrowLeft" : "ArrowUp";
|
|
94
|
+
const step = key === forward ? 1 : key === backward ? -1 : 0;
|
|
95
|
+
if (!step) return -1;
|
|
96
|
+
|
|
97
|
+
let candidate = index;
|
|
98
|
+
for (let steps = 0; steps < count; steps += 1) {
|
|
99
|
+
candidate += step;
|
|
100
|
+
if (candidate < 0) {
|
|
101
|
+
if (!wrap) return -1;
|
|
102
|
+
candidate = count - 1;
|
|
103
|
+
}
|
|
104
|
+
if (candidate >= count) {
|
|
105
|
+
if (!wrap) return -1;
|
|
106
|
+
candidate = 0;
|
|
107
|
+
}
|
|
108
|
+
if (enabled(candidate)) return candidate;
|
|
109
|
+
}
|
|
110
|
+
return -1;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The index Tab should move to inside a trap, or -1 to let the browser handle
|
|
115
|
+
* it. Only the edges need intercepting; everything between them is natural.
|
|
116
|
+
*/
|
|
117
|
+
export function focusTrapIndex(count, activeIndex, shiftKey) {
|
|
118
|
+
if (count <= 0) return -1;
|
|
119
|
+
if (shiftKey && activeIndex <= 0) return count - 1;
|
|
120
|
+
if (!shiftKey && activeIndex === count - 1) return 0;
|
|
121
|
+
return -1;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Which items stay open after toggling `index`. */
|
|
125
|
+
export function resolveDisclosure(open, index, { multiple = false, collapsible = true } = {}) {
|
|
126
|
+
const isOpen = open.includes(index);
|
|
127
|
+
if (isOpen && !collapsible && !multiple) return open;
|
|
128
|
+
if (multiple) {
|
|
129
|
+
return isOpen ? open.filter((entry) => entry !== index) : [...open, index].sort((a, b) => a - b);
|
|
130
|
+
}
|
|
131
|
+
if (isOpen) return collapsible ? [] : open;
|
|
132
|
+
return [index];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const OPPOSITE_SIDE = { top: "bottom", bottom: "top", left: "right", right: "left" };
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Where a floating layer goes next to its anchor, as a pure function of
|
|
139
|
+
* rectangles: prefer the requested side, flip when it does not fit, and shift
|
|
140
|
+
* along the edge to stay inside the viewport. The React components and the
|
|
141
|
+
* framework-free runtime place every popover, menu and listbox with it.
|
|
142
|
+
*/
|
|
143
|
+
export function placeFloating(anchor, floating, viewport, { side = "bottom", align = "center", offset = 8, padding = 8 } = {}) {
|
|
144
|
+
const room = {
|
|
145
|
+
top: anchor.y - padding,
|
|
146
|
+
bottom: viewport.height - (anchor.y + anchor.height) - padding,
|
|
147
|
+
left: anchor.x - padding,
|
|
148
|
+
right: viewport.width - (anchor.x + anchor.width) - padding,
|
|
149
|
+
};
|
|
150
|
+
const needs = (candidate) => (candidate === "top" || candidate === "bottom" ? floating.height : floating.width) + offset;
|
|
151
|
+
let chosen = side;
|
|
152
|
+
if (room[side] < needs(side) && room[OPPOSITE_SIDE[side]] > room[side]) chosen = OPPOSITE_SIDE[side];
|
|
153
|
+
|
|
154
|
+
const vertical = chosen === "top" || chosen === "bottom";
|
|
155
|
+
let x;
|
|
156
|
+
let y;
|
|
157
|
+
if (vertical) {
|
|
158
|
+
y = chosen === "bottom" ? anchor.y + anchor.height + offset : anchor.y - floating.height - offset;
|
|
159
|
+
x = align === "start" ? anchor.x : align === "end" ? anchor.x + anchor.width - floating.width : anchor.x + anchor.width / 2 - floating.width / 2;
|
|
160
|
+
} else {
|
|
161
|
+
x = chosen === "right" ? anchor.x + anchor.width + offset : anchor.x - floating.width - offset;
|
|
162
|
+
y = align === "start" ? anchor.y : align === "end" ? anchor.y + anchor.height - floating.height : anchor.y + anchor.height / 2 - floating.height / 2;
|
|
163
|
+
}
|
|
164
|
+
// Shift along the edge to stay on screen.
|
|
165
|
+
x = Math.min(Math.max(x, padding), Math.max(padding, viewport.width - floating.width - padding));
|
|
166
|
+
y = Math.min(Math.max(y, padding), Math.max(padding, viewport.height - floating.height - padding));
|
|
167
|
+
return { x: Math.round(x), y: Math.round(y), side: chosen };
|
|
168
|
+
}
|
package/src/logic.d.ts
CHANGED
|
@@ -111,3 +111,26 @@ export function contextUsage<Segment extends ContextSegment>(
|
|
|
111
111
|
limit: number,
|
|
112
112
|
options?: { warning?: number; critical?: number },
|
|
113
113
|
): ContextUsage<Segment>;
|
|
114
|
+
|
|
115
|
+
export type Side = "top" | "bottom" | "left" | "right";
|
|
116
|
+
export type Align = "start" | "center" | "end";
|
|
117
|
+
|
|
118
|
+
export interface Rect {
|
|
119
|
+
x: number;
|
|
120
|
+
y: number;
|
|
121
|
+
width: number;
|
|
122
|
+
height: number;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface Placement {
|
|
126
|
+
x: number;
|
|
127
|
+
y: number;
|
|
128
|
+
side: Side;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function placeFloating(
|
|
132
|
+
anchor: Rect,
|
|
133
|
+
floating: { width: number; height: number },
|
|
134
|
+
viewport: { width: number; height: number },
|
|
135
|
+
options?: { side?: Side; align?: Align; offset?: number; padding?: number },
|
|
136
|
+
): Placement;
|
package/src/logic.js
CHANGED
|
@@ -11,132 +11,7 @@
|
|
|
11
11
|
* particular framework stops being fashionable.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
|
|
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
|
-
}
|
|
14
|
+
export * from "./interaction.js";
|
|
140
15
|
|
|
141
16
|
/**
|
|
142
17
|
* A coarse password-strength hint, deliberately not authoritative. It exists so
|