diff --git a/frontend/css/style.css b/frontend/css/style.css index 819330c..45f7bde 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -3279,6 +3279,11 @@ body.theme-light .saved-badge { box-shadow: 0 0 0 2px rgba(246, 241, 231, 0.95); align-items: center; gap: 4px; border-bottom: 1px solid var(--border); +} + +/* Rows animate in when they arrive, and only then: a row already on screen + that a background check wrote a new count into has not arrived anywhere. */ +.saved-row.is-new { animation: saved-row-in 0.3s cubic-bezier(0.16, 1, 0.3, 1) both; animation-delay: calc(var(--i, 0) * 40ms); } diff --git a/frontend/js/ui.js b/frontend/js/ui.js index 2c59836..03c71a3 100644 --- a/frontend/js/ui.js +++ b/frontend/js/ui.js @@ -609,10 +609,19 @@ App.ui = App.ui || {}; return strip; }; + // Which of the three shapes a row is in. A row only has to be built + // again when it changes shape; otherwise the one on screen is updated + // where it stands (see render). + const modeOf = function(entry) { + return (pending && pending.id === entry.id) ? pending.mode : 'plain'; + }; + const buildRow = function(entry, index) { const row = document.createElement('div'); - row.className = 'saved-row'; + // Only a row that was not on screen a moment ago animates in. + row.className = 'saved-row is-new'; row.dataset.id = entry.id; + row.dataset.mode = modeOf(entry); row.style.setProperty('--i', String(index)); if (entry.unreachable) row.classList.add('is-unreachable'); @@ -674,6 +683,45 @@ App.ui = App.ui || {}; tools.appendChild(rename); tools.appendChild(remove); row.appendChild(tools); + // What updateRow writes to, so it doesn't have to search the row + // again on every check. + row._parts = { count: count, name: name, meta: meta, stamp: stamp, + rename: rename, remove: remove }; + return row; + }; + + // The same row, told what changed. A check writes a new count and a new + // "checked" time on every saved search it asks about; rebuilding the + // list for that would play the whole list's entry animation again, + // drop the reader's scroll position and take the pointer out of + // whatever it was over. A background check has no business doing any + // of that, so nothing is replaced -- the few strings that differ are + // written in place. + const updateRow = function(row, entry, index) { + const parts = row._parts; + if (!parts) return row; + row.classList.remove('is-new'); + row.style.setProperty('--i', String(index)); + row.classList.toggle('is-unreachable', !!entry.unreachable); + + const label = App.savedSearches.badgeLabel(entry.newCount); + const countText = label || 'ยท'; + if (parts.count.textContent !== countText) parts.count.textContent = countText; + parts.count.classList.toggle('is-quiet', !label); + if (label) parts.count.title = `${entry.newCount} new since you last opened this`; + else parts.count.removeAttribute('title'); + + if (parts.name.textContent !== entry.name) { + parts.name.textContent = entry.name; + parts.rename.title = `Rename ${entry.name}`; + parts.rename.setAttribute('aria-label', `Rename ${entry.name}`); + parts.remove.title = `Remove ${entry.name}`; + parts.remove.setAttribute('aria-label', `Remove ${entry.name}`); + } + const meta = metaLine(entry); + if (parts.meta.textContent !== meta) parts.meta.textContent = meta; + const stamp = stampLine(entry); + if (parts.stamp.textContent !== stamp) parts.stamp.textContent = stamp; return row; }; @@ -687,8 +735,27 @@ App.ui = App.ui || {}; if (!list) return; const entries = App.savedSearches.getAll(); - list.textContent = ''; - entries.forEach((entry, index) => list.appendChild(buildRow(entry, index))); + const onScreen = new Map(); + Array.from(list.children).forEach((row) => onScreen.set(row.dataset.id, row)); + entries.forEach((entry, index) => { + const had = onScreen.get(entry.id); + onScreen.delete(entry.id); + // A row mid-rename or mid-confirm is a different row: it has + // its own field and buttons, so that one is built. + let row; + if (had && had.dataset.mode === modeOf(entry)) { + row = updateRow(had, entry, index); + } else { + row = buildRow(entry, index); + // A row that changed shape where it stood has not arrived + // either -- it is the same row, asking something. + if (had) row.classList.remove('is-new'); + } + if (list.children[index] !== row) { + list.insertBefore(row, list.children[index] || null); + } + }); + onScreen.forEach((row) => row.remove()); if (empty) empty.hidden = entries.length > 0; if (note) { diff --git a/tests/smoke_saved_searches.py b/tests/smoke_saved_searches.py index a230354..8844b81 100644 --- a/tests/smoke_saved_searches.py +++ b/tests/smoke_saved_searches.py @@ -58,6 +58,20 @@ ROWS = """() => Array.from(document.querySelectorAll('#saved-panel .saved-row')) stamp: row.querySelector('.saved-row-stamp').textContent }))""" +MARK_ROWS = """() => { + // A mark on the elements themselves: a rebuilt row cannot carry it. + const rows = Array.from(document.querySelectorAll('#saved-panel .saved-row')); + rows.forEach((row, i) => { row.dataset.probe = `p${i}`; }); + return rows.length; +}""" + +PROBES = """() => Array.from(document.querySelectorAll('#saved-panel .saved-row')).map((row) => ({ + probe: row.dataset.probe || '', + count: row.querySelector('.saved-count').textContent, + fresh: row.classList.contains('is-new'), + stamp: row.querySelector('.saved-row-stamp').textContent +}))""" + READ_BTN = """() => { const button = document.getElementById('saved-read-btn'); return { disabled: button.disabled, text: button.textContent }; @@ -378,6 +392,20 @@ def main(): c.ok("and the button turns itself off with nothing left to clear", page.evaluate(READ_BTN)["disabled"] is True, str(page.evaluate(READ_BTN))) + print("\na check writes into the list without rebuilding it") + c.ok("the list is open with a row in it", page.evaluate(MARK_ROWS) == 1) + offset["at"] = offset["at"] + 2 + page.evaluate("() => App.savedSearches.check({ force: true })") + page.wait_for_timeout(1000) + rows = page.evaluate(PROBES) + c.ok("the row is the same element it was before the check", + len(rows) == 1 and rows[0]["probe"] == "p0", str(rows)) + c.ok("with the count written into it where it stood", + rows[0]["count"] == "2", str(rows[0]["count"])) + c.ok("and the time of the check with it", + "checked" in rows[0]["stamp"].lower(), str(rows[0]["stamp"])) + c.ok("nothing animated itself back in", rows[0]["fresh"] is False, str(rows[0])) + c.ok("nothing threw along the way", not crashes, str(crashes[:2])) browser.close()