Closes #532
This commit is contained in:
@@ -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++; };
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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();}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user