fix(images): rewrite the live status only when its text changes

A star press republishes the stream, and the textContent setter replaced
the continuation status, the count and the caption date with the same
text, so a polite role="status" region could announce it again. Text and
the role attribute are now written only when they change.

Failing before: a MutationObserver saw 10 records on those nodes after a
favorite toggle; now none.
This commit is contained in:
Vito Sansevero
2026-10-20 09:51:21 -07:00
parent 6a7cab2eda
commit af628e549c
2 changed files with 39 additions and 5 deletions
+23
View File
@@ -1435,3 +1435,26 @@ test('Import Rescan shows that it runs, ignores a second press and announces the
await expect(cards(page)).toHaveCount(3);
await expect(caption(page)).toContainText('3 of 3 images');
});
// #249 final review I2: a polite live region re-announces replaced text, so an unchanged status is never rewritten.
test('a star press leaves the continuation status, count and caption date untouched', async ({ page }) => {
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
await expect(page.locator('#gallery-continuation p')).toHaveText(/caught up/);
await expect(caption(page).locator('span').nth(1)).toBeVisible();
await page.evaluate(() => {
window.__statusMutations = [];
const observer = new MutationObserver(records => {
window.__statusMutations.push(...records.map(record => `${record.type}:${record.target.nodeName}`));
});
for (const node of [document.querySelector('#gallery-continuation p'), document.querySelector('#result-count'),
document.querySelector('.section-caption > span:nth-child(2)')]) {
observer.observe(node, { childList: true, characterData: true, subtree: true, attributes: true });
}
});
const card = page.locator('article[data-image-key="0:shared/same.png"]');
await star(card, 'same.png').click();
await expectStar(star(card, 'same.png'), true);
await expect(toast(page)).toHaveText('Added to favorites.');
expect(await page.evaluate(() => window.__statusMutations)).toEqual([]);
});
+16 -5
View File
@@ -20,6 +20,17 @@ function el(tag, text, className) {
return element;
}
/**
* Live regions re-announce replaced text even when it is the same, and a patched publish (a favorite) repeats the
* same status: write text and attributes only when they change.
*/
function setText(element, text) {
if (element.textContent !== text) element.textContent = text;
}
function setAttr(element, name, value) {
if (element.getAttribute(name) !== value) element.setAttribute(name, value);
}
function selectOf(id, options, value) {
const select = el('select');
select.id = id;
@@ -458,10 +469,10 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
for (let index = state?.items.length || 0; index < next.items.length; index++) itemIndexes.set(imageKey(next.items[index]), index);
}
state = next;
count.textContent = embedded ? `${next.loaded.toLocaleString('en-US')} loaded · ${next.total.toLocaleString('en-US')} ${noun(next.total, 'linked image')}` : countLine(next);
setText(count, embedded ? `${next.loaded.toLocaleString('en-US')} loaded · ${next.total.toLocaleString('en-US')} ${noun(next.total, 'linked image')}` : countLine(next));
if (!embedded) {
captionDay.textContent = captionDate(next.items, filters.sort);
captionDay.hidden = !captionDay.textContent;
setText(captionDay, captionDate(next.items, filters.sort));
if (captionDay.hidden !== !captionDay.textContent) captionDay.hidden = !captionDay.textContent;
}
if (changed) updateLayout();
// The concept's Load more toast (app.js loadMoreImages) for every batch appended to a loaded collection.
@@ -469,8 +480,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
const added = next.loaded - previous.loaded;
showWorkspaceToast(`${added.toLocaleString('en-US')} more ${noun(added, 'image')} loaded. ${next.loaded.toLocaleString('en-US')} of ${next.total.toLocaleString('en-US')}.`);
}
message.textContent = continuationText(next);
if (next.error) message.setAttribute('role', 'alert'); else message.setAttribute('role', 'status');
setText(message, continuationText(next));
setAttr(message, 'role', next.error ? 'alert' : 'status');
retry.hidden = !next.error;
more.hidden = !next.hasMore || !!next.error;
more.setAttribute('aria-disabled', String(next.loading || recovering));