fix: close a plugin dialog on Escape while the focus is in one of its fields

This commit is contained in:
Linus Rath
2026-11-03 08:53:45 +01:00
parent 966c5a4c14
commit 9a992e3ddd
4 changed files with 48 additions and 0 deletions
@@ -136,6 +136,34 @@ describe('slot sizing', () => {
});
});
describe('Escape inside a slot frame', () => {
const inFrame = (wrapperAttr?: string) => {
const wrapper = document.createElement('div');
if (wrapperAttr) wrapper.setAttribute(wrapperAttr, 'p1');
document.body.appendChild(wrapper);
const inst = createSlotInstance({
plugin: plugin(), slot: 'plugin-dialog', code: '', locale: 'en', tier: 'untrusted', extraProps: {}, hostContainer: wrapper,
});
instances.push(inst);
const keys: string[] = [];
const onKey = (e: KeyboardEvent) => { keys.push(e.key); };
document.addEventListener('keydown', onKey, true);
post(inst.iframe.contentWindow, 'null', { type: 'frame-escape' });
document.removeEventListener('keydown', onKey, true);
wrapper.remove();
return keys;
};
it('reaches the host dialog when the frame is one', () => {
expect(inFrame('data-plugin-dialog')).toEqual(['Escape']);
expect(inFrame('data-plugin-dialog-popover')).toEqual(['Escape']);
});
it('stays in the frame anywhere else', () => {
expect(inFrame()).toEqual([]);
});
});
describe('shouldShow context', () => {
it('leaves host callbacks out instead of failing to post them', async () => {
const inst = booted(createBackgroundInstance({ plugin: plugin(), code: '', locale: 'en', tier: 'untrusted' }));
+9
View File
@@ -551,6 +551,15 @@ export class SandboxInstance {
return;
}
case 'frame-escape': {
// Only for a dialog's own frame: elsewhere Escape in a frame means
// nothing to the host, and shouldn't close the message around it.
if (this.iframe.closest('[data-plugin-dialog], [data-plugin-dialog-popover]')) {
this.iframe.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));
}
return;
}
case 'slot-resize': {
// The iframe has no intrinsic height - sync it to the content height
// the sandbox reported, otherwise the wrapper reserves space but the
+7
View File
@@ -179,6 +179,12 @@ export interface SlotResizeMsg {
width?: number;
}
/**
* Escape pressed inside a slot frame and left alone by the plugin. Keys in a
* frame never reach the host, so a plugin dialog would not close on it.
*/
export interface FrameEscapeMsg { type: 'frame-escape'; }
export interface SlotShouldShowResultMsg {
type: 'slot-should-show-result';
id: string;
@@ -235,6 +241,7 @@ export type SandboxToHost =
| CallbackInvokeMsg
| HookResultMsg
| SlotResizeMsg
| FrameEscapeMsg
| ThemeAppliedMsg
| SlotShouldShowResultMsg
| FloatingDropdownOpenMsg
+4
View File
@@ -1516,6 +1516,10 @@ async function bootSlot(payload: SlotInit): Promise<void> {
const reactRoot = renderer.ReactDOM.createRoot(rootEl);
reactRoot.render(React.createElement(SlotShell));
// Last in line: a dropdown in the frame that closes on Escape prevents the default.
window.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && event.isTrusted && !event.defaultPrevented) sendToHost({ type: 'frame-escape' });
});
sendToHost({ type: 'init-done', hooks: [], slots: [], shortcuts: [] });
}