diff --git a/packages/overlay/src/chrome-layer.ts b/packages/overlay/src/chrome-layer.ts new file mode 100644 index 0000000..319bfd8 --- /dev/null +++ b/packages/overlay/src/chrome-layer.ts @@ -0,0 +1,121 @@ +import type { Rect } from "./canvas/space"; +import { cls, el } from "./dom"; + +/** + * The layer every piece of floating chrome is drawn on: hover box, selection + * outline and its eight handles, drop indicators, frame labels and grips. + * + * Two decisions are baked in here. + * + * **Chrome is screen space, not world space.** It would have been less code to + * park the outlines inside the transformed world and let the browser scale them + * along with the frames — but then a 2px selection border is 0.2px at 10% zoom + * and a resize handle becomes a speck. Design tools draw chrome at a constant size + * over a scaling canvas, and so do we: only the *rects* are scaled, by + * `canvas/space.ts`, and everything drawn here stays at 1×. + * + * **The layer is the containing block, so children use screen coordinates.** + * It is `position: fixed; inset: 0`, which makes it exactly the viewport; its + * `position: absolute` children therefore take coordinates that are already + * screen coordinates, with no per-element offset to apply. Setting a `clip-path` + * to fence chrome inside the canvas does not disturb that, because the clip + * changes what is *painted* rather than where the containing block is. + * + * That fence used to be what kept an outline from painting out over a dock, + * back when the canvas inset itself and stopped short of them. The canvas is + * full-bleed now, so the clip is the whole viewport and does nothing on the + * shell; what keeps chrome out of the panels is paint order. `#…-root` is + * appended after this layer at the same z-index, so the docks, the corner pills + * and the bar all draw over it — which is the usual arrangement, an outline + * running under a panel rather than being clipped at its edge. + */ +export class ChromeLayer { + readonly element: HTMLElement; + + constructor() { + this.element = el("div", { class: cls("chrome-layer") }); + } + + mount(host: HTMLElement): void { + host.append(this.element); + } + + add(...nodes: HTMLElement[]): void { + this.element.append(...nodes); + } + + /** + * Fence everything on the layer to a screen rect — the canvas viewport. On a + * full-bleed canvas that is the whole window and the fence is a formality, + * but it stays honest if the viewport is ever given bounds again. `null` + * removes it, which is the inline overlay's case: there, the page is the + * canvas and there is nothing to clip against. + */ + setClip(bounds: Rect | null): void { + if (!bounds) { + this.element.style.clipPath = ""; + return; + } + const right = Math.max(0, window.innerWidth - (bounds.left + bounds.width)); + const bottom = Math.max( + 0, + window.innerHeight - (bounds.top + bounds.height) + ); + this.element.style.clipPath = `inset(${bounds.top}px ${right}px ${bottom}px ${bounds.left}px)`; + } + + destroy(): void { + this.element.remove(); + } +} + +/** Position a chrome element on a screen-space box, with an optional clip. */ +export function place(node: HTMLElement, box: Rect, clip = "none"): void { + node.style.display = "block"; + node.style.left = `${box.left}px`; + node.style.top = `${box.top}px`; + node.style.width = `${box.width}px`; + node.style.height = `${box.height}px`; + node.style.clipPath = clip; +} + +export function hide(node: HTMLElement): void { + node.style.display = "none"; +} + +/** + * Room a badge needs above its box before it gives up and flips below. + * + * One line of caption text plus its padding, rounded up. It does not have to be + * exact — being wrong by a pixel flips a badge that would just have fitted, + * which is invisible; measuring the badge to get it exact would cost a layout + * read on every pointer move, which is not. + */ +const LABEL_CLEARANCE = 20; + +/** + * Anchor a badge to the top-left of its box, flipping it below when the box sits + * too close to `ceiling` for the badge to fit above. + * + * Deliberately not `place()`d. A badge is sized by its own text, and writing a + * measured width and height onto it would crop the name it exists to show. + * + * `ceiling` is the top edge the badge must clear — the *frame's*, not the + * window's, so that on the canvas a badge which clears the viewport can still be + * pinned against the top of its own frame. Callers with a `Surface` pass + * `surface.bounds()?.top ?? 0`; callers drawing straight onto the viewport pass + * nothing. + * + * Here rather than on `SelectionController`, where it lived, because it is a + * chrome-placement function and this is the chrome-placement module — and + * because the story catalogue draws the same badge over its specimens. Two + * copies of this would be two badges that flip at different heights, which is + * the kind of difference nobody notices until a screenshot looks subtly wrong. + */ +export function placeLabel(label: HTMLElement, box: Rect, ceiling = 0): void { + const flip = box.top - LABEL_CLEARANCE < ceiling; + label.style.display = "block"; + label.style.left = `${box.left}px`; + label.style.top = `${flip ? box.top + box.height : box.top}px`; + label.toggleAttribute("data-flip", flip); +} diff --git a/packages/overlay/src/chrome.stories.ts b/packages/overlay/src/chrome.stories.ts new file mode 100644 index 0000000..a3e2b20 --- /dev/null +++ b/packages/overlay/src/chrome.stories.ts @@ -0,0 +1,146 @@ +import type { Meta, StoryObj } from "@storybook/html-vite"; +import { cls, el } from "./dom"; +import { emptyState } from "./empty"; +import { createMenu } from "./popover-host"; +import { dock, plainStage } from "./stories/chrome"; +import { noop } from "./stories/fixtures"; +import { mountToastHost, type ToastTone, toast } from "./toast"; + +/* + * The overlay's shared chrome: empty states, toasts and popover shells. + * + * Three things that appear on every surface and belong to none of them, which is + * exactly why they are worth a catalogue entry. Each is a module singleton or a + * one-of-a-kind component, so in the running editor you see one at a time, in + * whatever context happened to raise it, and never side by side. + */ + +const meta: Meta = { + title: "Chrome/Shared", +}; + +export default meta; + +/** + * The one empty state, at both sizes. + * + * `md` fills a dock; `sm` sits inside a section. The mark is the airship + * monogram rather than a pictogram, and the reasoning in `empty.ts` is a + * legibility argument worth checking against the render: two slabs and one + * counter still resolve at any size an empty state would use, where the saucer + * this replaced closed into a smudge below about 44px. + * + * Deliberately *not* used inside a section's row list. Those used to render a + * "None" row on the reasoning that a placeholder stopped the section jumping + * when the first fill was added; it also stated a value the element did not + * have, and Filters — which stacks two lists — opened onto four lines of it. + */ +export const EmptyStates: StoryObj = { + render: () => + plainStage( + [ + dock( + el("div", { class: cls("insp") }, [ + el("div", { class: cls("insp-body") }, [ + emptyState({ + body: "Hover the page to see what is selectable.", + title: "Pick an element to start tweaking", + }), + emptyState({ + body: "Add one below, or edit a computed value.", + size: "sm", + title: "No overrides yet", + }), + emptyState({ size: "sm", title: "Nothing here yet" }), + ]), + ]) + ), + ], + { + what: "The empty states, side by side. Each is a module singleton, so the running editor shows one at a time and never two.", + } + ), +}; + +/** + * Both toast tones. + * + * A toast is `pointer-events: none` and gone in under three seconds, which is + * why it sits *above* the popover layer rather than below: putting it on top can + * never cost anyone a click, whereas a toast painted under an open colour picker + * is unreadable at exactly the moment it is trying to tell you something. + * + * Only one can be up at a time — `toast()` replaces whatever is showing and + * restarts its dwell — so the buttons here raise them one by one rather than + * stacking, which is the honest demonstration. + */ +export const Toasts: StoryObj = { + render: () => { + const host = el("div", { + style: "position: relative; min-height: 220px; width: 100%;", + }); + mountToastHost(host); + const raise = (label: string, tone: ToastTone) => + el("button", { + class: cls("select"), + onClick: () => toast(label, { tone }), + text: `${label} · ${tone}`, + type: "button", + }); + // Two tones, not three. `ToastTone` is `"error" | "neutral"` and there is no + // success case, which is a decision rather than an omission: the overlay + // confirms a successful edit by *showing the edit*, and a toast saying so + // would be a second, weaker report of something already on screen. + return plainStage( + [ + el( + "div", + { style: "display: grid; gap: 10px; justify-items: start;" }, + [ + raise("Undo", "neutral"), + raise("Nothing to undo", "neutral"), + raise("This layer has no text to edit", "error"), + host, + ] + ), + ], + { + what: "Both tones and the coalescing counter, which is what stops a repeated failure stacking six identical toasts.", + } + ); + }, +}; + +/** + * A menu, open, with every row kind it supports. + * + * Headers and separators carry no `data-pop-item`, which is what keeps the + * arrow-key roving on the rows you can actually choose. `pop-item-on` is the + * selected value; `data-pop-active` is the keyboard cursor — two different + * things that a single highlight would conflate. + */ +export const Menu: StoryObj = { + render: () => { + const anchor = el("button", { + class: cls("select"), + text: "Open menu", + type: "button", + }); + const menu = createMenu([ + { header: "Alignment" }, + { icon: "align-left", label: "Align left", on: true, run: noop }, + { icon: "align-h-center", label: "Align centre", run: noop }, + { icon: "align-right", label: "Align right", run: noop }, + { separator: true }, + { hint: "⌘⇧D", label: "Distribute horizontally", run: noop }, + { disabled: true, label: "Tidy up", run: noop }, + ]); + // After the anchor is in the document: `openPopover` positions against a + // measured rect, and a detached anchor measures as all zeros. + requestAnimationFrame(() => menu.open(anchor)); + return plainStage([el("div", { style: "width: 220px;" }, [anchor])], { + try: "arrow through it — `data-pop-on` is the selected value and `data-pop-active` is the keyboard cursor, deliberately two different marks", + what: "A popover menu with headers, a separator, a checked item and a disabled one.", + }); + }, +}; diff --git a/packages/overlay/src/keys.test.ts b/packages/overlay/src/keys.test.ts new file mode 100644 index 0000000..ad4d4d6 --- /dev/null +++ b/packages/overlay/src/keys.test.ts @@ -0,0 +1,245 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { PREFIX } from "./dom"; +import { isInsidePopover, keys } from "./keys"; + +/* + * The two questions the registry asks before it runs anything: "is the user + * typing?" and "is this keystroke inside a popover?". + * + * They used to be one flag, and that is the bug this file exists to hold shut. + * `isInsidePopover` was folded into `typing`, so a single `continue` skipped + * every binding without `allowWhileTyping` — including the ones `popover-host` + * registers for the popover *itself*, which carry no such flag. Every menu in + * the overlay lost Escape and its arrow keys, and nothing caught it because + * nothing here dispatched a key inside a popover. + * + * `within` is what separates them now, so the cases below are mostly about which + * of the two guards a binding is standing behind. + */ + +/** Undo every binding a test registered. The registry is a singleton. */ +const disposers: (() => void)[] = []; + +function bind(binding: Parameters[0]): () => void { + const off = keys.bind(binding); + disposers.push(off); + return off; +} + +/** + * Dispatch from a specific node. + * + * `bubbles` so the event reaches `document`, where the registry listens. The + * chord only needs `key` — `physicalKey` falls back to `e.key.toLowerCase()` + * for everything outside the digit and letter rows. + */ +function press(from: Node, key: string): KeyboardEvent { + const e = new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + key, + }); + from.dispatchEvent(e); + return e; +} + +/** A popover shell, as `popover-host` builds it: the class is what is matched. */ +function popover(): { item: HTMLElement; shell: HTMLElement } { + const shell = document.createElement("div"); + shell.className = `${PREFIX}-pop`; + const item = document.createElement("button"); + shell.append(item); + document.body.append(shell); + return { item, shell }; +} + +function plain(tag = "button"): HTMLElement { + const node = document.createElement(tag); + document.body.append(node); + return node; +} + +afterEach(() => { + for (const off of disposers.splice(0)) { + off(); + } + document.body.replaceChildren(); +}); + +describe("isInsidePopover", () => { + it("is true for a node inside a popover shell and false outside one", () => { + const { item } = popover(); + expect(isInsidePopover(item)).toBe(true); + expect(isInsidePopover(plain())).toBe(false); + }); + + it("is false for a target that is not an element", () => { + // `document` and `window` reach the handler too, and neither has `closest`. + expect(isInsidePopover(document)).toBe(false); + expect(isInsidePopover(null)).toBe(false); + }); +}); + +describe("a binding scoped with `within`", () => { + it("fires for a keystroke inside its element", () => { + const { item, shell } = popover(); + const run = vi.fn(); + bind({ keys: "escape", label: "Close", run, within: shell }); + + press(item, "Escape"); + + expect(run).toHaveBeenCalledTimes(1); + }); + + it("declines a keystroke from outside its element", () => { + const { shell } = popover(); + const run = vi.fn(); + bind({ keys: "escape", label: "Close", run, within: shell }); + + press(plain(), "Escape"); + + expect(run).not.toHaveBeenCalled(); + }); + + it("does not reach a sibling popover's rows", () => { + // The nesting case: a menu opened from inside a popover is the host's + // sibling, not its descendant, so the outer scope must not contain it. + const outer = popover(); + const inner = popover(); + const outerRun = vi.fn(); + const innerRun = vi.fn(); + bind({ + keys: "arrowdown", + label: "Next option", + run: outerRun, + within: outer.shell, + }); + bind({ + keys: "arrowdown", + label: "Next option", + run: innerRun, + within: inner.shell, + }); + + press(inner.item, "ArrowDown"); + + expect(innerRun).toHaveBeenCalledTimes(1); + expect(outerRun).not.toHaveBeenCalled(); + }); + + it("declines a target in another document, where `contains` cannot reach", () => { + // The `observe` path: a frame's own document shares this binding table. + const { shell } = popover(); + const run = vi.fn(); + bind({ keys: "escape", label: "Close", run, within: shell }); + + const other = document.implementation.createHTMLDocument(); + const node = other.createElement("button"); + other.body.append(node); + keys.observe(other); + disposers.push(() => keys.observe(other)); + + press(node, "Escape"); + + expect(run).not.toHaveBeenCalled(); + }); +}); + +describe("an unscoped binding", () => { + it("fires normally outside a popover", () => { + const run = vi.fn(); + bind({ keys: "escape", label: "Deselect", run }); + + press(plain(), "Escape"); + + expect(run).toHaveBeenCalledTimes(1); + }); + + it("is suppressed while the keystroke comes from inside a popover", () => { + // The guard's whole purpose: the canvas nudge must not run under an open + // colour picker, whose sliders are focusable divs and so read as "not typing". + const { item } = popover(); + const run = vi.fn(); + bind({ keys: "arrowright", label: "Nudge", run }); + + press(item, "ArrowRight"); + + expect(run).not.toHaveBeenCalled(); + }); + + it("yields to the popover's own binding on the same chord", () => { + const { item, shell } = popover(); + const global = vi.fn(); + const scoped = vi.fn(); + bind({ keys: "escape", label: "Deselect", run: global }); + bind({ keys: "escape", label: "Close", run: scoped, within: shell }); + + press(item, "Escape"); + + expect(scoped).toHaveBeenCalledTimes(1); + expect(global).not.toHaveBeenCalled(); + }); +}); + +describe("typing still outranks scope", () => { + it("withholds a scoped binding from a field inside the popover", () => { + // A field owns its own Escape — the token picker clears its query on the + // first press and closes on the second, and `bindField` reverts a value. + // The registry matching first would `preventDefault` both out of existence. + const { shell } = popover(); + const field = document.createElement("input"); + shell.append(field); + const run = vi.fn(); + bind({ keys: "escape", label: "Close", run, within: shell }); + + press(field, "Escape"); + + expect(run).not.toHaveBeenCalled(); + }); + + it("still lets an `allowWhileTyping` binding through in a field", () => { + const field = document.createElement("input"); + document.body.append(field); + const run = vi.fn(); + bind({ + allowWhileTyping: true, + keys: "escape", + label: "Send", + run, + }); + + press(field, "Escape"); + + expect(run).toHaveBeenCalledTimes(1); + }); +}); + +describe("a binding that matches", () => { + it("consumes the event so nothing underneath sees it", () => { + const { item, shell } = popover(); + bind({ + keys: "escape", + label: "Close", + run: () => undefined, + within: shell, + }); + + const e = press(item, "Escape"); + + expect(e.defaultPrevented).toBe(true); + }); + + it("leaves the event alone when scope declines it", () => { + const { shell } = popover(); + bind({ + keys: "escape", + label: "Close", + run: () => undefined, + within: shell, + }); + + const e = press(plain(), "Escape"); + + expect(e.defaultPrevented).toBe(false); + }); +}); diff --git a/packages/overlay/src/keys.ts b/packages/overlay/src/keys.ts new file mode 100644 index 0000000..d033f0c --- /dev/null +++ b/packages/overlay/src/keys.ts @@ -0,0 +1,303 @@ +/** + * One keyboard-shortcut registry for the whole overlay. + * + * Bindings used to live in three files (`picker.ts` owned Escape, + * `canvas/viewport.ts` owned the zoom set, `app.ts` owned ⌘Enter) with no shared + * notion of precedence or of "is the user typing right now". Every feature that + * came after — nudge, undo, delete, tool switching, tooltips that show their own + * shortcut — needs to answer those questions the same way, so they answer them + * here. + * + * Field-local Enter/Escape handlers are deliberately *not* migrated: they belong + * to the input they commit, and routing them through a global registry would + * mean every text field had to re-declare itself. + */ +import { PREFIX } from "./dom"; + +/** True on Apple platforms, where `mod` means ⌘ rather than Ctrl. */ +const IS_MAC = + typeof navigator !== "undefined" && + /Mac|iPhone|iPad/.test(navigator.platform); + +/** `KeyboardEvent.code` for the layout-independent digit and letter rows. */ +const DIGIT_CODE = /^Digit(\d)$/; +const LETTER_CODE = /^Key([A-Z])$/; + +export interface Binding { + /** Fire even while a text field has focus. Rare; ⌘Enter is the real case. */ + allowWhileTyping?: boolean; + /** + * A normalised chord, or several separated by commas: `"mod+z"`, + * `"shift+arrowleft"`, `"v"`, `"mod+=, mod+plus"`. Modifiers are `mod` + * (⌘ on macOS, Ctrl elsewhere), `alt` and `shift`, always in that order. + */ + keys: string; + /** Human-readable action name. Doubles as the tooltip lookup key. */ + label: string; + run: (e: KeyboardEvent) => void; + /** Skip this binding when the guard returns false — e.g. nothing selected. */ + when?: () => boolean; + /** + * Only fire for keystrokes originating inside this element. + * + * What lets a popover own its keys. An open popover suppresses every binding + * that is not scoped — a shortcut firing under it acts on something the user + * cannot see — while the bindings scoped to it still run. Scoping to the + * element rather than flagging "I am a popover" is what keeps nested + * popovers honest: children are siblings in the host, not descendants, so an + * outer menu's `within` cannot contain an inner one's rows and its roving + * keys stay out of the way. + */ + within?: HTMLElement; +} + +/** + * Is the target inside a popover the editor has open? + * + * The popover owns its own keys, and a global shortcut firing underneath it acts + * on something the user cannot see. So this suppresses every binding that has not + * scoped itself with `within` — deliberately *not* the same thing as typing, + * which suppresses scoped and unscoped alike so a field keeps its own Escape. + * + * Exported for `canvas/viewport.ts`, whose space-to-pan is a raw listener rather + * than a binding and so has to ask the same question for itself. + */ +export function isInsidePopover(target: EventTarget | null): boolean { + const node = target as Element | null; + return Boolean(node?.closest?.(`.${PREFIX}-pop`)); +} + +/** Is the user typing? Shortcuts must not fire inside the composer. */ +export function isTypingTarget(target: EventTarget | null): boolean { + const node = target as HTMLElement | null; + if (!node?.tagName) { + return false; + } + const tag = node.tagName.toLowerCase(); + return tag === "input" || tag === "textarea" || node.isContentEditable; +} + +/** + * The physical key, independent of layout and of what shift did to it. + * + * `e.key` alone is not enough: on a US layout ⇧1 arrives as `"!"`, so a binding + * written as `shift+1` would never match. Reading the digit and letter rows off + * `e.code` keeps chords stable across layouts and shift states, which is exactly + * what the old hand-rolled `e.key === "!" || e.code === "Digit1"` pairs in + * viewport.ts were working around. + */ +function physicalKey(e: KeyboardEvent): string { + const digit = DIGIT_CODE.exec(e.code); + if (digit) { + return digit[1]; + } + const letter = LETTER_CODE.exec(e.code); + if (letter) { + return letter[1].toLowerCase(); + } + switch (e.code) { + case "Equal": + return "="; + case "Minus": + return "-"; + case "Space": + return "space"; + default: + return e.key.toLowerCase(); + } +} + +/** `"mod+shift+z"` — the canonical form both sides of the match agree on. */ +function chordOf(e: KeyboardEvent): string { + const parts: string[] = []; + if (IS_MAC ? e.metaKey : e.ctrlKey) { + parts.push("mod"); + } + if (e.altKey) { + parts.push("alt"); + } + if (e.shiftKey) { + parts.push("shift"); + } + parts.push(physicalKey(e)); + return parts.join("+"); +} + +/** Split a binding's `keys` into the individual chords it answers to. */ +function chordsOf(spec: string): string[] { + return spec + .split(",") + .map((k) => k.trim().toLowerCase()) + .filter(Boolean); +} + +const DISPLAY: Record = { + alt: IS_MAC ? "⌥" : "Alt", + arrowdown: "↓", + arrowleft: "←", + arrowright: "→", + arrowup: "↑", + backspace: IS_MAC ? "⌫" : "Backspace", + delete: "Del", + enter: IS_MAC ? "↩" : "Enter", + escape: "Esc", + mod: IS_MAC ? "⌘" : "Ctrl", + shift: IS_MAC ? "⇧" : "Shift", + space: "Space", +}; + +/** `"mod+shift+z"` → `"⌘⇧Z"` (mac) or `"Ctrl+Shift+Z"`. */ +function displayChord(chord: string): string { + const parts = chord.split("+").map((p) => DISPLAY[p] ?? p.toUpperCase()); + return IS_MAC ? parts.join("") : parts.join("+"); +} + +export class Keys { + /** Newest first, so a later binding shadows an earlier one on the same chord. */ + private readonly bindings: Binding[] = []; + private listening = false; + /** Frame documents this registry also listens in. See `observe`. */ + private readonly observed = new Set(); + + /** Register a binding. Returns a disposer. */ + bind(binding: Binding): () => void { + this.bindings.unshift(binding); + this.listen(); + return () => { + const i = this.bindings.indexOf(binding); + if (i !== -1) { + this.bindings.splice(i, 1); + } + }; + } + + /** Register several at once; the disposer removes all of them. */ + bindAll(bindings: Binding[]): () => void { + const offs = bindings.map((b) => this.bind(b)); + return () => { + for (const off of offs) { + off(); + } + }; + } + + /** The display string for an action, for tooltips. `null` if unbound. */ + hintFor(label: string): string | null { + const found = this.bindings.find((b) => b.label === label); + if (!found) { + return null; + } + const [first] = chordsOf(found.keys); + return first ? displayChord(first) : null; + } + + private listen(): void { + if (this.listening) { + return; + } + this.listening = true; + // Capture phase, like the picker's own handler was: the host app may stop + // propagation on its own listeners, and the editor's shortcuts have to win + // over the page it is editing. + document.addEventListener("keydown", this.onKeyDown, true); + } + + /** + * Also listen in a frame's own document. + * + * A keydown inside a same-origin iframe does not reach the shell's `document`, so + * every shortcut was dead while focus was in the app — including Escape, which is + * what closes an open popover. `text-edit.ts` already worked around this by binding + * to `ownerWindow(node)`; this makes it the registry's job instead, so one binding + * table serves both realms. + * + * Idempotent per document, and the returned disposer is what a frame calls when it + * unloads. + */ + observe(doc: Document): () => void { + if (doc === document || this.observed.has(doc)) { + return () => undefined; + } + this.observed.add(doc); + doc.addEventListener("keydown", this.onKeyDown, true); + return () => { + this.observed.delete(doc); + doc.removeEventListener("keydown", this.onKeyDown, true); + }; + } + + /** + * Release every listener. + * + * There was no teardown at all and `listening` was never reset, so an overlay torn + * down and rebuilt in the same page — which `?__airship=inline` does on every HMR + * cycle — left the old registry's capture-phase handler on `document`, still matching + * chords and still calling `preventDefault` for bindings whose `when()` closed over a + * dead panel. + */ + destroy(): void { + if (this.listening) { + document.removeEventListener("keydown", this.onKeyDown, true); + this.listening = false; + } + for (const doc of this.observed) { + doc.removeEventListener("keydown", this.onKeyDown, true); + } + this.observed.clear(); + } + + private readonly onKeyDown = (e: KeyboardEvent): void => { + const chord = chordOf(e); + const target = e.target as Node | null; + const typing = isTypingTarget(e.target); + /* + * A keystroke inside an open popover belongs to the popover. + * + * `isTypingTarget` only recognises inputs, so focus resting on a colour picker's + * swatch trigger or one of its sliders read as "not typing" and the canvas nudge + * bindings fired: open the picker, press → four times, and the element slid 4px + * across the canvas while nothing in the picker changed. The popover's own handlers + * `stopPropagation`, but they run in the bubble phase and this listener is capture. + * + * Kept apart from `typing` rather than folded into it, which is how this + * started: one flag skipped every binding without `allowWhileTyping`, and + * the popover's *own* Escape and roving keys are registered here too and + * carry no such flag — so they suppressed themselves and every menu in the + * overlay lost its keyboard. `within` is what tells the two apart. + */ + const inPopover = isInsidePopover(e.target); + for (const b of this.bindings) { + if (typing && !b.allowWhileTyping) { + continue; + } + // A scoped binding never fires outside the subtree it belongs to... + if (b.within && !b.within.contains(target)) { + continue; + } + // ...and inside a popover, only scoped bindings fire at all: an unscoped + // one would act on something standing behind what the user is looking at. + if (inPopover && !b.within) { + continue; + } + if (!chordsOf(b.keys).includes(chord)) { + continue; + } + if (b.when && !b.when()) { + continue; + } + // A binding that matched owns the event. Anything that wants to fall + // through to a lower binding should say so with `when()`, not by + // declining inside `run` — otherwise precedence stops being inspectable. + e.preventDefault(); + e.stopPropagation(); + b.run(e); + return; + } + }; +} + +/** + * The overlay's registry. A singleton because the document has exactly one + * keyboard and both stages (inline and canvas) share it. + */ +export const keys = new Keys(); diff --git a/packages/overlay/src/popover-host.test.ts b/packages/overlay/src/popover-host.test.ts new file mode 100644 index 0000000..8ad2c9f --- /dev/null +++ b/packages/overlay/src/popover-host.test.ts @@ -0,0 +1,306 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cls, el } from "./dom"; +import { keys } from "./keys"; +import { + closeOpenPopover, + mountPopoverHost, + openPopover, + type PopoverHandle, + popoverHost, +} from "./popover-host"; + +/* + * Popovers nest, shallowly. + * + * The singleton this replaced made "a control inside a popover opens a popover" + * impossible, and impossible in the worst way: the inner open closed the + * popover it was standing in, which detached its own trigger mid-click, so the + * new one measured a zero-width anchor and the watchdog closed it a frame later. + * Nothing appeared and nothing errored. Stroke's Advanced form has two selects + * and the gradient editor has a swatch per stop; all of them were dead. + * + * The rules worth pinning down are the ones that were one line each when there + * was a single slot and are now statements about a stack. + */ + +/** A trigger in the page, outside every popover. */ +function anchor(): HTMLElement { + const node = el("button", { type: "button" }); + document.body.append(node); + return node; +} + +/** Open a popover, and hand back its shell and a spy on its close reason. */ +function open(from: HTMLElement, content = el("div")) { + const onClose = vi.fn(); + const handle = openPopover({ anchor: from, content, onClose }); + return { content, handle, onClose }; +} + +const shells = (): Element[] => [ + ...(popoverHost()?.querySelectorAll(`.${cls("pop")}`) ?? []), +]; + +/* + * Mount the host, and never wipe the body. + * + * `popover-host` caches its mount in a module variable, so + * `document.body.replaceChildren()` detaches the host while the host still + * believes it is mounted — and then every `isConnected` in here reads false for + * a reason that has nothing to do with what is being tested. `mountPopoverHost` + * re-appends the cached node, so calling it per test is both idempotent and the + * repair. Same trap `token-field.test.ts` documents. + */ +beforeEach(() => { + mountPopoverHost(document.body); +}); + +afterEach(() => { + closeOpenPopover("programmatic"); + for (const node of [...document.body.children]) { + if (node !== popoverHost()) { + node.remove(); + } + } +}); + +describe("a popover opened from the page", () => { + it("hands off: opening from a different anchor closes the first", () => { + const a = open(anchor()); + open(anchor()); + expect(a.onClose).toHaveBeenCalledTimes(1); + expect(shells()).toHaveLength(1); + }); + + it("toggles shut when its own anchor opens again", () => { + const trigger = anchor(); + const first = open(trigger); + open(trigger); + expect(first.onClose).toHaveBeenCalledTimes(1); + expect(shells()).toHaveLength(0); + }); +}); + +describe("a popover opened from inside another", () => { + /** A parent, and a child anchored to a trigger living in the parent's shell. */ + function nested() { + const parent = open(anchor()); + // The child's trigger is inside the parent's content — a select in the + // stroke form, a swatch in a gradient stop. + const trigger = el("button", { type: "button" }); + parent.content.append(trigger); + const child = open(trigger); + return { child, parent, trigger }; + } + + it("leaves its parent open", () => { + const { parent } = nested(); + expect(parent.onClose).not.toHaveBeenCalled(); + expect(shells()).toHaveLength(2); + }); + + it("keeps its anchor attached, which is what used to break", () => { + // The old prologue closed the incumbent unconditionally, and `shell.remove()` + // took the trigger with it — so placement measured a detached node. + const { trigger } = nested(); + expect(trigger.isConnected).toBe(true); + }); + + it("paints above its parent, by DOM order alone", () => { + const { parent, child } = nested(); + const order = shells(); + expect(order.indexOf(parent.handle.element)).toBeLessThan( + order.indexOf(child.handle.element) + ); + }); + + it("closes alone, leaving the parent up", () => { + const { parent, child } = nested(); + child.handle.close("escape"); + expect(child.onClose).toHaveBeenCalledTimes(1); + expect(parent.onClose).not.toHaveBeenCalled(); + expect(shells()).toHaveLength(1); + }); + + it("goes when its parent goes, and each closes exactly once", () => { + const { parent, child } = nested(); + parent.handle.close("outside"); + expect(child.onClose).toHaveBeenCalledTimes(1); + expect(parent.onClose).toHaveBeenCalledTimes(1); + expect(shells()).toHaveLength(0); + }); + + it("is closed before its parent leaves the DOM", () => { + /* + * Ordering, not just eventual state. A child's anchor lives inside its + * parent's shell, so a child that outlives its parent by even one frame is + * a popover anchored to a detached node — which is the state the watchdog + * exists to catch, and it would catch it with a visible flicker. + */ + const { parent, child } = nested(); + let parentStillMounted: boolean | null = null; + child.onClose.mockImplementation(() => { + parentStillMounted = parent.handle.element.isConnected; + }); + parent.handle.close("programmatic"); + expect(parentStillMounted).toBe(true); + }); + + it("survives a parent whose onClose closes it again", () => { + /* + * `createSelect.destroy` calls `menu?.close()`, and a parent's `onClose` + * destroys the controls it owns — so a close can re-enter from underneath. + * Idempotence is what makes that safe, not luck about ordering. + */ + const { parent, child } = nested(); + parent.onClose.mockImplementation(() => child.handle.close("programmatic")); + expect(() => parent.handle.close("outside")).not.toThrow(); + expect(child.onClose).toHaveBeenCalledTimes(1); + expect(shells()).toHaveLength(0); + }); + + it("gives way to a hand-off from outside the stack", () => { + nested(); + open(anchor()); + expect(shells()).toHaveLength(1); + }); +}); + +describe("closeOpenPopover", () => { + it("closes the whole stack, not the top of it", () => { + // `DesignPanel.renderBody` is about to destroy every anchor in the panel; + // closing only the top would leave a parent pointing at a dead control. + const parent = open(anchor()); + const trigger = el("button", { type: "button" }); + parent.content.append(trigger); + const child = open(trigger); + + closeOpenPopover("anchor-gone"); + expect(parent.onClose).toHaveBeenCalledWith("anchor-gone"); + expect(child.onClose).toHaveBeenCalledWith("anchor-gone"); + expect(shells()).toHaveLength(0); + }); + + it("is safe with nothing open", () => { + mountPopoverHost(document.body); + expect(() => closeOpenPopover()).not.toThrow(); + }); +}); + +/* + * The keyboard, which this file used to say nothing about at all. + * + * That silence is why every menu in the overlay shipped with Escape and its + * arrow keys dead: `openPopover` registers both through the key registry, the + * registry had folded "inside a popover" into its "is the user typing" guard, + * and so the popover's own bindings skipped themselves. Nothing dispatched a + * key from inside a shell, so nothing noticed. + * + * These press from the *item*, not from `document`, because that is the whole + * point — `openPopover` focuses the first row on open, so a real keystroke + * always originates inside the shell. + */ +describe("a popover's own keys", () => { + /** A roving menu with three rows, as `createMenu` builds one. */ + function menu(): { content: HTMLElement; handle: PopoverHandle } { + const content = el("div"); + for (const label of ["One", "Two", "Three"]) { + content.append( + el("button", { "data-pop-item": "", text: label, type: "button" }) + ); + } + const handle = openPopover({ anchor: anchor(), content, roving: true }); + return { content, handle }; + } + + const rows = (content: HTMLElement): HTMLElement[] => [ + ...content.querySelectorAll("[data-pop-item]"), + ]; + + const cursor = (content: HTMLElement): string | null => + content.querySelector("[data-pop-active]")?.textContent ?? + null; + + function press(from: Node, key: string): void { + from.dispatchEvent( + new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key }) + ); + } + + it("seeds the roving cursor on the first row", () => { + const { content } = menu(); + expect(cursor(content)).toBe("One"); + }); + + it("closes on Escape pressed from the focused row", () => { + const { content } = menu(); + press(rows(content)[0], "Escape"); + expect(shells()).toHaveLength(0); + }); + + it("moves the cursor down and back up", () => { + const { content } = menu(); + press(rows(content)[0], "ArrowDown"); + expect(cursor(content)).toBe("Two"); + press(rows(content)[1], "ArrowUp"); + expect(cursor(content)).toBe("One"); + }); + + it("wraps past the last row rather than dead-ending", () => { + const { content } = menu(); + press(rows(content)[0], "End"); + expect(cursor(content)).toBe("Three"); + press(rows(content)[2], "ArrowDown"); + expect(cursor(content)).toBe("One"); + }); + + it("jumps to the first row on Home", () => { + const { content } = menu(); + press(rows(content)[0], "End"); + press(rows(content)[2], "Home"); + expect(cursor(content)).toBe("One"); + }); + + it("closes a non-roving popover from a plain node inside it", () => { + // No rows to focus, so nothing moves focus into the shell on open — but a + // keystroke that originates there still belongs to the popover. + const content = el("div"); + const inside = el("div"); + content.append(inside); + const onClose = vi.fn(); + openPopover({ anchor: anchor(), content, onClose }); + + press(inside, "Escape"); + + expect(onClose).toHaveBeenCalledWith("escape"); + expect(shells()).toHaveLength(0); + }); + + it("leaves a global binding standing while it is open", () => { + // The guard's purpose, from the other side: the canvas nudge must not run + // under an open menu. + const nudge = vi.fn(); + const off = keys.bind({ keys: "arrowdown", label: "Nudge", run: nudge }); + try { + const { content } = menu(); + press(rows(content)[0], "ArrowDown"); + expect(nudge).not.toHaveBeenCalled(); + expect(cursor(content)).toBe("Two"); + } finally { + off(); + } + }); + + it("hands the keys back when it closes", () => { + const nudge = vi.fn(); + const off = keys.bind({ keys: "arrowdown", label: "Nudge", run: nudge }); + try { + const { content } = menu(); + press(rows(content)[0], "Escape"); + press(document.body, "ArrowDown"); + expect(nudge).toHaveBeenCalledTimes(1); + } finally { + off(); + } + }); +}); diff --git a/packages/overlay/src/popover-host.ts b/packages/overlay/src/popover-host.ts new file mode 100644 index 0000000..f424fcc --- /dev/null +++ b/packages/overlay/src/popover-host.ts @@ -0,0 +1,575 @@ +/* + * The layer every anchored popover — and the tooltip — is drawn on. + * + * **Why a host at all.** A popover rendered next to its trigger is clipped + * twice over: `.__airship-insp-body` is `overflow-y: auto` (and per spec + * `overflow-x: visible` computes to `auto` when the other axis is not visible, + * so it clips on *both*), and `.__airship-dock` is `overflow: hidden`. A + * `position: absolute` menu inside the design panel is cut off at the section + * edge and scrolls away with the content. It has to be parented somewhere that + * is not inside either box. + * + * **Why inside `#__airship-root` rather than a sibling layer.** The obvious + * choice — `ChromeLayer`, where the selection outlines live — is wrong, and + * subtly so: it and the root carry the *same* `z-index`, and the root is + * appended second, so the docks paint over it (see `chrome-layer.ts`, which + * chose that on purpose so an outline runs under a panel rather than being + * clipped at its edge). A menu mounted there renders behind an opaque dock. + * Hosting inside the root instead — as its last child — puts the popover above + * every dock *and* inherits three things a third body-level layer would have to + * re-earn: the `--ap-*` token vars (scoped to the root), the + * `${ROOT} button:hover .ic` icon-state rules, and membership of + * `edit-guard.ts`'s `CHROME` list, without which every click inside a colour + * picker would fall through and re-select the element underneath it. + * + * That last point is also why the tooltip moves here: mounted on the chrome + * layer it was painted *behind* the panels, so every tooltip in the design + * inspector was invisible. + * + * **One at a time.** A select and a colour picker are never usefully open + * together, and a singleton makes outside-press, Escape and "the panel just + * rebuilt" one code path each instead of a registry to keep in step. + */ +import { cls, el } from "./dom"; +import { type IconName, icon } from "./icons"; +import { type Binding, keys } from "./keys"; +import { type PlaceOptions, placePopover, type Side } from "./popover"; + +export type CloseReason = + | "anchor-gone" + | "escape" + | "outside" + | "programmatic" + | "reopen" + | "select"; + +export interface PopoverOptions extends PlaceOptions { + anchor: HTMLElement; + /** Extra class on the shell — `pop-menu`, `pop-color`. */ + className?: string; + content: HTMLElement; + /** Match the shell's width to the anchor's. For selects. */ + matchAnchorWidth?: boolean; + onClose?: (reason: CloseReason) => void; + prefer?: Side; + /** Arrow/Home/End roving over `[data-pop-item]` inside `content`. */ + roving?: boolean; +} + +export interface PopoverHandle { + close: (reason?: CloseReason) => void; + readonly element: HTMLElement; + reposition: () => void; +} + +const ITEM = "[data-pop-item]"; + +let host: HTMLElement | null = null; + +/* + * Popovers nest, shallowly. + * + * The singleton this replaces made "a control inside a popover opens a popover" + * impossible, in a way that looked like nothing happening at all: the inner + * `openPopover` saw a different anchor, closed the popover it was standing in, + * and that `shell.remove()` detached the trigger mid-click — so the new menu + * measured a zero-width anchor, placed itself in the screen corner, and the + * watchdog closed it a frame later. Stroke's Advanced form has two selects and + * the gradient editor has a colour swatch per stop; all of them were dead. + * + * A stack rather than a registry. The only nesting that exists is "a control + * inside an open shell opened its own", so entry `i + 1` was opened from inside + * entry `i` and dies with it — which keeps outside-press, Escape and "the panel + * just rebuilt" one rule each, now stated against the stack instead of against a + * variable. + */ +const stack: OpenPopover[] = []; + +interface OpenPopover { + anchor: HTMLElement; + dispose: () => void; + handle: PopoverHandle; + onClose?: (reason: CloseReason) => void; + shell: HTMLElement; +} + +/** + * Which level `node` sits inside, or `-1` for "outside every open popover". + * + * Shells are siblings in the host — a child popover is deliberately *not* a DOM + * descendant of its parent, or the parent's `overflow` would clip it — so + * `contains` matches at most one entry. + */ +function levelOf(node: Node | null): number { + return node ? stack.findIndex((entry) => entry.shell.contains(node)) : -1; +} + +/** Close everything above `index`, topmost first. `-1` closes the whole stack. */ +function closeAbove(index: number, reason: CloseReason): void { + // A reversed snapshot: `close` is a no-op once its entry has left the stack, + // so a fixed list cannot loop even if an `onClose` opens something new. + for (const entry of stack.slice(index + 1).reverse()) { + entry.handle.close(reason); + } +} + +/** + * Mount the host. Called once, immediately after the overlay root is in the + * document. + * + * It sits above every dock by declared `z-index` (`Z_POP`), not by being last in + * the DOM. The docks declare a numeric `z-index` of their own, and a positioned + * element with `z-index: auto` paints in an earlier step of the stacking + * algorithm than any positioned element with a numeric one — so an auto-stacked + * host loses to a dock however late it appears. It *is* the root's last child, + * and `toast.ts` mounts its own host ahead of this one to keep that true, but + * that is hygiene rather than the mechanism. + */ +export function mountPopoverHost(root: HTMLElement): HTMLElement { + if (!host) { + host = el("div", { class: cls("pop-host") }); + } + root.append(host); + return host; +} + +export function popoverHost(): HTMLElement | null { + return host; +} + +/** + * Close every open popover — the whole stack, not the top of it. + * + * Both callers mean everything. `DesignPanel.renderBody` is about to destroy the + * controls all of them are anchored to, and `closeGradientEditor` owns a popover + * that may have a colour picker open on top of it. Closing only the top would + * leave a parent anchored to a control that no longer exists — exactly the state + * the watchdog exists to catch, one frame later and with a visible flicker. + * + * `createMenu` deliberately does not come through here: a menu must close + * itself, not whatever it happens to be standing in. + */ +export function closeOpenPopover(reason: CloseReason = "programmatic"): void { + closeAbove(-1, reason); +} + +export function openPopover(opts: PopoverOptions): PopoverHandle { + /* + * Three cases, in order. + * + * The same anchor twice is a toggle: that entry and anything it parents go, + * and nothing opens. An anchor *inside* an open shell is a child — a select in + * the stroke form, a swatch in the gradient editor — so only the levels above + * it go. Anything else is a hand-off, which is the old unconditional close and + * still the common case. + */ + const mine = stack.findIndex((entry) => entry.anchor === opts.anchor); + if (mine !== -1) { + closeAbove(mine - 1, "reopen"); + return DEAD; + } + closeAbove(levelOf(opts.anchor), "reopen"); + + const mount = host ?? mountPopoverHost(document.body); + const shell = el("div", { + class: opts.className ? `${cls("pop")} ${cls(opts.className)}` : cls("pop"), + }); + shell.append(opts.content); + // Appended after its parent and left at `z-index: auto` like every other + // shell, inside the host's single stacking context — so DOM order alone paints + // a child above the popover it came from, and `Z_POP` stays one number. + mount.append(shell); + + const place = (): void => { + placePopover( + shell, + opts.anchor.getBoundingClientRect(), + opts.prefer ?? "below", + { + align: opts.align, + /* + * A floor, not a fixed width. + * + * This was `shell.style.width = anchor.offsetWidth`, which fought + * `.pop-menu`'s own `min-width: 150px`: off a narrow trigger the menu + * was wider than the shell it lived in and hung out of its side. It + * also lied to the placement below, which reads `offsetWidth` back off + * this element to clamp the right edge — so the clamp was computed from + * the constrained width and let the menu overrun the viewport by the + * difference. As a floor it does the one thing a select wants (never + * narrower than its trigger) and lets the content have the rest. + */ + minWidth: opts.matchAnchorWidth + ? opts.anchor.offsetWidth + : opts.minWidth, + scroll: opts.scroll, + } + ); + }; + place(); + + const handle: PopoverHandle = { + close(reason = "programmatic") { + if (levelOf(shell) === -1) { + // Already closed. This one guard is what makes every path idempotent. + return; + } + // Children first, and before this shell leaves the DOM: their anchors live + // inside it, and a child outliving its parent by even one frame is a + // popover anchored to a detached node. + closeAbove(levelOf(shell), reason); + // Re-read rather than reuse the index: a child's `onClose` is free to have + // closed levels below us while we were in there. + const at = levelOf(shell); + if (at === -1) { + return; + } + const [entry] = stack.splice(at, 1); + entry.dispose(); + /* + * Focus back to the anchor, before the shell leaves the DOM. + * + * `focusItem` moves focus *into* the popover on open and nothing put it back, so + * closing dropped focus to ``: open a select with the keyboard, arrow to a + * value, press Enter, and the next Tab restarted at the top of the document. + * Affects `createSelect`, the token picker and every `createMenu`. + * + * Only when focus is still inside this popover — if the user has already clicked + * elsewhere, stealing it back would be worse than leaving it. + */ + const active = shell.ownerDocument.activeElement; + const hadFocus = active instanceof Node && shell.contains(active); + shell.remove(); + if (hadFocus && opts.anchor.isConnected) { + opts.anchor.focus?.(); + } + // Out of the stack before `onClose` runs, as before — a parent's `onClose` + // may destroy a control that owns a child popover, and every path has to + // find a stack it can trust. + entry.onClose?.(reason); + }, + element: shell, + reposition: place, + }; + + const dispose = install(opts, handle); + stack.push({ + anchor: opts.anchor, + dispose, + handle, + onClose: opts.onClose, + shell, + }); + + if (opts.roving) { + focusItem(opts.content, 0); + } + return handle; +} + +/** + * Everything that has to be undone on close, wired in one place. + * + * No longer takes the shell: outside-press asks the stack whether the press + * landed in any open popover, not whether it landed in this one. + */ +function install(opts: PopoverOptions, handle: PopoverHandle): () => void { + // Deferred, or the press that opened the popover closes it again on the way + // back up. Same reason the old `createMenu` did it. + const onOutside = (e: Event): void => { + const target = e.target as Node | null; + // Inside *any* open shell counts as inside. `shell.contains` — all this used + // to test — cannot see a child, because children are siblings in the host, + // so a press in a menu opened from this popover read as a press outside it. + if (levelOf(target) !== -1 || opts.anchor.contains(target)) { + return; + } + handle.close("outside"); + }; + const armed = setTimeout( + () => document.addEventListener("pointerdown", onOutside, true), + 0 + ); + + /* + * Reposition rather than close. The design panel's body is a scroller and the + * canvas pans under a frame menu; closing on every wheel tick would make a + * dropdown unusable next to a scrollbar. Capture phase so scrolls inside + * `.insp-body` are seen — they do not bubble to `window`. + * + * This is also the backstop for an anchor removed by something that did not + * route through `DesignPanel.renderBody`. + */ + const onReflow = (): void => { + if (!opts.anchor.isConnected) { + handle.close("anchor-gone"); + return; + } + handle.reposition(); + }; + window.addEventListener("scroll", onReflow, true); + window.addEventListener("resize", onReflow); + + /* + * A frame-by-frame check that the anchor still exists. + * + * `DesignPanel.renderBody` closes explicitly, which covers the common case, + * but it is not the only thing that removes a control: `row-list.ts` re-renders + * its own rows, and a fill or shadow row can vanish without the panel doing + * anything. Rather than teach every list to remember, the host watches. The + * work is one property read per frame while a popover is open — and nothing at + * all when none is. + */ + let frame = requestAnimationFrame(function watch() { + if (!opts.anchor.isConnected) { + handle.close("anchor-gone"); + return; + } + frame = requestAnimationFrame(watch); + }); + + /* + * Through the registry, not a raw listener, and every one of them scoped to + * `content`. + * + * The scope is what makes them win: the registry suppresses unscoped bindings + * outright while focus sits inside a popover — the picker's Escape-to-deselect, + * the panel's arrow-key nudge — so these do not have to out-rank them, they are + * simply the only ones left standing. They also stop applying the moment the + * disposer runs, and cannot reach a *sibling* popover's rows, which is what + * keeps a nested menu's arrows from moving its parent's invisible cursor. + * + * Note `Keys` skips non-`allowWhileTyping` bindings while a field has focus, + * which is correct for the arrows (they should move the caret inside the + * picker's hex field) but means a focused field swallows Escape too. Fields + * inside a popover therefore keep their own Escape handler, which is what + * `keys.ts` prescribes for field-local commit/cancel anyway. + */ + const bindings: Binding[] = [ + { + keys: "escape", + label: "Close", + run: () => handle.close("escape"), + }, + ]; + if (opts.roving) { + const step = (delta: number) => () => moveItem(opts.content, delta); + bindings.push( + { keys: "arrowdown", label: "Next option", run: step(1) }, + { keys: "arrowup", label: "Previous option", run: step(-1) }, + { + keys: "home", + label: "First option", + run: () => focusItem(opts.content, 0), + }, + { + keys: "end", + label: "Last option", + run: () => focusItem(opts.content, -1), + }, + { + keys: "enter", + label: "Choose option", + run: () => activeItem(opts.content)?.click(), + } + ); + } + // Scoped in one place rather than on each literal above, so a binding added + // later cannot be the one that forgets and silently goes dead again. + const unbind = keys.bindAll( + bindings.map((b) => ({ ...b, within: opts.content })) + ); + + return () => { + clearTimeout(armed); + cancelAnimationFrame(frame); + document.removeEventListener("pointerdown", onOutside, true); + window.removeEventListener("scroll", onReflow, true); + window.removeEventListener("resize", onReflow); + unbind(); + }; +} + +// -- Roving ------------------------------------------------------------------- + +/* + * `data-pop-active` is the keyboard cursor; `pop-item-on` is the selected value. + * They are different things — you arrow *past* the current value on the way to + * another one — and collapsing them into one class is what makes a hand-rolled + * menu feel broken. + */ + +function items(content: HTMLElement): HTMLElement[] { + return Array.from(content.querySelectorAll(ITEM)); +} + +function activeItem(content: HTMLElement): HTMLElement | null { + return content.querySelector("[data-pop-active]"); +} + +function focusItem(content: HTMLElement, index: number): void { + const all = items(content); + if (!all.length) { + return; + } + const at = index < 0 ? all.length - 1 : Math.min(index, all.length - 1); + for (const [i, node] of all.entries()) { + node.tabIndex = i === at ? 0 : -1; + node.toggleAttribute("data-pop-active", i === at); + } + all[at].focus({ preventScroll: true }); + all[at].scrollIntoView({ block: "nearest" }); +} + +function moveItem(content: HTMLElement, delta: number): void { + const all = items(content); + if (!all.length) { + return; + } + const current = activeItem(content); + const from = current ? all.indexOf(current) : -1; + // Wrap, so ↓ from the last option lands on the first rather than dead-ending. + const next = (from + delta + all.length) % all.length; + focusItem(content, next); +} + +/** Returned when a toggle-close consumed the call; every method is inert. */ +const DEAD: PopoverHandle = { + close: () => undefined, + element: el("div"), + reposition: () => undefined, +}; + +// -- Menus -------------------------------------------------------------------- + +export interface MenuItem { + /** Present but unusable — greyed, and skipped by the keyboard cursor. */ + disabled?: boolean; + /** Rendered right-aligned in a dimmed mono — a shortcut, a size, a unit. */ + hint?: string; + /** Leading glyph. Optional: a menu of plain verbs is better without one. */ + icon?: IconName; + label: string; + on?: boolean; + run: () => void; +} + +/** + * A menu row. Headers and separators carry no `data-pop-item`, so the roving + * cursor steps over them without the keyboard code needing to know they exist. + */ +export type MenuEntry = MenuItem | { separator: true } | { header: string }; + +function isMenuItem(entry: MenuEntry): entry is MenuItem { + return "label" in entry; +} + +export interface MenuOpenOptions { + /** Match the menu's width to the trigger's — right for a full-width select. */ + matchAnchorWidth?: boolean; + /** Fires whenever the menu goes away, however it went. Drives `aria-expanded`. */ + onClose?: () => void; +} + +export interface MenuHandle { + close: () => void; + /** + * Open against a live anchor. Calling it again from the same anchor toggles + * shut, which is what a trigger button wants. + */ + open: (anchor: HTMLElement, prefer?: Side, opts?: MenuOpenOptions) => void; +} + +/** + * A dropdown of plain items — the zoom readout, the effect-type switch, the + * position select. + * + * The markup is built once and handed to `openPopover` on each open, which owns + * placement, outside-press, Escape, arrow-key roving and the "your anchor was + * just destroyed" case. The previous version owned a private copy of the first + * two and no version of the last three; more to the point it rendered *inside* + * whatever called it, which in the design panel meant inside two nested + * `overflow` boxes that clipped it. Hence no `element` on the handle any more — + * the content belongs to the host while it is up and to nobody when it is not, + * which is also what stops a rebuilt row from leaving its old menu behind. + * + * Still deliberately not a general menu system: no submenus. It does group, + * though — headers and separators, because a menu carrying "Undo", "Create + * pull request" and "Open in VS Code" in one undifferentiated column reads as a + * junk drawer. `frame-chrome.ts`'s device menu still is not a client of this: + * it places itself against world-space canvas geometry and re-anchors on pan. + */ +export function createMenu(entries: MenuEntry[]): MenuHandle { + /* + * This menu's own popover, not "whatever is open". + * + * The row used to call `closeOpenPopover`, which was right while only one + * popover could exist. Against a stack it takes the popover the menu is + * standing in down with it — choosing a value in the stroke form's select + * would close the stroke form. The row still closes *before* `entry.run()`, + * which is what lets a `run` that opens another popover from the same anchor + * place itself instead of being read as a toggle. + */ + let handle: PopoverHandle | null = null; + const menu = el("div", { class: cls("pop-menu"), role: "menu" }); + + for (const entry of entries) { + if (!isMenuItem(entry)) { + menu.append( + "header" in entry + ? el("div", { class: cls("pop-head"), text: entry.header }) + : el("div", { class: cls("pop-sep"), role: "separator" }) + ); + continue; + } + const disabled = Boolean(entry.disabled); + const row = el( + "button", + { + class: `${cls("pop-item")}${entry.on ? ` ${cls("pop-item-on")}` : ""}`, + role: "menuitem", + type: "button", + // Omitting the attribute is what keeps the roving cursor off it; the + // arrow-key code only ever queries `[data-pop-item]`. + ...(disabled + ? { "aria-disabled": "true", disabled: "" } + : { "data-pop-item": "" }), + onClick: disabled + ? undefined + : () => { + handle?.close("select"); + entry.run(); + }, + }, + [ + el("span", { class: cls("pop-item-main") }, [ + ...(entry.icon ? [icon(entry.icon, "sm")] : []), + el("span", { text: entry.label }), + ]), + ] + ); + if (entry.hint) { + row.append(el("span", { class: cls("pop-item-hint"), text: entry.hint })); + } + menu.append(row); + } + + return { + close: () => handle?.close(), + open(anchor, prefer = "above", opts = {}) { + const opened = openPopover({ + anchor, + content: menu, + matchAnchorWidth: opts.matchAnchorWidth, + onClose: () => { + handle = null; + opts.onClose?.(); + }, + prefer, + roving: true, + }); + // `DEAD` is the toggle-shut path — there is nothing open to hold on to. + handle = opened === DEAD ? null : opened; + }, + }; +} diff --git a/packages/overlay/src/popover.ts b/packages/overlay/src/popover.ts new file mode 100644 index 0000000..49adb00 --- /dev/null +++ b/packages/overlay/src/popover.ts @@ -0,0 +1,115 @@ +import { clamp } from "./num"; + +const MARGIN = 8; +const GAP = 6; + +export type Side = "above" | "below"; + +export interface PlaceOptions { + /** + * Which edge tracks the anchor's. `start` pins left to left — right for a + * dropdown as wide as its trigger. `end` pins right to right, which is what a + * 232px colour picker hanging off a 22px swatch needs: aligned left it runs + * straight off the side of a 360px dock. + */ + align?: "end" | "start"; + /** Floor for the popover's width, so a menu is never narrower than readable. */ + minWidth?: number; + /** + * Cap the height to the room available and let the content scroll. True for + * menus. The colour picker opts out — a squashed saturation/value area near a + * viewport edge is worse than one that flips. + */ + scroll?: boolean; +} + +function flip(side: Side): Side { + return side === "above" ? "below" : "above"; +} + +/** + * Place a popover next to `anchor` (a screen rect), flipping and clamping so it + * stays on screen. + * + * Declaring the side in CSS is not enough. A frame can sit anywhere on an + * infinite canvas, so a menu pinned to `bottom: 100%` opens off the top of the + * window the moment its anchor is near the top edge — which is exactly where a + * frame ends up after zoom-to-fit. The menu has to look at where it actually is. + * + * The maths is done in screen space and converted back at the end against the + * popover's *offset parent* — which is not the same element as the anchor. A + * frame's menu hangs off its title bar but is positioned inside the frame's + * chrome box, and using the anchor for both puts it out by the gap between them. + */ +export function placePopover( + menu: HTMLElement, + anchor: DOMRect, + prefer: Side, + opts: PlaceOptions = {} +): void { + const { align = "start", minWidth, scroll = true } = opts; + + if (minWidth) { + menu.style.minWidth = `${minWidth}px`; + } + + // Measure unconstrained, then cap to whatever room the chosen side has. + menu.style.maxHeight = ""; + /* + * Content plus the chrome around it, because the cap lands on the border box. + * + * Everything under the overlay root is `border-box` (`base.css.ts`), so a + * `max-height` written here has to cover the border as well — but `scrollHeight` + * is the content box and stops short of it. Capping straight to `scrollHeight` + * left every scrollable popover exactly `borderTop + borderBottom` short of its + * own content: a two-pixel overflow, and `.pop`'s `overflow-y: auto` duly drew a + * scrollbar on menus with three rows in them. It also placed them two pixels off, + * since `shown` is what the `above` case measures back from. + * + * `offsetHeight - clientHeight` rather than reading the border widths: it is the + * same number without parsing two computed styles, and it stays right if the + * shell ever grows a horizontal scrollbar. + */ + const chrome = menu.offsetHeight - menu.clientHeight; + const height = menu.scrollHeight + chrome; + const width = menu.offsetWidth; + + const roomAbove = anchor.top - GAP - MARGIN; + const roomBelow = window.innerHeight - anchor.bottom - GAP - MARGIN; + // Flip only when the preferred side cannot hold it and the other side is + // roomier — flipping into an even tighter space helps nobody. + const wanted = prefer === "above" ? roomAbove : roomBelow; + const other = prefer === "above" ? roomBelow : roomAbove; + const side = height <= wanted || wanted >= other ? prefer : flip(prefer); + + const room = side === "above" ? roomAbove : roomBelow; + // Unscrollable popovers keep their full height and rely on the clamp below to + // stay on screen; scrollable ones give up height instead. + const shown = scroll ? Math.min(height, Math.max(120, room)) : height; + menu.style.maxHeight = scroll ? `${shown}px` : ""; + + const top = side === "above" ? anchor.top - GAP - shown : anchor.bottom + GAP; + + let left = align === "end" ? anchor.right - width : anchor.left; + const overflowRight = left + width - (window.innerWidth - MARGIN); + if (overflowRight > 0) { + left -= overflowRight; + } + left = Math.max(MARGIN, left); + + const parent = ( + menu.offsetParent ?? menu.parentElement + )?.getBoundingClientRect(); + const originX = parent?.left ?? 0; + const originY = parent?.top ?? 0; + const clampedTop = clamp(top, MARGIN, window.innerHeight - MARGIN - shown); + menu.style.left = `${Math.round(left - originX)}px`; + menu.style.top = `${Math.round(clampedTop - originY)}px`; +} + +/* + * `createMenu` used to live here. It moved to `popover-host.ts`: a menu is a + * *client* of the host, and leaving it here made this module import the host + * that imports it back. What is left is the placement maths, which the host and + * the canvas's own world-space menus both call and neither owns. + */ diff --git a/packages/overlay/src/toast.test.ts b/packages/overlay/src/toast.test.ts new file mode 100644 index 0000000..0efa070 --- /dev/null +++ b/packages/overlay/src/toast.test.ts @@ -0,0 +1,145 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cls } from "./dom"; +import { mountToastHost, toast } from "./toast"; + +/* + * The actionable toast, and the coalescing rule it forced. + * + * `toast.ts` is a module singleton with one persistent node, so these tests + * mount one host and never tear it down — re-mounting is what the module is + * written to make unnecessary, and `mountToastHost` is idempotent by design + * (`host ??=`). The host is re-appended to a fresh body each time instead, which + * is exactly what a second `AirshipApp.mount` would do. + * + * Fake timers throughout: every path here is `DWELL`, `ACTION_DWELL` or `FADE`, + * and none of them is worth waiting six real seconds for. + */ + +/** Must match `FADE` in `toast.ts`. */ +const FADE = 140; +/** Must match `DWELL` in `toast.ts`. */ +const DWELL = 2600; +/** Must match `ACTION_DWELL` in `toast.ts`. */ +const ACTION_DWELL = 6000; + +function box(): HTMLElement { + const node = document.querySelector(`.${cls("toast")}`); + if (!node) { + throw new Error("no toast"); + } + return node; +} + +function actionBtn(): HTMLButtonElement | null { + return document.querySelector(`.${cls("toast-action")}`); +} + +beforeEach(() => { + vi.useFakeTimers(); + document.body.replaceChildren(); + mountToastHost(document.body); + // Whatever a previous test left up must be gone before this one measures. + vi.advanceTimersByTime(ACTION_DWELL + FADE); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("toast", () => { + it("renders a plain receipt with no button", () => { + toast("Reloaded Desktop"); + expect(box().textContent).toBe("Reloaded Desktop"); + expect(actionBtn()).toBeNull(); + }); + + it("renders the action as a real button carrying its label", () => { + toast("Deleted Desktop", { + action: { label: "Undo", run: () => undefined }, + }); + const button = actionBtn(); + expect(button).not.toBeNull(); + expect(button?.tagName).toBe("BUTTON"); + // `type` matters: a bare button inside anything form-like submits it. + expect(button?.type).toBe("button"); + expect(button?.textContent).toBe("Undo"); + }); + + it("runs the action once, however many times it is clicked", () => { + const run = vi.fn(); + toast("Deleted Desktop", { action: { label: "Undo", run } }); + const button = actionBtn(); + button?.click(); + button?.click(); + button?.click(); + expect(run).toHaveBeenCalledTimes(1); + }); + + it("ignores a click that lands during the fade-out", () => { + const run = vi.fn(); + toast("Deleted Desktop", { action: { label: "Undo", run } }); + const button = actionBtn(); + // The dwell expires and the node starts leaving, but for `FADE` more + // milliseconds it is still on screen and still clickable. + vi.advanceTimersByTime(ACTION_DWELL); + vi.advanceTimersByTime(FADE); + button?.click(); + expect(run).not.toHaveBeenCalled(); + }); + + it("outlives a plain receipt's dwell", () => { + toast("Deleted Desktop", { + action: { label: "Undo", run: () => undefined }, + }); + vi.advanceTimersByTime(DWELL + FADE); + // A plain toast would have been torn down by now. + expect(actionBtn()).not.toBeNull(); + vi.advanceTimersByTime(ACTION_DWELL); + expect(actionBtn()).toBeNull(); + }); + + it("still coalesces two identical plain messages", () => { + toast("Nudged"); + toast("Nudged"); + expect(box().querySelector(`.${cls("toast-count")}`)?.textContent).toBe( + "×2" + ); + }); + + it("never coalesces two identical actionable messages", () => { + const first = vi.fn(); + const second = vi.fn(); + toast("Deleted Desktop", { action: { label: "Undo", run: first } }); + toast("Deleted Desktop", { action: { label: "Undo", run: second } }); + + // No tally: the second toast replaced the first rather than counting it. + expect(box().querySelector(`.${cls("toast-count")}`)).toBeNull(); + // And the surviving button belongs to the second delete, not the first. + actionBtn()?.click(); + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledTimes(1); + }); + + it("does not fold an actionable message into a plain one already up", () => { + toast("Deleted Desktop"); + toast("Deleted Desktop", { + action: { label: "Undo", run: () => undefined }, + }); + expect(box().querySelector(`.${cls("toast-count")}`)).toBeNull(); + expect(actionBtn()).not.toBeNull(); + }); + + it("leaves a toast raised by the action itself standing", () => { + toast("Deleted Desktop", { + action: { + label: "Undo", + run: () => toast("Frame limit reached (8)", { tone: "error" }), + }, + }); + actionBtn()?.click(); + // The fade the click armed must not tear down the message that replaced it. + vi.advanceTimersByTime(FADE); + expect(box().textContent).toContain("Frame limit reached (8)"); + expect(box().classList.contains(cls("toast-error"))).toBe(true); + }); +}); diff --git a/packages/overlay/src/toast.ts b/packages/overlay/src/toast.ts new file mode 100644 index 0000000..e600593 --- /dev/null +++ b/packages/overlay/src/toast.ts @@ -0,0 +1,323 @@ +/* + * The editor's one line of feedback. + * + * **A module singleton, not an injected dep.** `keys.ts` ("a singleton because + * the document has exactly one keyboard") and `dnd/manager.ts` are already + * imported directly from everywhere, and a toast is the same shape of thing: + * one document, one place feedback appears, one at a time. Threading an + * `onToast` through `FrameChromeDeps`, `DesignPanelDeps` and `HistoryDeps` would + * be three new plumbing paths for a function with no per-instance state. The + * deps that *are* injected — `applyStyle`, `refresh` — exist to keep models from + * knowing about the DOM; this is the DOM, and it has nothing to invert. + * + * **One persistent element, visibility by class.** The node is built on first + * use and never removed; `toast-in` toggles opacity and offset. That single + * choice is what makes both of the hard requirements fall out: replacing a live + * toast is `replaceChildren()` on a node that never left the layout — no + * unmount/remount flicker when ⌘Z is held down — and coalescing a repeat is a + * counter plus a text rewrite on something already on screen. The previous + * implementation appended a fresh `
` per call at identical fixed + * coordinates, so two toasts in the same 2.6 seconds rendered on top of each + * other and neither could be read. + * + * --- + * + * **What earns a toast.** Raise one when the editor changed state and the change + * is not legible from the change itself. Never for a continuous or dragged + * gesture — the motion is the feedback, and a toast per `pointermove` would + * render `Resized ×212`. Never for what a control's own state already reports: a + * pressed tool, a dimmed button, a field showing its new value, a zoom readout. + * Never for anything the user typed; they know what they typed. + * + * Three clauses that are not about legibility: + * + * - **Refusals get a toast even though nothing changed**, because "nothing + * changed" is precisely what needs explaining. A button has a disabled state + * to lean on. A keyboard shortcut has nothing. + * - **Acts the undo stack does not cover get one even when they are legible**, + * because the message is not "this happened" but "this happened and ⌘Z will + * not take it back". + * - **Some of those can carry their own undo.** `ToastOptions.action` puts a + * real button on the toast, and a frame delete is the first caller. `History` + * journals element ops and nothing else; teaching it a frame op would put the + * model that replays DOM mutations in charge of rebuilding an iframe realm, + * which is not a thing a journal can honestly claim to reverse. So the + * affordance lives on the receipt instead, and lives exactly as long as it + * does. That bargain is the whole reason an actionable toast dwells more than + * twice as long and never coalesces — see `ACTION_DWELL` and `repeat`. + * + * **Which layer raises it:** whichever one holds the information the message + * contains. That is why element deletes speak from `DesignPanel` (only it has + * the node to name, and only it knows whether its own guard refused), why frame + * verbs speak from `FrameChrome` rather than from `FrameManager` (the manager is + * also driven by first-run seeding and by every frame of a resize drag) — and, + * now that a frame delete can be reached from the frame's own menu, from the bar + * group and from ⌫, from a *single* closure there rather than three, so that + * neither the receipt nor the undo can depend on which door you came through — + * and why undo speaks from `AirshipApp` rather than from `History` (the model + * returns a boolean and lets the caller decide whether an empty stack is worth + * saying out loud). + */ +import { cls, el } from "./dom"; +import { type IconName, icon } from "./icons"; + +export type ToastTone = "error" | "neutral"; + +/** An affordance on the toast itself. See the third clause of the header. */ +export interface ToastAction { + /** The button's text. One imperative word — "Undo". */ + label: string; + run: () => void; +} + +export interface ToastOptions { + /** Put a button on the toast. Implies `ACTION_DWELL` and suppresses `×N`. */ + action?: ToastAction; + /** Dwell before the fade-out begins. Defaults to `DWELL`. */ + duration?: number; + /** Leading glyph. `error` supplies `close` on its own; pass this to override. */ + icon?: IconName; + tone?: ToastTone; +} + +/** How long a toast stays at full opacity. */ +const DWELL = 2600; + +/** + * The dwell for a toast you are expected to *aim at*. + * + * 2.6s is right for a receipt: long enough to read, short enough to stay out of + * the way. It is not long enough to notice a button, decide, move the pointer + * and hit it — the affordance would expire under the cursor about as often as it + * worked. This is the low end of the usual guidance for actionable snackbars; + * much longer and a toast nobody wants starts loitering over the canvas. + */ +const ACTION_DWELL = 6000; + +/** Must match the `transition` duration in `styles/toast.css.ts`. */ +const FADE = 140; + +interface LiveToast { + /** The affordance, or null for a plain receipt. */ + action: ToastAction | null; + count: number; + /** True once the dwell has expired and the fade-out is running. */ + fading: boolean; + message: string; + /** True once the action has been run, so a second click does nothing. */ + spent: boolean; + timer: ReturnType | null; + tone: ToastTone; +} + +let host: HTMLElement | null = null; +/** + * The one toast element, created on first use and never removed. + * + * Deliberately *not* a field on `live`. `live` describes the message currently + * being shown and is nulled when one expires; hanging the element off it would + * mean every toast after the first expiry appended a fresh `
` at the same + * fixed coordinates — reintroducing, one fade-out later, exactly the pile-up + * this module was written to remove. + */ +let node: HTMLElement | null = null; +let live: LiveToast | null = null; + +/** + * Mount the singleton host. Called once from `AirshipApp.mount`, deliberately + * ahead of `mountPopoverHost` so the popover host stays the root's last child — + * which is hygiene rather than the mechanism, since both declare a numeric + * `z-index` (see `styles/const.ts`). + */ +export function mountToastHost(root: HTMLElement): HTMLElement { + host ??= el("div", { class: cls("toast-host") }); + root.append(host); + return host; +} + +/** + * Raise a toast. + * + * Replaces whatever is currently up and restarts its dwell. An identical + * message arriving while the same one is still at full opacity becomes a count + * instead of a replacement. + */ +export function toast(message: string, opts: ToastOptions = {}): void { + const mount = host; + if (!mount) { + return; + } + const tone: ToastTone = opts.tone ?? "neutral"; + const glyph = opts.icon ?? (tone === "error" ? "close" : undefined); + const action = opts.action ?? null; + + // Not coalescing across a fade-out is deliberate: once a toast has started + // leaving, the next one is a new event, and a counter that picks up again + // across the gap reads as a bug rather than as a tally. + // + // An actionable toast never coalesces at all, in either direction. A count is + // a tally over one event repeated; an action is a handle on one *specific* + // event. `Deleted Frame 3 ×2` would carry a single Undo that restores the + // second frame while claiming to speak for both — and would drop the first + // frame's restore closure on a message the user watched not change, so nothing + // on screen would say that half of what it names is now unrecoverable. Two + // deletes therefore make two toasts, the second replacing the first, and only + // the second is recoverable. The toast is the undo stack, and it is one deep. + const repeat = + live !== null && + !live.fading && + live.message === message && + live.tone === tone && + live.action === null && + action === null; + + const box = ensureNode(mount); + if (live?.timer) { + clearTimeout(live.timer); + } + // A fresh record every time, even on a repeat. The timers close over the one + // they were armed for and bail if `live` has moved on since — an identity + // check that only means something if each call really is a new identity. + const current: LiveToast = { + action, + count: repeat && live ? live.count + 1 : 1, + fading: false, + message, + spent: false, + timer: null, + tone, + }; + live = current; + if (!repeat) { + box.classList.toggle(cls("toast-error"), tone === "error"); + } + render(box, current, glyph); + show(box); + current.timer = setTimeout( + () => startFade(current, box), + opts.duration ?? (action ? ACTION_DWELL : DWELL) + ); +} + +function ensureNode(mount: HTMLElement): HTMLElement { + if (node) { + return node; + } + // `role="status"` implies `aria-live="polite"`. The `×N` rewrite re-announces + // on every repeat, which is the accepted cost of having the counter at all — + // the alternative is a burst of undos that says nothing after the first. + node = el("div", { class: cls("toast"), role: "status" }); + mount.append(node); + return node; +} + +/** + * Takes the record rather than three of its fields. + * + * The action is the fourth thing to render and every one of them already lives + * on `current`; four positionals describing one object that is right there is + * the point at which the object should be the argument. + */ +function render( + box: HTMLElement, + current: LiveToast, + glyph: IconName | undefined +): void { + const children: (HTMLElement | string)[] = []; + if (glyph) { + children.push(icon(glyph, "sm")); + } + children.push(el("span", { text: current.message })); + if (current.count > 1) { + children.push( + el("span", { class: cls("toast-count"), text: `×${current.count}` }) + ); + } + if (current.action) { + children.push(actionButton(current, box)); + } + box.replaceChildren(...children); +} + +/** + * The affordance, as a real button. + * + * Not a clickable span: this is Tab-reachable, answers Enter and Space, and + * takes its accessible name from its own text. Deliberately *not* autofocused — + * stealing focus for a six-second affordance is worse than making the user reach + * for it, and the toast host is late enough in the root that one Tab from the + * canvas lands here anyway. + */ +function actionButton(current: LiveToast, box: HTMLElement): HTMLElement { + return el("button", { + class: cls("toast-action"), + onClick: () => runAction(current, box), + text: current.action?.label ?? "", + type: "button", + }); +} + +/** + * Run the affordance, once. + * + * Two guards answering two different questions. `spent` stops a second click on + * the button that is still on screen. The identity check stops a click on a + * button belonging to a toast that has already moved on — `replaceChildren` + * detaches the old one, but a press already in flight, and the whole `FADE` + * during which the node is still there and still clickable, both reach this + * closure. Same `live !== current` idiom the two timers use, and for the same + * reason. + */ +function runAction(current: LiveToast, box: HTMLElement): void { + const { action } = current; + if (current.spent || live !== current || !action) { + return; + } + current.spent = true; + if (current.timer) { + clearTimeout(current.timer); + } + // Fade first, run second. The action may raise a toast of its own — a refusal + // — and `toast()` replaces `live`, at which point this fade's own teardown + // bails on its identity check and the new message is left standing. + startFade(current, box); + action.run(); +} + +function show(box: HTMLElement): void { + if (box.classList.contains(cls("toast-in"))) { + return; + } + // The node is never `display: none` — only `opacity: 0` — so a class toggle + // transitions correctly on every toast but the first, where the element was + // appended in this same tick and has no computed style to transition *from*. + // Measuring forces the layout that gives it one. + box.getBoundingClientRect(); + box.classList.add(cls("toast-in")); +} + +/** + * Begin the fade-out, and arm the teardown behind it. + * + * Two timers rather than a `transitionend` listener. An interrupted transition — + * a new toast arriving mid-fade — never fires `transitionend`, so the listener + * would either leak or, worse, fire late and erase the *replacement*. A timer + * that a later `toast()` call simply clears has neither failure mode. + */ +function startFade(current: LiveToast, box: HTMLElement): void { + if (live !== current) { + return; + } + current.fading = true; + box.classList.remove(cls("toast-in")); + current.timer = setTimeout(() => { + if (live !== current) { + return; + } + // The element stays; only its contents and the live state go. Removing it + // would mean rebuilding — and re-mounting — on the next toast. + box.replaceChildren(); + box.classList.remove(cls("toast-error")); + live = null; + }, FADE); +} diff --git a/packages/overlay/src/tooltip.stories.ts b/packages/overlay/src/tooltip.stories.ts new file mode 100644 index 0000000..34d455d --- /dev/null +++ b/packages/overlay/src/tooltip.stories.ts @@ -0,0 +1,229 @@ +import type { Meta, StoryObj } from "@storybook/html-vite"; +import { cls, el } from "./dom"; +import { type IconName, icon } from "./icons"; +import { keys } from "./keys"; +import { popoverHost } from "./popover-host"; +import { + type Caption, + dock, + inspectorBody, + plainStage, + section, +} from "./stories/chrome"; +import { noop } from "./stories/fixtures"; +import { onStoryTeardown } from "./stories/lifecycle"; +import { Tooltips } from "./tooltip"; + +/* + * The overlay's tooltips. + * + * Native `title` was doing this job and cannot: it opens after about a second, + * cannot be styled, and — the reason this module exists — cannot show a keyboard + * shortcut. A design tool's tooltips carry their own shortcut, and that is a + * real part of why an app feels like someone cared rather than like a prototype. + * + * One delegated listener and one shared node, not a tooltip per control. Every + * icon button in the editor would otherwise be an extra DOM node and an extra + * pair of listeners for something shown a few hundred milliseconds at a time. + * + * Two placement rules make up most of the design, and both are about the + * inspector specifically: + * + * **Above inside the panel body, below everywhere else.** The panel is the one + * place in the editor where full-width controls stack six pixels apart, so a tip + * placed below a row lands squarely on the next one — the control you are on + * your way to. The rows above it are values you have already read. + * + * **Clamped to the dock before the viewport.** A tip on a caret at the right + * edge of a 360px panel is inside the window and still outside the panel, + * hanging past a rounded border with nothing under it. That reads as a bug + * rather than as that control's label. + * + * Hovering is driven by dispatching a real `pointerover`, which is what the + * delegated listener is bound to. No new test dependency — the same raw-event + * idiom `select.stories.ts` already uses for its `.click()`. + */ + +const meta: Meta = { + title: "Chrome/Tooltips", +}; + +export default meta; + +/** How long a pointer must rest before a tooltip opens, plus a frame. */ +const DELAY = 450; + +/** + * Stand a `Tooltips` up, then hover a control and wait out the open delay. + * + * Both halves belong in `play` rather than in `render`, and the ordering is the + * reason. `Tooltips` mounts into the popover host — the overlay root's *last* + * child — and `preview.ts` calls `mountPopoverHost` only *after* `story()` has + * returned, so during `render` there is no host to mount into. `play` runs after + * the story is in the document, which is the first moment this can be built. + * + * The host and not the chrome layer, incidentally: the layer shares the root's + * `z-index` but is appended before it, so it paints under the docks. Mounted + * there, a tooltip anchored to any control inside a panel was drawn behind that + * panel and never seen. + */ +function hover(canvasElement: HTMLElement, tip: string): Promise { + const host = popoverHost(); + if (!host) { + throw new Error( + "No popover host — `mountPopoverHost` did not run. See .storybook/preview.ts." + ); + } + const instance = new Tooltips(host); + onStoryTeardown(() => instance.destroy()); + + canvasElement + .querySelector(`[data-tip="${tip}"]`) + ?.dispatchEvent(new PointerEvent("pointerover", { bubbles: true })); + return new Promise((resolve) => setTimeout(resolve, DELAY)); +} + +function tipButton(glyph: IconName, tip: string, extra = ""): HTMLElement { + return el( + "button", + { + "aria-label": tip, + class: `${cls("action")} ${cls("action-icon")} ${extra}`.trim(), + "data-tip": tip, + onClick: noop, + type: "button", + }, + [icon(glyph, "sm")] + ); +} + +/** + * A rail of icon buttons, one of them hovered. + * + * The bottom bar's shape: chrome outside any panel, so the tip goes below, where + * the space under the control is empty. + */ +export const Grid: StoryObj = { + play: ({ canvasElement }) => hover(canvasElement, "Move"), + render: () => + plainStage( + [ + el("div", { class: cls("bar"), style: "display: flex; gap: 4px;" }, [ + tipButton("tool-move", "Move"), + tipButton("tool-comment", "Comment"), + tipButton("edit", "Edit"), + tipButton("view", "View"), + ]), + ], + { + try: "hover along the row — after the first tip the rest open instantly, which is what makes scanning a toolbar feel responsive", + what: "Toolbar chrome, outside any dock. The tip opens below, after a 400ms rest.", + } + ), +}; + +/** + * A tip that carries its shortcut. + * + * The whole reason this module exists rather than a `title` attribute. + * `keys.hintFor(label)` looks the binding up by its *label*, so the tip and the + * keymap cannot drift: a shortcut that is rebound changes here with no edit, and + * one that is removed stops being advertised. + * + * The binding is registered by the story and its disposer handed to the + * lifecycle registry — `keys` is a module singleton, so a story that bound + * without unbinding would leave a live shortcut in every story after it. + */ +export const WithShortcut: StoryObj = { + play: ({ canvasElement }) => hover(canvasElement, "Undo"), + render: () => { + onStoryTeardown(keys.bind({ keys: "mod+z", label: "Undo", run: noop })); + onStoryTeardown( + keys.bind({ keys: "mod+shift+z", label: "Redo", run: noop }) + ); + return plainStage( + [ + el("div", { class: cls("bar"), style: "display: flex; gap: 4px;" }, [ + // Both `rotate-ccw`, as the bottom bar builds them: there is no + // redo glyph, and `.bar-redo` mirrors the undo one. + tipButton("rotate-ccw", "Undo"), + tipButton("rotate-ccw", "Redo", cls("bar-redo")), + // No binding registered for this one, so `hintFor` returns null and + // the tip is text alone — which is the common case and has to look + // deliberate rather than truncated. + tipButton("more", "More actions"), + ]), + ], + { + try: "compare Undo with More actions — the chord is looked up by label, so an unbound control shows text and nothing else", + what: "A tooltip carrying its keyboard shortcut, which is the thing `title` cannot do.", + } + ); + }, +}; + +/** + * Inside the panel body, where the tip goes above. + * + * Full-width rows stack six pixels apart here, so a tip below a row would cover + * the next one — the control you are moving towards, rather than the values you + * have already read. + */ +export const InsidePanel: StoryObj = { + play: ({ canvasElement }) => hover(canvasElement, "Independent corners"), + render: () => + plainStage( + [ + dock( + inspectorBody([ + section( + "Appearance", + el("div", { style: "display: grid; gap: 6px;" }, [ + tipButton("corners-independent", "Independent corners"), + tipButton("opacity", "Opacity"), + tipButton("effect-drop-shadow", "Drop shadow"), + ]) + ), + ]) + ), + ], + { + try: "compare with Grid above — same control, opposite side, decided by whether it sits in `.insp-body`", + what: "A tip inside the inspector body, placed above so it does not land on the next row.", + } + ), +}; + +/** + * A control at the right edge of the narrowest dock. + * + * The clamp `bounds()` exists for. The tip is centred on its control, which puts + * it past the panel's right border — inside the window, outside the panel, and + * hanging over nothing. Clamping to the dock first and the viewport second is + * what keeps it attached to the thing it names. + */ +export const AtTheEdge: StoryObj = { + play: ({ canvasElement }) => hover(canvasElement, "Advanced stroke settings"), + render: () => + plainStage( + [ + dock( + inspectorBody([ + section( + "Stroke", + el( + "div", + { style: "display: flex; justify-content: flex-end;" }, + [tipButton("settings", "Advanced stroke settings")] + ) + ), + ]), + { narrow: true } + ), + ], + { + try: "the tip must stay inside the dock's rounded border — past it, a label reads as a rendering bug", + what: "A long tip on a control flush with the right edge of a `MIN_DOCK_W` panel.", + } + ), +}; diff --git a/packages/overlay/src/tooltip.ts b/packages/overlay/src/tooltip.ts new file mode 100644 index 0000000..1f93596 --- /dev/null +++ b/packages/overlay/src/tooltip.ts @@ -0,0 +1,201 @@ +/* + * The overlay's tooltips. + * + * Native `title` was doing this job, and it cannot: it opens after ~1s, cannot + * be styled, and — the reason this exists — cannot show a keyboard shortcut. + * Design-tool tooltips carry their own shortcut, and that is a real part of why the + * app feels like someone cared rather than like a prototype. + * + * One delegated listener and one shared element, not a tooltip per control: + * every icon button in the editor would otherwise be an extra DOM node and an + * extra pair of listeners for something shown a few hundred milliseconds at a + * time. + */ +import { cls, el } from "./dom"; +import { keys } from "./keys"; +import { clamp } from "./num"; +import { placePopover, type Side } from "./popover"; + +/** Breathing room between the tip and the edge of the panel it sits in. */ +const MARGIN = 6; +/** How long a pointer must rest before a tooltip opens. */ +const DELAY = 400; +/** + * Once one tooltip has been shown, the next opens instantly for this long. This + * is what makes scanning a toolbar feel responsive instead of like waiting out + * the delay once per button. + */ +const CHAIN = 500; + +export class Tooltips { + private readonly node: HTMLElement; + private timer: ReturnType | null = null; + private lastShown = 0; + private current: HTMLElement | null = null; + + /** + * `host` is the popover host — the overlay root's last child. It is not the + * chrome layer, which shares the root's `z-index` but is appended before it + * and therefore paints *under* the docks: mounted there, a tooltip anchored to + * any control inside a panel was drawn behind that panel and never seen. + */ + constructor(host: HTMLElement) { + this.node = el("div", { class: `${cls("tip")} ${cls("hidden")}` }); + host.append(this.node); + document.addEventListener("pointerover", this.onOver, true); + document.addEventListener("pointerdown", this.hide, true); + document.addEventListener("pointerout", this.onOut, true); + // Capture, because scrolls inside `.insp-body` do not bubble to `window` — + // the same reason `popover-host`'s reflow listener is registered this way. + window.addEventListener("scroll", this.onScroll, true); + // A tooltip left open over a control that just disappeared (a re-render, + // a section collapse) would hang there with nothing under it. + window.addEventListener("blur", this.hide); + } + + destroy(): void { + document.removeEventListener("pointerover", this.onOver, true); + document.removeEventListener("pointerdown", this.hide, true); + document.removeEventListener("pointerout", this.onOut, true); + window.removeEventListener("scroll", this.onScroll, true); + window.removeEventListener("blur", this.hide); + this.clearTimer(); + this.node.remove(); + } + + private clearTimer(): void { + if (this.timer !== null) { + clearTimeout(this.timer); + this.timer = null; + } + } + + private readonly hide = (): void => { + this.clearTimer(); + this.current = null; + this.node.classList.add(cls("hidden")); + }; + + private readonly onOver = (e: Event): void => { + const target = (e.target as Element | null)?.closest?.("[data-tip]"); + if (!(target instanceof HTMLElement)) { + if (this.current) { + this.hide(); + } + return; + } + if (target === this.current) { + return; + } + this.clearTimer(); + this.current = target; + const wait = Date.now() - this.lastShown < CHAIN ? 0 : DELAY; + this.timer = setTimeout(() => this.show(target), wait); + }; + + /** + * The pointer left the window. + * + * Hiding was driven entirely by `pointerover` of something *else*, and leaving + * the window fires no such event — so a tip stayed up over a control the + * pointer had long since left. A null `relatedTarget` is that exit; moves + * between children carry one and `onOver` already handles them. + */ + private readonly onOut = (e: Event): void => { + if (!(e as PointerEvent).relatedTarget) { + this.hide(); + } + }; + + /** + * Hide rather than follow. + * + * `.insp-body` scrolls, so the control slides out from under its own tip — + * and because `onOver` early-returns while the same element is under the + * pointer, nothing ever re-placed it. A tooltip is a fact about where the + * pointer is resting; the next `pointerover` re-earns it. Following would also + * have to notice the anchor leaving the panel's clipped body, which is more + * machinery than a 400ms label is worth. + */ + private readonly onScroll = (): void => { + if (this.current || this.timer !== null) { + this.hide(); + } + }; + + private show(target: HTMLElement): void { + const text = target.dataset.tip; + if (!(text && target.isConnected)) { + return; + } + this.node.replaceChildren(el("span", { text })); + + // The shortcut is looked up by the tooltip's own text, so a control and its + // binding agree by construction: `data-tip="Undo"` finds the binding + // labelled "Undo" and nothing has to repeat the chord. + const hint = keys.hintFor(text); + if (hint) { + this.node.append(el("span", { class: cls("tip-key"), text: hint })); + } + + this.node.classList.remove(cls("hidden")); + this.place(target.getBoundingClientRect()); + this.lastShown = Date.now(); + } + + /** Centred on the control, kept inside the panel it belongs to. */ + private place(anchor: DOMRect): void { + const w = this.node.offsetWidth; + const bounds = this.bounds(); + /* + * Clamped to the dock first, the viewport second. + * + * A tip on a caret at the right edge of a 360px panel is inside the window + * and still outside the panel — hanging past a rounded border with nothing + * under it, which reads as a bug rather than as that control's label. Doing + * this here rather than in `placePopover` keeps the canvas's world-space + * menus, which share that function, out of it. + */ + const left = clamp( + anchor.left + anchor.width / 2 - w / 2, + bounds.left + MARGIN, + bounds.right - w - MARGIN + ); + /* + * `placePopover` owns the vertical, and the horizontal clamp of last resort. + * + * This used to be bespoke: always 6px below, flipping only when the tip ran + * past `window.innerHeight` — it never asked whether the space it flipped + * *into* was any bigger — and never converting through the offset parent. + * The rect handed over is the anchor's box slid to the left chosen above, so + * the default `align: "start"` reproduces "centred on the control". + */ + placePopover( + this.node, + new DOMRect(left, anchor.top, w, anchor.height), + this.side(), + { scroll: false } + ); + } + + /** The panel the anchor belongs to, or the viewport for chrome outside one. */ + private bounds(): DOMRect { + const rect = this.current + ?.closest(`.${cls("dock")}`) + ?.getBoundingClientRect(); + return rect ?? new DOMRect(0, 0, window.innerWidth, window.innerHeight); + } + + /** + * Above inside the inspector body, below everywhere else. + * + * The panel is the one place in the editor where full-width controls stack six + * pixels apart, so a tip placed below a row lands squarely on the next one — + * the control you are on your way to. The rows above it are values you have + * already read. Toolbars and canvas chrome keep "below", where the space under + * the control is empty. + */ + private side(): Side { + return this.current?.closest(`.${cls("insp-body")}`) ? "above" : "below"; + } +}