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:
+24
-19
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user