From b9fbe811ba1a4a90dbd77bf03f1b409c0f148246 Mon Sep 17 00:00:00 2001 From: Jacob Zeitz Date: Sat, 31 Oct 2026 11:44:13 +0100 Subject: [PATCH] feat(plugins): a plugin dialog as a popover at the clicked control `ui.openDialog({ anchor: 'trigger' })`: opened within 2 s of a click or Enter/Space on a host control (a plugin action, a context-menu entry, the star), the dialog stands as a popover beside that control, still naming the plugin; without such a trigger it is the centred dialog as before. --- .../__tests__/plugin-dialog-popover.test.tsx | 62 ++++++++ components/plugins/plugin-dialog-host.tsx | 145 ++++++++++++++---- lib/__tests__/plugin-dialog-anchor.test.ts | 136 ++++++++++++++++ lib/plugin-sandbox/dialog-anchor.ts | 125 +++++++++++++++ lib/plugin-sandbox/host-api.ts | 6 +- lib/plugin-sandbox/host-dialog.ts | 8 + lib/plugin-sandbox/runtime.tsx | 14 +- lib/plugin-slot-catalog.ts | 2 +- 8 files changed, 466 insertions(+), 32 deletions(-) create mode 100644 components/plugins/__tests__/plugin-dialog-popover.test.tsx create mode 100644 lib/__tests__/plugin-dialog-anchor.test.ts create mode 100644 lib/plugin-sandbox/dialog-anchor.ts diff --git a/components/plugins/__tests__/plugin-dialog-popover.test.tsx b/components/plugins/__tests__/plugin-dialog-popover.test.tsx new file mode 100644 index 000000000..699b86019 --- /dev/null +++ b/components/plugins/__tests__/plugin-dialog-popover.test.tsx @@ -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) => (values?.name ? `${key}:${values.name}` : key) })); +vi.mock('../plugin-iframe-slot', () => ({ PluginIframeSlot: () =>
})); + +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(); + 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('[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(); + let result: Promise; + 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(); + let result: Promise; + 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(); + 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(); + }); +}); diff --git a/components/plugins/plugin-dialog-host.tsx b/components/plugins/plugin-dialog-host.tsx index 7032cd9ad..13f4cee80 100644 --- a/components/plugins/plugin-dialog-host.tsx +++ b/components/plugins/plugin-dialog-host.tsx @@ -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: '

) : null; + const footer = buttons.length > 0 && ( +
+ {buttons.map((b) => ( + + ))} +
+ ); + 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 ( +
{ + if (e.target === e.currentTarget) cancel(); + }} + data-plugin-dialog-popover="" + > +
+
+

{current.title || name}

+

+

+ +
+
{frame}
+ {footer} +
+
+ ); + } + return (
{message} - + {frame}
- {buttons.length > 0 && ( -
- {buttons.map((b) => ( - - ))} -
- )} + {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 ────────────────────────────────────────────