import "@nibling/web/menu"; import { MenuSelectEvent, type MenuElement, type MenuItemElement } from "@nibling/web/menu"; import type { TabElement } from "@nibling/web/tabs"; import { isVisible, stacks, type NodeId, type PanelId, type Stack } from "../layout.ts"; import type { Workbench } from "../workbench.ts"; /** A point in its view's viewport, the coordinate space the menu's trigger (a right-click or a key) gave. */ interface Point { readonly x: number; readonly y: number; } /** What `open` needs: the panel's tab and the stack it's in, and the workbench to act on. */ interface Context { readonly workbench: Workbench; readonly stack: Stack; readonly panel: PanelId; readonly tab: TabElement; readonly afterMove?: (panel: PanelId) => void; } /** * A tab's "…" menu: Atlassian's recommended pointer/keyboard alternative to dragging a tab between * groups or into its own window (dragging stays available too). One instance lives in each * `StackElement`, appended to its light DOM so it moves with the stack into a floating window. * * It has no trigger button, since it opens at a tab, not at one fixed control: positioning and * focus are handled by hand (see `#place` and `#onToggle`) rather than through `MenuElement.trigger`. */ export class TabMenu { readonly element: MenuElement; readonly #moveWindow: MenuItemElement; readonly #moveNext: MenuItemElement; readonly #movePrevious: MenuItemElement; readonly #close: MenuItemElement; #context: Context | undefined; #at: Point | undefined; constructor() { this.element = document.createElement("nib-menu"); this.element.ariaLabel = "Tab actions"; // The tree layer this stack sits in is `pointer-events: none` so gutters can overhang it; that's // inherited, so without this the menu would show but never take a click or hover. this.element.style.pointerEvents = "auto"; this.#moveWindow = item("Move to New Window"); this.#moveNext = item("Move to Next Group"); this.#movePrevious = item("Move to Previous Group"); this.#close = item("Close"); this.element.append(this.#moveWindow, this.#moveNext, this.#movePrevious, this.#close); this.element.addEventListener("menuselect", (event) => this.#onSelect((event as MenuSelectEvent).item)); this.element.addEventListener("toggle", (event) => this.#onToggle(event as ToggleEvent)); } /** * Opens the menu for `panel`'s tab in `stack`, anchored at `at` (a pointer position, or a focused * tab's own corner for the keyboard path). "Move to *Group" is left off when there's no other * group to move to. `afterMove`, when given, is called with `panel` once a move lands, so the * caller can put focus back on its tab (Atlassian's guidance: a menu-triggered move returns * focus to the panel it moved). */ open(workbench: Workbench, stack: Stack, panel: PanelId, tab: TabElement, at: Point, afterMove?: (panel: PanelId) => void): void { this.#context = { workbench, stack, panel, tab, afterMove }; this.#moveNext.hidden = !stackStep(workbench, stack.id, 1); this.#movePrevious.hidden = !stackStep(workbench, stack.id, -1); this.#at = at; this.element.showPopover(); } #onToggle(event: ToggleEvent): void { if (event.newState === "open") { this.#place(); this.element.querySelector("nib-menu-item:not([hidden])")?.focus(); return; } // Closing with no action taken (Escape, or an outside click that focused nothing else) returns // focus to the tab the menu opened for. `MenuElement` has no trigger of its own to do this for // us. Left set rather than cleared: a chosen action closes the menu before `#onSelect` runs (see // below), and its own focus change, when it has one, follows right after and wins. this.#context?.tab.focus(); } /** Places the menu at `#at`, clamped to its window's viewport, since it has no trigger to anchor to. */ #place(): void { const at = this.#at; const view = this.element.ownerDocument.defaultView; if (!at || !view) return; const rect = this.element.getBoundingClientRect(); const edge = 8; const left = Math.min(Math.max(at.x, edge), view.innerWidth - rect.width - edge); const top = Math.min(Math.max(at.y, edge), view.innerHeight - rect.height - edge); Object.assign(this.element.style, { position: "fixed", margin: "0", left: `${Math.max(edge, left)}px`, top: `${Math.max(edge, top)}px` }); } #onSelect(chosen: MenuItemElement): void { const ctx = this.#context; if (!ctx) return; const { workbench, stack, panel, afterMove } = ctx; if (chosen === this.#moveWindow) return void workbench.dispatch({ type: "float", panel }); if (chosen === this.#close) return void workbench.dispatch({ type: "close", panel }); const step = chosen === this.#moveNext ? 1 : chosen === this.#movePrevious ? -1 : undefined; const target = step !== undefined ? stackStep(workbench, stack.id, step) : undefined; if (!target) return; workbench.dispatch({ type: "move", panel, to: { stack: target.id } }); afterMove?.(panel); } } function item(label: string): MenuItemElement { const el = document.createElement("nib-menu-item"); el.textContent = label; return el; } /** * The visible tree stack `step` places from `from`, wrapping around; undefined when there's no * other group (fewer than two visible stacks, or `from` isn't one of them — a floating stack, say). * Mirrors `commands.ts`'s `stackFrom`, but from an arbitrary stack instead of the workbench's focus. */ function stackStep(workbench: Workbench, from: NodeId, step: number): Stack | undefined { const { layout } = workbench; const shown = stacks(layout.root).filter((stack) => isVisible(layout, stack)); const at = shown.findIndex((stack) => stack.id === from); if (shown.length < 2 || at === -1) return undefined; return shown[(at + step + shown.length) % shown.length]; }