feat(overlay): floating chrome — popovers, tooltips, toasts and keys

A shared popover host so panels, menus and colour pickers stack, dismiss and
restore focus by one set of rules rather than each inventing its own.
This commit is contained in:
Nayan
2026-08-02 00:55:00 +05:30
parent f29fbece1b
commit 455cbce785
11 changed files with 2709 additions and 0 deletions
+121
View File
@@ -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);
}
+146
View File
@@ -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.",
});
},
};
+245
View File
@@ -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<typeof keys.bind>[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);
});
});
+303
View File
@@ -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<string, string> = {
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<Document>();
/** 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();
+306
View File
@@ -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<HTMLElement>("[data-pop-item]"),
];
const cursor = (content: HTMLElement): string | null =>
content.querySelector<HTMLElement>("[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();
}
});
});
+575
View File
@@ -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 `<body>`: 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<HTMLElement>(ITEM));
}
function activeItem(content: HTMLElement): HTMLElement | null {
return content.querySelector<HTMLElement>("[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;
},
};
}
+115
View File
@@ -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.
*/
+145
View File
@@ -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<HTMLElement>(`.${cls("toast")}`);
if (!node) {
throw new Error("no toast");
}
return node;
}
function actionBtn(): HTMLButtonElement | null {
return document.querySelector<HTMLButtonElement>(`.${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);
});
});
+323
View File
@@ -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 `<div>` 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<typeof setTimeout> | 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 `<div>` 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);
}
+229
View File
@@ -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<void> {
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.",
}
),
};
+201
View File
@@ -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<typeof setTimeout> | 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";
}
}