fix(images): mark a star busy while its favorite request is pending
The star carries aria-disabled="true" until the answer arrives, also on a card rendered after returning to Images, so the ignored second press is announced as unavailable instead of doing nothing silently.
This commit is contained in:
@@ -160,6 +160,7 @@ for (const embedded of [false, true]) {
|
||||
const viewer = page.locator('dialog#viewer-dialog');
|
||||
await expect(viewer.locator('.viewer-canvas img')).toBeVisible();
|
||||
await page.evaluate(() => window.continuationFixture.finishPage());
|
||||
await expect(page.getByRole('button', { name: 'Load more images', exact: true })).toHaveAttribute('aria-disabled', 'false');
|
||||
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
expect(await page.evaluate(() => ({ reads: window.continuationFixture.reads, nativeObserver: typeof IntersectionObserver, visible: document.querySelector('.infinite-continuation').getBoundingClientRect().top <= innerHeight })))
|
||||
.toEqual({ reads: [null, 'next-0'], nativeObserver: 'function', visible: true });
|
||||
@@ -442,8 +443,10 @@ test('collection viewer ignores delayed media callbacks and keeps inspection ret
|
||||
});
|
||||
await cards(page).first().getByRole('button',{name:'Open same.png',exact:true}).click();await waiting;
|
||||
const preview=page.locator('dialog#viewer-dialog');
|
||||
await expect(preview.getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','true');
|
||||
await page.route('**/images/inspect/shared/same.png?*',route=>route.fulfill({status:503,json:{success:false,error:'Unavailable'}}),{times:1});
|
||||
await preview.getByRole('button',{name:'Next media',exact:true}).click();
|
||||
await expect(preview).toContainText('Could not read prompts from this file.');await expect(preview.getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
|
||||
await expect(preview.locator('#viewer-toolbar').getByRole('link',{name:'Download original',exact:true})).toHaveAttribute('href',/root=1&root_scope=/);
|
||||
await preview.getByRole('button',{name:'Rotate image 90 degrees',exact:true}).click();
|
||||
await page.evaluate(()=>{window.latestCanvas=document.querySelector('.viewer-canvas img');window.liveViewers[0].options.viewed();});release();
|
||||
@@ -459,8 +462,10 @@ test('collection viewer describes video transforms and releases native playback
|
||||
await page.locator('article[data-image-key="0:clip.webm"] .image-open').click();
|
||||
const preview=page.locator('dialog#viewer-dialog'),video=preview.locator('video');
|
||||
await expect(video).toHaveJSProperty('controls',true);await expect(video).toHaveJSProperty('playsInline',true);
|
||||
const zoom=preview.getByRole('button',{name:'Zoom in',exact:true});await expect(zoom).toHaveAttribute('aria-disabled','true');await expect(zoom).toHaveAccessibleDescription('Image transforms are unavailable for video.');
|
||||
await video.focus();const position=await preview.locator('.media-position').textContent();await page.keyboard.press('ArrowRight');await expect(preview.locator('.media-position')).toHaveText(position);
|
||||
await video.evaluate(async node=>{window.previousClip=node;node.muted=true;await node.play();});
|
||||
await preview.getByRole('button',{name:'Next media',exact:true}).click();await expect(preview.locator('video')).toHaveCount(0);await expect(zoom).toHaveAttribute('aria-disabled','false');
|
||||
expect(await page.evaluate(()=>window.previousClip.paused&&!window.previousClip.isConnected&&!window.previousClip.hasAttribute('src'))).toBe(true);
|
||||
await preview.getByRole('button',{name:'Close image viewer',exact:true}).click();await expect(page.locator('#viewer-dialog')).toHaveCount(0);
|
||||
});
|
||||
@@ -1334,12 +1339,15 @@ test('favorite failure keeps the previous state and announces it', async ({ page
|
||||
await button.click();
|
||||
await expectStar(button, true);
|
||||
// The pending star says it is busy, so the ignored second press is not a silent no-op.
|
||||
await button.click();
|
||||
await expect(button).toHaveAttribute('aria-disabled', 'true');
|
||||
// Playwright waits for an aria-disabled button to enable; a user can still press it, so force the press.
|
||||
await button.click({ force: true });
|
||||
await expect.poll(() => held.length).toBe(1);
|
||||
await page.waitForTimeout(150);
|
||||
expect(held.length).toBe(1);
|
||||
await held[0].fulfill({ status: 500, json: { success: false, error: 'Favorite update failed' } });
|
||||
await expectStar(button, false);
|
||||
await expect(button).not.toHaveAttribute('aria-disabled', 'true');
|
||||
await expect(toast(page)).toHaveText('Favorite could not be added. Try again.');
|
||||
expect((await listedFavorites(request))['0:shared/same.png']).toBe(false);
|
||||
|
||||
@@ -1382,12 +1390,14 @@ test('favorite failure keeps the previous state and announces it', async ({ page
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
|
||||
await page.evaluate(() => { window.location.hash = '/images'; });
|
||||
await expectStar(star(card, 'same.png'), false);
|
||||
await star(card, 'same.png').click();
|
||||
await expect(star(card, 'same.png')).toHaveAttribute('aria-disabled', 'true');
|
||||
await star(card, 'same.png').click({ force: true });
|
||||
await page.waitForTimeout(150);
|
||||
expect(across.length).toBe(1);
|
||||
await expectStar(star(card, 'same.png'), false);
|
||||
await across[0].fulfill({ status: 500, json: { success: false, error: 'Favorite update failed' } });
|
||||
await expectStar(star(card, 'same.png'), true);
|
||||
await expect(star(card, 'same.png')).not.toHaveAttribute('aria-disabled', 'true');
|
||||
await expect(toast(page)).toHaveText('Favorite could not be removed. Try again.');
|
||||
// The rolled-back star is also what the next return shows, and the server never changed.
|
||||
await page.evaluate(() => { window.location.hash = '/overview'; });
|
||||
|
||||
@@ -239,7 +239,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
if (typeof image.favorite === 'boolean') {
|
||||
const star = createButton('', 'star-button');
|
||||
star.setAttribute('aria-label', `Favorite ${image.filename}`);
|
||||
paintStar(star, image.favorite);
|
||||
paintStar(star, image.favorite, favoritePending.has(key));
|
||||
overlays.append(star);
|
||||
}
|
||||
art.append(open, overlays);
|
||||
@@ -258,15 +258,17 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
markSelected(card, key === selection);
|
||||
return card;
|
||||
}
|
||||
function paintStar(button, favorite) {
|
||||
/** A star whose request is still in flight is marked busy: pressing it again does nothing until the answer. */
|
||||
function paintStar(button, favorite, pending) {
|
||||
button.textContent = favorite ? '★' : '☆';
|
||||
button.setAttribute('aria-pressed', String(favorite));
|
||||
if (pending) button.setAttribute('aria-disabled', 'true'); else button.removeAttribute('aria-disabled');
|
||||
}
|
||||
function setFavorite(key, favorite) {
|
||||
if (scope.signal.aborted) return;
|
||||
stream.updateItem(key, {favorite});
|
||||
const button = rendered.get(key)?.querySelector('.star-button');
|
||||
if (button) paintStar(button, favorite);
|
||||
if (button) paintStar(button, favorite, favoritePending.has(key));
|
||||
}
|
||||
// Only the Images page receives favorite answers; Library-embedded media has no star (#249 D7).
|
||||
if (!embedded) {
|
||||
@@ -289,7 +291,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
// Announced below; the star returns to its previous state.
|
||||
}
|
||||
favoritePending.delete(key);
|
||||
if (!saved) deliverFavorite(key, !favorite);
|
||||
deliverFavorite(key, saved ? favorite : !favorite);
|
||||
showWorkspaceToast(saved ? FAVORITE_DONE[favorite] : FAVORITE_FAILED[favorite]);
|
||||
}
|
||||
scope.listen(grid, 'click', event => {
|
||||
|
||||
Reference in New Issue
Block a user