Merge pull request #1497 from HOLOEYE-JZ/feat/plugin-dialog-anchor

This commit is contained in:
Linus Rath
2026-11-02 20:24:04 +01:00
8 changed files with 466 additions and 32 deletions
@@ -0,0 +1,62 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { act, fireEvent, render, screen } from '@testing-library/react';
vi.mock('next-intl', () => ({ useTranslations: () => (key: string, values?: Record<string, string>) => (values?.name ? `${key}:${values.name}` : key) }));
vi.mock('../plugin-iframe-slot', () => ({ PluginIframeSlot: () => <div data-testid="custom-frame" /> }));
import { PluginDialogHost } from '../plugin-dialog-host';
import { awaitCustomDialog, head, resetDialogLimits, resolveHead } from '@/lib/plugin-sandbox/host-dialog';
import { POPOVER_GAP } from '@/lib/plugin-sandbox/dialog-anchor';
// A custom dialog with an anchor: a popover beside the control the user
// clicked, naming the plugin in one line, closed by Escape or a click outside.
beforeEach(() => {
while (head()) resolveHead(null);
resetDialogLimits();
});
const anchor = { left: 100, top: 50, right: 124, bottom: 74 };
describe('a plugin dialog beside its trigger', () => {
it('stands below the trigger, names the plugin, and has no centred backdrop', () => {
render(<PluginDialogHost />);
act(() => { void awaitCustomDialog({ pluginId: 'follow-ups', title: 'Follow up', message: '', slot: 'plugin-dialog', width: 320, anchor }); });
const dialog = screen.getByRole('dialog', { name: 'Follow up' });
expect(dialog).toHaveAttribute('data-plugin-dialog-popover');
expect(dialog.className).not.toMatch(/bg-black/);
expect(screen.getByTestId('custom-frame')).toBeInTheDocument();
expect(dialog.querySelector('[data-plugin-dialog-source]')).toHaveTextContent('from_plugin:follow-ups');
expect(screen.getByRole('button', { name: 'plugin_menu:follow-ups' })).toBeInTheDocument();
const panel = dialog.querySelector<HTMLElement>('[data-plugin-dialog="follow-ups"]')!;
expect(panel.style.left).toBe('100px');
expect(panel.style.top).toBe(`${74 + POPOVER_GAP}px`);
expect(panel.style.width).toContain('320px');
});
it('closes with null on a click outside', async () => {
render(<PluginDialogHost />);
let result: Promise<unknown>;
act(() => { result = awaitCustomDialog({ pluginId: 'follow-ups', title: 'Follow up', message: '', slot: 'plugin-dialog', anchor }); });
await act(async () => { fireEvent.mouseDown(screen.getByRole('dialog')); });
await expect(result!).resolves.toBeNull();
expect(screen.queryByRole('dialog')).toBeNull();
});
it('closes with null on Escape', async () => {
render(<PluginDialogHost />);
let result: Promise<unknown>;
act(() => { result = awaitCustomDialog({ pluginId: 'follow-ups', title: 'Follow up', message: '', slot: 'plugin-dialog', anchor }); });
await act(async () => { fireEvent.keyDown(document, { key: 'Escape' }); });
await expect(result!).resolves.toBeNull();
});
it('is the centred dialog without an anchor', () => {
render(<PluginDialogHost />);
act(() => { void awaitCustomDialog({ pluginId: 'follow-ups', title: 'Follow up', message: '', slot: 'plugin-dialog' }); });
const dialog = screen.getByRole('dialog', { name: 'Follow up' });
expect(dialog).not.toHaveAttribute('data-plugin-dialog-popover');
expect(screen.getByRole('button', { name: 'close' })).toBeInTheDocument();
});
});
+117 -28
View File
@@ -9,11 +9,14 @@
// They look like the app's own dialogs: a custom dialog (api.ui.openDialog)
// is the filter editor's layout, with the plugin's buttons (kit
// DialogButtons) drawn by the host in its footer; confirm, alert and prompt
// are the app's ConfirmDialog and PromptDialog.
// are the app's ConfirmDialog and PromptDialog. A custom dialog opened with
// `anchor: 'trigger'` is a popover beside the control the user clicked
// (dialog-anchor.ts), with a one-line header naming the plugin.
import React, { useCallback, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import React, { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import { useTranslations } from 'next-intl';
import { cancelForPlugin, head, overlay, resolveDialog, subscribe, type DialogRequest } from '@/lib/plugin-sandbox/host-dialog';
import { placePopover, POPOVER_MARGIN, trackTriggers, type AnchorRect } from '@/lib/plugin-sandbox/dialog-anchor';
import type { DialogButtonSpec } from '@/lib/plugin-sandbox/ui-kit/dialog-buttons';
import { usePluginStore } from '@/stores/plugin-store';
import { useFocusTrap } from '@/hooks/use-focus-trap';
@@ -43,6 +46,9 @@ export function PluginDialogHost(): React.JSX.Element | null {
const top = useSyncExternalStore(subscribe, overlay, () => null);
const t = useTranslations('plugins.dialog');
// What the user clicked last, for a dialog that opens beside it.
useEffect(() => trackTriggers(), []);
// Say so when a plugin was turned off for failing; otherwise it just
// stops working with no hint why.
useEffect(() => {
@@ -183,6 +189,9 @@ function PluginDialogView({ current, layer }: { current: DialogRequest; layer: '
},
});
const anchor = current?.kind === 'custom' ? current.anchor : undefined;
const place = usePopoverPlace(anchor, panelRef);
if (!current) return null;
const name = pluginName ?? current.pluginId;
@@ -204,6 +213,76 @@ function PluginDialogView({ current, layer }: { current: DialogRequest; layer: '
</p>
) : null;
const footer = buttons.length > 0 && (
<div className={cn('flex flex-wrap items-center justify-end gap-2 border-t border-border', anchor ? 'px-3 py-2' : 'px-6 py-4')} data-plugin-dialog-buttons="">
{buttons.map((b) => (
<Button
key={b.id}
type="button"
variant={DIALOG_BUTTON_VARIANT[b.variant ?? 'primary']}
size={anchor ? 'sm' : undefined}
disabled={b.disabled || b.busy}
aria-busy={b.busy || undefined}
className="gap-1.5"
onClick={() => pressButton(b.id)}
>
{b.busy ? <Loader2 className="w-4 h-4 animate-spin" aria-hidden="true" /> : <FooterIcon name={b.icon} />}
{b.label}
</Button>
))}
</div>
);
const frame = (
<PluginIframeSlot
pluginId={current.pluginId}
slot={(current.slot ?? 'plugin-dialog') as SlotName}
extraProps={customProps}
/>
);
// 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 (
<div
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-hidden={layer === 'below' ? true : undefined}
className="fixed inset-0 z-[100000]"
onMouseDown={(e) => {
if (e.target === e.currentTarget) cancel();
}}
data-plugin-dialog-popover=""
>
<div
ref={panelRef}
className="absolute flex flex-col overflow-hidden rounded-lg border border-border bg-background text-foreground shadow-xl animate-in fade-in zoom-in-95 duration-150"
style={{
left: place?.left ?? 0,
top: place?.top ?? 0,
visibility: place ? undefined : 'hidden',
width: `min(${current.width ?? 560}px, calc(100vw - ${2 * POPOVER_MARGIN}px))`,
maxHeight: `calc(100vh - ${2 * POPOVER_MARGIN}px)`,
}}
data-plugin-dialog={current.pluginId}
>
<div className="flex items-center gap-1 border-b border-border ps-3 pe-1 py-0.5">
<h2 id={titleId} className="sr-only">{current.title || name}</h2>
<p className="flex min-w-0 flex-1 items-center gap-1 text-xs text-muted-foreground" data-plugin-dialog-source="">
<Puzzle className="h-3 w-3 flex-shrink-0" aria-hidden="true" />
<span className="min-w-0 truncate">{t('from_plugin', { name })}</span>
</p>
<PluginDialogMenu name={name} onTurnOff={turnOff} compact />
</div>
<div className="min-h-0 flex-1 overflow-y-auto">{frame}</div>
{footer}
</div>
</div>
);
}
return (
<div
role="dialog"
@@ -242,30 +321,9 @@ function PluginDialogView({ current, layer }: { current: DialogRequest; layer: '
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-4">
{message}
<PluginIframeSlot
pluginId={current.pluginId}
slot={(current.slot ?? 'plugin-dialog') as SlotName}
extraProps={customProps}
/>
{frame}
</div>
{buttons.length > 0 && (
<div className="flex flex-wrap items-center justify-end gap-2 px-6 py-4 border-t border-border" data-plugin-dialog-buttons="">
{buttons.map((b) => (
<Button
key={b.id}
type="button"
variant={DIALOG_BUTTON_VARIANT[b.variant ?? 'primary']}
disabled={b.disabled || b.busy}
aria-busy={b.busy || undefined}
className="gap-1.5"
onClick={() => pressButton(b.id)}
>
{b.busy ? <Loader2 className="w-4 h-4 animate-spin" aria-hidden="true" /> : <FooterIcon name={b.icon} />}
{b.label}
</Button>
))}
</div>
)}
{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<HTMLElement | null>): { 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<HTMLDivElement>(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')}
>
<MoreVertical className="w-5 h-5" />
<MoreVertical className={compact ? 'w-4 h-4' : 'w-5 h-5'} />
</button>
{open && (
<div role="menu" className="absolute end-0 top-full z-10 mt-1 min-w-[200px] rounded-md border border-border bg-background py-1 shadow-lg">
+136
View File
@@ -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<HTMLElement>('[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('<button data-at><span>Follow up</span></button>', { 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('<div role="menu"><div role="menuitem" data-at>Follow up</div></div>', { 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('<button data-at>Flag</button>', { 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('<button data-at>Flag</button>', { 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('<button data-at>Flag</button>', { 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('<button data-at>Follow up</button>', { 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('<button data-at>Follow up</button>', { 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;
});
});
+125
View File
@@ -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 };
}
+5 -1
View File
@@ -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';
@@ -2196,7 +2197,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<string, unknown>; width?: number };
const opts = (args[0] ?? {}) as { title?: string; slot?: string; extraProps?: Record<string, unknown>; width?: number; anchor?: unknown };
if (!opts.slot || typeof opts.slot !== 'string') {
throw new Error('ui.openDialog requires a "slot" name');
}
@@ -2207,6 +2208,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': {
+8
View File
@@ -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<string, unknown>;
/** 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;
}
+12 -2
View File
@@ -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<string, unknown>; 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<string, unknown>; width?: number; anchor?: 'trigger' }) =>
callApi('ui.openDialog', [opts], 0) as Promise<unknown>,
/** 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. */
+1 -1
View File
@@ -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 ────────────────────────────────────────────