fix(images): find the top modal by hit test and cover toasts across modals (#533)

This commit is contained in:
Vito Sansevero
2026-10-30 09:51:30 -07:00
parent b01c769121
commit b28ac64c4a
7 changed files with 49 additions and 5 deletions
+3 -2
View File
@@ -1573,8 +1573,9 @@ Recorded deviations on this page (spec §8):
| Anything else (415, 404, 409, 500, offline) | "The folder could not be opened. Try again." |
**D176 (#533, beyond the concept):** the viewer is a modal, so `showWorkspaceToast`
moves `#workspace-toast` into the topmost open modal, keeping its fixed position,
and back when that modal closes. Otherwise every toast raised from a modal is
moves `#workspace-toast` into the topmost open modal (by hit test when several are
open), keeping its fixed position, and back when that modal closes. A modal opened
after a toast covers it until the next toast. Otherwise every toast raised from a modal is
covered and inert while `toHaveText` still passes: assert `toBeVisible` and the
hit test as `full-inspector.spec.mjs` does.
+33
View File
@@ -387,6 +387,39 @@ test('Show in folder asks the server to open the listed file and announces each
await page.keyboard.press('Escape');await expect(page.locator('#viewer-dialog')).toHaveCount(0);
await expect(toast).toBeVisible();await expect(page.locator('body > #workspace-toast')).toHaveCount(1);
});
// #533 D176: real modals in the real top layer, with the page's own toast module.
test('a toast stays on screen across nested, switched and closing modals',async({page,request})=>{
await fullFixture(page,request);
await page.evaluate(async()=>{
const url=performance.getEntriesByType('resource').map(entry=>entry.name).find(name=>name.includes('/toast.mjs'));
const {showWorkspaceToast}=await import(url);
window.say=showWorkspaceToast;
window.modal=(id,first=false)=>{const dialog=document.createElement('dialog');dialog.id=id;dialog.textContent=id;document.body[first?'prepend':'append'](dialog);dialog.showModal();return dialog;};
window.onTop=()=>{const node=document.getElementById('workspace-toast');const box=node.getBoundingClientRect();return box.width>0&&document.elementFromPoint(box.x+box.width/2,box.y+box.height/2)===node;};
});
const parent=()=>page.evaluate(()=>{const host=document.getElementById('workspace-toast')?.parentElement;return host?host.id||host.localName:null;});
const onTop=()=>page.evaluate(()=>window.onTop());
await page.evaluate(()=>{window.modal('a');say('one');});
expect(await parent()).toBe('a');expect(await onTop()).toBe(true);
// Nested: the top one holds it, even earlier in the page than the one under it.
await page.evaluate(()=>{window.modal('b',true);say('two');});
expect(await parent()).toBe('b');expect(await onTop()).toBe(true);
// The top one closes while the toast shows: the message stays, in the modal now on top.
await page.evaluate(()=>document.getElementById('b').close());
await expect.poll(parent).toBe('a');expect(await onTop()).toBe(true);
await expect(page.locator('#workspace-toast')).toHaveText('two');
// Switched: the next modal takes the next toast.
await page.evaluate(()=>{document.getElementById('a').close();window.modal('c');say('three');});
expect(await parent()).toBe('c');expect(await onTop()).toBe(true);
// Closed and removed at once, as the workspace's dialogs do: home, still showing.
await page.evaluate(()=>{const c=document.getElementById('c');c.close();c.remove();});
await expect.poll(parent).toBe('body');expect(await onTop()).toBe(true);
await expect(page.locator('#workspace-toast')).toHaveText('three');
// Removed while open, with no close event: the next message still shows.
await page.evaluate(()=>{window.modal('d');say('four');document.getElementById('d').remove();say('five');});
expect(await parent()).toBe('body');expect(await onTop()).toBe(true);
await expect(page.locator('#workspace-toast')).toHaveText('five');
});
test('full inspector shows six facts and scoped provenance',async({page,request},testInfo)=>{
await page.setViewportSize({width:1600,height:1100});await fullFixture(page,request);const panel=inspector(page);
await expect(panel.getByRole('heading',{name:POSITIVE_TITLE,exact:true})).toBeVisible();await expect(panel.locator('.panel-heading .chip')).toHaveText('PNG');await expect(panel.locator('.detail-inner > p.small')).toHaveText('complete.png');
+1 -1
View File
@@ -13,7 +13,7 @@ function dom() {
closest(selector){if(selector==='section'&&this.tagName==='section')return this;if(selector==='[data-send-role]'&&Object.hasOwn(this.dataset,'sendRole'))return this;if(selector==='[data-copy-role]'&&Object.hasOwn(this.dataset,'copyRole'))return this;if(selector==='[data-retry-prompts]'&&Object.hasOwn(this.dataset,'retryPrompts'))return this;return this.parent?.closest(selector);}
click(target){this.listeners.get('click')?.({target});}
}
const previous=global.document,toast=new Element('div');global.document={createElement:tag=>new Element(tag),createElementNS:(_,tag)=>new Element(tag),getElementById:id=>id==='workspace-toast'?toast:null};
const previous=global.document,toast=new Element('div');global.document={querySelectorAll:()=>[],createElement:tag=>new Element(tag),createElementNS:(_,tag)=>new Element(tag),getElementById:id=>id==='workspace-toast'?toast:null};
const walk=(root,predicate)=>[...(predicate(root)?[root]:[]),...root.children.flatMap(child=>walk(child,predicate))];
return {Element,walk,toast,restore:()=>{global.document=previous;}};
}
@@ -69,6 +69,7 @@ function fakeDOM() {
createElement: tag => new Element(tag),
createTextNode: text => Object.assign(new Element('#text'), { _text: String(text) }),
getElementById: id => (id === 'workspace-toast' ? toast : null),
querySelectorAll: () => [],
};
return { Element, body, toast, restore() { globalThis.document = previous; } };
}
+2
View File
@@ -440,6 +440,7 @@ class TestRevealInFileManager(unittest.TestCase):
"DESKTOP_SESSION": "plasma",
"XDG_SESSION_DESKTOP": "KDE",
"XDG_CONFIG_HOME": "/home/u/.config",
"XDG_DATA_HOME": "/home/u/.local/share",
"XDG_DATA_DIRS": "/usr/share",
# What xdg-open reads to choose an opener (kde-open6 on Plasma 6).
"KDE_FULL_SESSION": "true",
@@ -465,6 +466,7 @@ class TestRevealInFileManager(unittest.TestCase):
"DESKTOP_SESSION": "plasma",
"XDG_SESSION_DESKTOP": "KDE",
"XDG_CONFIG_HOME": "/home/u/.config",
"XDG_DATA_HOME": "/home/u/.local/share",
"XDG_DATA_DIRS": "/usr/share",
"KDE_FULL_SESSION": "true",
"KDE_SESSION_VERSION": "6",
+1
View File
@@ -75,6 +75,7 @@ CHILD_ENV_NAMES = (
"XDG_DATA_DIRS",
"XDG_CONFIG_DIRS",
"XDG_CONFIG_HOME",
"XDG_DATA_HOME",
# The X11 cookie: a non-default path under GDM or SDDM.
"XAUTHORITY",
"DBUS_SESSION_BUS_ADDRESS",
+8 -2
View File
@@ -9,9 +9,15 @@ let releaseWait = null;
let toastElement = null;
let toastHome = null;
/** The open modal the user is in: the last in the page, as a nested dialog is opened after its parent. */
/**
* The open modal the user is in. With several, the top layer decides, not page order (a viewer can sit earlier in the
* page than the dialog it opened from): only the top one can be hit, the rest being inert.
*/
function topModal() {
return [...document.querySelectorAll('dialog')].filter(dialog => dialog.matches(':modal')).at(-1) ?? null;
const modals = [...document.querySelectorAll('dialog')].filter(dialog => dialog.matches(':modal'));
if (modals.length < 2) return modals[0] ?? null;
const hit = document.elementFromPoint?.(window.innerWidth / 2, window.innerHeight / 2)?.closest('dialog');
return modals.includes(hit) ? hit : modals.at(-1);
}
/**