mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 08:15:38 +00:00
Merge pull request #1497 from HOLOEYE-JZ/feat/plugin-dialog-anchor
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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': {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 ────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user