fix(popup): 入场动画仅视图切换播放,首帧即正确视图,隐藏全部滚动条

- 动画绑定 .is-entering.is-ready(syncPopupView 独占 is-entering,渲染器不触碰;
  同视图刷新清除),内容刷新不再重播 opacity 0 淡入
- .is-entering 不再隐藏容器本身,滞留期间不再致面板空白
- 打开标签页视图时首帧即正确面板:localStorage 同步解析视图 + 模块加载时
  先于首帧 syncPopupView(chrome.storage 仅作导入/导出对账),消除
  快捷方式面板闪现
- 列表与快捷网格滚动条完全隐藏(scrollbar-width:none + webkit display:none,
  与导航一致),瞬时溢出不再闪现滚动条
- 测试与回归断言同步更新(303 pass)
This commit is contained in:
Leexunhuan743
2026-08-14 18:05:36 +08:00
parent fcce2bc8ec
commit 6f460ba11c
4 changed files with 91 additions and 35 deletions
+24 -19
View File
@@ -357,7 +357,6 @@ body.popup-shell {
flex-direction: column;
gap: 8px;
padding-right: 2px;
scrollbar-width: thin;
}
.popup-tab-group {
@@ -489,15 +488,18 @@ body.popup-shell {
}
/* ── Scrollbars ──────────────────────────────────────────────── */
.popup-shortcuts-grid::-webkit-scrollbar,
.popup-tabs-list::-webkit-scrollbar {
width: 4px;
/* Scrollbars are hidden everywhere (like the group nav): the panels still
scroll with the wheel and keyboard, and transient overflow (e.g. during
the entry animation) can never flash a visible scrollbar. */
.popup-shortcuts-grid,
.popup-tabs-list {
scrollbar-width: none;
-ms-overflow-style: none;
}
.popup-shortcuts-grid::-webkit-scrollbar-thumb,
.popup-tabs-list::-webkit-scrollbar-thumb {
background: var(--warm-gray);
border-radius: 999px;
.popup-shortcuts-grid::-webkit-scrollbar,
.popup-tabs-list::-webkit-scrollbar {
display: none;
}
/* ── Empty State ─────────────────────────────────────────────── */
@@ -559,8 +561,9 @@ body.popup-shell {
}
}
/* Animation base classes — start invisible */
.is-entering,
/* Animation base classes — children start invisible; the container itself
stays visible so a lingering .is-entering (cleared on the next same-view
sync) can never blank the whole panel. */
.is-entering .group-nav-button,
.is-entering .popup-tab-group,
.is-entering .popup-tab-row,
@@ -573,22 +576,25 @@ body.popup-shell.is-ready {
animation: popup-rise 240ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
/* Entrance animations run only during an actual view switch: the
.is-entering class exists only then (added by syncPopupView), so content
replaced by a background refresh never replays them from opacity 0. */
/* Shortcuts */
.popup-shortcuts-grid.is-ready .popup-shortcut-card {
.popup-shortcuts-grid.is-entering.is-ready .popup-shortcut-card {
animation: shortcut-rise 200ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
/* Tabs — nav buttons stagger */
.popup-group-nav-wrap.is-ready .group-nav-button {
.popup-group-nav-wrap.is-entering.is-ready .group-nav-button {
animation: nav-rise 180ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
/* Tabs — groups stagger, rows cascade after */
.popup-tabs-list.is-ready .popup-tab-group {
.popup-tabs-list.is-entering.is-ready .popup-tab-group {
animation: popup-rise 220ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.popup-tabs-list.is-ready .popup-tab-row {
.popup-tabs-list.is-entering.is-ready .popup-tab-row {
animation: tab-rise 160ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
@@ -602,16 +608,15 @@ body.popup-shell.is-ready {
transition: none !important;
transform: none !important;
}
.is-entering,
.is-entering .group-nav-button,
.is-entering .popup-tab-group,
.is-entering .popup-tab-row,
.is-entering .popup-shortcut-card,
body.popup-shell.is-ready,
.popup-shortcuts-grid.is-ready .popup-shortcut-card,
.popup-group-nav-wrap.is-ready .group-nav-button,
.popup-tabs-list.is-ready .popup-tab-group,
.popup-tabs-list.is-ready .popup-tab-row {
.popup-shortcuts-grid.is-entering.is-ready .popup-shortcut-card,
.popup-group-nav-wrap.is-entering.is-ready .group-nav-button,
.popup-tabs-list.is-entering.is-ready .popup-tab-group,
.popup-tabs-list.is-entering.is-ready .popup-tab-row {
opacity: 1 !important;
animation: none !important;
transform: none !important;
+32 -9
View File
@@ -22,6 +22,16 @@ const popupState = {
groupLabelOverrides: {},
};
// Resolve the remembered view synchronously (localStorage is sync) so the
// very first paint shows the correct panel. chrome.storage is async and only
// reconciles later; without this the popup flashes the default shortcuts
// view for the first frame(s) when the remembered view is tabs.
try {
popupState.view = localStorage.getItem(POPUP_VIEW_KEY) === 'tabs' ? 'tabs' : 'shortcuts';
} catch {
popupState.view = 'shortcuts';
}
// Test exposure
globalThis.popupState = popupState;
globalThis.loadPopupView = loadPopupView;
@@ -377,7 +387,6 @@ function renderPopupShortcuts() {
emptyEl.hidden = popupState.quickShortcuts.length > 0;
requestAnimationFrame(() => requestAnimationFrame(() => {
listEl.classList.remove('is-entering');
listEl.classList.add('is-ready');
}));
}
@@ -508,8 +517,6 @@ function renderPopupTabs() {
}
requestAnimationFrame(() => requestAnimationFrame(() => {
navEl.classList.remove('is-entering');
listEl.classList.remove('is-entering');
navEl.classList.add('is-ready');
listEl.classList.add('is-ready');
}));
@@ -561,21 +568,27 @@ function syncPopupView() {
// Re-trigger animation for the incoming active panel (skip when the
// class is already present so background refreshes cause no mutations).
// is-entering is transient: the double-rAF below clears it once the
// animation has started, so the panel never stays hidden after a switch.
// is-entering (set above on view switch) stays on while the entrance
// animation plays; only the same-view sync below clears it.
if (!isTabs && shortcutsList && !shortcutsList.classList.contains('is-ready')) {
requestAnimationFrame(() => requestAnimationFrame(() => {
shortcutsList.classList.remove('is-entering');
shortcutsList.classList.add('is-ready');
}));
} else if (isTabs && tabsList && navEl) {
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!tabsList.classList.contains('is-ready')) tabsList.classList.add('is-ready');
if (!navEl.classList.contains('is-ready')) navEl.classList.add('is-ready');
tabsList.classList.remove('is-entering');
navEl.classList.remove('is-entering');
}));
}
// Background refreshes must not replay the entrance animation: the child
// animations are bound to .is-entering.is-ready, so clearing is-entering
// here (and on every later same-view sync) keeps replaced content visible.
if (!viewChanged) {
[shortcutsList, tabsList, navEl].forEach(el => {
el?.classList.remove('is-entering');
});
}
}
// Popup quick links always open a new active tab, regardless of the
@@ -748,6 +761,7 @@ function initializePopup() {
popupState.view = actionEl.dataset.view === 'tabs' ? 'tabs' : 'shortcuts';
syncPopupView();
void chrome.storage.local.set({ [POPUP_VIEW_KEY]: popupState.view });
try { localStorage.setItem(POPUP_VIEW_KEY, popupState.view); } catch { /* storage unavailable */ }
return;
}
@@ -811,8 +825,17 @@ function initializePopup() {
}
});
// Apply the remembered view before the first paint (scripts run during
// parse, ahead of any frame) so opening on the tabs view never flashes
// the default shortcuts panel. loadPopupView below reconciles with
// chrome.storage for imports/exports.
syncPopupView();
loadPopupView()
.then(() => refreshPopupSafely())
.then(() => {
syncPopupView();
return refreshPopupSafely();
})
.then(() => requestAnimationFrame(() => document.body.classList.add('is-ready')))
.catch(() => {
renderPopupShortcuts();
+15 -4
View File
@@ -23,6 +23,15 @@ globalThis.flushRaf = () => {
snapshot.forEach(({ fn }) => fn());
};
// popup.js resolves the remembered view synchronously from localStorage at
// module load — mock it so the require-time read is safe and predictable.
globalThis.localStorage = {
_store: {},
getItem: key => (key in globalThis.localStorage._store ? globalThis.localStorage._store[key] : null),
setItem: (key, value) => { globalThis.localStorage._store[key] = String(value); },
removeItem: key => { delete globalThis.localStorage._store[key]; },
};
globalThis.document = {
addEventListener: () => {},
querySelector: () => null,
@@ -665,13 +674,15 @@ test('syncPopupView adds is-entering to the incoming panel only on view switch',
assert.ok(!classes.shortcuts.has('is-entering'), 'outgoing shortcuts panel is not hidden');
flushRaf();
flushRaf();
assert.ok(!classes.tabs.has('is-entering'), 'is-entering removed after animation applies');
assert.ok(classes.tabs.has('is-entering'), 'is-entering stays while the entrance animation plays');
assert.ok(classes.tabs.has('is-ready'), 'is-ready applied to the tabs panel');
// Same-view sync (background refresh) must not re-hide the panel.
classes.tabs.delete('is-entering');
// Same-view sync (background refresh) clears is-entering so replaced
// content never replays the entrance animation.
globalThis.syncPopupView();
assert.ok(!classes.tabs.has('is-entering'), 'same-view sync must not add is-entering');
assert.ok(!classes.tabs.has('is-entering'), 'same-view sync clears is-entering');
assert.ok(!classes.nav.has('is-entering'), 'same-view sync clears nav is-entering');
assert.ok(classes.tabs.has('is-ready'), 'is-ready is preserved across refreshes');
} finally {
globalThis.document.getElementById = originalGet;
globalThis.document.body = originalBody;
+20 -3
View File
@@ -1143,6 +1143,11 @@ test('popup scrolls inside panels only, never the document', () => {
assert.match(popupCss, /\.popup-app \{\s*[\s\S]*height: fit-content;[\s\S]*max-height: 600px;/);
assert.match(popupCss, /\.popup-tabs-list \{[\s\S]*overflow-y: auto;/);
assert.match(popupCss, /\.popup-shortcuts-grid \{[\s\S]*overflow-y: auto;/);
// Panel scrollbars are hidden like the nav's, so transient overflow (e.g.
// during the entry animation) can never flash a visible scrollbar.
assert.match(popupCss, /\.popup-shortcuts-grid,\s*\.popup-tabs-list \{\s*scrollbar-width: none;/);
assert.match(popupCss, /\.popup-shortcuts-grid::-webkit-scrollbar,\s*\.popup-tabs-list::-webkit-scrollbar \{\s*display: none;/);
assert.doesNotMatch(popupCss, /\.popup-tabs-list \{[\s\S]*scrollbar-width: thin;/);
// The top group nav keeps horizontal scrolling but never shows a scrollbar.
assert.match(popupCss, /\.popup-group-nav-wrap \{\s*[\s\S]*flex-wrap: nowrap;[\s\S]*flex: 0 0 auto;[\s\S]*overflow-x: auto;[\s\S]*overflow-y: hidden;[\s\S]*scrollbar-width: none;/);
assert.match(popupCss, /\.popup-group-nav-wrap::-webkit-scrollbar \{\s*display: none;/);
@@ -1163,14 +1168,26 @@ test('popup scrolls inside panels only, never the document', () => {
// The popup entry animation replays only on view switches, not refreshes.
assert.match(popupJs, /const viewChanged = lastSyncedPopupView !== popupState\.view;/);
assert.match(popupJs, /if \(viewChanged\) \{\s*\[shortcutsList, tabsList, navEl\]\.forEach\(el => \{\s*el\?\.classList\.remove\('is-ready', 'is-entering'\);/);
// The remembered view is shown before the async refresh resolves, so the
// popup never flashes the default shortcuts panel when opened on tabs.
assert.match(popupJs, /localStorage\.getItem\(POPUP_VIEW_KEY\) === 'tabs' \? 'tabs' : 'shortcuts'/);
assert.match(popupJs, /localStorage\.setItem\(POPUP_VIEW_KEY, popupState\.view\)/);
assert.match(popupJs, /\/\/ Apply the remembered view before the first paint[\s\S]*syncPopupView\(\);/);
assert.match(popupJs, /loadPopupView\(\)\s*\.then\(\(\) => \{\s*syncPopupView\(\);\s*return refreshPopupSafely\(\);/);
// The incoming panel hides only on an actual view switch (is-entering added
// by syncPopupView); renderers must never add it on background refreshes.
assert.match(popupJs, /if \(viewChanged\) \{[\s\S]*shortcutsList\?\.classList\.add\('is-entering'\)/);
assert.match(popupJs, /if \(viewChanged\) \{[\s\S]*tabsList\?\.classList\.add\('is-entering'\)/);
assert.doesNotMatch(popupJs, /listEl\.classList\.add\('is-entering'\)/);
// is-entering is transient: removed when the entry animation applies.
assert.match(popupJs, /listEl\.classList\.remove\('is-entering'\);\s*listEl\.classList\.add\('is-ready'\);/);
assert.match(popupJs, /navEl\.classList\.remove\('is-entering'\);\s*listEl\.classList\.remove\('is-entering'\);/);
// Entrance animations are bound to .is-entering.is-ready, so refresh
// content (is-entering cleared by same-view sync) never replays them.
assert.match(popupJs, /if \(!viewChanged\) \{\s*\[shortcutsList, tabsList, navEl\]\.forEach\(el => \{\s*el\?\.classList\.remove\('is-entering'\)/);
assert.doesNotMatch(popupJs, /classList\.remove\('is-entering'\);\s*listEl\.classList\.add\('is-ready'\)/);
assert.match(popupCss, /\.popup-tabs-list\.is-entering\.is-ready \.popup-tab-group \{/);
assert.match(popupCss, /\.popup-tabs-list\.is-entering\.is-ready \.popup-tab-row \{/);
assert.match(popupCss, /\.popup-group-nav-wrap\.is-entering\.is-ready \.group-nav-button \{/);
assert.match(popupCss, /\.popup-shortcuts-grid\.is-entering\.is-ready \.popup-shortcut-card \{/);
assert.doesNotMatch(popupCss, /\.popup-tabs-list\.is-ready \.popup-tab-group \{/);
// The popup window fits its content instead of inheriting min-height:100vh.
const htmlBodyStart = popupCss.indexOf('html, body {');
const htmlBodyEnd = popupCss.indexOf('\n}', htmlBodyStart);