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:
Vito Sansevero
2026-10-20 06:11:05 -07:00
parent 38f596b894
commit 18804ed446
2 changed files with 18 additions and 6 deletions
+12 -2
View File
@@ -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'; });
+6 -4
View File
@@ -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 => {