Something went wrong. Try again.
Monorepo for the nibling.run platform
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126import "@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<MenuItemElement>("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];}