/** Styles for a drop indicator, for whichever tree holds it: the shell's shadow root, or a floating window's page. */ export const DROP_CSS = ` [part="drop-layer"] { position: absolute; inset: 0; z-index: 2; pointer-events: none; } [part="drop"] { position: absolute; view-transition-name: drop; background: var(--nib-color-drop-target); outline: var(--nib-size-border) solid var(--nib-color-accent); } [part="drop"][hidden] { display: none; } /* * Between tabs, a line marks where the tab lands, with a terminal at its top, as Atlassian's do. It's * its own element with its own transition name: a box and a line morphing into each other stretched * one's snapshot into the other's shape. */ [part="drop-line"] { position: absolute; view-transition-name: drop-line; background: var(--nib-color-accent); } [part="drop-line"][hidden] { display: none; } [part="drop-line"]::before { content: ""; position: absolute; top: 0; left: -3px; width: 8px; height: 8px; box-sizing: border-box; border: 2px solid var(--nib-color-accent); border-radius: 50%; background: var(--nib-color-canvas); } [part="drop-layer"]::view-transition-group(*) { animation-duration: 120ms; animation-timing-function: ease-out; } @media (prefers-reduced-motion: reduce) { [part="drop-layer"]::view-transition-group(*), [part="drop-layer"]::view-transition-old(*), [part="drop-layer"]::view-transition-new(*) { animation: none !important; } } `; export interface Rect { readonly left: number; readonly top: number; readonly width: number; readonly height: number; } /** Element-scoped view transitions are Chromium-only so far; elsewhere the indicator just jumps. */ const morphs = typeof Element !== "undefined" && "startViewTransition" in Element.prototype; /** * Where a dragged tab would land. Moving between targets morphs the box with a view transition * scoped to its own layer, so the rest of the page keeps taking the drag's events. */ export class DropIndicator { readonly layer: HTMLDivElement; readonly #box: HTMLDivElement; readonly #line: HTMLDivElement; #shown = ""; #target: { readonly box: Rect | undefined; readonly line: Rect | undefined } = { box: undefined, line: undefined }; #morph: Morph | undefined; constructor(doc: Document = document) { this.layer = doc.createElement("div"); this.layer.part = "drop-layer"; this.#box = doc.createElement("div"); this.#box.part = "drop"; this.#box.hidden = true; this.#line = doc.createElement("div"); this.#line.part = "drop-line"; this.#line.hidden = true; this.layer.append(this.#box, this.#line); } /** * Shows the box over an area and the line between tabs, either or both, in the layer's coordinates; * with neither, hides them. dragover repeats, so an unchanged target does nothing. */ show(box?: Rect, line?: Rect): void { const key = [box, line].map((rect) => rect && `${rect.left},${rect.top},${rect.width},${rect.height}`).join("|"); if (key === this.#shown) return; this.#shown = key; this.#target = { box, line }; // Applies the latest target, not this call's: a transition another one skipped may run its update late. this.#morph = morph(this.layer, () => { place(this.#box, this.#target.box); place(this.#line, this.#target.line); }); } /** * Hides the target at once, for a drag that ended: it goes with the drop rather than fading out * over it. Resolves once the layer's own transition is over: until then, Chrome aborts a * transition over the window around it ("Prepaint layout check failed"). */ clear(): Promise { const running = this.#morph; running?.skip(); this.#morph = undefined; this.#shown = ""; this.#target = { box: undefined, line: undefined }; place(this.#box, undefined); place(this.#line, undefined); return running?.finished ?? Promise.resolve(); } } function place(element: HTMLElement, rect: Rect | undefined) { element.hidden = !rect; if (rect) Object.assign(element.style, { left: `${rect.left}px`, top: `${rect.top}px`, width: `${rect.width}px`, height: `${rect.height}px` }); } /** * Runs `update` in a view transition scoped to `scope`, so only it pauses and animates, or straight * away where scoped transitions don't exist or motion is reduced. Starting another on the same scope * skips the running one. Resolves once the animation is over. */ export function morph(scope: Element, update: () => void): Morph { const view = scope.ownerDocument.defaultView; if (!morphs || !scope.isConnected || view?.matchMedia("(prefers-reduced-motion: reduce)").matches) { update(); return { finished: Promise.resolve(), skip: () => {} }; } const transition = (scope as unknown as { startViewTransition(update: () => void): ViewTransition }).startViewTransition(update); transition.ready.catch(() => {}); return { finished: transition.finished.catch(() => {}), skip: () => transition.skipTransition() }; } export interface Morph { readonly finished: Promise; /** Jumps to the end, running the update if it hasn't run. */ readonly skip: () => void; } /** The view transition name that carries a tab, or a body, across a move between stacks. */ export function transitionName(kind: "tab" | "body", panel: string): string { return `${kind}-${panel.replace(/[^\w-]/g, "_")}`; } /** * Atlassian's post-move flash: the moved tab's background fades from the drop target's tint to its * own, so the eye finds where it went. */ export function flash(element: HTMLElement): void { const style = getComputedStyle(element); const tint = style.getPropertyValue("--nib-color-drop-target").trim(); if (!tint) return; element.animate([{ backgroundColor: tint }, { backgroundColor: style.backgroundColor }], { duration: 700, easing: "cubic-bezier(0.25, 0.1, 0.25, 1)" }); } /** * Tabs while one drags: each can be picked up; the one in hand stays in its place, dimmed, until the * drop moves it. (Mid-drag the cursor is the operating system's: browsers ignore CSS there.) */ export const TAB_DRAG_CSS = ` nib-tablist > nib-tab { cursor: grab; } nib-tablist > nib-tab[data-dragging] { opacity: 0.4; } /* For the moment the browser paints its drag image: a whole tab, not one strip's slice of it. */ nib-tablist > nib-tab[data-lifting] { opacity: 1; border: var(--nib-size-border) solid var(--nib-color-border); background: var(--nib-color-canvas); } `;