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:
@@ -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);
|
||||
}
|
||||
@@ -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.",
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.",
|
||||
}
|
||||
),
|
||||
};
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user