@threadlabs/looma 0.8.1 → 0.9.1
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.
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<template component="ui-context-menu" controller="./ui-context-menu.js">
|
|
2
2
|
<defs>
|
|
3
3
|
<prop name="open" type="boolean" default="false">Whether the context menu is open initially or when set externally.</prop>
|
|
4
|
-
<prop name="for" type="string" default="">ID of the
|
|
4
|
+
<prop name="for" type="string" default="">ID of the element whose context click (right-click, long-press, Shift+F10, or the Menu key) opens the menu.</prop>
|
|
5
5
|
<state name="internalOpen" :value="false"></state>
|
|
6
6
|
<event
|
|
7
7
|
name="open"
|
|
@@ -9,40 +9,37 @@ export default function controller(host) {
|
|
|
9
9
|
const document = element.ownerDocument;
|
|
10
10
|
const menuSurface = host.refs.menu;
|
|
11
11
|
const overlayId = `ui-context-menu-${Math.random().toString(36).slice(2, 11)}`;
|
|
12
|
-
let
|
|
13
|
-
let contextTarget = null;
|
|
12
|
+
let target = null;
|
|
14
13
|
let surface = null;
|
|
15
14
|
let point = null;
|
|
15
|
+
let press = null;
|
|
16
16
|
let lastFor = host.state.for;
|
|
17
17
|
let lastOpenProp = Boolean(host.state.open);
|
|
18
18
|
host.state.internalOpen = lastOpenProp;
|
|
19
19
|
|
|
20
20
|
const items = () => Array.from(element.querySelectorAll('[data-component~="ui-menu-item"]')).filter((item) => !disabled(item));
|
|
21
21
|
const focusFirst = () => requestAnimationFrame(() => items()[0]?.focus());
|
|
22
|
+
const targetEvents = { contextmenu: onContextMenu, keydown: onTargetKeydown, pointerdown: onPointerdown, pointerup: endPress, pointercancel: endPress };
|
|
22
23
|
const detach = () => {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
contextTarget?.removeEventListener("contextmenu", onContextMenu);
|
|
24
|
+
for (const [type, listener] of Object.entries(targetEvents)) target?.removeEventListener(type, listener);
|
|
25
|
+
endPress();
|
|
26
26
|
};
|
|
27
27
|
const ids = createIdResolver(document, () => resolveTargets());
|
|
28
28
|
const resolveTargets = () => {
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
if (nextTrigger === trigger && nextContext === contextTarget) return;
|
|
29
|
+
const next = ids.get(String(host.state.for ?? ""));
|
|
30
|
+
if (next === target) return;
|
|
32
31
|
detach();
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
trigger?.addEventListener("keydown", onTriggerKeydown);
|
|
37
|
-
contextTarget?.addEventListener("contextmenu", onContextMenu);
|
|
38
|
-
surface?.setAnchor(trigger);
|
|
32
|
+
target = next;
|
|
33
|
+
for (const [type, listener] of Object.entries(targetEvents)) target?.addEventListener(type, listener);
|
|
34
|
+
surface?.setAnchor(target);
|
|
39
35
|
};
|
|
40
|
-
// Light dismiss leaves focus where the user pointed instead of pulling it back to the
|
|
36
|
+
// Light dismiss leaves focus where the user pointed instead of pulling it back to the target.
|
|
41
37
|
const close = (reason, input, returnFocus = reason !== "light-dismiss") => {
|
|
42
38
|
if (!host.state.internalOpen) return;
|
|
43
39
|
host.state.internalOpen = false;
|
|
40
|
+
endPress();
|
|
44
41
|
host.dispatch("close", { open: false, reason, trigger: input });
|
|
45
|
-
if (returnFocus)
|
|
42
|
+
if (returnFocus) target?.focus();
|
|
46
43
|
};
|
|
47
44
|
const apply = () => {
|
|
48
45
|
if (host.state.for !== lastFor) {
|
|
@@ -54,12 +51,7 @@ export default function controller(host) {
|
|
|
54
51
|
lastOpenProp = externalOpen;
|
|
55
52
|
host.state.internalOpen = externalOpen;
|
|
56
53
|
}
|
|
57
|
-
|
|
58
|
-
if (trigger) {
|
|
59
|
-
trigger.setAttribute("aria-haspopup", "menu");
|
|
60
|
-
trigger.setAttribute("aria-expanded", String(open));
|
|
61
|
-
}
|
|
62
|
-
if (open) {
|
|
54
|
+
if (host.state.internalOpen) {
|
|
63
55
|
if (point) surface?.showAtPoint(point);
|
|
64
56
|
else surface?.show();
|
|
65
57
|
openOverlay({ id: overlayId, modal: false, element, dismissible: true, requestClose: close });
|
|
@@ -68,31 +60,46 @@ export default function controller(host) {
|
|
|
68
60
|
closeOverlay(document, overlayId);
|
|
69
61
|
}
|
|
70
62
|
};
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
point =
|
|
63
|
+
// A null point anchors the menu to the target, for keyboard opens that have no pointer position.
|
|
64
|
+
const openAt = (at, input) => {
|
|
65
|
+
point = at;
|
|
66
|
+
if (at) surface?.showAtPoint(at);
|
|
74
67
|
host.state.internalOpen = true;
|
|
75
68
|
host.dispatch("open", { open: true, reason: "action", trigger: input });
|
|
76
69
|
focusFirst();
|
|
77
70
|
};
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
if (
|
|
81
|
-
else openFromTrigger(input);
|
|
82
|
-
}
|
|
83
|
-
function onTriggerKeydown(event) {
|
|
84
|
-
if (!["Enter", " ", "ArrowDown"].includes(event.key)) return;
|
|
71
|
+
// A nested target that already opened its own menu prevents the default, so outer targets stay shut.
|
|
72
|
+
function onContextMenu(event) {
|
|
73
|
+
if (event.defaultPrevented) return;
|
|
85
74
|
event.preventDefault();
|
|
86
|
-
|
|
87
|
-
|
|
75
|
+
// A touch long-press opens the menu itself; Android's own contextmenu for that press is the same gesture.
|
|
76
|
+
if (press) {
|
|
77
|
+
clearTimeout(press.timer);
|
|
78
|
+
if (!press.opened) openAt(press.at, "pointer");
|
|
79
|
+
press.opened = true;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
openAt({ x: event.clientX, y: event.clientY }, "pointer");
|
|
88
83
|
}
|
|
89
|
-
|
|
84
|
+
// Shift+F10 and the Menu key, handled here so every browser (Safari fires no contextmenu for them) behaves alike.
|
|
85
|
+
function onTargetKeydown(event) {
|
|
86
|
+
if (event.defaultPrevented || !(event.key === "ContextMenu" || (event.shiftKey && event.key === "F10"))) return;
|
|
90
87
|
event.preventDefault();
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
88
|
+
openAt(null, "keyboard");
|
|
89
|
+
}
|
|
90
|
+
// iOS Safari fires no contextmenu on long-press, so touch opens the menu after a held press.
|
|
91
|
+
function onPointerdown(event) {
|
|
92
|
+
if (event.pointerType !== "touch") return;
|
|
93
|
+
endPress();
|
|
94
|
+
press = { at: { x: event.clientX, y: event.clientY }, opened: false };
|
|
95
|
+
press.timer = setTimeout(() => {
|
|
96
|
+
press.opened = true;
|
|
97
|
+
openAt(press.at, "pointer");
|
|
98
|
+
}, 500);
|
|
99
|
+
}
|
|
100
|
+
function endPress() {
|
|
101
|
+
clearTimeout(press?.timer);
|
|
102
|
+
press = null;
|
|
96
103
|
}
|
|
97
104
|
const onKeydown = (event) => {
|
|
98
105
|
if (!host.state.internalOpen) return;
|
|
@@ -123,7 +130,7 @@ export default function controller(host) {
|
|
|
123
130
|
close("action", "pointer");
|
|
124
131
|
};
|
|
125
132
|
resolveTargets();
|
|
126
|
-
if (menuSurface) surface = createAnchoredSurface(menuSurface, { anchor:
|
|
133
|
+
if (menuSurface) surface = createAnchoredSurface(menuSurface, { anchor: target, placement: "bottom-start" });
|
|
127
134
|
element.addEventListener("keydown", onKeydown);
|
|
128
135
|
element.addEventListener("click", onClick);
|
|
129
136
|
const observer = new MutationObserver(resolveTargets);
|