@mlola-ui/behavior 1.1.12 → 1.1.14
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 +84 -50
- package/src/logic.d.ts +5 -1
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -21,7 +21,6 @@ import {
|
|
|
21
21
|
clampToStep,
|
|
22
22
|
focusTrapIndex,
|
|
23
23
|
percentOf,
|
|
24
|
-
fitMenu,
|
|
25
24
|
fitTooltip,
|
|
26
25
|
isSidewaysDrag,
|
|
27
26
|
placeFloating,
|
|
@@ -53,6 +52,55 @@ function on(target, type, handler, options) {
|
|
|
53
52
|
return () => target.removeEventListener(type, handler, options);
|
|
54
53
|
}
|
|
55
54
|
|
|
55
|
+
/**
|
|
56
|
+
* A layer that opens beside its anchor (a menu, a listbox): placed with
|
|
57
|
+
* placeFloating, and followed while open. Where the browser has the top
|
|
58
|
+
* layer (the Popover API) it opens there, so no overflow, transform or
|
|
59
|
+
* backdrop-filter of a card, panel or modal can cut it; elsewhere it is
|
|
60
|
+
* fixed to the viewport. It stays where it is in the DOM, so it keeps its
|
|
61
|
+
* theme and its place in the widget.
|
|
62
|
+
*/
|
|
63
|
+
function floatingLayer(layer, anchor, options) {
|
|
64
|
+
const topLayer = typeof layer.showPopover === "function";
|
|
65
|
+
if (topLayer && !layer.hasAttribute("popover")) layer.setAttribute("popover", "manual");
|
|
66
|
+
let open = false;
|
|
67
|
+
let frame = 0;
|
|
68
|
+
const place = () => {
|
|
69
|
+
const rect = anchor.getBoundingClientRect();
|
|
70
|
+
const { minWidth, ...placement } = options();
|
|
71
|
+
if (minWidth) layer.style.minWidth = `${minWidth}px`;
|
|
72
|
+
const at = placeFloating(
|
|
73
|
+
{ x: rect.left, y: rect.top, width: rect.width, height: rect.height },
|
|
74
|
+
{ width: layer.offsetWidth, height: layer.offsetHeight },
|
|
75
|
+
{ width: window.innerWidth, height: window.innerHeight },
|
|
76
|
+
placement,
|
|
77
|
+
);
|
|
78
|
+
layer.style.left = `${at.x}px`;
|
|
79
|
+
layer.style.top = `${at.y}px`;
|
|
80
|
+
layer.dataset.side = at.side;
|
|
81
|
+
};
|
|
82
|
+
const follow = () => {
|
|
83
|
+
cancelAnimationFrame(frame);
|
|
84
|
+
frame = requestAnimationFrame(() => {
|
|
85
|
+
if (open) place();
|
|
86
|
+
});
|
|
87
|
+
};
|
|
88
|
+
return {
|
|
89
|
+
show() {
|
|
90
|
+
open = true;
|
|
91
|
+
layer.hidden = false;
|
|
92
|
+
if (topLayer && layer.isConnected && !layer.matches(":popover-open")) layer.showPopover();
|
|
93
|
+
place();
|
|
94
|
+
},
|
|
95
|
+
hide() {
|
|
96
|
+
open = false;
|
|
97
|
+
if (topLayer && layer.matches(":popover-open")) layer.hidePopover();
|
|
98
|
+
layer.hidden = true;
|
|
99
|
+
},
|
|
100
|
+
cleanups: [on(window, "scroll", follow, true), on(window, "resize", follow), () => cancelAnimationFrame(frame)],
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
56
104
|
/** Element-shaped wrapper over the shared rovingIndex decision. */
|
|
57
105
|
function roving(items, index, key, options = {}) {
|
|
58
106
|
return rovingIndex(items.length, index, key, {
|
|
@@ -157,24 +205,13 @@ const behaviors = {
|
|
|
157
205
|
(item) => !item.disabled && item.getAttribute("aria-disabled") !== "true",
|
|
158
206
|
);
|
|
159
207
|
|
|
160
|
-
|
|
161
|
-
const fit = () => {
|
|
162
|
-
const rect = root.getBoundingClientRect();
|
|
163
|
-
const { side, shift } = fitMenu(
|
|
164
|
-
{ x: rect.left, y: rect.top, width: rect.width, height: rect.height },
|
|
165
|
-
{ width: menu.offsetWidth, height: menu.offsetHeight },
|
|
166
|
-
{ width: window.innerWidth, height: window.innerHeight },
|
|
167
|
-
menu.dataset.align === "end" ? "end" : "start",
|
|
168
|
-
);
|
|
169
|
-
menu.style.setProperty("--ml-dropdown-shift", `${shift}px`);
|
|
170
|
-
menu.dataset.side = side;
|
|
171
|
-
};
|
|
208
|
+
const layer = floatingLayer(menu, trigger, () => ({ side: "bottom", align: menu.dataset.align === "end" ? "end" : "start", offset: 6 }));
|
|
172
209
|
const setOpen = (open) => {
|
|
173
210
|
root.dataset.state = open ? "open" : "closed";
|
|
174
211
|
menu.dataset.state = open ? "open" : "closed";
|
|
175
|
-
|
|
212
|
+
if (open) layer.show();
|
|
213
|
+
else layer.hide();
|
|
176
214
|
trigger.setAttribute("aria-expanded", String(open));
|
|
177
|
-
if (open) fit();
|
|
178
215
|
if (!open) for (const item of items()) delete item.dataset.highlighted;
|
|
179
216
|
};
|
|
180
217
|
const isOpen = () => root.dataset.state === "open";
|
|
@@ -190,10 +227,20 @@ const behaviors = {
|
|
|
190
227
|
setOpen(false);
|
|
191
228
|
|
|
192
229
|
return [
|
|
193
|
-
|
|
230
|
+
// Opened by a press, focus moves to the first item, as the React menu does, so the keys (and Escape) reach it.
|
|
231
|
+
on(trigger, "click", () => {
|
|
232
|
+
if (isOpen()) {
|
|
233
|
+
setOpen(false);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
setOpen(true);
|
|
237
|
+
highlight(items()[0]);
|
|
238
|
+
}),
|
|
194
239
|
on(root, "keydown", (event) => {
|
|
195
240
|
const all = items();
|
|
196
241
|
if (event.key === "Escape") {
|
|
242
|
+
if (!isOpen()) return;
|
|
243
|
+
event.preventDefault();
|
|
197
244
|
setOpen(false);
|
|
198
245
|
trigger.focus();
|
|
199
246
|
return;
|
|
@@ -221,9 +268,7 @@ const behaviors = {
|
|
|
221
268
|
on(document, "pointerdown", (event) => {
|
|
222
269
|
if (isOpen() && !root.contains(event.target)) setOpen(false);
|
|
223
270
|
}),
|
|
224
|
-
|
|
225
|
-
if (isOpen()) fit();
|
|
226
|
-
}),
|
|
271
|
+
...layer.cleanups,
|
|
227
272
|
];
|
|
228
273
|
},
|
|
229
274
|
|
|
@@ -291,7 +336,8 @@ const behaviors = {
|
|
|
291
336
|
}),
|
|
292
337
|
on(document, "keydown", (event) => {
|
|
293
338
|
if (root.hidden) return;
|
|
294
|
-
|
|
339
|
+
// A menu or listbox open above the dialog closes first: it marks the Escape it used.
|
|
340
|
+
if (event.key === "Escape" && closeOnEscape && !event.defaultPrevented) {
|
|
295
341
|
event.preventDefault();
|
|
296
342
|
close();
|
|
297
343
|
return;
|
|
@@ -321,34 +367,14 @@ const behaviors = {
|
|
|
321
367
|
(option) => option.getAttribute("aria-disabled") !== "true",
|
|
322
368
|
);
|
|
323
369
|
|
|
324
|
-
// The listbox
|
|
325
|
-
|
|
326
|
-
const place = () => {
|
|
327
|
-
const rect = trigger.getBoundingClientRect();
|
|
328
|
-
popover.style.minWidth = `${trigger.offsetWidth}px`;
|
|
329
|
-
const at = placeFloating(
|
|
330
|
-
{ x: rect.left, y: rect.top, width: rect.width, height: rect.height },
|
|
331
|
-
{ width: popover.offsetWidth, height: popover.offsetHeight },
|
|
332
|
-
{ width: window.innerWidth, height: window.innerHeight },
|
|
333
|
-
{ side: "bottom", align: "start", offset: 6 },
|
|
334
|
-
);
|
|
335
|
-
popover.style.left = `${at.x}px`;
|
|
336
|
-
popover.style.top = `${at.y}px`;
|
|
337
|
-
popover.dataset.side = at.side;
|
|
338
|
-
};
|
|
339
|
-
let frame = 0;
|
|
340
|
-
const follow = () => {
|
|
341
|
-
cancelAnimationFrame(frame);
|
|
342
|
-
frame = requestAnimationFrame(() => {
|
|
343
|
-
if (isOpen()) place();
|
|
344
|
-
});
|
|
345
|
-
};
|
|
370
|
+
// The listbox opens as a layer beside the trigger, at least as wide as it.
|
|
371
|
+
const layer = floatingLayer(popover, trigger, () => ({ side: "bottom", align: "start", offset: 6, minWidth: trigger.offsetWidth }));
|
|
346
372
|
|
|
347
373
|
const setOpen = (open) => {
|
|
348
374
|
trigger.dataset.state = open ? "open" : "closed";
|
|
349
|
-
|
|
375
|
+
if (open) layer.show();
|
|
376
|
+
else layer.hide();
|
|
350
377
|
trigger.setAttribute("aria-expanded", String(open));
|
|
351
|
-
if (open) place();
|
|
352
378
|
if (!open) {
|
|
353
379
|
for (const option of options()) delete option.dataset.highlighted;
|
|
354
380
|
trigger.removeAttribute("aria-activedescendant");
|
|
@@ -395,6 +421,8 @@ const behaviors = {
|
|
|
395
421
|
on(root, "keydown", (event) => {
|
|
396
422
|
const all = options();
|
|
397
423
|
if (event.key === "Escape") {
|
|
424
|
+
if (!isOpen()) return;
|
|
425
|
+
event.preventDefault();
|
|
398
426
|
setOpen(false);
|
|
399
427
|
trigger.focus();
|
|
400
428
|
return;
|
|
@@ -433,9 +461,7 @@ const behaviors = {
|
|
|
433
461
|
on(document, "pointerdown", (event) => {
|
|
434
462
|
if (isOpen() && !root.contains(event.target)) setOpen(false);
|
|
435
463
|
}),
|
|
436
|
-
|
|
437
|
-
on(window, "resize", follow),
|
|
438
|
-
() => cancelAnimationFrame(frame),
|
|
464
|
+
...layer.cleanups,
|
|
439
465
|
];
|
|
440
466
|
},
|
|
441
467
|
|
|
@@ -556,9 +582,17 @@ const behaviors = {
|
|
|
556
582
|
on(root, "pointerleave", hide),
|
|
557
583
|
on(root, "focusin", show),
|
|
558
584
|
on(root, "focusout", hide),
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
585
|
+
// Heard before a dialog around it (capture), and marked as used while the tip shows, so one Escape closes one layer.
|
|
586
|
+
on(
|
|
587
|
+
document,
|
|
588
|
+
"keydown",
|
|
589
|
+
(event) => {
|
|
590
|
+
if (event.key !== "Escape" || tip.hidden || event.defaultPrevented) return;
|
|
591
|
+
event.preventDefault();
|
|
592
|
+
hide();
|
|
593
|
+
},
|
|
594
|
+
true,
|
|
595
|
+
),
|
|
562
596
|
];
|
|
563
597
|
},
|
|
564
598
|
|
package/src/logic.d.ts
CHANGED
|
@@ -136,7 +136,11 @@ export function placeFloating(
|
|
|
136
136
|
options?: { side?: Side; align?: Align; offset?: number; padding?: number },
|
|
137
137
|
): Placement;
|
|
138
138
|
|
|
139
|
-
/**
|
|
139
|
+
/**
|
|
140
|
+
* A CSS-anchored menu's side (bottom, or top without room) and its sideways slide to stay on screen.
|
|
141
|
+
* @deprecated Since 1.1.13 the dropdown menu is a floating layer placed with `placeFloating`, which escapes any
|
|
142
|
+
* container that clips; `fitMenu` kept a menu on screen but not out of a clipping card. Kept for 1.x.
|
|
143
|
+
*/
|
|
140
144
|
export function fitMenu(
|
|
141
145
|
anchor: Rect,
|
|
142
146
|
menu: { width: number; height: number },
|