// ============================================================================
// Stylescape | Context Menu Manager
// ============================================================================
// Opens a `.ss-c-context-menu` from a right click on its target, or from
// Shift+F10 / the ContextMenu key while the target has focus. Positions the
// menu inside the viewport, moves focus through the items with the arrow
// keys and returns focus to where it came from on close.
// ============================================================================
/**
* Configuration options for ContextMenuManager
*/
export interface ContextMenuOptions {
/** Element (or selector) that opens the menu. Defaults to the element
* named by the menu's `data-ss-context-menu-target`. */
target?: string | HTMLElement;
/** Gap kept between the menu and the viewport edge, in px */
viewportMargin?: number;
/** Called when an enabled item is activated */
onSelect?: (item: HTMLElement, menu: HTMLElement) => void;
/** Called after the menu opens */
onOpen?: (menu: HTMLElement) => void;
/** Called after the menu closes */
onClose?: (menu: HTMLElement) => void;
}
const ITEM_SELECTOR =
'[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]';
/**
* Context menu with keyboard support and viewport-aware placement.
*
* @example HTML with data-ss
* ```html
*
…
*
* ```
*
* @example JavaScript
* ```typescript
* const menu = new ContextMenuManager("#layer-menu", {
* target: "#canvas",
* onSelect: (item) => console.log(item.dataset.action),
* })
* ```
*/
export class ContextMenuManager {
private menu: HTMLElement | null;
private target: HTMLElement | null;
private options: Required>;
private returnFocus: HTMLElement | null = null;
private typeahead = "";
private typeaheadTimer: ReturnType | undefined;
constructor(
menuOrSelector: string | HTMLElement,
options: ContextMenuOptions = {},
) {
this.menu =
typeof menuOrSelector === "string"
? document.querySelector(menuOrSelector)
: menuOrSelector;
const target =
options.target ?? this.menu?.dataset.ssContextMenuTarget ?? null;
this.target =
typeof target === "string"
? document.querySelector(target)
: target;
this.options = {
viewportMargin: options.viewportMargin ?? 8,
onSelect: options.onSelect ?? (() => {}),
onOpen: options.onOpen ?? (() => {}),
onClose: options.onClose ?? (() => {}),
};
if (!this.menu || !this.target) {
console.warn(
"[Stylescape] ContextMenuManager menu or target not found",
);
return;
}
this.init();
}
// ========================================================================
// Public API
// ========================================================================
/** Whether the menu is currently shown */
get isOpen(): boolean {
return !!this.menu && !this.menu.hidden;
}
/**
* Open the menu with its top-left corner at viewport coordinates
* (`x`, `y`), shifted as needed to stay inside the viewport.
*/
public openAt(x: number, y: number): void {
if (!this.menu) return;
if (!this.isOpen) {
const active = document.activeElement;
this.returnFocus =
active instanceof HTMLElement && active !== document.body
? active
: this.target;
}
this.menu.hidden = false;
this.position(x, y);
this.focusItem(this.items()[0]);
document.addEventListener("pointerdown", this.handleOutside, true);
window.addEventListener("resize", this.handleViewportChange);
window.addEventListener("scroll", this.handleViewportChange, true);
this.options.onOpen(this.menu);
}
/** Close the menu; focus returns to the element that had it. */
public close(restoreFocus = true): void {
if (!this.menu || !this.isOpen) return;
this.menu.hidden = true;
this.removeGlobalListeners();
if (restoreFocus) this.returnFocus?.focus();
this.returnFocus = null;
this.options.onClose(this.menu);
}
/** Remove every listener and close the menu. */
public destroy(): void {
this.close(false);
this.target?.removeEventListener(
"contextmenu",
this.handleContextMenu,
);
this.target?.removeEventListener("keydown", this.handleTargetKeydown);
this.menu?.removeEventListener("keydown", this.handleMenuKeydown);
this.menu?.removeEventListener("click", this.handleClick);
clearTimeout(this.typeaheadTimer);
this.menu = null;
this.target = null;
}
// ========================================================================
// Setup
// ========================================================================
private init(): void {
if (!this.menu || !this.target) return;
this.menu.setAttribute(
"role",
this.menu.getAttribute("role") ?? "menu",
);
this.menu.hidden = true;
if (!this.menu.hasAttribute("tabindex")) this.menu.tabIndex = -1;
this.items(true).forEach((item) => {
if (!item.getAttribute("role"))
item.setAttribute("role", "menuitem");
item.tabIndex = -1;
});
this.target.addEventListener("contextmenu", this.handleContextMenu);
this.target.addEventListener("keydown", this.handleTargetKeydown);
this.menu.addEventListener("keydown", this.handleMenuKeydown);
this.menu.addEventListener("click", this.handleClick);
}
/** Menu items; `all` includes disabled ones. */
private items(all = false): HTMLElement[] {
if (!this.menu) return [];
const found = Array.from(
this.menu.querySelectorAll(
`${ITEM_SELECTOR}, .ss-c-context-menu__item`,
),
);
return all
? found
: found.filter(
(item) =>
item.getAttribute("aria-disabled") !== "true" &&
!(item as HTMLButtonElement).disabled,
);
}
private focusItem(item: HTMLElement | undefined): void {
if (item) item.focus();
else this.menu?.focus();
}
private position(x: number, y: number): void {
if (!this.menu) return;
const margin = this.options.viewportMargin;
const rect = this.menu.getBoundingClientRect();
const maxX = window.innerWidth - rect.width - margin;
const maxY = window.innerHeight - rect.height - margin;
// Flip above the point when it doesn't fit below.
const top =
y > maxY && y - rect.height >= margin ? y - rect.height : y;
this.menu.style.left = `${Math.max(margin, Math.min(x, maxX))}px`;
this.menu.style.top = `${Math.max(margin, Math.min(top, maxY))}px`;
}
private removeGlobalListeners(): void {
document.removeEventListener("pointerdown", this.handleOutside, true);
window.removeEventListener("resize", this.handleViewportChange);
window.removeEventListener("scroll", this.handleViewportChange, true);
}
// ========================================================================
// Event handlers
// ========================================================================
private handleContextMenu = (event: MouseEvent): void => {
event.preventDefault();
// A keyboard-generated contextmenu reports (0, 0); place it by the
// focused element instead.
if (event.clientX === 0 && event.clientY === 0)
this.openFromKeyboard();
else this.openAt(event.clientX, event.clientY);
};
private handleTargetKeydown = (event: KeyboardEvent): void => {
if (
(event.shiftKey && event.key === "F10") ||
event.key === "ContextMenu"
) {
event.preventDefault();
this.openFromKeyboard();
}
};
private openFromKeyboard(): void {
const origin =
document.activeElement instanceof HTMLElement &&
this.target?.contains(document.activeElement)
? document.activeElement
: this.target;
const rect = origin?.getBoundingClientRect();
this.openAt(rect?.left ?? 0, rect?.bottom ?? 0);
}
private handleMenuKeydown = (event: KeyboardEvent): void => {
const items = this.items();
const index = items.indexOf(document.activeElement as HTMLElement);
switch (event.key) {
case "ArrowDown":
event.preventDefault();
this.focusItem(items[(index + 1) % items.length]);
break;
case "ArrowUp":
event.preventDefault();
this.focusItem(
items[(index - 1 + items.length) % items.length],
);
break;
case "Home":
event.preventDefault();
this.focusItem(items[0]);
break;
case "End":
event.preventDefault();
this.focusItem(items[items.length - 1]);
break;
case "Escape":
event.preventDefault();
this.close();
break;
case "Tab":
event.preventDefault();
this.close();
break;
case "Enter":
case " ":
if (
index >= 0 &&
!(items[index] instanceof HTMLButtonElement)
) {
event.preventDefault();
items[index].click();
}
break;
default:
if (
event.key.length === 1 &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey
) {
this.matchTypeahead(event.key, items, index);
}
}
};
private matchTypeahead(
char: string,
items: HTMLElement[],
index: number,
): void {
clearTimeout(this.typeaheadTimer);
this.typeahead += char.toLowerCase();
this.typeaheadTimer = setTimeout(() => (this.typeahead = ""), 500);
const ordered = [
...items.slice(index + 1),
...items.slice(0, index + 1),
];
const match = ordered.find((item) =>
(item.textContent ?? "")
.trim()
.toLowerCase()
.startsWith(this.typeahead),
);
if (match) match.focus();
}
private handleClick = (event: MouseEvent): void => {
if (!this.menu) return;
const item = (event.target as HTMLElement).closest(
`${ITEM_SELECTOR}, .ss-c-context-menu__item`,
);
if (!item || !this.menu.contains(item)) return;
if (item.getAttribute("aria-disabled") === "true") {
event.preventDefault();
return;
}
if (item.getAttribute("role") === "menuitemcheckbox") {
item.setAttribute(
"aria-checked",
String(item.getAttribute("aria-checked") !== "true"),
);
}
const menu = this.menu;
this.options.onSelect(item, menu);
menu.dispatchEvent(
new CustomEvent("ss:context-menu:select", {
bubbles: true,
detail: { item },
}),
);
this.close();
};
private handleOutside = (event: Event): void => {
if (this.menu && !this.menu.contains(event.target as Node)) {
this.close(false);
}
};
private handleViewportChange = (event: Event): void => {
if (
event.type === "scroll" &&
this.menu?.contains(event.target as Node)
)
return;
this.close(false);
};
}