fix(viewer): arrow keys navigate from anywhere; a shortcuts overlay (#532) (#534)

Closes #532
This commit is contained in:
Vito Sansevero
2026-10-30 16:52:44 -07:00
parent 06ea91da66
commit 494a5d0bfa
12 changed files with 502 additions and 13 deletions
+4
View File
@@ -676,6 +676,10 @@ test('viewer matches the concept shell and toolbar order',async({page,request})=
expect(modePaths.map(path=>path.split(' > ').slice(2).join(' > '))).toEqual(['button#viewer-mode.icon-btn','button#viewer-mode.icon-btn > svg.icon','button#viewer-mode.icon-btn > svg.icon > path']);
expect(production.paths[production.paths.indexOf(modePaths[2])+1]).toBe('dialog#viewer-dialog > div.dialog-header > button#viewer-close');
production.paths=production.paths.filter(path=>!modePaths.includes(path));
// #532 D170: production's shortcuts button sits right before the view switch; the concept has none.
const keysPaths=production.paths.filter(path=>path.includes('button#viewer-keys'));
expect(keysPaths.map(path=>path.split(' > ').slice(2).join(' > '))).toEqual(['button#viewer-keys.icon-btn','button#viewer-keys.icon-btn > svg.icon','button#viewer-keys.icon-btn > svg.icon > path']);
production.paths=production.paths.filter(path=>!keysPaths.includes(path));
// #480 D159: the Images viewer's Delete… (`danger`) follows Download; the concept has none.
expect(production.toolbar[8].text).toBe('Delete…');
production.toolbar.splice(8,1);
@@ -764,6 +764,11 @@ test('Collections… in the viewer keeps the viewer until it closes (CUJ 7)', as
await page.getByRole('button', { name: 'Open landscape_01.png', exact: true }).click();
const viewer = page.locator('#viewer-dialog[open]');
await expect(viewer.locator('#viewer-title')).toContainText('landscape_01.png');
// #532: the arrows move through the collection from the first moment.
await page.keyboard.press('ArrowRight');
await expect(viewer.locator('#viewer-title')).toContainText('landscape_02.png');
await page.keyboard.press('ArrowLeft');
await expect(viewer.locator('#viewer-title')).toContainText('landscape_01.png');
// Review L7: no listing request while the viewer is open.
let listings = 0;
const countListing = request => { if (new URL(request.url()).pathname === '/prompt_manager/images/output') listings++; };
+2
View File
@@ -231,6 +231,8 @@ test('every ordered receipt remains selected through distinct current locations,
const loadedReads=outputReads;const view=receipts.nth(1).getByRole('button',{name:'View current file',exact:true});await view.click();
const viewer=page.locator('dialog#viewer-dialog');await expect(viewer).toBeVisible();
await expect(viewer).toContainText('1 of 2 loaded');await expect(viewer.getByRole('region',{name:'Negative prompt',exact:true})).not.toContainText('same visible negative');
// #532: the arrows work over the recorded run from the first moment.
await page.keyboard.press('ArrowRight');await expect(viewer).toContainText('2 of 2 loaded');await page.keyboard.press('ArrowLeft');await expect(viewer).toContainText('1 of 2 loaded');
await viewer.getByRole('button',{name:'Next media',exact:true}).click();await expect(viewer).toContainText('2 of 2 loaded');
await page.keyboard.press('Escape');await expect(viewer).toHaveCount(0);await expect(dialog(page)).toBeVisible();
await expect(receipts.nth(2)).toHaveAttribute('aria-current','true');await expect(receipts.nth(2).getByRole('button',{name:'View current file',exact:true})).toBeFocused();
+8
View File
@@ -91,6 +91,14 @@ test('queue shows persisted items and the nav count', async ({ page, request })
// The card's image opens the shared viewer.
await cards(page).nth(1).getByRole('button', { name: 'Open review_0001.png', exact: true }).click();
await expect(page.locator('#viewer-dialog')).toBeVisible();
// #532: the arrows move through the queue from the first moment, with focus still on ✕.
await expect(page.locator('#viewer-close')).toBeFocused();
const before = await page.locator('#viewer-dialog .media-position').textContent();
await page.keyboard.press('ArrowRight');
await expect(page.locator('#viewer-dialog .media-position')).not.toHaveText(before);
// This viewer has no Delete…, so the Delete key does nothing.
await page.keyboard.press('Delete');
await expect(page.locator('dialog#modal[open]')).toHaveCount(0);
await page.keyboard.press('Escape');
await expect(page.locator('#viewer-dialog')).toHaveCount(0);
// Open activity.
@@ -0,0 +1,304 @@
// #532 (TESTING.md D170): ← and → move through the viewer's files wherever focus is, except where the arrows belong to
// what has focus; ⌘ beside the view switch lists the shortcuts in an overlay that closes on its own.
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
import { seedConcept, openProduction } from './concept-inventory.mjs';
test.use({ locale: 'en-US', timezoneId: 'UTC', reducedMotion: 'reduce' });
const CARDS = '.gallery-body > .image-grid > .media-card';
const viewer = page => page.locator('dialog#viewer-dialog[open]');
const position = page => viewer(page).locator('.media-position');
const keysButton = page => viewer(page).locator('.dialog-header > button#viewer-keys');
const ALL_KEYS = ['←', '→', 'Space', '+', '-', '0', 'R', 'H', 'F', 'I', '?', 'Esc', 'D', 'Delete'];
const overlay = page => page.getByRole('dialog', { name: 'Keyboard shortcuts', exact: true });
test.beforeEach(async ({ page, request, browserName }, testInfo) => {
await startCoverage(page, browserName);
testInfo.manifest = await seedConcept(request);
});
test.afterEach(async ({ page, browserName }) => { await saveCoverage(page, browserName); });
async function openImages(page, width = 1600) {
await page.setViewportSize({ width, height: 1100 });
await openProduction(page, 'images', 'dark');
await page.locator(CARDS).first().locator('.image-open').click();
await expect(viewer(page).locator('#viewer-details .panel-heading')).toBeVisible();
await expect(position(page)).toContainText('1 of ');
}
async function movesBothWays(page) {
await page.keyboard.press('ArrowRight');
await expect(position(page)).toContainText('2 of ');
await page.keyboard.press('ArrowLeft');
await expect(position(page)).toContainText('1 of ');
}
async function staysPut(page) {
const before = await position(page).textContent();
await page.keyboard.press('ArrowRight');
await page.evaluate(() => new Promise(requestAnimationFrame));
await expect(position(page)).toHaveText(before);
}
test('the arrows move through the files as soon as the viewer opens, with focus on ✕', async ({ page }) => {
await openImages(page);
await expect(viewer(page).locator('#viewer-close')).toBeFocused();
await movesBothWays(page);
});
test('the arrows keep working from every header control and when focus falls to the page', async ({ page }) => {
await openImages(page);
await viewer(page).locator('#viewer-mode').click();
await movesBothWays(page);
await viewer(page).locator('#viewer-mode').click();
// Header text is not focusable: the click leaves focus outside any control.
await viewer(page).locator('.dialog-header p').click();
await movesBothWays(page);
await page.evaluate(() => document.activeElement?.blur());
await movesBothWays(page);
});
test('the arrows move from a details button but stay with focused text and with modifier keys', async ({ page }) => {
await openImages(page);
await viewer(page).locator('#viewer-details button').first().focus();
await movesBothWays(page);
await viewer(page).locator('#viewer-details p.prompt-text').first().focus();
await staysPut(page);
await viewer(page).locator('#viewer-close').focus();
const before = await position(page).textContent();
await page.keyboard.press('Shift+ArrowRight');
await page.evaluate(() => new Promise(requestAnimationFrame));
await expect(position(page)).toHaveText(before);
});
test('⌘ lists the shortcuts; Esc closes only the overlay and the arrows wait for it', async ({ page }) => {
await openImages(page);
await expect(keysButton(page)).toHaveAccessibleName('Keyboard shortcuts');
await expect(keysButton(page)).toHaveAttribute('title', 'Keyboard shortcuts');
await expect(keysButton(page).locator('svg.icon')).toHaveCount(1);
await expect(page.locator('#viewer-keys-dialog')).toHaveCount(0);
await keysButton(page).click();
await expect(overlay(page)).toBeVisible();
expect(await overlay(page).locator('dt').allTextContents()).toEqual(ALL_KEYS);
// A small dialog sized to its content, its title inset from the edge.
const box = await overlay(page).evaluate(node => {
const frame = node.getBoundingClientRect(), title = node.querySelector('h2').getBoundingClientRect();
return { height: frame.height, inset: title.left - frame.left };
});
expect(box.height).toBeLessThan(700);
expect(box.inset).toBeGreaterThan(8);
await staysPut(page);
await page.keyboard.press('Escape');
await expect(overlay(page)).toHaveCount(0);
await expect(viewer(page)).toBeVisible();
await expect(keysButton(page)).toBeFocused();
await movesBothWays(page);
await page.keyboard.press('Escape');
await expect(viewer(page)).toHaveCount(0);
});
test('the overlay × closes only the overlay; closing the viewer never leaves one behind', async ({ page }) => {
await openImages(page);
await keysButton(page).click();
await overlay(page).getByRole('button', { name: 'Close keyboard shortcuts', exact: true }).click();
await expect(overlay(page)).toHaveCount(0);
await expect(viewer(page)).toBeVisible();
await expect(keysButton(page)).toBeFocused();
await keysButton(page).click();
await expect(overlay(page)).toBeVisible();
await page.evaluate(() => { location.hash = '/overview'; });
await expect(page.locator('#viewer-dialog,#viewer-keys-dialog')).toHaveCount(0);
});
test('opening the shortcuts stops the slideshow', async ({ page }) => {
await openImages(page);
const play = viewer(page).locator('#viewer-toolbar').getByRole('button', { name: /slideshow$/ });
await play.click();
await expect(play).toHaveAttribute('aria-label', 'Pause slideshow');
await keysButton(page).click();
await expect(overlay(page)).toBeVisible();
await page.keyboard.press('Escape');
await expect(play).toHaveAttribute('aria-label', 'Play slideshow');
});
test('the shortcuts button hides at phone width, like the view switch', async ({ page }) => {
await openImages(page, 600);
await expect(keysButton(page)).toBeHidden();
await movesBothWays(page);
});
// ── #532: the toolbar's actions on single keys. Each key clicks the toolbar's own control, so the two cannot disagree.
const stage = page => viewer(page).locator('#viewer-stage');
const image = page => viewer(page).locator('.viewer-canvas img');
const control = (page, name) => viewer(page).getByRole('button', { name, exact: true });
const transform = page => image(page).evaluate(node => node.style.transform + '|' + node.style.width);
const playing = page => viewer(page).locator('#viewer-toolbar').getByRole('button', { name: /slideshow$/ });
const review = page => page.locator('dialog#modal[open]');
/** A key event a browser would keep for itself, or that repeats, sent straight to what has focus. */
const synth = (page, key, init = {}) => page.evaluate(([key, init]) => {
(document.activeElement || document.body).dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init }));
}, [key, init]);
async function ready(page) {
await expect(control(page, 'Zoom in')).toHaveAttribute('aria-disabled', 'false');
await stage(page).focus();
}
/** Nothing the keys can change has changed. */
const snapshot = async page => ({ position: await position(page).textContent(), transform: await transform(page),
play: await playing(page).getAttribute('aria-label'), classes: await viewer(page).getAttribute('class'),
details: await viewer(page).locator('#viewer-details').isVisible(), modals: await page.locator('dialog[open]').count() });
test('+ - 0 zoom and fit, R rotates, H flips', async ({ page }) => {
await openImages(page);await ready(page);
const fit = await transform(page);
await page.keyboard.press('+');
await expect.poll(() => transform(page)).not.toBe(fit);
const zoomed = await transform(page);
await page.keyboard.press('-');
await expect.poll(() => transform(page)).not.toBe(zoomed);
await page.keyboard.press('+');await page.keyboard.press('0');
await expect.poll(() => transform(page)).toBe(fit);
await page.keyboard.press('r');
await expect.poll(() => transform(page)).toContain('rotate(90deg)');
await page.keyboard.press('Shift+R');
await expect.poll(() => transform(page)).toContain('rotate(180deg)');
await page.keyboard.press('h');
await expect.poll(() => transform(page)).toContain('scaleX(-1)');
});
test('Space plays and pauses on the stage, and activates a focused button instead', async ({ page }) => {
await openImages(page);await ready(page);
await page.keyboard.press('Space');
await expect(playing(page)).toHaveAttribute('aria-label', 'Pause slideshow');
await page.keyboard.press('Space');
await expect(playing(page)).toHaveAttribute('aria-label', 'Play slideshow');
await control(page, 'Next media').focus();
await page.keyboard.press('Space');
await expect(position(page)).toContainText('2 of ');
await expect(playing(page)).toHaveAttribute('aria-label', 'Play slideshow');
});
test('F switches the view, I shows and hides the details the toggle offers', async ({ page }) => {
await openImages(page);await ready(page);
const details = viewer(page).locator('#viewer-details');
// Centered without the collapse setting: the toolbar has no toggle, so I does nothing.
await page.keyboard.press('i');
await page.evaluate(() => new Promise(requestAnimationFrame));
await expect(details).toBeVisible();
await page.keyboard.press('f');
await expect(viewer(page)).toHaveClass(/\bviewer-fullscreen\b/);
await page.keyboard.press('i');
await expect(details).toBeHidden();
await page.keyboard.press('i');
await expect(details).toBeVisible();
await page.keyboard.press('f');
await expect(viewer(page)).not.toHaveClass(/\bviewer-fullscreen\b/);
});
test('F does nothing where the view switch is hidden; ? still opens the shortcuts, which fit at 390 px', async ({ page }) => {
await openImages(page, 390);await ready(page);
await page.keyboard.press('f');
await page.evaluate(() => new Promise(requestAnimationFrame));
await expect(viewer(page)).not.toHaveClass(/\bviewer-fullscreen\b/);
await page.keyboard.press('?');
await expect(overlay(page)).toBeVisible();
const fit = await overlay(page).evaluate(node => {
const box = node.getBoundingClientRect();
return { inside: box.left >= 0 && box.right <= innerWidth && box.top >= 0 && box.bottom <= innerHeight,
noSideScroll: node.scrollWidth <= node.clientWidth };
});
expect(fit).toEqual({ inside: true, noSideScroll: true });
expect(await overlay(page).locator('dt').allTextContents()).toEqual(ALL_KEYS);
await overlay(page).locator('dt').last().scrollIntoViewIfNeeded();
await expect(overlay(page).locator('dt').last()).toBeInViewport();
});
test('D downloads the original', async ({ page }) => {
await openImages(page);await ready(page);
const [download] = await Promise.all([page.waitForEvent('download'), page.keyboard.press('d')]);
expect(download.suggestedFilename()).toMatch(/\.(png|jpe?g|webp)$/);
});
test('Delete opens the review and never deletes; every viewer key waits while the review is open', async ({ page }) => {
const writes = [];
page.on('request', request => { if (request.method() !== 'GET' && !request.url().includes('/__test__/')) writes.push(request.url()); });
await openImages(page);await ready(page);
const before = await snapshot(page);
const downloads = [];page.on('download', download => downloads.push(download));
writes.length = 0;
await page.keyboard.press('Delete');
await expect(review(page)).toBeVisible();
// The review reads its preview; repeating the key must not ask again.
await page.waitForLoadState('networkidle');
const opened = writes.length;
for (const key of ['Delete', 'r', 'f', 'i', 'd', '?']) await page.keyboard.press(key);
// With focus fallen to the page, too.
await page.evaluate(() => document.activeElement?.blur());
for (const key of ['Delete', 'ArrowRight', 'r', ' ']) await page.keyboard.press(key === ' ' ? 'Space' : key);
await page.waitForLoadState('networkidle');
expect(writes.length).toBe(opened);
// Inside the review the arrows and Space are its own: they may choose a mode, which only reads a new preview.
await review(page).getByRole('radio').first().focus();
for (const key of ['ArrowRight', 'Space']) await page.keyboard.press(key);
await expect(overlay(page)).toHaveCount(0);
await page.keyboard.press('Escape');
await expect(review(page)).toHaveCount(0);
await expect(viewer(page)).toBeVisible();
expect(writes.filter(url => /delete|trash/i.test(url) && !url.endsWith('/delete/preview'))).toEqual([]);
expect(await snapshot(page)).toEqual(before);
expect(downloads).toEqual([]);
});
test('the keys do nothing with Ctrl, Cmd or Alt held, on a repeat, or while the overlay is open', async ({ page }) => {
await openImages(page);await ready(page);
const before = await snapshot(page);
const downloads = [];page.on('download', download => downloads.push(download));
for (const modifier of ['ctrlKey', 'metaKey', 'altKey'])
for (const key of ['+', '-', '0', 'r', 'h', ' ', 'f', 'd', 'Delete', '?']) await synth(page, key, { [modifier]: true });
for (const key of [' ', 'r', 'h', 'f', 'd', 'Delete', '?']) await synth(page, key, { repeat: true });
await page.evaluate(() => new Promise(requestAnimationFrame));
expect(await snapshot(page)).toEqual(before);
expect(downloads).toEqual([]);
await page.keyboard.press('?');
await expect(overlay(page)).toBeVisible();
for (const key of ['r', 'h', 'f', 'i', '0', 'Delete']) await page.keyboard.press(key);
await page.keyboard.press('Escape');
await expect(overlay(page)).toHaveCount(0);
expect(await snapshot(page)).toEqual(before);
await expect(viewer(page)).toBeVisible();
});
test('the keys stay out of a form field, raw metadata and a disclosure', async ({ page }) => {
await openImages(page);await ready(page);
const before = await snapshot(page);
await viewer(page).locator('#viewer-details').evaluate(node => {
const field = document.createElement('input');field.id = 'probe';node.append(field);
});
await viewer(page).locator('#probe').focus();
await page.keyboard.type('rhfid0+-? ');
await expect(viewer(page).locator('#probe')).toHaveValue('rhfid0+-? ');
await viewer(page).locator('#probe').evaluate(node => node.remove());
const summary = viewer(page).getByText('Workflow & raw metadata', { exact: true });
await summary.focus();
for (const key of ['r', 'f', '0']) await page.keyboard.press(key);
await page.evaluate(() => new Promise(requestAnimationFrame));
expect(await snapshot(page)).toEqual(before);
});
test('on a video the image keys and Space do nothing, as the toolbar disables them; a focused video keeps its keys', async ({ page, request }) => {
await expect(await request.post('/__test__/reset', { data: { include_video: true } })).toBeOK();
await page.setViewportSize({ width: 1600, height: 1100 });
await page.goto('/prompt_manager/workspace#/images');
await page.locator('article[data-image-key="0:clip.webm"] .image-open').click();
const video = viewer(page).locator('video');
await expect(video).toHaveJSProperty('controls', true);
await expect(control(page, 'Zoom in')).toHaveAttribute('aria-disabled', 'true');
const errors = [];page.on('pageerror', error => errors.push(error.message));
await stage(page).focus();
for (const key of ['+', '-', '0', 'r', 'h', 'Space']) await page.keyboard.press(key);
await expect(playing(page)).toHaveAttribute('aria-label', 'Play slideshow');
await expect(viewer(page).locator('.viewer-canvas')).toHaveCount(0);
expect(errors).toEqual([]);
await video.focus();
await page.keyboard.press('f');
await page.evaluate(() => new Promise(requestAnimationFrame));
await expect(viewer(page)).not.toHaveClass(/\bviewer-fullscreen\b/);
});
+1 -1
View File
@@ -32,7 +32,7 @@ const frame = page => viewer(page).evaluate(node => {
test('the button beside ✕ switches between Centered and Full screen and names the view it switches to', async ({ page }) => {
await openImages(page);
const header = await viewer(page).locator('.dialog-header').evaluate(node => [...node.children].map(child => child.id || child.localName));
expect(header).toEqual(['div', 'viewer-mode', 'viewer-close']);
expect(header).toEqual(['div', 'viewer-keys', 'viewer-mode', 'viewer-close']);
await expect(modeButton(page)).toHaveAccessibleName('Full screen view');
await expect(modeButton(page)).toHaveAttribute('title', 'Full screen view');
await expect(modeButton(page).locator('svg.icon')).toHaveCount(1);
+3
View File
@@ -21,11 +21,14 @@ export function mountedDom() {
removeEventListener(type,callback){this.listeners.set(type,(this.listeners.get(type)||[]).filter(e=>e.callback!==callback));}
fire(type,event={}){for(const e of [...(this.listeners.get(type)||[])])if(!e.signal?.aborted)e.callback({target:this,preventDefault(){},...event});}
focus(){this.focused=true;}
click(){this.fire('click');}
getClientRects(){return this.isConnected&&!this.layoutHidden?[{}]:[];}
showModal(){this.open=true;}
close(){this.open=false;this.fire('close');}
contains(node){return node===this||this.children.some(c=>c.contains(node));}
closest(selector){if(selector==='section'&&this.tagName==='section')return this;if(selector==='[data-send-role]'&&this.dataset.sendRole)return this;if(selector==='[data-copy-role]'&&this.dataset.copyRole)return this;return this.parent?.closest(selector)||null;}
querySelector(selector){return walk(this,n=>n.tagName===selector)[0]||null;}
querySelectorAll(selector){return walk(this,n=>n.tagName===selector);}
pause(){this.paused=true;}
load(){this.loaded=true;}
play(){this.played=true;return Promise.resolve();}
+80 -1
View File
@@ -200,7 +200,9 @@ test('viewer renders the concept shell and toolbar order', async () => {
assert.equal(dialog.attributes['aria-label'],undefined,'the title names the dialog');
const [header,layout] = dialog.children;
assert.equal(header.className,'dialog-header');
const [heading,mode,close] = header.children, [title,subtitle] = heading.children;
const [heading,keys,mode,close] = header.children, [title,subtitle] = heading.children;
// #532 D170: ⌘ before the view switch opens the shortcuts.
assert.deepEqual([keys.tagName,keys.id,keys.className,keys.attributes['aria-label'],keys.title],['button','viewer-keys','icon-btn','Keyboard shortcuts','Keyboard shortcuts']);
// #450 D156: the view switch before ✕ names the view it switches to (Centered is the stored default).
assert.deepEqual([mode.tagName,mode.id,mode.className,mode.attributes['aria-label'],mode.title],['button','viewer-mode','icon-btn','Full screen view','Full screen view']);
mode.fire('click');
@@ -233,6 +235,83 @@ test('viewer renders the concept shell and toolbar order', async () => {
} finally { view.dispose(); parentScope.dispose(); f.restore(); }
});
test('⌘ opens the shortcuts on top of the viewer and closing them leaves it open (#532)', async () => {
const {reportedLocationAdapter:adapter} = await import('../../web/js/workspace/history-media.mjs');
const f = mountedDom(), parentScope = createScope(), controller = borrowedController(), items = [occurrenceMedia('1',1,'c'),occurrenceMedia('2',0,'d')];
const view = module.createMediaViewer({container:f.container,parentScope,inspectionController:controller,adapter,sender:{open(){}}});
try {
view.open({items,selectedKey:'c'.repeat(64)});
const [dialog] = f.container.children, [keys] = f.walk(dialog,n => n.id === 'viewer-keys');
const position = () => f.walk(dialog,n => n.attributes.role === 'status')[0].textContent;
const overlays = () => f.walk(dialog,n => n.id === 'viewer-keys-dialog');
assert.equal(overlays().length,0,'built on demand');
assert.match(position(),/^1 of 2/);
f.doc.fire('keydown',{key:'ArrowRight',target:f.walk(dialog,n => n.id === 'viewer-close')[0]});
assert.match(position(),/^2 of 2/,'the arrows work with focus on ×');
keys.fire('click');
const [overlay] = overlays();
assert.equal(overlay.open,true);
assert.equal(overlay.attributes['aria-labelledby'],'viewer-keys-title');
assert.deepEqual(f.walk(overlay,n => n.tagName === 'kbd').map(n => n.textContent),['←','→','Space','+','-','0','R','H','F','I','?','Esc','D']);
f.doc.fire('keydown',{key:'ArrowLeft',target:keys});
assert.match(position(),/^2 of 2/,'the arrows wait for the overlay');
f.walk(overlay,n => n.attributes['aria-label'] === 'Close keyboard shortcuts')[0].fire('click');
assert.equal(overlays().length,0);
assert.equal(dialog.open,true,'the viewer stays open');
} finally { view.dispose(); parentScope.dispose(); f.restore(); }
});
test('hotkeys click the toolbar\'s own controls, so an unavailable control does nothing (#532)', async () => {
const {reportedLocationAdapter:adapter} = await import('../../web/js/workspace/history-media.mjs');
const f = mountedDom(), parentScope = createScope(), controller = borrowedController(), items = [occurrenceMedia('1',1,'c'),occurrenceMedia('2',0,'d')], deletes = [];
const view = module.createMediaViewer({container:f.container,parentScope,inspectionController:controller,adapter,sender:{open(){}},onDelete:(entry,button) => deletes.push([entry,button])});
try {
view.open({items,selectedKey:'c'.repeat(64)});
const [dialog] = f.container.children, by = (test) => f.walk(dialog,test)[0];
const stage = by(n => n.id === 'viewer-stage'), mode = by(n => n.id === 'viewer-mode'), remove = by(n => n.textContent === 'Delete…');
const press = (key,init = {}) => { let prevented = false; f.doc.fire('keydown',{key,target:stage,preventDefault(){prevented = true;},...init});return prevented; };
// No media is ready: every transform is aria-disabled, so its key is swallowed by the same check as a click.
for (const key of ['+','=','-','0','r','H']) assert.equal(press(key),true,key);
// Modifiers, repeats and composition leave every key alone; Shift does not.
for (const init of [{ctrlKey:true},{metaKey:true},{altKey:true},{repeat:true},{isComposing:true}]) assert.equal(press('f',init),false);
assert.equal(press('+',{repeat:true}),true,'zoom may repeat');
assert.equal(press('Unbound'),false);
// F clicks the switch, and does nothing while the switch is hidden.
assert.equal(mode.attributes['aria-label'],'Full screen view');
press('F',{shiftKey:true});assert.equal(mode.attributes['aria-label'],'Centered view');
mode.layoutHidden = true;assert.equal(press('f'),false);assert.equal(mode.attributes['aria-label'],'Centered view');mode.layoutHidden = false;
// I needs the toggle: it is there in full screen and gone when Centered.
const details = by(n => n.id === 'viewer-details');
press('i');assert.equal(details.hidden,true);press('i');assert.equal(details.hidden,false);
press('f');assert.equal(press('i'),false,'no toggle when Centered');
// Delete only opens the page's review, for this file.
press('Delete');assert.equal(deletes.length,1);assert.equal(deletes[0][1],remove);
press('Delete',{repeat:true});assert.equal(deletes.length,1);
// D goes through the link's own check while the file cannot be downloaded.
controller.publish({loading:false,error:false,record:null,refreshRequired:true,message:'Refresh required.'});
const download = by(n => n.attributes['aria-label'] === 'Download original');
let blocked = false;download.fire('click',{preventDefault(){blocked = true;}});assert.equal(blocked,true);
assert.equal(press('d'),true);
// Space and ? on the stage; a target that keeps its keys leaves them alone.
assert.equal(press(' '),true);
assert.equal(press('?'),true);
const overlay = by(n => n.id === 'viewer-keys-dialog');assert.equal(overlay.open,true);
assert.equal(press('r'),false,'off under the overlay');
overlay.close();
assert.equal(press('r',{target:new f.Node('body')}),false,'a target outside the viewer that is not the page');
} finally { view.dispose(); parentScope.dispose(); f.restore(); }
const g = mountedDom(), scope = createScope();
const plain = module.createMediaViewer({container:g.container,parentScope:scope,inspectionController:borrowedController(),adapter,sender:{open(){}}});
try {
plain.open({items,selectedKey:'c'.repeat(64)});
const stage = g.walk(g.container,n => n.id === 'viewer-stage')[0];
let prevented = false;g.doc.fire('keydown',{key:'Delete',target:stage,preventDefault(){prevented = true;}});
assert.equal(prevented,false,'no Delete… in this viewer, so no Delete key');
g.doc.fire('keydown',{key:'?',target:stage});
assert.ok(!g.walk(g.container,n => n.tagName === 'kbd').some(n => n.textContent === 'Delete'),'and none listed');
} finally { plain.dispose(); scope.dispose(); g.restore(); }
});
test('viewer titles a linked card "<title> · <filename>" before any inspection arrives', async () => {
const {reportedLocationAdapter:adapter} = await import('../../web/js/workspace/history-media.mjs');
const f = mountedDom(), parentScope = createScope(), controller = borrowedController();