@starlove/orbit 1.0.7 → 1.1.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.
- package/README.md +22 -7
- package/SKILL-ORBIT.md +26 -1
- package/dist/full/index.html +2 -2
- package/dist/full/js/app.a4f045fc.js +676 -0
- package/dist/full/js/app.a4f045fc.js.map +7 -0
- package/dist/full/{styles.14e9dbbd.css → styles.836872e7.css} +836 -39
- package/dist/full/version.json +2 -2
- package/docs/AGENT_PROTOCOL.md +23 -5
- package/docs/MCP_SETUP.md +5 -3
- package/docs/ORBIT_DISPATCH.md +185 -0
- package/docs/setup/orbit-init-wizard.md +174 -0
- package/package.json +2 -2
- package/public/js/actor-labels.js +65 -0
- package/public/js/app.js +8 -1
- package/public/js/card-actions.js +526 -0
- package/public/js/config.js +2 -1
- package/public/js/format.js +204 -10
- package/public/js/kanban.js +15 -0
- package/public/js/local-backend.js +24 -8
- package/public/js/router.js +3 -2
- package/public/js/search.js +6 -3
- package/public/js/settings.js +96 -26
- package/public/js/state.js +50 -0
- package/public/js/ticket-detail.js +428 -125
- package/public/styles.css +836 -39
- package/src/cli/orbit.js +217 -32
- package/src/core/agent.js +114 -24
- package/src/core/agents-md.js +15 -10
- package/src/core/attachments.js +238 -0
- package/src/core/auth.js +36 -0
- package/src/core/backups.js +9 -3
- package/src/core/board.js +32 -1
- package/src/core/boards.js +1 -1
- package/src/core/db.js +42 -2
- package/src/core/delete-board.js +87 -21
- package/src/core/dispatch.js +532 -0
- package/src/core/http.js +31 -11
- package/src/core/migrate.js +198 -0
- package/src/core/paths.js +6 -0
- package/src/core/registry.js +12 -9
- package/src/core/router.js +81 -7
- package/src/core/search.js +83 -10
- package/src/core/snapshots.js +14 -2
- package/src/mcp/orbit-client.js +109 -46
- package/src/mcp-server.js +238 -12
- package/src/server.js +20 -0
- package/dist/full/js/app.f4fd5e94.js +0 -604
- package/dist/full/js/app.f4fd5e94.js.map +0 -7
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
// Shared card action menu data, mutations, and desktop context-menu presentation.
|
|
2
|
+
// Mobile can reuse buildCardActionMenu() + createCardActionHandlers() and render
|
|
3
|
+
// the same model as a bottom sheet without duplicating mutation logic.
|
|
4
|
+
//
|
|
5
|
+
// Submenus are reparented to document.body on open so they escape the menu's
|
|
6
|
+
// backdrop-filter containing block (otherwise position: fixed coords would be
|
|
7
|
+
// interpreted relative to the menu instead of the viewport).
|
|
8
|
+
|
|
9
|
+
import { api, withBoardQuery } from "./api.js";
|
|
10
|
+
import { state, visibleStatesForProject } from "./state.js";
|
|
11
|
+
import { canonicalTicketType, escapeHtml, priorityLabel, typeLabel } from "./format.js";
|
|
12
|
+
import { toast } from "./toast.js";
|
|
13
|
+
|
|
14
|
+
const CARD_TYPES = ["epic", "feature", "task", "bug"];
|
|
15
|
+
const PRIORITIES = [0, 1, 2, 3, 4];
|
|
16
|
+
const MENU_MARGIN = 8;
|
|
17
|
+
const SUBMENU_GAP = 6;
|
|
18
|
+
const HOVER_CLOSE_DELAY = 180;
|
|
19
|
+
|
|
20
|
+
let openMenu = null;
|
|
21
|
+
let dragInFlight = false;
|
|
22
|
+
let dismissController = null;
|
|
23
|
+
let lastFocusedElement = null;
|
|
24
|
+
const submenuByItem = new WeakMap();
|
|
25
|
+
const itemBySubmenu = new WeakMap();
|
|
26
|
+
const openSubmenus = new Set();
|
|
27
|
+
let pendingCloseTimer = null;
|
|
28
|
+
|
|
29
|
+
export function buildCardActionMenu(ticket, lanes = visibleStatesForProject()) {
|
|
30
|
+
if (!ticket) return [];
|
|
31
|
+
return [
|
|
32
|
+
{
|
|
33
|
+
id: "move",
|
|
34
|
+
label: "Move to",
|
|
35
|
+
children: lanes.map((lane) => ({
|
|
36
|
+
id: `move:${lane.id}`,
|
|
37
|
+
label: lane.name,
|
|
38
|
+
action: "move",
|
|
39
|
+
value: lane.id,
|
|
40
|
+
checked: lane.id === ticket.state_id,
|
|
41
|
+
disabled: lane.id === ticket.state_id
|
|
42
|
+
}))
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: "priority",
|
|
46
|
+
label: "Priority",
|
|
47
|
+
children: PRIORITIES.map((priority) => ({
|
|
48
|
+
id: `priority:${priority}`,
|
|
49
|
+
label: priorityLabel(priority),
|
|
50
|
+
action: "priority",
|
|
51
|
+
value: priority,
|
|
52
|
+
checked: Number(ticket.priority) === priority,
|
|
53
|
+
disabled: Number(ticket.priority) === priority
|
|
54
|
+
}))
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: "type",
|
|
58
|
+
label: "Type",
|
|
59
|
+
children: CARD_TYPES.map((type) => ({
|
|
60
|
+
id: `type:${type}`,
|
|
61
|
+
label: typeLabel(type),
|
|
62
|
+
action: "type",
|
|
63
|
+
value: type,
|
|
64
|
+
checked: canonicalTicketType(ticket.type) === type,
|
|
65
|
+
disabled: canonicalTicketType(ticket.type) === type
|
|
66
|
+
}))
|
|
67
|
+
},
|
|
68
|
+
{ id: "open", label: "Open", action: "open" },
|
|
69
|
+
{ id: "archive", label: "Archive", action: "archive", danger: true }
|
|
70
|
+
];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function calculateMenuPosition({ x, y, menuWidth, menuHeight, viewportWidth, viewportHeight, margin = MENU_MARGIN }) {
|
|
74
|
+
const maxLeft = Math.max(margin, viewportWidth - menuWidth - margin);
|
|
75
|
+
const maxTop = Math.max(margin, viewportHeight - menuHeight - margin);
|
|
76
|
+
const preferredLeft = x + menuWidth + margin > viewportWidth ? x - menuWidth : x;
|
|
77
|
+
const preferredTop = y + menuHeight + margin > viewportHeight ? y - menuHeight : y;
|
|
78
|
+
return {
|
|
79
|
+
left: Math.min(Math.max(margin, preferredLeft), maxLeft),
|
|
80
|
+
top: Math.min(Math.max(margin, preferredTop), maxTop)
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function calculateSubmenuPosition({
|
|
85
|
+
triggerRect,
|
|
86
|
+
submenuWidth,
|
|
87
|
+
submenuHeight,
|
|
88
|
+
viewportWidth,
|
|
89
|
+
viewportHeight,
|
|
90
|
+
margin = MENU_MARGIN,
|
|
91
|
+
gap = SUBMENU_GAP
|
|
92
|
+
}) {
|
|
93
|
+
const maxLeft = Math.max(margin, viewportWidth - submenuWidth - margin);
|
|
94
|
+
const rightLeft = triggerRect.right + gap;
|
|
95
|
+
const leftLeft = triggerRect.left - submenuWidth - gap;
|
|
96
|
+
const fitsRight = rightLeft + submenuWidth + margin <= viewportWidth;
|
|
97
|
+
const fitsLeft = leftLeft >= margin;
|
|
98
|
+
const preferredLeft = fitsRight || !fitsLeft ? rightLeft : leftLeft;
|
|
99
|
+
const maxHeight = Math.max(0, viewportHeight - margin * 2);
|
|
100
|
+
const boundedHeight = Math.min(submenuHeight, maxHeight);
|
|
101
|
+
const maxTop = Math.max(margin, viewportHeight - boundedHeight - margin);
|
|
102
|
+
const preferredTop = triggerRect.top - gap;
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
left: Math.min(Math.max(margin, preferredLeft), maxLeft),
|
|
106
|
+
top: Math.min(Math.max(margin, preferredTop), maxTop),
|
|
107
|
+
maxHeight
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function createCardActionHandlers({ apiClient = api, navigator = null, confirmer = globalThis.confirm } = {}) {
|
|
112
|
+
async function patchTicket(ticket, body) {
|
|
113
|
+
await apiClient(withBoardQuery(`/api/tickets/${encodeURIComponent(ticket.id)}`), {
|
|
114
|
+
method: "PATCH",
|
|
115
|
+
body
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
async open(ticket) {
|
|
121
|
+
if (navigator) return navigator({ boardId: state.boardId, view: "ticket", ticketId: ticket.id });
|
|
122
|
+
return null;
|
|
123
|
+
},
|
|
124
|
+
async move(ticket, stateId) {
|
|
125
|
+
await patchTicket(ticket, { state_id: stateId });
|
|
126
|
+
},
|
|
127
|
+
async priority(ticket, priority) {
|
|
128
|
+
await patchTicket(ticket, { priority });
|
|
129
|
+
},
|
|
130
|
+
async type(ticket, type) {
|
|
131
|
+
await patchTicket(ticket, { type });
|
|
132
|
+
},
|
|
133
|
+
async archive(ticket) {
|
|
134
|
+
if (typeof confirmer === "function" && !confirmer(`Archive "${ticket.title || "card"}"? You can restore it from Settings → Card Archive.`)) return false;
|
|
135
|
+
await apiClient(withBoardQuery(`/api/tickets/${encodeURIComponent(ticket.id)}/archive`), { method: "POST" });
|
|
136
|
+
toast.error(`Archived: ${ticket.title || "card"}`);
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function setCardActionDragInFlight(value) {
|
|
143
|
+
dragInFlight = Boolean(value);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function closeCardActionMenu({ restoreFocus = false } = {}) {
|
|
147
|
+
cancelPendingClose();
|
|
148
|
+
if (dismissController) {
|
|
149
|
+
dismissController.abort();
|
|
150
|
+
dismissController = null;
|
|
151
|
+
}
|
|
152
|
+
for (const submenu of openSubmenus) {
|
|
153
|
+
if (submenu.isConnected) submenu.remove();
|
|
154
|
+
}
|
|
155
|
+
openSubmenus.clear();
|
|
156
|
+
if (openMenu) {
|
|
157
|
+
openMenu.querySelectorAll(".card-action-menu-item.has-submenu").forEach((item) => {
|
|
158
|
+
const submenu = submenuByItem.get(item);
|
|
159
|
+
if (submenu?.isConnected) submenu.remove();
|
|
160
|
+
submenuByItem.delete(item);
|
|
161
|
+
});
|
|
162
|
+
openMenu.remove();
|
|
163
|
+
openMenu = null;
|
|
164
|
+
}
|
|
165
|
+
if (restoreFocus && lastFocusedElement?.isConnected) {
|
|
166
|
+
lastFocusedElement.focus({ preventScroll: true });
|
|
167
|
+
}
|
|
168
|
+
lastFocusedElement = null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function openCardActionMenu({ ticket, anchor, x, y, handlers = createCardActionHandlers(), onBeforeOpen = null }) {
|
|
172
|
+
closeCardActionMenu();
|
|
173
|
+
if (!ticket || dragInFlight) return null;
|
|
174
|
+
|
|
175
|
+
if (typeof onBeforeOpen === "function") onBeforeOpen();
|
|
176
|
+
lastFocusedElement = anchor || document.activeElement;
|
|
177
|
+
|
|
178
|
+
const menu = document.createElement("div");
|
|
179
|
+
menu.className = "card-action-menu";
|
|
180
|
+
menu.setAttribute("role", "menu");
|
|
181
|
+
menu.setAttribute("aria-label", `Actions for #${ticket.number || ""} ${ticket.title || "card"}`.trim());
|
|
182
|
+
menu.dataset.ticketId = ticket.id;
|
|
183
|
+
menu.innerHTML = renderMenuItems(buildCardActionMenu(ticket));
|
|
184
|
+
document.body.append(menu);
|
|
185
|
+
openMenu = menu;
|
|
186
|
+
|
|
187
|
+
// Detach each submenu from its <li> and reparent to document.body. The menu's
|
|
188
|
+
// backdrop-filter creates a containing block, so a nested position:fixed
|
|
189
|
+
// submenu would be positioned relative to the menu, not the viewport.
|
|
190
|
+
menu.querySelectorAll(".card-action-menu-item.has-submenu").forEach((item) => {
|
|
191
|
+
const submenu = item.querySelector(":scope > .card-action-submenu");
|
|
192
|
+
if (!submenu) return;
|
|
193
|
+
submenu.remove();
|
|
194
|
+
document.body.append(submenu);
|
|
195
|
+
submenuByItem.set(item, submenu);
|
|
196
|
+
itemBySubmenu.set(submenu, item);
|
|
197
|
+
wireSubmenu(submenu, ticket, handlers);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const rect = menu.getBoundingClientRect();
|
|
201
|
+
const position = calculateMenuPosition({
|
|
202
|
+
x,
|
|
203
|
+
y,
|
|
204
|
+
menuWidth: rect.width,
|
|
205
|
+
menuHeight: rect.height,
|
|
206
|
+
viewportWidth: window.innerWidth,
|
|
207
|
+
viewportHeight: window.innerHeight
|
|
208
|
+
});
|
|
209
|
+
menu.style.left = `${position.left}px`;
|
|
210
|
+
menu.style.top = `${position.top}px`;
|
|
211
|
+
|
|
212
|
+
wireMenu(menu, ticket, handlers);
|
|
213
|
+
const first = firstEnabledItem(menu);
|
|
214
|
+
first?.focus({ preventScroll: true });
|
|
215
|
+
|
|
216
|
+
dismissController = new AbortController();
|
|
217
|
+
const signal = dismissController.signal;
|
|
218
|
+
requestAnimationFrame(() => {
|
|
219
|
+
document.addEventListener("pointerdown", dismissOnOutsidePointer, { signal, capture: true });
|
|
220
|
+
});
|
|
221
|
+
document.addEventListener("keydown", dismissOnEscape, { signal, capture: true });
|
|
222
|
+
document.addEventListener("scroll", dismissOnScroll, { signal, capture: true, passive: true });
|
|
223
|
+
window.addEventListener("resize", () => closeCardActionMenu(), { signal, passive: true });
|
|
224
|
+
window.addEventListener("blur", () => closeCardActionMenu(), { signal });
|
|
225
|
+
|
|
226
|
+
return menu;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function bindCardContextMenu(card, ticket, { handlers = createCardActionHandlers(), onBeforeOpen = null } = {}) {
|
|
230
|
+
card.addEventListener("contextmenu", (event) => {
|
|
231
|
+
event.preventDefault();
|
|
232
|
+
event.stopPropagation();
|
|
233
|
+
if (dragInFlight) {
|
|
234
|
+
closeCardActionMenu();
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
openCardActionMenu({
|
|
238
|
+
ticket,
|
|
239
|
+
anchor: card,
|
|
240
|
+
x: event.clientX,
|
|
241
|
+
y: event.clientY,
|
|
242
|
+
handlers,
|
|
243
|
+
onBeforeOpen
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function renderMenuItems(items) {
|
|
249
|
+
return `<ul class="card-action-menu-list" role="none">${items.map(renderMenuItem).join("")}</ul>`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function renderMenuItem(item) {
|
|
253
|
+
const hasChildren = Boolean(item.children?.length);
|
|
254
|
+
const classes = ["card-action-menu-item"];
|
|
255
|
+
if (hasChildren) classes.push("has-submenu");
|
|
256
|
+
if (item.danger) classes.push("is-danger");
|
|
257
|
+
const check = item.checked ? `<span class="card-action-check" aria-hidden="true">✓</span>` : `<span class="card-action-check" aria-hidden="true"></span>`;
|
|
258
|
+
const suffix = hasChildren ? `<span class="card-action-arrow" aria-hidden="true">›</span>` : "";
|
|
259
|
+
return `
|
|
260
|
+
<li class="${classes.join(" ")}" role="none">
|
|
261
|
+
<button type="button"
|
|
262
|
+
class="card-action-button"
|
|
263
|
+
role="menuitem${item.checked ? "checkbox" : ""}"
|
|
264
|
+
data-action="${escapeHtml(item.action || "")}" data-value="${escapeHtml(item.value ?? "")}" data-menu-id="${escapeHtml(item.id)}"
|
|
265
|
+
${item.disabled ? "disabled" : ""}
|
|
266
|
+
${item.checked ? 'aria-checked="true"' : ""}
|
|
267
|
+
${hasChildren ? 'aria-haspopup="menu" aria-expanded="false"' : ""}>
|
|
268
|
+
${hasChildren ? "" : check}
|
|
269
|
+
<span class="card-action-label">${escapeHtml(item.label)}</span>
|
|
270
|
+
${suffix}
|
|
271
|
+
</button>
|
|
272
|
+
${hasChildren ? `<div class="card-action-submenu" role="menu">${renderMenuItems(item.children)}</div>` : ""}
|
|
273
|
+
</li>
|
|
274
|
+
`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function wireMenu(menu, ticket, handlers) {
|
|
278
|
+
menu.addEventListener("pointerover", (event) => {
|
|
279
|
+
const item = event.target instanceof Element ? event.target.closest(".card-action-menu-item") : null;
|
|
280
|
+
if (!item || !menu.contains(item)) return;
|
|
281
|
+
if (item.classList.contains("has-submenu")) {
|
|
282
|
+
cancelPendingClose();
|
|
283
|
+
openSubmenu(item);
|
|
284
|
+
} else {
|
|
285
|
+
scheduleCloseOtherSubmenus(menu);
|
|
286
|
+
}
|
|
287
|
+
}, true);
|
|
288
|
+
|
|
289
|
+
menu.addEventListener("focusin", (event) => {
|
|
290
|
+
const item = event.target instanceof Element ? event.target.closest(".card-action-menu-item") : null;
|
|
291
|
+
if (!item || !menu.contains(item)) return;
|
|
292
|
+
if (item.classList.contains("has-submenu")) {
|
|
293
|
+
cancelPendingClose();
|
|
294
|
+
openSubmenu(item);
|
|
295
|
+
} else {
|
|
296
|
+
cancelPendingClose();
|
|
297
|
+
closeOpenSubmenus(menu);
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
menu.addEventListener("click", (event) => handleItemClick(event, menu, ticket, handlers));
|
|
302
|
+
menu.addEventListener("keydown", (event) => handleMenuKeydown(event, menu));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function wireSubmenu(submenu, ticket, handlers) {
|
|
306
|
+
// Keep the submenu open while the pointer is inside it.
|
|
307
|
+
submenu.addEventListener("pointerenter", cancelPendingClose);
|
|
308
|
+
submenu.addEventListener("pointerover", (event) => {
|
|
309
|
+
if (event.target instanceof Element && submenu.contains(event.target)) {
|
|
310
|
+
cancelPendingClose();
|
|
311
|
+
}
|
|
312
|
+
}, true);
|
|
313
|
+
|
|
314
|
+
submenu.addEventListener("click", (event) => {
|
|
315
|
+
if (!openMenu) return;
|
|
316
|
+
handleItemClick(event, openMenu, ticket, handlers, submenu);
|
|
317
|
+
});
|
|
318
|
+
submenu.addEventListener("keydown", (event) => {
|
|
319
|
+
if (!openMenu) return;
|
|
320
|
+
handleMenuKeydown(event, openMenu);
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function handleItemClick(event, rootMenu, ticket, handlers, container = rootMenu) {
|
|
325
|
+
const button = event.target instanceof Element ? event.target.closest("button.card-action-button") : null;
|
|
326
|
+
if (!button || !container.contains(button) || button.disabled) return;
|
|
327
|
+
event.preventDefault();
|
|
328
|
+
event.stopPropagation();
|
|
329
|
+
|
|
330
|
+
const parent = button.closest(".card-action-menu-item");
|
|
331
|
+
if (parent?.classList.contains("has-submenu")) {
|
|
332
|
+
cancelPendingClose();
|
|
333
|
+
openSubmenu(parent);
|
|
334
|
+
firstEnabledItem(submenuByItem.get(parent))?.focus({ preventScroll: true });
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const action = button.dataset.action;
|
|
339
|
+
const value = button.dataset.value;
|
|
340
|
+
closeCardActionMenu();
|
|
341
|
+
if (action === "open") return handlers.open(ticket);
|
|
342
|
+
if (action === "move") return handlers.move(ticket, value);
|
|
343
|
+
if (action === "priority") return handlers.priority(ticket, Number(value));
|
|
344
|
+
if (action === "type") return handlers.type(ticket, value);
|
|
345
|
+
if (action === "archive") return handlers.archive(ticket);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function handleMenuKeydown(event, rootMenu) {
|
|
349
|
+
const button = event.target instanceof Element ? event.target.closest("button.card-action-button") : null;
|
|
350
|
+
if (!button) return;
|
|
351
|
+
const submenu = button.closest(".card-action-submenu");
|
|
352
|
+
const inRoot = rootMenu.contains(button);
|
|
353
|
+
if (!inRoot && !submenu) return;
|
|
354
|
+
|
|
355
|
+
const currentMenu = button.closest(".card-action-menu-list");
|
|
356
|
+
const item = button.closest(".card-action-menu-item");
|
|
357
|
+
|
|
358
|
+
if (event.key === "Escape") {
|
|
359
|
+
event.preventDefault();
|
|
360
|
+
if (submenu) {
|
|
361
|
+
const parentItem = itemBySubmenu.get(submenu);
|
|
362
|
+
const parentButton = parentItem?.querySelector(":scope > .card-action-button");
|
|
363
|
+
closeSubmenu(parentItem);
|
|
364
|
+
parentButton?.focus({ preventScroll: true });
|
|
365
|
+
} else {
|
|
366
|
+
closeCardActionMenu({ restoreFocus: true });
|
|
367
|
+
}
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
372
|
+
event.preventDefault();
|
|
373
|
+
moveFocus(currentMenu, button, event.key === "ArrowDown" ? 1 : -1);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (event.key === "Home" || event.key === "End") {
|
|
378
|
+
event.preventDefault();
|
|
379
|
+
const enabled = enabledItems(currentMenu);
|
|
380
|
+
enabled[event.key === "Home" ? 0 : enabled.length - 1]?.focus({ preventScroll: true });
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (event.key === "ArrowRight" && item?.classList.contains("has-submenu")) {
|
|
385
|
+
event.preventDefault();
|
|
386
|
+
cancelPendingClose();
|
|
387
|
+
openSubmenu(item);
|
|
388
|
+
firstEnabledItem(submenuByItem.get(item))?.focus({ preventScroll: true });
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (event.key === "ArrowLeft" && submenu) {
|
|
393
|
+
const parentItem = itemBySubmenu.get(submenu);
|
|
394
|
+
const parentButton = parentItem?.querySelector(":scope > .card-action-button");
|
|
395
|
+
if (parentButton) {
|
|
396
|
+
event.preventDefault();
|
|
397
|
+
closeSubmenu(parentItem);
|
|
398
|
+
parentButton.focus({ preventScroll: true });
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function dismissOnOutsidePointer(event) {
|
|
404
|
+
if (!openMenu) return;
|
|
405
|
+
if (isEventInsideAnyMenu(event)) return;
|
|
406
|
+
closeCardActionMenu();
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function dismissOnEscape(event) {
|
|
410
|
+
if (event.key === "Escape" && openMenu) {
|
|
411
|
+
event.preventDefault();
|
|
412
|
+
closeCardActionMenu({ restoreFocus: true });
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function dismissOnScroll(event) {
|
|
417
|
+
if (!openMenu) return;
|
|
418
|
+
if (isEventInsideAnyMenu(event)) return;
|
|
419
|
+
closeCardActionMenu();
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function isEventInsideAnyMenu(event) {
|
|
423
|
+
const target = event.target;
|
|
424
|
+
if (!(target instanceof Node)) return false;
|
|
425
|
+
if (openMenu?.contains(target)) return true;
|
|
426
|
+
for (const submenu of openSubmenus) {
|
|
427
|
+
if (submenu.contains(target)) return true;
|
|
428
|
+
}
|
|
429
|
+
return false;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function openSubmenu(item) {
|
|
433
|
+
const siblings = item.parentElement?.children || [];
|
|
434
|
+
for (const sibling of siblings) {
|
|
435
|
+
if (sibling !== item && sibling.classList?.contains("has-submenu") && sibling.classList.contains("is-open")) {
|
|
436
|
+
closeSubmenu(sibling);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
const submenu = submenuByItem.get(item);
|
|
440
|
+
if (!submenu) return;
|
|
441
|
+
if (item.classList.contains("is-open")) {
|
|
442
|
+
positionSubmenu(item, submenu);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
item.classList.add("is-open");
|
|
446
|
+
item.querySelector(":scope > .card-action-button")?.setAttribute("aria-expanded", "true");
|
|
447
|
+
submenu.classList.add("is-open");
|
|
448
|
+
openSubmenus.add(submenu);
|
|
449
|
+
positionSubmenu(item, submenu);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function closeSubmenu(item) {
|
|
453
|
+
if (!item) return;
|
|
454
|
+
item.classList.remove("is-open");
|
|
455
|
+
item.querySelector(":scope > .card-action-button")?.setAttribute("aria-expanded", "false");
|
|
456
|
+
const submenu = submenuByItem.get(item);
|
|
457
|
+
if (!submenu) return;
|
|
458
|
+
submenu.classList.remove("is-open");
|
|
459
|
+
submenu.style.removeProperty("left");
|
|
460
|
+
submenu.style.removeProperty("top");
|
|
461
|
+
submenu.style.removeProperty("max-height");
|
|
462
|
+
openSubmenus.delete(submenu);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function closeOpenSubmenus(scope) {
|
|
466
|
+
scope.querySelectorAll(".card-action-menu-item.has-submenu.is-open").forEach(closeSubmenu);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function scheduleCloseOtherSubmenus(rootMenu) {
|
|
470
|
+
cancelPendingClose();
|
|
471
|
+
pendingCloseTimer = setTimeout(() => {
|
|
472
|
+
pendingCloseTimer = null;
|
|
473
|
+
if (openMenu === rootMenu) closeOpenSubmenus(rootMenu);
|
|
474
|
+
}, HOVER_CLOSE_DELAY);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function cancelPendingClose() {
|
|
478
|
+
if (pendingCloseTimer != null) {
|
|
479
|
+
clearTimeout(pendingCloseTimer);
|
|
480
|
+
pendingCloseTimer = null;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function positionSubmenu(item, submenu) {
|
|
485
|
+
const trigger = item.querySelector(":scope > .card-action-button");
|
|
486
|
+
if (!submenu || !trigger) return;
|
|
487
|
+
|
|
488
|
+
const availableHeight = Math.max(0, window.innerHeight - MENU_MARGIN * 2);
|
|
489
|
+
submenu.style.maxHeight = `${availableHeight}px`;
|
|
490
|
+
submenu.style.left = "0px";
|
|
491
|
+
submenu.style.top = "0px";
|
|
492
|
+
|
|
493
|
+
const submenuRect = submenu.getBoundingClientRect();
|
|
494
|
+
const position = calculateSubmenuPosition({
|
|
495
|
+
triggerRect: trigger.getBoundingClientRect(),
|
|
496
|
+
submenuWidth: submenuRect.width,
|
|
497
|
+
submenuHeight: submenuRect.height,
|
|
498
|
+
viewportWidth: window.innerWidth,
|
|
499
|
+
viewportHeight: window.innerHeight
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
submenu.style.left = `${position.left}px`;
|
|
503
|
+
submenu.style.top = `${position.top}px`;
|
|
504
|
+
submenu.style.maxHeight = `${position.maxHeight}px`;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function enabledItems(scope) {
|
|
508
|
+
if (!scope) return [];
|
|
509
|
+
const list = scope.classList?.contains("card-action-menu-list")
|
|
510
|
+
? scope
|
|
511
|
+
: scope.querySelector(":scope > .card-action-menu-list");
|
|
512
|
+
if (!list) return [];
|
|
513
|
+
return [...list.querySelectorAll(":scope > .card-action-menu-item > .card-action-button")].filter((button) => !button.disabled);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function firstEnabledItem(scope) {
|
|
517
|
+
return enabledItems(scope)[0] || null;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function moveFocus(scope, current, step) {
|
|
521
|
+
const items = enabledItems(scope);
|
|
522
|
+
if (!items.length) return;
|
|
523
|
+
const index = items.indexOf(current);
|
|
524
|
+
const nextIndex = index === -1 ? 0 : (index + step + items.length) % items.length;
|
|
525
|
+
items[nextIndex].focus({ preventScroll: true });
|
|
526
|
+
}
|