馃悕馃悕馃悕
1
2$css(`
3 .context-backdrop {
4 position: absolute;
5 margin: 0;
6 padding: 0;
7 border: none;
8 width: 100%;
9 height: 100%;
10 display: none;
11 }
12
13 .context-menu {
14 position: fixed;
15 background-color: var(--main-background);
16 border: 1px solid var(--main-faded);
17 border-radius: 2px;
18 min-width: 8rem;
19 font-size: 0.875rem;
20 user-select: none;
21 z-index: 10;
22 }
23
24 .context-menu[centered] {
25 position: absolute;
26 left: 50%;
27 top: 50%;
28 transform: translate(-50%, -50%);
29 }
30
31 .context-menu-item {
32 padding: 0.2rem 0.5rem;
33 cursor: pointer;
34 white-space: nowrap;
35 color: var(--main-solid);
36 background-color: var(--main-background);
37 display: block;
38 width: 100%;
39 border-radius: 0;
40 text-align: left;
41 height: auto;
42 }
43
44 .context-menu-item:focus {
45 outline: none;
46 background-color: var(--main-faded);
47 }
48
49 .context-menu-item:hover {
50 background-color: var(--main-faded);
51 }
52
53 .context-menu-item.disabled {
54 opacity: 0.5;
55 cursor: default;
56 }
57
58 .context-menu-item.disabled:hover {
59 background-color: transparent;
60 }
61
62 .context-menu-separator {
63 height: 1px;
64 background-color: var(--main-faded);
65 margin: 0.25rem 0;
66 }
67`);
68
69function collectItems(element) {
70 const items = [];
71
72 for (let node = element; node; node = node.parentNode) {
73 if (node.$contextMenu !== null && node.$contextMenu !== undefined) {
74 const nodeItems = $actualize(node.$contextMenu.items);
75 for (const item of nodeItems.map($actualize)) {
76 if (Array.isArray(item) && item[0] && Array.isArray(item[0])) {
77 items.push(...item);
78 }
79 else {
80 items.push(item);
81 }
82 }
83
84 if (node.$contextMenu.override) {
85 break;
86 }
87 }
88 }
89
90 return items;
91}
92
93
94const backdrop = document.createElement("div");
95backdrop.className = "context-backdrop";
96
97const menu = document.createElement("div");
98menu.$ = {};
99menu.className = "context-menu";
100menu.setAttribute("role", "menu");
101menu.setAttribute("aria-orientation", "vertical");
102
103menu.addEventListener("mouseenter", () => {
104 //menu.firstChild?.blur();
105 menu.focus();
106});
107
108const onBackdropClick = (e) => {
109 if (e.target !== backdrop) return;
110 e.preventDefault();
111
112 backdrop.style.display = "none";
113 menu.$.previousFocus?.focus();
114
115 // don't make user click twice when clicking away from the context menu
116 const clickTarget = document.elementFromPoint(e.clientX, e.clientY);
117 if (clickTarget) {
118 clickTarget.focus();
119 clickTarget.dispatchEvent(new MouseEvent(e.type, {
120 bubbles: true,
121 cancelable: true,
122 clientX: e.clientX,
123 clientY: e.clientY
124 }));
125 }
126};
127
128backdrop.addEventListener("click", onBackdropClick);
129backdrop.addEventListener("contextmenu", onBackdropClick);
130
131menu.addEventListener("keydown", (e) => {
132 if (!["ArrowDown", "ArrowUp", "j", "k", "Escape"].includes(e.key)) return;
133
134 e.preventDefault();
135 e.stopPropagation();
136
137 if (e.key === "Escape") {
138 backdrop.style.display = "none";
139 menu.$.previousFocus?.focus();
140 return;
141 }
142
143 const currentItem = document.activeElement;
144 if (!menu.contains(currentItem)) {
145 menu.firstElementChild?.focus();
146 return;
147 }
148
149 let nextItem;
150 if (e.key === "ArrowDown" || e.key === "j") {
151 nextItem = currentItem.nextElementSibling || menu.firstElementChild;
152 } else {
153 nextItem = currentItem.previousElementSibling || menu.lastElementChild;
154 }
155
156 nextItem.focus();
157});
158
159backdrop.appendChild(menu);
160
161const showMenu = (target, position = null) => {
162 document.body.appendChild(backdrop);
163 backdrop.style.display = "block";
164 menu.$.previousFocus = document.activeElement;
165 menu.firstChild?.focus();
166
167 const bounds = target.getBoundingClientRect();
168
169 if (!position) {
170 menu.setAttribute("centered", "");
171 menu.style.left = "";
172 menu.style.top = "";
173 return;
174 }
175
176 const {x,y} = position;
177
178 menu.removeAttribute("centered");
179 menu.style.left = x + "px";
180 menu.style.top = y + "px";
181
182 const rect = menu.getBoundingClientRect();
183
184 if (rect.right > bounds.right) {
185 menu.style.left = (x - rect.width) + "px";
186 }
187 if (rect.left < bounds.left) {
188 menu.style.left = bounds.left + "px";
189 }
190 if (rect.bottom > bounds.bottom) {
191 menu.style.top = (y - rect.height) + "px";
192 }
193 if (rect.top < bounds.top) {
194 menu.style.top = bounds.top + "px";
195 }
196};
197
198document.addEventListener("contextmenu", (e) => {
199 menu.replaceChildren();
200
201 const items = collectItems(e.target);
202
203 if (items.length === 0) return;
204
205 items.forEach(item => {
206 if (!item) return;
207 if (item === "separator") { // TODO improve this
208 const separator = document.createElement("div");
209 separator.className = "context-menu-separator";
210 menu.appendChild(separator);
211 return;
212 }
213
214 const menuItem = document.createElement("button");
215 menuItem.className = "context-menu-item";
216 menu.setAttribute("role", "menuItem");
217 menu.setAttribute("tabIndex", "-1");
218
219 menuItem.textContent = item[0];
220
221 const select = async () => {
222 backdrop.style.display = "none";
223 menu.$.previousFocus?.focus();
224 await item[1]();
225 };
226
227 menuItem.onclick = select;
228 menuItem.addEventListener("keydown", (e) => {
229 if (e.key === "o" || e.key === "Enter") {
230 select();
231 e.stopPropagation();
232 }
233 });
234
235 menu.appendChild(menuItem);
236 });
237
238 e.preventDefault();
239
240 showMenu(e.target, {x: e.clientX, y: e.clientY});
241});
242