+ );
+ const frame = (
+
+ );
+
+ // Beside the control the user clicked: no backdrop, no big header. The
+ // plugin is still named in the host's own line, with the way to turn it
+ // off; a click outside or Escape closes it as the dialog's X does.
+ if (anchor) {
+ return (
+
- )}
+ {footer}
>
) : (
<>
@@ -328,6 +386,37 @@ function PluginDialogView({ current, layer }: { current: DialogRequest; layer: '
);
}
+/**
+ * Where a popover dialog's panel stands: measured before it paints and again
+ * when its content (the plugin's frame) or the window changes size.
+ */
+function usePopoverPlace(anchor: AnchorRect | undefined, panelRef: React.RefObject): { left: number; top: number } | null {
+ const [place, setPlace] = useState<{ left: number; top: number } | null>(null);
+ useLayoutEffect(() => {
+ const panel = panelRef.current;
+ if (!anchor || !panel) return;
+ const update = () => {
+ const r = panel.getBoundingClientRect();
+ const next = placePopover(
+ anchor,
+ { width: r.width, height: r.height },
+ { width: window.innerWidth, height: window.innerHeight },
+ document.documentElement.dir === 'rtl',
+ );
+ setPlace((p) => (p && p.left === next.left && p.top === next.top ? p : next));
+ };
+ update();
+ const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(update);
+ observer?.observe(panel);
+ window.addEventListener('resize', update);
+ return () => {
+ observer?.disconnect();
+ window.removeEventListener('resize', update);
+ };
+ }, [anchor, panelRef]);
+ return place;
+}
+
/** A footer button's icon by name; unknown names show none. */
function FooterIcon({ name }: { name?: string }) {
const Glyph = name ? iconForName(name) : null;
@@ -335,7 +424,7 @@ function FooterIcon({ name }: { name?: string }) {
}
/** The "⋯" menu in a plugin dialog's header: the way out of a plugin that keeps asking. */
-function PluginDialogMenu({ name, onTurnOff }: { name: string; onTurnOff: () => void }) {
+function PluginDialogMenu({ name, onTurnOff, compact }: { name: string; onTurnOff: () => void; compact?: boolean }) {
const t = useTranslations('plugins.dialog');
const [open, setOpen] = useState(false);
const ref = useRef(null);
@@ -356,9 +445,9 @@ function PluginDialogMenu({ name, onTurnOff }: { name: string; onTurnOff: () =>
aria-expanded={open}
aria-label={t('plugin_menu', { name })}
title={t('plugin_menu', { name })}
- className={cn('p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground', open && 'bg-muted text-foreground')}
+ className={cn(compact ? 'p-1' : 'p-1.5', 'rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground', open && 'bg-muted text-foreground')}
>
-
+
{open && (
diff --git a/lib/__tests__/plugin-dialog-anchor.test.ts b/lib/__tests__/plugin-dialog-anchor.test.ts
new file mode 100644
index 000000000..77a9c71d3
--- /dev/null
+++ b/lib/__tests__/plugin-dialog-anchor.test.ts
@@ -0,0 +1,136 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { InstalledPlugin } from '../plugin-types';
+import { dispatchApiCall } from '../plugin-sandbox/host-api';
+import { head, resetDialogLimits, resolveHead } from '../plugin-sandbox/host-dialog';
+import {
+ forgetTrigger, noteTrigger, placePopover, trackTriggers, triggerAnchor, POPOVER_GAP, POPOVER_MARGIN, TRIGGER_WINDOW_MS,
+} from '../plugin-sandbox/dialog-anchor';
+
+// api.ui.openDialog({ anchor: 'trigger' }): the dialog beside the control the
+// user activated last, when that was a moment ago.
+
+function plugin(): InstalledPlugin {
+ return {
+ id: 'p1', name: 'Plugin One', version: '1.0.0', author: '', description: '', type: 'hook',
+ permissions: [], entrypoint: 'index.js', enabled: true, status: 'running', settings: {}, managed: true,
+ };
+}
+
+/** A control on the page standing at this rect. */
+function control(html: string, rect: { left: number; top: number; width: number; height: number }): HTMLElement {
+ const host = document.createElement('div');
+ host.innerHTML = html;
+ document.body.appendChild(host);
+ const el = host.querySelector('[data-at]') ?? host.firstElementChild as HTMLElement;
+ el.getBoundingClientRect = () => ({
+ left: rect.left, top: rect.top, right: rect.left + rect.width, bottom: rect.top + rect.height,
+ width: rect.width, height: rect.height, x: rect.left, y: rect.top, toJSON: () => ({}),
+ });
+ return el;
+}
+
+let stop: () => void = () => {};
+beforeEach(() => {
+ forgetTrigger();
+ resetDialogLimits();
+ while (head()) resolveHead(null);
+ stop = trackTriggers();
+});
+afterEach(() => {
+ stop();
+ document.body.innerHTML = '';
+});
+
+describe('the trigger a dialog opens beside', () => {
+ it('is the button under the pointer, measured again when the dialog opens', () => {
+ const button = control('', { left: 100, top: 50, width: 24, height: 24 });
+ button.querySelector('span')!.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 110, clientY: 60 }));
+ expect(triggerAnchor()).toEqual({ left: 100, top: 50, right: 124, bottom: 74 });
+ });
+
+ it('is the point a menu entry was chosen at, the menu being gone', () => {
+ const entry = control('
Follow up
', { left: 300, top: 200, width: 180, height: 28 });
+ entry.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 320, clientY: 210 }));
+ entry.parentElement!.remove();
+ expect(triggerAnchor()).toEqual({ left: 320, top: 210, right: 320, bottom: 210 });
+ });
+
+ it('is the focused control on Enter, and nothing after another key', () => {
+ const button = control('', { left: 10, top: 20, width: 20, height: 20 });
+ button.focus();
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
+ expect(triggerAnchor()).toEqual({ left: 10, top: 20, right: 30, bottom: 40 });
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Shift' }));
+ expect(triggerAnchor()).not.toBeNull();
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 's' }));
+ expect(triggerAnchor()).toBeNull();
+ });
+
+ it('is forgotten after the window', () => {
+ const button = control('', { left: 10, top: 20, width: 20, height: 20 });
+ noteTrigger(button, undefined, 1000);
+ expect(triggerAnchor(1000 + TRIGGER_WINDOW_MS)).not.toBeNull();
+ expect(triggerAnchor(1001 + TRIGGER_WINDOW_MS)).toBeNull();
+ });
+
+ it('keeps its rect when the control has gone', () => {
+ const button = control('', { left: 10, top: 20, width: 20, height: 20 });
+ noteTrigger(button);
+ button.remove();
+ expect(triggerAnchor()).toEqual({ left: 10, top: 20, right: 30, bottom: 40 });
+ });
+});
+
+describe('placePopover', () => {
+ const viewport = { width: 1000, height: 800 };
+ const size = { width: 320, height: 200 };
+
+ it('stands below the anchor, at its start', () => {
+ expect(placePopover({ left: 100, top: 50, right: 124, bottom: 74 }, size, viewport))
+ .toEqual({ left: 100, top: 74 + POPOVER_GAP });
+ });
+
+ it('lines up with the end in RTL', () => {
+ expect(placePopover({ left: 600, top: 50, right: 624, bottom: 74 }, size, viewport, true).left).toBe(624 - 320);
+ });
+
+ it('goes above when it does not fit below', () => {
+ expect(placePopover({ left: 100, top: 700, right: 124, bottom: 724 }, size, viewport).top).toBe(700 - POPOVER_GAP - 200);
+ });
+
+ it('stays inside the window', () => {
+ expect(placePopover({ left: 950, top: 10, right: 980, bottom: 30 }, size, viewport).left).toBe(1000 - POPOVER_MARGIN - 320);
+ expect(placePopover({ left: 100, top: 300, right: 100, bottom: 300 }, { width: 320, height: 700 }, viewport).top)
+ .toBe(800 - POPOVER_MARGIN - 700);
+ });
+});
+
+describe("ui.openDialog's anchor", () => {
+ it("is the trigger's place when the plugin asks for it", async () => {
+ const button = control('', { left: 100, top: 50, width: 24, height: 24 });
+ button.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 110, clientY: 60 }));
+ const result = dispatchApiCall(plugin(), 'ui.openDialog', [{ slot: 'plugin-dialog', anchor: 'trigger', width: 320 }]);
+ await vi.waitFor(() => expect(head()).not.toBeNull());
+ expect(head()?.anchor).toEqual({ left: 100, top: 50, right: 124, bottom: 74 });
+ resolveHead(null);
+ await expect(result).resolves.toBeNull();
+ });
+
+ it('is none without the option, without a trigger, or with a place the plugin names', async () => {
+ const button = control('', { left: 100, top: 50, width: 24, height: 24 });
+ button.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }));
+ for (const opts of [{ slot: 'plugin-dialog' }, { slot: 'plugin-dialog', anchor: { left: 0, top: 0, right: 0, bottom: 0 } }]) {
+ const result = dispatchApiCall(plugin(), 'ui.openDialog', [opts]);
+ await vi.waitFor(() => expect(head()).not.toBeNull());
+ expect(head()?.anchor).toBeUndefined();
+ resolveHead(null);
+ await result;
+ }
+ forgetTrigger();
+ const result = dispatchApiCall(plugin(), 'ui.openDialog', [{ slot: 'plugin-dialog', anchor: 'trigger' }]);
+ await vi.waitFor(() => expect(head()).not.toBeNull());
+ expect(head()?.anchor).toBeUndefined();
+ resolveHead(null);
+ await result;
+ });
+});
diff --git a/lib/plugin-sandbox/dialog-anchor.ts b/lib/plugin-sandbox/dialog-anchor.ts
new file mode 100644
index 000000000..05fd7e81f
--- /dev/null
+++ b/lib/plugin-sandbox/dialog-anchor.ts
@@ -0,0 +1,125 @@
+// Where a plugin dialog opened with `anchor: 'trigger'` is drawn: next to
+// what the user just clicked, as a popover, instead of the centred modal.
+//
+// A plugin never sees the host's DOM, so it can't name an element. The host
+// remembers the user's last activation instead - the control under a
+// pointerdown, or the focused one on Enter / Space - and a dialog asking for
+// it within TRIGGER_WINDOW_MS takes that place. Any other key forgets it, so
+// a keyboard shortcut (s to star) never borrows an older click's place.
+// Without one the dialog is centred as before.
+
+/** A place in viewport coordinates; a point has left === right, top === bottom. */
+export interface AnchorRect {
+ left: number;
+ top: number;
+ right: number;
+ bottom: number;
+}
+
+/** How long after the activation a dialog may still open at its place. */
+export const TRIGGER_WINDOW_MS = 2000;
+/** Space kept to the window's edges and to the anchor. */
+export const POPOVER_MARGIN = 8;
+export const POPOVER_GAP = 4;
+
+const CONTROL = 'button, a[href], [role="button"], [role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], [role="option"], [role="checkbox"], [role="tab"]';
+
+interface Trigger {
+ element: Element | null;
+ rect: AnchorRect;
+ at: number;
+}
+
+let last: Trigger | null = null;
+
+function rectOf(element: Element): AnchorRect {
+ const r = element.getBoundingClientRect();
+ return { left: r.left, top: r.top, right: r.right, bottom: r.bottom };
+}
+
+function isEmpty(r: AnchorRect): boolean {
+ return r.right - r.left <= 0 && r.bottom - r.top <= 0 && r.left === 0 && r.top === 0;
+}
+
+/**
+ * Remember an activation. A control in a menu is placed by the pointer, not
+ * by itself: the menu closes on the click, and the dialog should stand
+ * where the menu was opened, not beside an entry that is gone.
+ */
+export function noteTrigger(target: Element, point?: { x: number; y: number }, now = Date.now()): void {
+ const control = target.closest(CONTROL) ?? target;
+ const inMenu = !!control.closest('[role="menu"]');
+ if (inMenu && point) {
+ last = { element: null, rect: { left: point.x, top: point.y, right: point.x, bottom: point.y }, at: now };
+ return;
+ }
+ last = { element: control, rect: rectOf(control), at: now };
+}
+
+/** Forget the last activation (another key, tests). */
+export function forgetTrigger(): void {
+ last = null;
+}
+
+/**
+ * The place of the activation that led to a dialog opening now, or null
+ * when there was none within TRIGGER_WINDOW_MS. A control still on the page
+ * is measured again (the list may have scrolled while the plugin worked).
+ */
+export function triggerAnchor(now = Date.now()): AnchorRect | null {
+ if (!last || now - last.at > TRIGGER_WINDOW_MS) return null;
+ if (last.element?.isConnected) {
+ const r = rectOf(last.element);
+ if (!isEmpty(r)) return r;
+ }
+ return last.rect;
+}
+
+const MODIFIERS = new Set(['Shift', 'Control', 'Alt', 'Meta', 'AltGraph', 'CapsLock']);
+
+/** Follow the user's activations on the page; returns the way to stop. */
+export function trackTriggers(doc: Document = document): () => void {
+ const onPointerDown = (e: PointerEvent | MouseEvent) => {
+ if (e.target instanceof Element) noteTrigger(e.target, { x: e.clientX, y: e.clientY });
+ };
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ const active = doc.activeElement;
+ if (active instanceof Element && active !== doc.body) noteTrigger(active);
+ else forgetTrigger();
+ } else if (!MODIFIERS.has(e.key)) {
+ forgetTrigger();
+ }
+ };
+ // Capture: a menu entry closes its menu (and is gone) during the click.
+ doc.addEventListener('pointerdown', onPointerDown, true);
+ doc.addEventListener('keydown', onKeyDown, true);
+ return () => {
+ doc.removeEventListener('pointerdown', onPointerDown, true);
+ doc.removeEventListener('keydown', onKeyDown, true);
+ };
+}
+
+/**
+ * Top-left corner of a popover of `size` next to `anchor`: below it, lined
+ * up with its start (its end in RTL); above when it doesn't fit below and
+ * does above; always inside the viewport.
+ */
+export function placePopover(
+ anchor: AnchorRect,
+ size: { width: number; height: number },
+ viewport: { width: number; height: number },
+ rtl = false,
+): { left: number; top: number } {
+ const m = POPOVER_MARGIN;
+ let left = rtl ? anchor.right - size.width : anchor.left;
+ left = Math.min(left, viewport.width - m - size.width);
+ left = Math.max(m, left);
+
+ let top = anchor.bottom + POPOVER_GAP;
+ const above = anchor.top - POPOVER_GAP - size.height;
+ if (top + size.height > viewport.height - m && above >= m) top = above;
+ top = Math.min(top, viewport.height - m - size.height);
+ top = Math.max(m, top);
+ return { left, top };
+}
diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts
index 76e9a50e7..1ef704fd6 100644
--- a/lib/plugin-sandbox/host-api.ts
+++ b/lib/plugin-sandbox/host-api.ts
@@ -44,6 +44,7 @@ import { MAX_KEYWORD_LENGTH } from '../keyword-nesting';
import { emailKeyFor, KEYWORD_PREFIX } from '../thread-utils';
import { DEFAULT_SEARCH_FILTERS, normalizePluginFilter } from '../jmap/search-utils';
import { awaitDialog, awaitPrompt, awaitCustomDialog, type PromptField } from './host-dialog';
+import { triggerAnchor } from './dialog-anchor';
import { fileStorage } from '../plugin-storage';
import { generateUUID } from '../utils';
import { AddressBook, ContactCard, Email, Identity } from '../jmap/types';
@@ -2192,7 +2193,7 @@ export async function dispatchApiCall(
// real app-root overlay, instead of a fixed confirm/prompt form - see
// the 'plugin-dialog' SlotName / host-dialog.ts comments for why this
// exists (a small toolbar/row slot can't show a large custom UI).
- const opts = (args[0] ?? {}) as { title?: string; slot?: string; extraProps?: Record; width?: number };
+ const opts = (args[0] ?? {}) as { title?: string; slot?: string; extraProps?: Record; width?: number; anchor?: unknown };
if (!opts.slot || typeof opts.slot !== 'string') {
throw new Error('ui.openDialog requires a "slot" name');
}
@@ -2203,6 +2204,9 @@ export async function dispatchApiCall(
slot: opts.slot,
extraProps: (opts.extraProps && typeof opts.extraProps === 'object') ? opts.extraProps : {},
width: typeof opts.width === 'number' ? opts.width : undefined,
+ // Next to what the user just clicked, when the plugin asks and there
+ // was such a click; the place is the host's, never the plugin's.
+ anchor: opts.anchor === 'trigger' ? triggerAnchor() ?? undefined : undefined,
});
}
case 'ui.rerenderEmail': {
diff --git a/lib/plugin-sandbox/host-dialog.ts b/lib/plugin-sandbox/host-dialog.ts
index c5d019787..367067cfb 100644
--- a/lib/plugin-sandbox/host-dialog.ts
+++ b/lib/plugin-sandbox/host-dialog.ts
@@ -5,6 +5,8 @@
// at a time. Prompts collect one or more (optionally masked) text fields so a
// plugin never has to fall back to the sandbox-blocked `window.prompt`.
+import type { AnchorRect } from './dialog-anchor';
+
export type DialogKind = 'confirm' | 'alert' | 'prompt' | 'custom';
export interface PromptField {
@@ -50,6 +52,12 @@ export interface DialogRequest {
extraProps?: Record;
/** Optional width override in px for the dialog box (custom kind only). */
width?: number;
+ /**
+ * Custom kind only: draw the dialog as a popover next to this place (the
+ * control whose activation led to it, see dialog-anchor.ts) instead of
+ * centred. Set by the host, never taken from the plugin.
+ */
+ anchor?: AnchorRect;
/** Called when the dialog closes with its typed result (see DialogResult). */
resolve: (result: unknown) => void;
}
diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx
index daf0ee0db..56663f8a9 100644
--- a/lib/plugin-sandbox/runtime.tsx
+++ b/lib/plugin-sandbox/runtime.tsx
@@ -679,8 +679,18 @@ function buildPluginApi(manifest: PluginManifest) {
* a multi-step form, etc.) rather than a toolbar/row-sized control.
* Resolves to whatever value the slot component passes to its
* `onResult` prop, or null if the user closes the dialog without
- * calling it. No timeout - it waits for the user. */
- openDialog: (opts: { title?: string; slot: string; extraProps?: Record; width?: number }) =>
+ * calling it. No timeout - it waits for the user.
+ *
+ * `anchor: 'trigger'` draws it as a popover next to what the user
+ * activated last - an action button (message row, reader), the
+ * place a menu was opened at for a menu entry, the flag whose toggle
+ * a hook intercepted - when that was at most 2 s ago and no other
+ * key was pressed since; else centred as without it. Meant for a
+ * small menu of choices. The popover has a one-line header naming
+ * the plugin, takes `width` as the dialog does and the content's
+ * height, and closes on Escape or a click outside (null). Hosts
+ * without it ignore the option. */
+ openDialog: (opts: { title?: string; slot: string; extraProps?: Record; width?: number; anchor?: 'trigger' }) =>
callApi('ui.openDialog', [opts], 0) as Promise,
/** Re-runs the onRenderEmailBody hook for the open message (e.g. after a
* crypto plugin unlocks a key) so its body re-renders without a reload. */
diff --git a/lib/plugin-slot-catalog.ts b/lib/plugin-slot-catalog.ts
index 043667986..5750523af 100644
--- a/lib/plugin-slot-catalog.ts
+++ b/lib/plugin-slot-catalog.ts
@@ -44,7 +44,7 @@ const S = (
export const SLOT_CATALOG = {
// ─── App ───────────────────────────────────────────────────
'app-top-banner': S('app', 'frame', ['ui:app-top-banner'], { username: 'account:read', serverUrl: 'account:read', impersonated: 'account:read' }, 'a banner across the top of every page; `impersonated` is true when an admin opened the mailbox through an impersonation handoff'),
- 'plugin-dialog': S('dialog', 'frame', ['ui:observe'], '*', 'inside a dialog the plugin opened with api.ui.openDialog'),
+ 'plugin-dialog': S('dialog', 'frame', ['ui:observe'], '*', 'inside a dialog the plugin opened with api.ui.openDialog (with `anchor: \'trigger\'` a popover beside what the user clicked)'),
'command-palette': S('app', 'action', ['ui:commands'], {}, 'entries in the command palette (Ctrl+K)'),
// ─── Navigation ────────────────────────────────────────────