diff --git a/package.json b/package.json
index 65d8a0b6..6341717c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "impro",
- "version": "0.20.86",
+ "version": "0.20.87",
"type": "module",
"scripts": {
"start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve",
diff --git a/src/css/style.css b/src/css/style.css
index a1e39022..a79a9a77 100644
--- a/src/css/style.css
+++ b/src/css/style.css
@@ -1391,6 +1391,10 @@ tab-bar:not([full-width]) {
scrollbar-width: none;
}
+tab-bar.is-scroll-frozen {
+ overflow-x: hidden;
+}
+
.profile-card {
margin-bottom: 8px;
}
diff --git a/src/js/components/tab-bar.js b/src/js/components/tab-bar.js
index 9d438e00..19febe9f 100644
--- a/src/js/components/tab-bar.js
+++ b/src/js/components/tab-bar.js
@@ -2,9 +2,13 @@ import { html, render } from "/js/lib/lit-html.js";
import { Component } from "/js/components/component.js";
import { classnames } from "/js/utils.js";
+const SCROLL_FREEZE_FALLBACK_MS = 500;
+
class TabBar extends Component {
static observedAttributes = ["active-tab", "full-width"];
+ #unfreezeTimeout = null;
+
connectedCallback() {
if (this.initialized) return;
this._tabs = this._tabs ?? [];
@@ -13,9 +17,17 @@ class TabBar extends Component {
this.initialized = true;
}
- attributeChangedCallback() {
+ attributeChangedCallback(name) {
if (!this.initialized) return;
this.render();
+ // The host restores page scroll in the frame it renders the new tab
+ if (name === "active-tab" && this.classList.contains("is-scroll-frozen")) {
+ requestAnimationFrame(() => this.#unfreezeScroll());
+ }
+ }
+
+ disconnectedCallback() {
+ clearTimeout(this.#unfreezeTimeout);
}
set tabs(tabs) {
@@ -45,10 +57,7 @@ class TabBar extends Component {
active: activeTab === tab.value,
})}
data-testid="tab-${tab.value}"
- @click=${() =>
- this.dispatchEvent(
- new CustomEvent("tab-click", { detail: tab.value }),
- )}
+ @click=${() => this.#handleTabClick(tab.value)}
>
${tab.label}
`,
@@ -58,6 +67,30 @@ class TabBar extends Component {
this.scrollActiveIntoView();
}
+ #handleTabClick(value) {
+ if (value !== this.activeTab) {
+ this.#freezeScroll();
+ }
+ this.dispatchEvent(new CustomEvent("tab-click", { detail: value }));
+ }
+
+ // Temporarily freeze horizontal scroll to avoid flashing on tab changes
+ #freezeScroll() {
+ if (this.fullWidth) return;
+ this.classList.add("is-scroll-frozen");
+ clearTimeout(this.#unfreezeTimeout);
+ // In case the host never switches to the clicked tab
+ this.#unfreezeTimeout = setTimeout(
+ () => this.#unfreezeScroll(),
+ SCROLL_FREEZE_FALLBACK_MS,
+ );
+ }
+
+ #unfreezeScroll() {
+ clearTimeout(this.#unfreezeTimeout);
+ this.classList.remove("is-scroll-frozen");
+ }
+
scrollActiveIntoView() {
if (this.fullWidth) return;
const activeTab = this.activeTab;
diff --git a/src/js/router.js b/src/js/router.js
index a6a6b16e..52a74c40 100644
--- a/src/js/router.js
+++ b/src/js/router.js
@@ -23,17 +23,22 @@ function bindMiddleClickRedispatch() {
});
}
-// Runs attach whenever the page becomes active (first entry or a return from
-// the route cache) and detach when it's swapped out.
-function bindActive(root, attach, detach) {
- root.addEventListener("page-show", attach);
- root.addEventListener("page-hide", detach);
-}
-
// Lifecycle helpers
+// Runs handler whenever the page becomes active (first entry or a return from
+// the route cache). A function returned by the handler is called as cleanup
+// when the page is hidden.
export function onPageShow(root, handler) {
- root.addEventListener("page-show", (event) => handler(event.detail));
+ let cleanup = null;
+ root.addEventListener("page-show", (event) => {
+ cleanup?.();
+ const result = handler(event.detail);
+ cleanup = typeof result === "function" ? result : null;
+ });
+ root.addEventListener("page-hide", () => {
+ cleanup?.();
+ cleanup = null;
+ });
}
export function onPageHide(root, handler) {
@@ -42,18 +47,17 @@ export function onPageHide(root, handler) {
export function bindToPage(root, source, event, handler) {
if (!source) return;
- const usesEmitterApi = typeof source.on === "function";
- bindActive(
- root,
- () =>
- usesEmitterApi
- ? source.on(event, handler)
- : source.addEventListener(event, handler),
- () =>
- usesEmitterApi
- ? source.off(event, handler)
- : source.removeEventListener(event, handler),
- );
+ if (typeof source.on === "function") {
+ onPageShow(root, () => {
+ source.on(event, handler);
+ return () => source.off(event, handler);
+ });
+ return;
+ }
+ onPageShow(root, () => {
+ source.addEventListener(event, handler);
+ return () => source.removeEventListener(event, handler);
+ });
}
export class Layout extends EventTarget {
@@ -63,18 +67,7 @@ export class Layout extends EventTarget {
}
export function pageEffect(root, callback, options) {
- let dispose;
- bindActive(
- root,
- () => {
- dispose?.();
- dispose = effect(callback, options);
- },
- () => {
- dispose?.();
- dispose = null;
- },
- );
+ onPageShow(root, () => effect(callback, options));
}
const APP_TITLE = document.title;
@@ -84,17 +77,13 @@ export function bindPageTitle(root, callback, options) {
const res = callback();
document.title = res ? `${res} — ${APP_TITLE}` : APP_TITLE;
};
- let dispose;
- const attach = () => {
- dispose?.();
- dispose = effect(titleCb, options);
- };
- const detach = () => {
- dispose?.();
- dispose = null;
- document.title = APP_TITLE;
- };
- bindActive(root, attach, detach);
+ onPageShow(root, () => {
+ const dispose = effect(titleCb, options);
+ return () => {
+ dispose();
+ document.title = APP_TITLE;
+ };
+ });
}
export class Router extends EventEmitter {
diff --git a/src/js/scrollLocks.js b/src/js/scrollLocks.js
index a43a93bd..93602134 100644
--- a/src/js/scrollLocks.js
+++ b/src/js/scrollLocks.js
@@ -11,7 +11,10 @@ function getHeaderElement(container) {
function lockScroll(container) {
// Important: read scrollY before pinning the header
const scrollY = window.scrollY;
- const header = getHeaderElement(container);
+ let header = getHeaderElement(container);
+ if (header && window.getComputedStyle(header).position === "fixed") {
+ header = null;
+ }
let headerHeight = 0;
if (header) {
headerHeight = header.getBoundingClientRect().height;
@@ -44,25 +47,27 @@ function lockScroll(container) {
header.style.width = columnRect.width - borderLeft - borderRight + "px";
header.style.right = "auto";
}
+ return {
+ header,
+ main,
+ scrollY,
+ lockedMainTop: main ? main.getBoundingClientRect().top : null,
+ };
}
-function unlockScroll(container, { restoreScroll = true } = {}) {
- const header = getHeaderElement(container);
- let headerHeight = 0;
- if (header) {
- headerHeight = header.getBoundingClientRect().height;
- }
+function unlockScroll(
+ { header, main, scrollY, lockedMainTop },
+ { restoreScroll = true } = {},
+) {
let scrollTo = 0;
- const main = container.querySelector("main");
if (main) {
- scrollTo = -1 * (main.getBoundingClientRect().top - headerHeight);
+ // Follows main if it moved while locked
+ scrollTo = scrollY + lockedMainTop - main.getBoundingClientRect().top;
main.style.marginTop = "";
main.style.display = "";
}
if (header) {
header.classList.remove("scroll-lock-pinned");
- }
- if (header) {
header.style.left = "";
header.style.width = "";
header.style.right = "";
@@ -102,7 +107,7 @@ function findScrollableAncestor(element) {
class ScrollLockManager {
#getContainer = null;
#leases = new Set();
- #lockedContainer = null;
+ #lockState = null;
setContainerProvider(getContainer) {
this.#getContainer = getContainer;
@@ -123,9 +128,9 @@ class ScrollLockManager {
}
this.#leases.delete(release);
- if (this.#leases.size === 0 && this.#lockedContainer) {
- unlockScroll(this.#lockedContainer, { restoreScroll });
- this.#lockedContainer = null;
+ if (this.#leases.size === 0 && this.#lockState) {
+ unlockScroll(this.#lockState, { restoreScroll });
+ this.#lockState = null;
}
};
@@ -137,8 +142,7 @@ class ScrollLockManager {
);
return { release };
}
- lockScroll(container);
- this.#lockedContainer = container;
+ this.#lockState = lockScroll(container);
}
this.#leases.add(release);
diff --git a/src/js/utils.js b/src/js/utils.js
index 3c35d30a..93edd268 100644
--- a/src/js/utils.js
+++ b/src/js/utils.js
@@ -995,6 +995,155 @@ export function pinScrollPosition({
return stop;
}
+// Switches a sticky element to position: fixed
+// while it's stuck, leaving a spacer in its place.
+export class StickyFixer {
+ #getElement;
+ #pinned = null;
+
+ constructor(getElement) {
+ this.#getElement = getElement;
+ }
+
+ start() {
+ window.addEventListener("scroll", this.#update, { passive: true });
+ window.addEventListener("resize", this.#update);
+ this.#update();
+ return () => {
+ window.removeEventListener("scroll", this.#update);
+ window.removeEventListener("resize", this.#update);
+ if (this.#pinned) this.#unpin();
+ };
+ }
+
+ // The scroll position at which the element starts sticking
+ getStuckScrollY() {
+ if (this.#pinned) {
+ return this.#measureStuckScrollY(
+ this.#pinned.spacer,
+ this.#pinned.stickyTop,
+ );
+ }
+ const element = this.#getElement();
+ if (!element) return null;
+ const style = window.getComputedStyle(element);
+ if (style.position !== "sticky") return null;
+ return this.#measureStuckScrollY(element, parseFloat(style.top));
+ }
+
+ #measureStuckScrollY(element, stickyTop) {
+ return window.scrollY + element.getBoundingClientRect().top - stickyTop;
+ }
+
+ #update = () => {
+ const element = this.#getElement();
+ if (this.#pinned && this.#pinned.element !== element) {
+ this.#unpin();
+ }
+ if (!element) return;
+ if (!this.#pinned) {
+ const style = window.getComputedStyle(element);
+ if (style.position !== "sticky") return;
+ const stickyTop = parseFloat(style.top);
+ if (element.getBoundingClientRect().top > stickyTop) return;
+ this.#pin(element, stickyTop);
+ }
+ const { spacer, stickyTop } = this.#pinned;
+ // Clamped so overscroll bounce at the top doesn't unpin an element that
+ // starts at the top of the page
+ const scrollY = Math.max(window.scrollY, 0);
+ if (scrollY < this.#measureStuckScrollY(spacer, stickyTop)) {
+ this.#unpin();
+ return;
+ }
+ this.#syncHorizontalPosition();
+ };
+
+ #syncHorizontalPosition() {
+ const { element, spacer } = this.#pinned;
+ const { left, width } = spacer.getBoundingClientRect();
+ if (left === this.#pinned.left && width === this.#pinned.width) return;
+ element.style.left = `${left}px`;
+ element.style.width = `${width}px`;
+ this.#pinned.left = left;
+ this.#pinned.width = width;
+ }
+
+ #pin(element, stickyTop) {
+ const spacer = document.createElement("div");
+ spacer.style.height = `${element.getBoundingClientRect().height}px`;
+ element.before(spacer);
+ const resizeObserver = new ResizeObserver(([entry]) => {
+ spacer.style.height = `${entry.borderBoxSize[0].blockSize}px`;
+ });
+ resizeObserver.observe(element);
+ const { position, top, left, width, right } = element.style;
+ this.#pinned = {
+ element,
+ spacer,
+ stickyTop,
+ resizeObserver,
+ previousStyle: { position, top, left, width, right },
+ left: null,
+ width: null,
+ };
+ Object.assign(element.style, {
+ position: "fixed",
+ top: `${stickyTop}px`,
+ right: "auto",
+ });
+ this.#syncHorizontalPosition();
+ }
+
+ #unpin() {
+ const { element, spacer, resizeObserver, previousStyle } = this.#pinned;
+ resizeObserver.disconnect();
+ spacer.remove();
+ Object.assign(element.style, previousStyle);
+ this.#pinned = null;
+ }
+}
+
+export class TabScrollMemory {
+ #positions = new Map();
+ #minScrollY;
+ #switching = false;
+
+ constructor({ minScrollY = null } = {}) {
+ this.#minScrollY = minScrollY;
+ }
+
+ async switch(fromTab, toTab, applySwitch) {
+ if (this.#switching) return false;
+ this.#switching = true;
+ try {
+ this.#positions.set(fromTab, window.scrollY);
+ await raf();
+ const scrollY = this.#resolve(this.#positions.get(toTab) ?? 0);
+ applySwitch();
+ await raf();
+ window.scrollTo(0, scrollY);
+ return true;
+ } finally {
+ this.#switching = false;
+ }
+ }
+
+ clear() {
+ this.#positions.clear();
+ }
+
+ #resolve(savedScrollY) {
+ if (!this.#minScrollY) return savedScrollY;
+ const minScrollY = this.#minScrollY();
+ if (minScrollY === null) return savedScrollY;
+ if (window.scrollY < minScrollY) {
+ return window.scrollY;
+ }
+ return Math.max(savedScrollY, minScrollY);
+ }
+}
+
const LONG_PRESS_TIMEOUT_MS = 500;
const LONG_PRESS_MOVE_CANCEL_THRESHOLD_PX = 10;
const LONG_PRESS_GHOST_CLICK_WINDOW_MS = 400;
diff --git a/src/js/views/hashtag.view.js b/src/js/views/hashtag.view.js
index d30ef5fc..cb931341 100644
--- a/src/js/views/hashtag.view.js
+++ b/src/js/views/hashtag.view.js
@@ -5,6 +5,7 @@ import "/js/components/tab-bar.js";
import { HASHTAG_FEED_PAGE_SIZE } from "/js/config.js";
import { pageEffect, bindPageTitle, onPageShow } from "/js/router.js";
import { Signal, ReactiveStore } from "/js/signals.js";
+import { TabScrollMemory, StickyFixer } from "/js/utils.js";
export default async function hashtagView({
root,
@@ -31,7 +32,7 @@ export default async function hashtagView({
const { postInteractionHandler } = interactionHandlers;
- const feedScrollState = new Map();
+ const feedScroll = new TabScrollMemory();
async function scrollAndReloadFeed() {
if (window.scrollY > 0) {
@@ -46,16 +47,11 @@ export default async function hashtagView({
scrollAndReloadFeed();
return;
}
- // Save scroll state
- feedScrollState.set(currentSort, window.scrollY);
- // Switch sort
- state.$currentSort.set(sortValue);
- // Scroll to saved scroll state
- if (feedScrollState.has(sortValue)) {
- window.scrollTo(0, feedScrollState.get(sortValue));
- } else {
- window.scrollTo(0, 0);
- }
+ // Switch sort, restoring its saved scroll position
+ const switched = await feedScroll.switch(currentSort, sortValue, () => {
+ state.$currentSort.set(sortValue);
+ });
+ if (!switched) return;
// Load feed if not cached
const hashtagKey = `${hashtag}-${sortValue}`;
const feed = dataLayer.derived.$hydratedHashtagFeeds.get(hashtagKey);
@@ -108,6 +104,9 @@ export default async function hashtagView({
);
});
+ const fixedHeader = new StickyFixer(() => root.querySelector("header"));
+ onPageShow(root, () => fixedHeader.start());
+
async function loadCurrentFeed({ reload = false } = {}) {
await dataLayer.requests.loadHashtagFeed(
hashtag,
diff --git a/src/js/views/home.view.js b/src/js/views/home.view.js
index 479ae2bd..97ca64f0 100644
--- a/src/js/views/home.view.js
+++ b/src/js/views/home.view.js
@@ -22,6 +22,7 @@ import { showToast } from "/js/toasts.js";
import { Signal, ReactiveStore, SignalSet } from "/js/signals.js";
import { WelcomeModal } from "/js/modals/welcome.modal.js";
import { getFeedGeneratorProxyUrl } from "/js/dataHelpers.js";
+import { TabScrollMemory, StickyFixer } from "/js/utils.js";
import { tryAgainButtonTemplate } from "/js/templates/tryAgainButton.template.js";
const requestIdle =
@@ -135,7 +136,7 @@ export default async function homeView({
showToast("Feedback sent to feed operator");
}
- const feedScrollState = new Map();
+ const feedScroll = new TabScrollMemory();
async function scrollAndReloadFeed() {
if (window.scrollY > 0) {
@@ -155,16 +156,12 @@ export default async function homeView({
scrollAndReloadFeed();
return;
}
- // Save scroll state
- feedScrollState.set(currentFeedUri, window.scrollY);
- state.$materializedFeedUris.add(currentFeedUri);
- // Switch feed
- dataLayer.mutations.setSelectedFeedUri(feedUri);
- // Scroll to saved position for new feed
- const savedScrollY = feedScrollState.get(feedUri) ?? 0;
- requestAnimationFrame(() => {
- window.scrollTo(0, savedScrollY);
+ // Switch feed, restoring its saved scroll position
+ const switched = await feedScroll.switch(currentFeedUri, feedUri, () => {
+ state.$materializedFeedUris.add(currentFeedUri);
+ dataLayer.mutations.setSelectedFeedUri(feedUri);
});
+ if (!switched) return;
if (!dataLayer.hasCachedFeed(feedUri)) {
await loadCurrentFeed();
}
@@ -354,6 +351,9 @@ export default async function homeView({
scheduleMaterializeFeeds(pinnedItems);
});
+ const fixedHeader = new StickyFixer(() => root.querySelector("header"));
+ onPageShow(root, () => fixedHeader.start());
+
function getFeedRequestDescriptor(uri) {
const pinnedItems = dataLayer.derived.$hydratedPinnedItems.get() ?? [];
const item = pinnedItems.find((i) => i.uri === uri);
diff --git a/src/js/views/listDetail.view.js b/src/js/views/listDetail.view.js
index 236cdb3b..2f6174e0 100644
--- a/src/js/views/listDetail.view.js
+++ b/src/js/views/listDetail.view.js
@@ -1,7 +1,7 @@
import { html, render } from "/js/lib/lit-html.js";
import { resolveDidFromHandleOrDid } from "/js/atproto.js";
import { Signal, ReactiveStore } from "/js/signals.js";
-import { classnames } from "/js/utils.js";
+import { classnames, TabScrollMemory, StickyFixer } from "/js/utils.js";
import {
cdnImageUrl,
isCurateList,
@@ -63,6 +63,16 @@ export default async function listDetailView({
const state = new ReactiveStore("listDetailView");
state.$activeTab = new Signal.State("posts");
+ const tabScroll = new TabScrollMemory({
+ minScrollY: () => fixedTabBar.getStuckScrollY(),
+ });
+
+ function handleTabClick(tab) {
+ const currentTab = state.$activeTab.get();
+ if (tab === currentTab) return;
+ tabScroll.switch(currentTab, tab, () => state.$activeTab.set(tab));
+ }
+
function listSubscriptionButtonTemplate({ list, listInteractionHandler }) {
const isMuted = !!list.viewer?.muted;
const isBlocked = !!list.viewer?.blocked;
@@ -346,8 +356,7 @@ export default async function listDetailView({
]}
active-tab=${activeTab}
full-width
- @tab-click=${(event) =>
- state.$activeTab.set(event.detail)}
+ @tab-click=${(event) => handleTabClick(event.detail)}
>
`
: html`
`}
@@ -356,8 +365,11 @@ export default async function listDetailView({
data-testid="list-tab-content"
data-teststate=${activeTab}
>
- ${activeTab === "posts" && isCurate
- ? html`
+ ${isCurate
+ ? html`
${postFeedTemplate({
feed,
currentUser,
@@ -379,30 +391,34 @@ export default async function listDetailView({
`
: ""}
`
- : html`
- ${profileListTemplate({
- profiles: members,
- hasMore: hasMoreMembers,
- onLoadMore: () => loadMembers(),
- emptyMessage: "This list has no members.",
- showEndMessage: true,
- isAuthenticated,
- currentUserDid: currentUser?.did ?? null,
- profileInteractionHandler,
- pluginService,
- ...(isModeration ? { rightItemTemplate: null } : {}),
- })}
- ${members?.length === 0 && isCurrentUserList
- ? html`
`
- : ""}
-
`}
+ : ""}
+
+ ${profileListTemplate({
+ profiles: members,
+ hasMore: hasMoreMembers,
+ onLoadMore: () => loadMembers(),
+ emptyMessage: "This list has no members.",
+ showEndMessage: true,
+ isAuthenticated,
+ currentUserDid: currentUser?.did ?? null,
+ profileInteractionHandler,
+ pluginService,
+ ...(isModeration ? { rightItemTemplate: null } : {}),
+ })}
+ ${members?.length === 0 && isCurrentUserList
+ ? html`
`
+ : ""}
+
`}
`,
@@ -410,6 +426,13 @@ export default async function listDetailView({
);
});
+ const fixedHeader = new StickyFixer(() => root.querySelector("header"));
+ onPageShow(root, () => fixedHeader.start());
+ const fixedTabBar = new StickyFixer(() =>
+ root.querySelector(".list-detail-tab-bar"),
+ );
+ onPageShow(root, () => fixedTabBar.start());
+
async function handleOptOut(list) {
const optedOut = await starterPackInteractionHandler.handleOptOut(list);
if (optedOut) loadMembers({ reload: true }).catch(console.warn);
diff --git a/src/js/views/notifications.view.js b/src/js/views/notifications.view.js
index 9c7f71a0..de75195a 100644
--- a/src/js/views/notifications.view.js
+++ b/src/js/views/notifications.view.js
@@ -5,7 +5,13 @@ import { headerTemplate } from "/js/templates/header.template.js";
import { floatingComposeButtonTemplate } from "/js/templates/floatingComposeButton.template.js";
import { smallPostTemplate } from "/js/templates/smallPost.template.js";
import { postSkeletonTemplate } from "/js/templates/postSkeleton.template.js";
-import { formatRelativeTime, batch, unique } from "/js/utils.js";
+import {
+ formatRelativeTime,
+ batch,
+ unique,
+ TabScrollMemory,
+ StickyFixer,
+} from "/js/utils.js";
import { Signal, ReactiveStore } from "/js/signals.js";
import {
bindToPage,
@@ -776,7 +782,7 @@ export default async function notificationsView({
}
}
- const tabScrollState = new Map();
+ const tabScroll = new TabScrollMemory();
async function handleTabClick(tab) {
const currentTab = state.$activeTab.get();
@@ -784,12 +790,10 @@ export default async function notificationsView({
scrollAndReloadNotifications();
return;
}
- tabScrollState.set(currentTab, window.scrollY);
- state.$activeTab.set(tab);
- const savedScrollY = tabScrollState.get(tab) ?? 0;
- requestAnimationFrame(() => {
- window.scrollTo(0, savedScrollY);
- });
+ const switched = await tabScroll.switch(currentTab, tab, () =>
+ state.$activeTab.set(tab),
+ );
+ if (!switched) return;
if (tab === "mentions" && !dataLayer.derived.$mentionNotifications.get()) {
await loadMentionNotifications({ reload: true });
}
@@ -899,6 +903,9 @@ export default async function notificationsView({
);
});
+ const fixedHeader = new StickyFixer(() => root.querySelector("header"));
+ onPageShow(root, () => fixedHeader.start());
+
async function loadNotifications({ reload = false } = {}) {
await dataLayer.requests.loadNotifications({
reload,
diff --git a/src/js/views/profile.view.js b/src/js/views/profile.view.js
index 62c4e26e..3cd27995 100644
--- a/src/js/views/profile.view.js
+++ b/src/js/views/profile.view.js
@@ -1,6 +1,6 @@
import { html, render } from "/js/lib/lit-html.js";
import { resolveDidFromHandleOrDid } from "/js/atproto.js";
-import { wait } from "/js/utils.js";
+import { wait, TabScrollMemory, StickyFixer } from "/js/utils.js";
import { Signal, ReactiveStore } from "/js/signals.js";
import {
doHideAuthorOnUnauthenticated,
@@ -11,7 +11,12 @@ import { floatingComposeButtonTemplate } from "/js/templates/floatingComposeButt
import { postFeedTemplate } from "/js/templates/postFeed.template.js";
import { labelerSettingsTemplate } from "/js/templates/labelerSettings.template.js";
import { ApiError } from "/js/api.js";
-import { bindToPage, pageEffect, bindPageTitle } from "/js/router.js";
+import {
+ bindToPage,
+ pageEffect,
+ bindPageTitle,
+ onPageShow,
+} from "/js/router.js";
import { AUTHOR_FEED_PAGE_SIZE, BSKY_LABELER_DID } from "/js/config.js";
import { showToast } from "/js/toasts.js";
import "/js/components/tab-bar.js";
@@ -137,6 +142,10 @@ export default async function profileView({
await loadAuthorFeed({ reload: true });
}
+ const tabScroll = new TabScrollMemory({
+ minScrollY: () => fixedTabBar.getStuckScrollY(),
+ });
+
async function handleTabClick(tab) {
const currentTab = state.$activeTab.get();
if (tab === currentTab) {
@@ -151,8 +160,10 @@ export default async function profileView({
}
return;
}
- // switch tab
- state.$activeTab.set(tab);
+ const switched = await tabScroll.switch(currentTab, tab, () =>
+ state.$activeTab.set(tab),
+ );
+ if (!switched) return;
// Load feed if needed
if (tab === "feeds") {
if (!dataLayer.derived.$actorFeeds.get(profileDid)) {
@@ -563,6 +574,11 @@ export default async function profileView({
);
});
+ const fixedTabBar = new StickyFixer(() =>
+ root.querySelector(".profile-tab-bar"),
+ );
+ onPageShow(root, () => fixedTabBar.start());
+
async function loadAuthorFeed({ reload = false } = {}) {
const activeTab = state.$activeTab.get();
if (
diff --git a/src/js/views/search.view.js b/src/js/views/search.view.js
index 551de7dd..97b56504 100644
--- a/src/js/views/search.view.js
+++ b/src/js/views/search.view.js
@@ -2,16 +2,21 @@ import { html, keyed, render } from "/js/lib/lit-html.js";
import "/js/components/app-icon.js";
import { headerTemplate } from "/js/templates/header.template.js";
import { avatarTemplate } from "/js/templates/avatar.template.js";
-import { classnames } from "/js/utils.js";
+import { classnames, TabScrollMemory, StickyFixer } from "/js/utils.js";
import { getDisplayName, MISSING_HANDLE } from "/js/dataHelpers.js";
-import { Signal, ReactiveStore } from "/js/signals.js";
+import { Signal, ReactiveStore, SignalSet } from "/js/signals.js";
import {
linkToProfile,
linkToProfileByDid,
linkToStarterPack,
} from "/js/navigation.js";
import { smallPostTemplate } from "/js/templates/smallPost.template.js";
-import { bindToPage, pageEffect, bindPageTitle } from "/js/router.js";
+import {
+ bindToPage,
+ pageEffect,
+ bindPageTitle,
+ onPageShow,
+} from "/js/router.js";
import { fillableIconTemplate } from "/js/templates/fillableIcon.template.js";
import "/js/components/container-link.js";
import "/js/components/tab-bar.js";
@@ -46,8 +51,9 @@ export default async function searchView({
state.$committedQuery = new Signal.State(initialQuery);
state.$showTypeahead = new Signal.State(false);
state.$recentProfilesLoading = new Signal.State(true);
+ state.$visitedTabs = new SignalSet();
- const tabScrollState = new Map();
+ const tabScroll = new TabScrollMemory();
const loadedTabs = new Set();
const TAB_LOADERS = {
@@ -104,7 +110,8 @@ export default async function searchView({
state.$showTypeahead.set(false);
state.$committedQuery.set("");
loadedTabs.clear();
- tabScrollState.clear();
+ tabScroll.clear();
+ state.$visitedTabs.clear();
const url = new URL(window.location);
url.searchParams.delete("q");
window.history.replaceState({}, "", url);
@@ -146,7 +153,8 @@ export default async function searchView({
window.history.replaceState({}, "", url);
if (queryChanged) {
loadedTabs.clear();
- tabScrollState.clear();
+ tabScroll.clear();
+ state.$visitedTabs.clear();
}
loadTabIfNeeded(state.$activeTab.get());
root.querySelector(".search-input")?.blur();
@@ -235,7 +243,8 @@ export default async function searchView({
state.$showTypeahead.set(false);
state.$committedQuery.set(q);
loadedTabs.clear();
- tabScrollState.clear();
+ tabScroll.clear();
+ state.$visitedTabs.clear();
loadTabIfNeeded(state.$activeTab.get());
}
hydrateAndPruneRecentProfiles();
@@ -248,11 +257,11 @@ export default async function searchView({
}
return;
}
- tabScrollState.set(state.$activeTab.get(), window.scrollY);
- state.$activeTab.set(tab);
- loadTabIfNeeded(tab);
- requestAnimationFrame(() => {
- window.scrollTo(0, tabScrollState.get(tab) ?? 0);
+ const currentTab = state.$activeTab.get();
+ tabScroll.switch(currentTab, tab, () => {
+ state.$visitedTabs.add(currentTab);
+ state.$activeTab.set(tab);
+ loadTabIfNeeded(tab);
});
}
@@ -578,11 +587,11 @@ export default async function searchView({
});
}
- function getActivePanelTemplate(activeTab, committedQuery, currentUser) {
+ function getPanelTemplate(tab, committedQuery, currentUser) {
const status = dataLayer.requests.statusStore.$statuses.get(
- TAB_STATUS_PREFIXES[activeTab] + committedQuery,
+ TAB_STATUS_PREFIXES[tab] + committedQuery,
);
- switch (activeTab) {
+ switch (tab) {
case "top":
return html`
-
- ${getActivePanelTemplate(
- isAuthenticated ? activeTab : "profiles",
- committedQuery,
- currentUser,
- )}
-
+ ${searchTabs.map(
+ (tab) =>
+ html`
+ ${tab === shownTab || state.$visitedTabs.has(tab)
+ ? getPanelTemplate(tab, committedQuery, currentUser)
+ : null}
+
`,
+ )}
`;
} else {
const recentTerms = isAuthenticated
@@ -792,4 +812,7 @@ export default async function searchView({
root,
);
});
+
+ const fixedHeader = new StickyFixer(() => root.querySelector("header"));
+ onPageShow(root, () => fixedHeader.start());
}
diff --git a/src/js/views/starterPackDetail.view.js b/src/js/views/starterPackDetail.view.js
index d574ab0b..cbe7c25c 100644
--- a/src/js/views/starterPackDetail.view.js
+++ b/src/js/views/starterPackDetail.view.js
@@ -13,6 +13,7 @@ import {
getPermalinkForStarterPack,
} from "/js/navigation.js";
import { pageEffect, bindPageTitle, onPageShow } from "/js/router.js";
+import { TabScrollMemory, StickyFixer } from "/js/utils.js";
import { FEED_PAGE_SIZE } from "/js/config.js";
import { showToast } from "/js/toasts.js";
import "/js/components/tab-bar.js";
@@ -253,6 +254,16 @@ export default async function starterPackDetailView({
state.$isFollowingAll = new Signal.State(false);
state.$hasFetchedStarterPack = new Signal.State(false);
+ const tabScroll = new TabScrollMemory({
+ minScrollY: () => fixedTabBar.getStuckScrollY(),
+ });
+
+ function handleTabClick(tab) {
+ const currentTab = state.$activeTab.get();
+ if (tab === currentTab) return;
+ tabScroll.switch(currentTab, tab, () => state.$activeTab.set(tab));
+ }
+
bindPageTitle(root, () => {
return (
dataLayer.derived.$starterPacks.get(starterPackUri)?.record?.name ?? null
@@ -338,7 +349,7 @@ export default async function starterPackDetailView({
.tabs=${tabs}
active-tab=${activeTab}
full-width
- @tab-click=${(event) => state.$activeTab.set(event.detail)}
+ @tab-click=${(event) => handleTabClick(event.detail)}
>
- ${activeTab === "people"
- ? html`
- ${profileListTemplate({
- profiles: members,
- hasMore: false,
- emptyMessage: "This starter pack has no members.",
- showEndMessage: true,
- isAuthenticated,
+
+ ${profileListTemplate({
+ profiles: members,
+ hasMore: false,
+ emptyMessage: "This starter pack has no members.",
+ showEndMessage: true,
+ isAuthenticated,
+ currentUserDid: currentUser?.did ?? null,
+ profileInteractionHandler,
+ pluginService,
+ })}
+
+ ${feeds.length > 0
+ ? html`
+ ${feedGeneratorListTemplate({
+ feedGenerators: feeds,
currentUserDid: currentUser?.did ?? null,
- profileInteractionHandler,
- pluginService,
})}
`
- : activeTab === "feeds"
- ? feedGeneratorListTemplate({
- feedGenerators: feeds,
- currentUserDid: currentUser?.did ?? null,
- })
- : html`
- ${postFeedTemplate({
- feed,
- currentUser,
- isAuthenticated,
- hiddenPostUris,
- onLoadMore: () => loadFeed(listUri),
- postInteractionHandler,
- pluginService,
- showEndMessage: true,
- })}
-
`}
+ : ""}
+
+ ${postFeedTemplate({
+ feed,
+ currentUser,
+ isAuthenticated,
+ hiddenPostUris,
+ onLoadMore: () => loadFeed(listUri),
+ postInteractionHandler,
+ pluginService,
+ showEndMessage: true,
+ })}
+
`}
`,
@@ -383,6 +396,13 @@ export default async function starterPackDetailView({
);
});
+ const fixedHeader = new StickyFixer(() => root.querySelector("header"));
+ onPageShow(root, () => fixedHeader.start());
+ const fixedTabBar = new StickyFixer(() =>
+ root.querySelector(".starter-pack-detail-tab-bar"),
+ );
+ onPageShow(root, () => fixedTabBar.start());
+
pageEffect(root, () => {
if (state.$activeTab.get() !== "feeds") return;
// Reset tab if feeds are no longer available
diff --git a/tests/e2e/specs/concerns/scroll.test.js b/tests/e2e/specs/concerns/scroll.test.js
index a99add73..c32e612b 100644
--- a/tests/e2e/specs/concerns/scroll.test.js
+++ b/tests/e2e/specs/concerns/scroll.test.js
@@ -1,7 +1,8 @@
import { test, expect } from "../../base.js";
import { login } from "../../helpers.js";
import { MockServer } from "../../mockServer.js";
-import { createPost } from "../../../shared/factories.js";
+import { userProfile } from "../../testData.js";
+import { createFeedGenerator, createPost } from "../../../shared/factories.js";
test.describe("Scroll position restoration", () => {
test("should restore scroll position after navigating back from post thread", async ({
@@ -116,3 +117,215 @@ test.describe("Scroll position restoration", () => {
});
});
});
+
+test.describe("StickyFixer home header", () => {
+ test.use({ viewport: { width: 375, height: 667 } });
+
+ test.beforeEach(async ({ page }) => {
+ const mockServer = new MockServer();
+ const posts = [];
+ for (let i = 1; i <= 60; i++) {
+ posts.push(
+ createPost({
+ uri: `at://did:plc:author${i}/app.bsky.feed.post/post${i}`,
+ text: `Timeline post ${i}`,
+ authorHandle: `author${i}.bsky.social`,
+ authorDisplayName: `Author ${i}`,
+ }),
+ );
+ }
+ mockServer.addTimelinePosts(posts);
+ await mockServer.setup(page);
+
+ await login(page);
+ await page.goto("/");
+ await expect(
+ page.locator('#home-view [data-testid="feed-item"]'),
+ ).toHaveCount(41, { timeout: 10000 });
+ });
+
+ test("should sit flush against the first feed item", async ({ page }) => {
+ const view = page.locator("#home-view");
+ const header = view.locator('[data-testid="header"]');
+ await expect(header).toHaveCSS("position", "fixed");
+ const headerBox = await header.boundingBox();
+ const firstItemBox = await view
+ .locator('[data-testid="feed-item"]')
+ .first()
+ .boundingBox();
+ const headerBottom = headerBox.y + headerBox.height;
+ expect(Math.abs(firstItemBox.y - headerBottom)).toBeLessThanOrEqual(1);
+ });
+
+ test("should keep content in place while the sidebar locks scrolling", async ({
+ page,
+ }) => {
+ const view = page.locator("#home-view");
+ const targetPost = view
+ .locator('[data-testid="feed-item"]')
+ .filter({ hasText: "Timeline post 30" });
+ await targetPost.scrollIntoViewIfNeeded();
+ const scrollY = await page.evaluate(() => window.scrollY);
+ const { y: initialY } = await targetPost.boundingBox();
+
+ await view.locator('[data-testid="menu-button"]').click();
+ await expect(page.locator("dialog.sidebar")).toBeVisible();
+ const { y: lockedY } = await targetPost.boundingBox();
+ expect(Math.abs(lockedY - initialY)).toBeLessThanOrEqual(1);
+
+ await page.keyboard.press("Escape");
+ await expect(page.locator("dialog.sidebar")).not.toHaveAttribute("open");
+ await expect.poll(() => page.evaluate(() => window.scrollY)).toBe(scrollY);
+ });
+});
+
+test.describe("StickyFixer search header", () => {
+ test.use({ viewport: { width: 375, height: 667 } });
+
+ test.beforeEach(async ({ page }) => {
+ const mockServer = new MockServer();
+ mockServer.addSearchPosts(
+ Array.from({ length: 30 }, (_, index) =>
+ createPost({
+ uri: `at://did:plc:author${index + 1}/app.bsky.feed.post/post${index + 1}`,
+ text: `Search result #${index + 1}.`,
+ authorHandle: `author${index + 1}.bsky.social`,
+ authorDisplayName: `Author ${index + 1}`,
+ }),
+ ),
+ );
+ await mockServer.setup(page);
+
+ await login(page);
+ await page.goto("/search?q=test&tab=top");
+ await expect(
+ page.locator("#search-view .search-post-results-top [data-post-uri]"),
+ ).toHaveCount(30, { timeout: 10000 });
+ });
+
+ test("should sit flush against the first result", async ({ page }) => {
+ const view = page.locator("#search-view");
+ const header = view.locator('[data-testid="header"]');
+ await expect(header).toHaveCSS("position", "fixed");
+ const headerBox = await header.boundingBox();
+ const firstResultBox = await view
+ .locator(".search-post-results-top [data-post-uri]")
+ .first()
+ .boundingBox();
+ const headerBottom = headerBox.y + headerBox.height;
+ expect(Math.abs(firstResultBox.y - headerBottom)).toBeLessThanOrEqual(1);
+ });
+
+ test("should keep content in place while the sidebar locks scrolling", async ({
+ page,
+ }) => {
+ const view = page.locator("#search-view");
+ const targetResult = view
+ .locator(".search-post-results-top [data-post-uri]")
+ .filter({ hasText: "Search result #15." });
+ await targetResult.scrollIntoViewIfNeeded();
+ const scrollY = await page.evaluate(() => window.scrollY);
+ const { y: initialY } = await targetResult.boundingBox();
+
+ await view.locator('[data-testid="menu-button"]').click();
+ await expect(page.locator("dialog.sidebar")).toBeVisible();
+ const { y: lockedY } = await targetResult.boundingBox();
+ expect(Math.abs(lockedY - initialY)).toBeLessThanOrEqual(1);
+
+ await page.keyboard.press("Escape");
+ await expect(page.locator("dialog.sidebar")).not.toHaveAttribute("open");
+ await expect.poll(() => page.evaluate(() => window.scrollY)).toBe(scrollY);
+ const headerBox = await view
+ .locator('[data-testid="header"]')
+ .boundingBox();
+ expect(headerBox.y).toBeCloseTo(0, 0);
+ });
+});
+
+test.describe("Tab bar scroll position", () => {
+ test.use({ viewport: { width: 320, height: 667 } });
+
+ const getScrollLeft = (tabBar) => tabBar.evaluate((el) => el.scrollLeft);
+
+ test("should survive navigating away from home and back", async ({
+ page,
+ }) => {
+ const mockServer = new MockServer();
+ const feeds = Array.from({ length: 8 }, (_, index) =>
+ createFeedGenerator({
+ uri: `at://did:plc:creator1/app.bsky.feed.generator/feed${index}`,
+ displayName: `Long Feed Name ${index}`,
+ creatorHandle: "creator1.bsky.social",
+ }),
+ );
+ mockServer.addFeedGenerators(feeds);
+ mockServer.setPinnedFeeds(feeds.map((feed) => feed.uri));
+ await mockServer.setup(page);
+ await login(page);
+ await page.goto("/");
+
+ const tabBar = page.locator("#home-view tab-bar");
+ await expect(tabBar.locator(".tab-bar-button")).toHaveCount(9, {
+ timeout: 10000,
+ });
+ await tabBar.evaluate((el) => {
+ el.scrollLeft = 200;
+ });
+
+ await page.locator('[data-testid="footer-nav-notifications"]').click();
+ await expect(page.locator("#notifications-view")).toBeVisible({
+ timeout: 10000,
+ });
+ await page.goBack();
+ await expect(page.locator('#home-view [data-testid="header"]')).toHaveCSS(
+ "position",
+ "fixed",
+ );
+
+ expect(await getScrollLeft(tabBar)).toBe(200);
+ });
+
+ test("should survive the profile tab bar sticking and unsticking", async ({
+ page,
+ }) => {
+ const mockServer = new MockServer();
+ mockServer.addProfile({
+ ...userProfile,
+ associated: { feedgens: 1, lists: 1, starterPacks: 1 },
+ });
+ mockServer.addAuthorFeedPosts(
+ userProfile.did,
+ "posts_and_author_threads",
+ Array.from({ length: 30 }, (_, index) =>
+ createPost({
+ uri: `at://${userProfile.did}/app.bsky.feed.post/post${index}`,
+ text: `Profile post ${index}`,
+ authorHandle: userProfile.handle,
+ }),
+ ),
+ );
+ await mockServer.setup(page);
+ await login(page);
+ await page.goto(`/profile/${userProfile.did}`);
+
+ const stickyTabBar = page.locator("#profile-view .profile-tab-bar");
+ const tabBar = stickyTabBar.locator("tab-bar");
+ await expect(
+ page.locator('#profile-view [data-testid="feed-item"]').first(),
+ ).toBeVisible({ timeout: 10000 });
+
+ await page.evaluate(() => window.scrollTo(0, 1500));
+ await expect(stickyTabBar).toHaveCSS("position", "fixed");
+ await tabBar.evaluate((el) => {
+ el.scrollLeft = 100;
+ });
+
+ await page.evaluate(() => window.scrollTo(0, 0));
+ await expect(stickyTabBar).toHaveCSS("position", "sticky");
+ expect(await getScrollLeft(tabBar)).toBe(100);
+
+ await page.evaluate(() => window.scrollTo(0, 1500));
+ await expect(stickyTabBar).toHaveCSS("position", "fixed");
+ expect(await getScrollLeft(tabBar)).toBe(100);
+ });
+});
diff --git a/tests/e2e/specs/views/hashtag.view.test.js b/tests/e2e/specs/views/hashtag.view.test.js
index be87562a..39accb0c 100644
--- a/tests/e2e/specs/views/hashtag.view.test.js
+++ b/tests/e2e/specs/views/hashtag.view.test.js
@@ -132,4 +132,51 @@ test.describe("Hashtag view", () => {
await expect(page).toHaveURL(/\/login(\?|$)/, { timeout: 10000 });
});
});
+
+ test("should restore each tab's scroll position", async ({ page }) => {
+ const createPosts = (sort) =>
+ Array.from({ length: 25 }, (_, index) =>
+ createPost({
+ uri: `at://did:plc:author${index + 1}/app.bsky.feed.post/${sort}${index + 1}`,
+ text: `${sort} #javascript post #${index + 1}.`,
+ authorHandle: `author${index + 1}.bsky.social`,
+ authorDisplayName: `Author ${index + 1}`,
+ }),
+ );
+ const mockServer = new MockServer();
+ mockServer.addSearchPosts(createPosts("top"), { sort: "top" });
+ mockServer.addSearchPosts(createPosts("latest"), { sort: "latest" });
+ await mockServer.setup(page);
+
+ await login(page);
+ await page.goto("/hashtag/javascript");
+
+ const view = page.locator("#hashtag-view");
+ const activeFeed = view.locator(".feed-container:not([hidden])");
+ const getScrollY = () => page.evaluate(() => window.scrollY);
+ await expect(activeFeed.locator('[data-testid="feed-item"]')).toHaveCount(
+ 25,
+ { timeout: 10000 },
+ );
+ await expect(view.locator('[data-testid="header"]')).toHaveCSS(
+ "position",
+ "fixed",
+ );
+
+ await activeFeed
+ .locator('[data-testid="feed-item"]')
+ .filter({ hasText: "top #javascript post #15." })
+ .scrollIntoViewIfNeeded();
+ const topScrollY = await getScrollY();
+
+ await view.locator('[data-testid="tab-latest"]').click();
+ await expect(activeFeed.locator('[data-testid="feed-item"]')).toHaveCount(
+ 25,
+ { timeout: 10000 },
+ );
+ await expect.poll(getScrollY).toBe(0);
+
+ await view.locator('[data-testid="tab-top"]').click();
+ await expect.poll(getScrollY).toBe(topScrollY);
+ });
});
diff --git a/tests/e2e/specs/views/listDetail.view.test.js b/tests/e2e/specs/views/listDetail.view.test.js
index e78886b1..6317a16c 100644
--- a/tests/e2e/specs/views/listDetail.view.test.js
+++ b/tests/e2e/specs/views/listDetail.view.test.js
@@ -125,7 +125,10 @@ test.describe("List Detail view", () => {
await page.goto("/profile/creator1.bsky.social/lists/mylist");
const view = page.locator("#list-detail-view");
- await expect(view.locator('[data-testid="empty-state"]')).toBeVisible({
+ const activePanel = view.locator(".feed-container:not([hidden])");
+ await expect(
+ activePanel.locator('[data-testid="empty-state"]'),
+ ).toBeVisible({
timeout: 10000,
});
});
@@ -147,15 +150,21 @@ test.describe("List Detail view", () => {
await page.goto("/profile/testuser.bsky.social/lists/ownlist");
const view = page.locator("#list-detail-view");
+ const activePanel = view.locator(".feed-container:not([hidden])");
await expect(view.locator('[data-testid="tab-people"]')).toBeVisible({
timeout: 10000,
});
await view.locator('[data-testid="tab-people"]').click();
+ await expect(
+ view.locator('[data-testid="list-tab-content"]'),
+ ).toHaveAttribute("data-teststate", "people");
await expect(
- view.locator('[data-testid="list-empty-add-people-button"]'),
+ activePanel.locator('[data-testid="list-empty-add-people-button"]'),
).toBeVisible({ timeout: 10000 });
- await view.locator('[data-testid="list-empty-add-people-button"]').click();
+ await activePanel
+ .locator('[data-testid="list-empty-add-people-button"]')
+ .click();
await expect(
page.locator('[data-testid="manage-list-members-dialog"]'),
).toBeVisible({ timeout: 10000 });
@@ -178,11 +187,12 @@ test.describe("List Detail view", () => {
await page.goto("/profile/testuser.bsky.social/lists/ownlist");
const view = page.locator("#list-detail-view");
+ const activePanel = view.locator(".feed-container:not([hidden])");
await expect(
view.locator('[data-testid="list-tab-content"]'),
).toHaveAttribute("data-teststate", "posts", { timeout: 10000 });
await expect(
- view.locator('[data-testid="list-empty-add-people-button"]'),
+ activePanel.locator('[data-testid="list-empty-add-people-button"]'),
).toBeVisible({ timeout: 10000 });
});
@@ -197,11 +207,14 @@ test.describe("List Detail view", () => {
await page.goto("/profile/creator1.bsky.social/lists/mylist");
const view = page.locator("#list-detail-view");
- await expect(view.locator('[data-testid="empty-state"]')).toBeVisible({
+ const activePanel = view.locator(".feed-container:not([hidden])");
+ await expect(
+ activePanel.locator('[data-testid="empty-state"]'),
+ ).toBeVisible({
timeout: 10000,
});
await expect(
- view.locator('[data-testid="list-empty-add-people-button"]'),
+ activePanel.locator('[data-testid="list-empty-add-people-button"]'),
).toHaveCount(0);
});
@@ -216,16 +229,22 @@ test.describe("List Detail view", () => {
await page.goto("/profile/creator1.bsky.social/lists/mylist");
const view = page.locator("#list-detail-view");
+ const activePanel = view.locator(".feed-container:not([hidden])");
await expect(view.locator('[data-testid="tab-people"]')).toBeVisible({
timeout: 10000,
});
await view.locator('[data-testid="tab-people"]').click();
+ await expect(
+ view.locator('[data-testid="list-tab-content"]'),
+ ).toHaveAttribute("data-teststate", "people");
- await expect(view.locator('[data-testid="empty-state"]')).toBeVisible({
+ await expect(
+ activePanel.locator('[data-testid="empty-state"]'),
+ ).toBeVisible({
timeout: 10000,
});
await expect(
- view.locator('[data-testid="list-empty-add-people-button"]'),
+ activePanel.locator('[data-testid="list-empty-add-people-button"]'),
).toHaveCount(0);
});
@@ -373,6 +392,9 @@ test.describe("List Detail view", () => {
const view = page.locator("#list-detail-view");
await view.locator('[data-testid="tab-people"]').click();
+ await expect(
+ view.locator('[data-testid="list-tab-content"]'),
+ ).toHaveAttribute("data-teststate", "people");
const followsBackRow = view
.locator(".profile-list-item")
@@ -430,6 +452,9 @@ test.describe("List Detail view", () => {
const view = page.locator("#list-detail-view");
await view.locator('[data-testid="tab-people"]').click();
+ await expect(
+ view.locator('[data-testid="list-tab-content"]'),
+ ).toHaveAttribute("data-teststate", "people");
const targetRow = view
.locator(".profile-list-item")
@@ -1050,6 +1075,9 @@ test.describe("List Detail view", () => {
);
await view.locator('[data-testid="tab-people"]').click();
+ await expect(
+ view.locator('[data-testid="list-tab-content"]'),
+ ).toHaveAttribute("data-teststate", "people");
const memberRow = view
.locator(".profile-list-item")
.filter({ hasText: "member1.bsky.social" });
@@ -1188,4 +1216,68 @@ test.describe("List Detail view", () => {
).toHaveCount(0);
});
});
+
+ test("should restore each tab's scroll position once the tab bar is stuck", async ({
+ page,
+ }) => {
+ const mockServer = new MockServer();
+ setupList(mockServer);
+ mockServer.addListFeedItems(
+ LIST_URI,
+ Array.from({ length: 30 }, (_, index) =>
+ createPost({
+ uri: `at://did:plc:author${index + 1}/app.bsky.feed.post/p${index + 1}`,
+ text: `List post #${index + 1}.`,
+ authorHandle: `author${index + 1}.bsky.social`,
+ authorDisplayName: `Author ${index + 1}`,
+ }),
+ ),
+ );
+ mockServer.addListMembers(
+ LIST_URI,
+ Array.from({ length: 30 }, (_, index) =>
+ createProfile({
+ did: `did:plc:member${index + 1}`,
+ handle: `member${index + 1}.bsky.social`,
+ displayName: `Member #${index + 1}.`,
+ }),
+ ),
+ );
+ await mockServer.setup(page);
+
+ await login(page);
+ await page.goto("/profile/creator1.bsky.social/lists/mylist");
+
+ const view = page.locator("#list-detail-view");
+ const activePanel = view.locator(".feed-container:not([hidden])");
+ const tabBar = view.locator(".list-detail-tab-bar");
+ const getScrollY = () => page.evaluate(() => window.scrollY);
+ await expect(activePanel.locator('[data-testid="feed-item"]')).toHaveCount(
+ 30,
+ { timeout: 10000 },
+ );
+
+ await activePanel
+ .locator('[data-testid="feed-item"]')
+ .filter({ hasText: "List post #20." })
+ .scrollIntoViewIfNeeded();
+ const postsScrollY = await getScrollY();
+
+ // An unvisited tab opens with the tab bar stuck at the top
+ await view.locator('[data-testid="tab-people"]').click();
+ await expect(activePanel).toContainText("Member #1.");
+ await expect
+ .poll(async () => (await tabBar.boundingBox()).y)
+ .toBeCloseTo(0, 0);
+ expect(await getScrollY()).toBeLessThan(postsScrollY);
+
+ await activePanel.getByText("Member #25.").scrollIntoViewIfNeeded();
+ const peopleScrollY = await getScrollY();
+
+ await view.locator('[data-testid="tab-posts"]').click();
+ await expect.poll(getScrollY).toBe(postsScrollY);
+
+ await view.locator('[data-testid="tab-people"]').click();
+ await expect.poll(getScrollY).toBe(peopleScrollY);
+ });
});
diff --git a/tests/e2e/specs/views/notifications.view.test.js b/tests/e2e/specs/views/notifications.view.test.js
index 86eaadb3..dafe8589 100644
--- a/tests/e2e/specs/views/notifications.view.test.js
+++ b/tests/e2e/specs/views/notifications.view.test.js
@@ -1669,6 +1669,9 @@ test.describe("Notifications view", () => {
// Switch to Mentions tab
await view.locator(".tab-bar-button").nth(1).click();
+ await expect(view.locator('[data-testid="tab-mentions"]')).toHaveClass(
+ /active/,
+ );
const activePanel = view.locator(".notifications-feed:not([hidden])");
@@ -1709,6 +1712,9 @@ test.describe("Notifications view", () => {
});
await view.locator(".tab-bar-button").nth(1).click();
+ await expect(view.locator('[data-testid="tab-mentions"]')).toHaveClass(
+ /active/,
+ );
const activePanel = view.locator(".notifications-feed:not([hidden])");
await expect(
@@ -1759,6 +1765,9 @@ test.describe("Notifications view", () => {
// Switch to Mentions then back to All
await view.locator(".tab-bar-button").nth(1).click();
+ await expect(view.locator('[data-testid="tab-mentions"]')).toHaveClass(
+ /active/,
+ );
await expect(view).toContainText("A mention post", { timeout: 10000 });
await view.locator(".tab-bar-button").nth(0).click();
@@ -1811,6 +1820,9 @@ test.describe("Notifications view", () => {
await expect.poll(() => page.evaluate(() => window.scrollY)).toBe(600);
await view.locator(".tab-bar-button").nth(1).click();
+ await expect(view.locator('[data-testid="tab-mentions"]')).toHaveClass(
+ /active/,
+ );
await expect
.poll(() => page.evaluate(() => window.scrollY), { timeout: 10000 })
.toBe(0);
@@ -1870,6 +1882,9 @@ test.describe("Notifications view", () => {
const view = page.locator("#notifications-view");
await view.locator(".tab-bar-button").nth(1).click();
+ await expect(view.locator('[data-testid="tab-mentions"]')).toHaveClass(
+ /active/,
+ );
const activePanel = view.locator(".notifications-feed:not([hidden])");
const items = activePanel.locator(".notification-reply-wrapper");
@@ -1906,6 +1921,9 @@ test.describe("Notifications view", () => {
const view = page.locator("#notifications-view");
await view.locator(".tab-bar-button").nth(1).click();
+ await expect(view.locator('[data-testid="tab-mentions"]')).toHaveClass(
+ /active/,
+ );
const activePanel = view.locator(".notifications-feed:not([hidden])");
const items = activePanel.locator(".notification-reply-wrapper");
diff --git a/tests/e2e/specs/views/profile.view.test.js b/tests/e2e/specs/views/profile.view.test.js
index a82ca5c9..27ba134b 100644
--- a/tests/e2e/specs/views/profile.view.test.js
+++ b/tests/e2e/specs/views/profile.view.test.js
@@ -469,6 +469,82 @@ test.describe("Profile view", () => {
);
});
+ test.describe("Tab scroll positions", () => {
+ test.beforeEach(async ({ page }) => {
+ const createPosts = (prefix) =>
+ Array.from({ length: 30 }, (_, index) =>
+ createPost({
+ uri: `at://did:plc:otheruser1/app.bsky.feed.post/${prefix}${index + 1}`,
+ text: `${prefix} #${index + 1}.`,
+ authorHandle: otherUser.handle,
+ authorDisplayName: otherUser.displayName,
+ }),
+ );
+ const mockServer = new MockServer();
+ mockServer.addProfile(otherUser);
+ mockServer.addAuthorFeedPosts(
+ otherUser.did,
+ "posts_and_author_threads",
+ createPosts("post"),
+ );
+ mockServer.addAuthorFeedPosts(
+ otherUser.did,
+ "posts_with_media",
+ createPosts("media"),
+ );
+ await mockServer.setup(page);
+ await login(page);
+ await page.goto(`/profile/${otherUser.did}`);
+ await expect(
+ page.locator('#profile-view [data-testid="feed-item"]').first(),
+ ).toBeVisible({ timeout: 10000 });
+ });
+
+ const getScrollY = (page) => page.evaluate(() => window.scrollY);
+ const visibleFeedItem = (page, text) =>
+ page
+ .locator(
+ '#profile-view .feed-container:not([hidden]) [data-testid="feed-item"]',
+ )
+ .filter({ hasText: text });
+
+ test("should keep the scroll position when the tab bar isn't stuck", async ({
+ page,
+ }) => {
+ const tabBar = page.locator("#profile-view tab-bar");
+ await tabBar.locator('[data-testid="tab-media"]').click();
+ await expect(visibleFeedItem(page, "media #1.")).toBeVisible();
+ expect(await getScrollY(page)).toBe(0);
+ });
+
+ test("should restore each tab's scroll position once the tab bar is stuck", async ({
+ page,
+ }) => {
+ const tabBar = page.locator("#profile-view tab-bar");
+ const stickyTabBar = page.locator("#profile-view .profile-tab-bar");
+
+ await visibleFeedItem(page, "post #20.").scrollIntoViewIfNeeded();
+ const postsScrollY = await getScrollY(page);
+
+ // An unvisited tab opens with the tab bar stuck at the top
+ await tabBar.locator('[data-testid="tab-media"]').click();
+ await expect(visibleFeedItem(page, "media #1.")).toBeVisible();
+ await expect
+ .poll(async () => (await stickyTabBar.boundingBox()).y)
+ .toBeCloseTo(0, 0);
+ expect(await getScrollY(page)).toBeLessThan(postsScrollY);
+
+ await visibleFeedItem(page, "media #25.").scrollIntoViewIfNeeded();
+ const mediaScrollY = await getScrollY(page);
+
+ await tabBar.locator('[data-testid="tab-posts"]').click();
+ await expect.poll(() => getScrollY(page)).toBe(postsScrollY);
+
+ await tabBar.locator('[data-testid="tab-media"]').click();
+ await expect.poll(() => getScrollY(page)).toBe(mediaScrollY);
+ });
+ });
+
test("should show Likes tab on own profile", async ({ page }) => {
const currentUserProfile = {
...userProfile,
diff --git a/tests/e2e/specs/views/search.view.test.js b/tests/e2e/specs/views/search.view.test.js
index caac2340..90118d16 100644
--- a/tests/e2e/specs/views/search.view.test.js
+++ b/tests/e2e/specs/views/search.view.test.js
@@ -405,6 +405,56 @@ test.describe("Search view", () => {
await expect(topPanel).toContainText("A top ranked post");
});
+ test("should keep visited tabs mounted and restore their scroll positions", async ({
+ page,
+ }) => {
+ const createPosts = (sort) =>
+ Array.from({ length: 25 }, (_, index) =>
+ createPost({
+ uri: `at://did:plc:author${index + 1}/app.bsky.feed.post/${sort}${index + 1}`,
+ text: `${sort} result #${index + 1}.`,
+ authorHandle: `author${index + 1}.bsky.social`,
+ authorDisplayName: `Author ${index + 1}`,
+ }),
+ );
+ const mockServer = new MockServer();
+ mockServer.addSearchPosts(createPosts("top"), { sort: "top" });
+ mockServer.addSearchPosts(createPosts("latest"), { sort: "latest" });
+ await mockServer.setup(page);
+
+ await login(page);
+ await page.goto("/search?q=post&tab=top");
+
+ const view = page.locator("#search-view");
+ const topPanel = view.locator(".search-post-results-top");
+ const latestPanel = view.locator(".search-post-results-latest");
+ const getScrollY = () => page.evaluate(() => window.scrollY);
+
+ await expect(topPanel.locator("[data-post-uri]")).toHaveCount(25, {
+ timeout: 10000,
+ });
+ await topPanel
+ .locator("[data-post-uri]")
+ .filter({ hasText: "top result #15." })
+ .scrollIntoViewIfNeeded();
+ const topScrollY = await getScrollY();
+ await topPanel.evaluate((panel) => {
+ panel.dataset.marker = "original";
+ });
+
+ await view.locator('[data-testid="tab-latest"]').click();
+ await expect(latestPanel.locator("[data-post-uri]")).toHaveCount(25, {
+ timeout: 10000,
+ });
+ await expect(topPanel).toBeHidden();
+ await expect.poll(getScrollY).toBe(0);
+
+ await view.locator('[data-testid="tab-top"]').click();
+ await expect(topPanel).toBeVisible();
+ await expect(topPanel).toHaveAttribute("data-marker", "original");
+ await expect.poll(getScrollY).toBe(topScrollY);
+ });
+
test("should render tabs in order: Top, Latest, People, Feeds, Starter Packs", async ({
page,
}) => {
diff --git a/tests/e2e/specs/views/starterPackDetail.view.test.js b/tests/e2e/specs/views/starterPackDetail.view.test.js
index 4dd18f3b..4338be7e 100644
--- a/tests/e2e/specs/views/starterPackDetail.view.test.js
+++ b/tests/e2e/specs/views/starterPackDetail.view.test.js
@@ -638,4 +638,61 @@ test.describe("Starter Pack Detail view", () => {
otherView.locator('[data-testid="menu-action-starter-pack-delete"]'),
).toHaveCount(0);
});
+
+ test("should restore each tab's scroll position once the tab bar is stuck", async ({
+ page,
+ }) => {
+ const mockServer = new MockServer();
+ setupPack(mockServer);
+ mockServer.addListMembers(
+ LIST_URI,
+ Array.from({ length: 30 }, (_, index) =>
+ createProfile({
+ did: `did:plc:member${index + 1}`,
+ handle: `member${index + 1}.bsky.social`,
+ displayName: `Member #${index + 1}.`,
+ }),
+ ),
+ );
+ mockServer.addListFeedItems(
+ LIST_URI,
+ Array.from({ length: 30 }, (_, index) =>
+ createPost({
+ uri: `at://did:plc:author${index + 1}/app.bsky.feed.post/p${index + 1}`,
+ text: `Pack post #${index + 1}.`,
+ authorHandle: `author${index + 1}.bsky.social`,
+ authorDisplayName: `Author ${index + 1}`,
+ }),
+ ),
+ );
+ await mockServer.setup(page);
+
+ const view = await openPack(page);
+ const activePanel = view.locator(".feed-container:not([hidden])");
+ const tabBar = view.locator(".starter-pack-detail-tab-bar");
+ const getScrollY = () => page.evaluate(() => window.scrollY);
+
+ await activePanel.getByText("Member #20.").scrollIntoViewIfNeeded();
+ const peopleScrollY = await getScrollY();
+
+ // An unvisited tab opens with the tab bar stuck at the top
+ await view.locator('[data-testid="tab-posts"]').click();
+ await expect(activePanel).toContainText("Pack post #1.");
+ await expect
+ .poll(async () => (await tabBar.boundingBox()).y)
+ .toBeCloseTo(0, 0);
+ expect(await getScrollY()).toBeLessThan(peopleScrollY);
+
+ await activePanel
+ .locator('[data-testid="feed-item"]')
+ .filter({ hasText: "Pack post #25." })
+ .scrollIntoViewIfNeeded();
+ const postsScrollY = await getScrollY();
+
+ await view.locator('[data-testid="tab-people"]').click();
+ await expect.poll(getScrollY).toBe(peopleScrollY);
+
+ await view.locator('[data-testid="tab-posts"]').click();
+ await expect.poll(getScrollY).toBe(postsScrollY);
+ });
});
diff --git a/tests/unit/specs/components/tab-bar.test.js b/tests/unit/specs/components/tab-bar.test.js
index 2a911d16..97241303 100644
--- a/tests/unit/specs/components/tab-bar.test.js
+++ b/tests/unit/specs/components/tab-bar.test.js
@@ -204,6 +204,59 @@ describe("tab-bar", () => {
});
});
+ describe("TabBar - scroll freeze", () => {
+ const clickTab = (element, index) =>
+ element.querySelectorAll(".tab-bar-button")[index].click();
+
+ afterEach(() => {
+ mock.timers.reset();
+ });
+
+ it("should freeze scrolling when an inactive tab is clicked", () => {
+ const element = createTabBar({ activeTab: "one" });
+ document.body.appendChild(element);
+ clickTab(element, 1);
+ assert(element.classList.contains("is-scroll-frozen"));
+ });
+
+ it("should not freeze scrolling when the active tab is clicked", () => {
+ const element = createTabBar({ activeTab: "one" });
+ document.body.appendChild(element);
+ clickTab(element, 0);
+ assert(!element.classList.contains("is-scroll-frozen"));
+ });
+
+ it("should not freeze scrolling when full-width is set", () => {
+ const element = createTabBar({ activeTab: "one", fullWidth: true });
+ document.body.appendChild(element);
+ clickTab(element, 1);
+ assert(!element.classList.contains("is-scroll-frozen"));
+ });
+
+ it("should unfreeze a frame after switching to the new tab", async () => {
+ const element = createTabBar({ activeTab: "one" });
+ document.body.appendChild(element);
+ clickTab(element, 1);
+
+ element.setAttribute("active-tab", "two");
+ assert(element.classList.contains("is-scroll-frozen"));
+ await waitForAnimationFrame();
+ assert(!element.classList.contains("is-scroll-frozen"));
+ });
+
+ it("should unfreeze if the host never switches tabs", () => {
+ mock.timers.enable({ apis: ["setTimeout"] });
+ const element = createTabBar({ activeTab: "one" });
+ document.body.appendChild(element);
+ clickTab(element, 1);
+
+ mock.timers.tick(499);
+ assert(element.classList.contains("is-scroll-frozen"));
+ mock.timers.tick(1);
+ assert(!element.classList.contains("is-scroll-frozen"));
+ });
+ });
+
describe("TabBar - reinitialization protection", () => {
it("should not reinitialize when connectedCallback fires again", () => {
const element = createTabBar({ activeTab: "one" });
diff --git a/tests/unit/specs/router.test.js b/tests/unit/specs/router.test.js
index 8fd7ecd3..c94df001 100644
--- a/tests/unit/specs/router.test.js
+++ b/tests/unit/specs/router.test.js
@@ -1,6 +1,6 @@
import { describe, it, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert/strict";
-import { Router, Layout } from "/js/router.js";
+import { Router, Layout, onPageShow } from "/js/router.js";
class TestLayout extends Layout {
constructor() {
@@ -1489,3 +1489,64 @@ describe("page event dispatch", () => {
assert.deepEqual(rendered.length, 2);
});
});
+
+describe("onPageShow", () => {
+ let root;
+
+ const showPage = (detail = {}) =>
+ root.dispatchEvent(new window.CustomEvent("page-show", { detail }));
+ const hidePage = () =>
+ root.dispatchEvent(new window.CustomEvent("page-hide"));
+
+ beforeEach(() => {
+ root = document.createElement("div");
+ });
+
+ it("runs the handler with the page-show detail", () => {
+ const handler = mock.fn();
+ onPageShow(root, handler);
+ showPage({ action: "restore", scrollY: 120 });
+ assert.deepEqual(handler.mock.calls[0].arguments, [
+ { action: "restore", scrollY: 120 },
+ ]);
+ });
+
+ it("calls a returned cleanup on page hide", () => {
+ const cleanup = mock.fn();
+ const handler = mock.fn(() => cleanup);
+ onPageShow(root, handler);
+
+ showPage();
+ assert.equal(cleanup.mock.callCount(), 0);
+ hidePage();
+ assert.equal(cleanup.mock.callCount(), 1);
+
+ showPage();
+ assert.equal(handler.mock.callCount(), 2);
+ });
+
+ it("cleans up before running again on a repeated page show", () => {
+ const calls = [];
+ onPageShow(root, () => {
+ calls.push("show");
+ return () => calls.push("cleanup");
+ });
+
+ showPage();
+ showPage();
+ assert.deepEqual(calls, ["show", "cleanup", "show"]);
+ });
+
+ it("ignores return values that aren't functions", () => {
+ onPageShow(root, async () => {});
+ showPage();
+ assert.doesNotThrow(() => hidePage());
+ });
+
+ it("ignores a page hide before the first page show", () => {
+ const handler = mock.fn(() => () => {});
+ onPageShow(root, handler);
+ hidePage();
+ assert.equal(handler.mock.callCount(), 0);
+ });
+});
diff --git a/tests/unit/specs/scrollLock.test.js b/tests/unit/specs/scrollLock.test.js
index 6bb7d43d..d12c6aae 100644
--- a/tests/unit/specs/scrollLock.test.js
+++ b/tests/unit/specs/scrollLock.test.js
@@ -112,6 +112,69 @@ describe("ScrollLock", () => {
}
});
+ describe("with a fixed header", () => {
+ let scrollYDescriptor;
+ let originalScrollTo;
+ let scrollToCalls;
+
+ beforeEach(() => {
+ const header = container.querySelector("header");
+ header.style.position = "fixed";
+ header.getBoundingClientRect = () => ({ height: 100 });
+ scrollYDescriptor = Object.getOwnPropertyDescriptor(window, "scrollY");
+ Object.defineProperty(window, "scrollY", {
+ configurable: true,
+ get: () => 3600,
+ });
+ originalScrollTo = window.scrollTo;
+ scrollToCalls = [];
+ window.scrollTo = (...args) => scrollToCalls.push(args);
+ });
+
+ afterEach(() => {
+ if (scrollYDescriptor) {
+ Object.defineProperty(window, "scrollY", scrollYDescriptor);
+ } else {
+ delete window.scrollY;
+ }
+ window.scrollTo = originalScrollTo;
+ });
+
+ it("doesn't compensate for the header height when pinning", () => {
+ createLock();
+ const main = container.querySelector("main");
+ assert.deepEqual(main.style.marginTop, "-3600px");
+ });
+
+ it("leaves the header's inline position alone", () => {
+ const header = container.querySelector("header");
+ header.style.left = "5px";
+ header.style.width = "300px";
+ const lock = createLock();
+ assert(!header.classList.contains("scroll-lock-pinned"));
+ lock.release();
+ assert.deepEqual(header.style.left, "5px");
+ assert.deepEqual(header.style.width, "300px");
+ });
+
+ it("restores the scroll position on unlock", () => {
+ const main = container.querySelector("main");
+ main.getBoundingClientRect = () => ({ top: -3500 });
+ const lock = createLock();
+ lock.release();
+ assert.deepEqual(scrollToCalls[0], [0, 3600]);
+ });
+
+ it("follows main if it moved while locked", () => {
+ const main = container.querySelector("main");
+ main.getBoundingClientRect = () => ({ top: -3500 });
+ const lock = createLock();
+ main.getBoundingClientRect = () => ({ top: -3540 });
+ lock.release();
+ assert.deepEqual(scrollToCalls[0], [0, 3640]);
+ });
+ });
+
it("restores a locked scrollable ancestor's overflow on unlock", () => {
const scrollable = document.createElement("div");
scrollable.style.overflowY = "auto";
diff --git a/tests/unit/specs/utils.test.js b/tests/unit/specs/utils.test.js
index a8d719d5..68d1fc89 100644
--- a/tests/unit/specs/utils.test.js
+++ b/tests/unit/specs/utils.test.js
@@ -26,6 +26,8 @@ import {
enableLongPress,
TimeoutError,
pinScrollPosition,
+ TabScrollMemory,
+ StickyFixer,
KVIndexedDB,
isOnlyEmoji,
batchPerTick,
@@ -1375,6 +1377,132 @@ describe("pinScrollPosition", () => {
});
});
+describe("TabScrollMemory", () => {
+ let scrollY;
+ let scrollYDescriptor;
+ let originalScrollTo;
+ let scrollToCalls;
+
+ beforeEach(() => {
+ scrollY = 0;
+ scrollYDescriptor = Object.getOwnPropertyDescriptor(window, "scrollY");
+ Object.defineProperty(window, "scrollY", {
+ configurable: true,
+ get: () => scrollY,
+ });
+ originalScrollTo = window.scrollTo;
+ scrollToCalls = [];
+ window.scrollTo = (...args) => {
+ scrollToCalls.push(args);
+ scrollY = args[1];
+ };
+ });
+
+ afterEach(() => {
+ if (scrollYDescriptor) {
+ Object.defineProperty(window, "scrollY", scrollYDescriptor);
+ } else {
+ delete window.scrollY;
+ }
+ window.scrollTo = originalScrollTo;
+ });
+
+ it("applies the switch a frame later", async () => {
+ const tabScroll = new TabScrollMemory();
+ const applySwitch = mock.fn();
+ const pending = tabScroll.switch("a", "b", applySwitch);
+
+ assert.equal(applySwitch.mock.callCount(), 0);
+ await pending;
+ assert.equal(applySwitch.mock.callCount(), 1);
+ });
+
+ it("scrolls an unvisited tab to the top", async () => {
+ scrollY = 1200;
+ const tabScroll = new TabScrollMemory();
+ await tabScroll.switch("a", "b", () => {});
+ assert.deepEqual(scrollToCalls, [[0, 0]]);
+ });
+
+ it("restores each tab's saved position", async () => {
+ const tabScroll = new TabScrollMemory();
+ scrollY = 1200;
+ await tabScroll.switch("a", "b", () => {});
+ scrollY = 300;
+ await tabScroll.switch("b", "a", () => {});
+ assert.equal(scrollY, 1200);
+ await tabScroll.switch("a", "b", () => {});
+ assert.equal(scrollY, 300);
+ });
+
+ it("forgets saved positions when cleared", async () => {
+ const tabScroll = new TabScrollMemory();
+ scrollY = 1200;
+ await tabScroll.switch("a", "b", () => {});
+ tabScroll.clear();
+ await tabScroll.switch("b", "a", () => {});
+ assert.equal(scrollY, 0);
+ });
+
+ it("ignores a switch while another is in flight", async () => {
+ const tabScroll = new TabScrollMemory();
+ const firstSwitch = mock.fn();
+ const secondSwitch = mock.fn();
+ const first = tabScroll.switch("a", "b", firstSwitch);
+ const second = tabScroll.switch("a", "c", secondSwitch);
+
+ assert.equal(await second, false);
+ assert.equal(await first, true);
+ assert.equal(firstSwitch.mock.callCount(), 1);
+ assert.equal(secondSwitch.mock.callCount(), 0);
+ });
+
+ it("accepts a new switch once the previous one finishes", async () => {
+ const tabScroll = new TabScrollMemory();
+ await tabScroll.switch("a", "b", () => {});
+ assert.equal(await tabScroll.switch("b", "c", () => {}), true);
+ });
+
+ it("restores the saved position when the minimum is unavailable", async () => {
+ const tabScroll = new TabScrollMemory({ minScrollY: () => null });
+ scrollY = 1200;
+ await tabScroll.switch("a", "b", () => {});
+ scrollY = 300;
+ await tabScroll.switch("b", "a", () => {});
+ assert.equal(scrollY, 1200);
+ });
+
+ describe("with a minimum scroll position", () => {
+ const createTabScroll = () =>
+ new TabScrollMemory({ minScrollY: () => 400 });
+
+ it("keeps the current position until it's reached", async () => {
+ const tabScroll = createTabScroll();
+ scrollY = 2000;
+ await tabScroll.switch("a", "b", () => {});
+ scrollY = 150;
+ await tabScroll.switch("b", "a", () => {});
+ assert.equal(scrollY, 150);
+ });
+
+ it("raises saved positions above it once reached", async () => {
+ const tabScroll = createTabScroll();
+ scrollY = 2000;
+ await tabScroll.switch("a", "b", () => {});
+ assert.equal(scrollY, 400);
+ });
+
+ it("restores saved positions below it once reached", async () => {
+ const tabScroll = createTabScroll();
+ scrollY = 2000;
+ await tabScroll.switch("a", "b", () => {});
+ scrollY = 3000;
+ await tabScroll.switch("b", "a", () => {});
+ assert.equal(scrollY, 2000);
+ });
+ });
+});
+
describe("KVIndexedDB", () => {
afterEach(() => {
delete globalThis.indexedDB;
@@ -2124,3 +2252,190 @@ describe("truncateGraphemes", () => {
assert.equal(truncateGraphemes("hello", 5, { suffix: "…" }), "hello");
});
});
+
+describe("StickyFixer", () => {
+ let root;
+ let element;
+ let scrollYDescriptor;
+ let originalResizeObserver;
+ let resizeObserverCallback;
+ let scrollY;
+ let fixedElement;
+ let stop;
+
+ const setScrollY = (value) => {
+ scrollY = value;
+ };
+ const getSpacer = () => element.previousElementSibling;
+ const start = () => {
+ fixedElement = new StickyFixer(() => root.querySelector(".tabs"));
+ stop = fixedElement.start();
+ };
+ const scroll = () => window.dispatchEvent(new window.Event("scroll"));
+
+ beforeEach(() => {
+ root = document.createElement("div");
+ root.innerHTML = '';
+ element = root.querySelector(".tabs");
+ element.style.position = "sticky";
+ element.style.top = "0px";
+ element.getBoundingClientRect = () => ({ top: 0, height: 46 });
+ document.body.append(root);
+ scrollY = 0;
+ scrollYDescriptor = Object.getOwnPropertyDescriptor(window, "scrollY");
+ Object.defineProperty(window, "scrollY", {
+ configurable: true,
+ get: () => scrollY,
+ });
+ originalResizeObserver = globalThis.ResizeObserver;
+ globalThis.ResizeObserver = class {
+ constructor(callback) {
+ resizeObserverCallback = callback;
+ }
+ observe() {}
+ disconnect() {
+ resizeObserverCallback = null;
+ }
+ };
+ });
+
+ afterEach(() => {
+ stop();
+ root.remove();
+ if (scrollYDescriptor) {
+ Object.defineProperty(window, "scrollY", scrollYDescriptor);
+ } else {
+ delete window.scrollY;
+ }
+ globalThis.ResizeObserver = originalResizeObserver;
+ });
+
+ it("pins a stuck sticky element as fixed with a spacer in its place", () => {
+ setScrollY(500);
+ start();
+
+ assert.equal(element.style.position, "fixed");
+ assert.equal(element.style.top, "0px");
+ assert(!getSpacer().classList.contains("above"));
+ assert.equal(getSpacer().style.height, "46px");
+ });
+
+ it("leaves a sticky element that isn't stuck in place", () => {
+ element.getBoundingClientRect = () => ({ top: 200, height: 46 });
+ start();
+
+ assert.equal(element.style.position, "sticky");
+ assert(getSpacer().classList.contains("above"));
+ });
+
+ it("sizes the pinned element to its spacer", () => {
+ setScrollY(500);
+ start();
+ getSpacer().getBoundingClientRect = () => ({
+ top: -100,
+ left: 20,
+ width: 600,
+ });
+ scroll();
+
+ assert.equal(element.style.left, "20px");
+ assert.equal(element.style.width, "600px");
+ });
+
+ it("skips repositioning when the spacer hasn't moved", () => {
+ setScrollY(500);
+ start();
+ getSpacer().getBoundingClientRect = () => ({
+ top: -100,
+ left: 20,
+ width: 600,
+ });
+ scroll();
+ element.style.left = "999px";
+ scroll();
+
+ assert.equal(element.style.left, "999px");
+ });
+
+ it("keeps the spacer the same height as the pinned element", () => {
+ setScrollY(500);
+ start();
+ resizeObserverCallback([{ borderBoxSize: [{ blockSize: 92 }] }]);
+
+ assert.equal(getSpacer().style.height, "92px");
+ });
+
+ it("unpins once scrolled back above where the element sticks", () => {
+ setScrollY(500);
+ start();
+ getSpacer().getBoundingClientRect = () => ({ top: 50, left: 0, width: 0 });
+ setScrollY(250);
+ scroll();
+
+ assert.equal(element.style.position, "sticky");
+ assert(getSpacer().classList.contains("above"));
+ });
+
+ it("stays pinned through overscroll at the top of the page", () => {
+ start();
+ getSpacer().getBoundingClientRect = () => ({ top: 40, left: 0, width: 0 });
+ setScrollY(-40);
+ scroll();
+
+ assert.equal(element.style.position, "fixed");
+ });
+
+ it("unpins and stops updating when stopped", () => {
+ setScrollY(500);
+ start();
+ stop();
+
+ assert.equal(element.style.position, "sticky");
+ assert(getSpacer().classList.contains("above"));
+ scroll();
+ assert.equal(element.style.position, "sticky");
+ });
+
+ it("unpins an element that was replaced by a re-render", () => {
+ setScrollY(500);
+ start();
+ const replacement = document.createElement("div");
+ replacement.className = "tabs";
+ replacement.style.position = "sticky";
+ replacement.style.top = "0px";
+ replacement.getBoundingClientRect = () => ({ top: 200, height: 46 });
+ const spacer = getSpacer();
+ element.remove();
+ root.append(replacement);
+ scroll();
+
+ assert(!spacer.isConnected);
+ assert.equal(replacement.style.position, "sticky");
+ });
+
+ it("reports the scroll position where the element starts sticking", () => {
+ element.getBoundingClientRect = () => ({ top: 300, height: 46 });
+ setScrollY(100);
+ start();
+ assert.equal(fixedElement.getStuckScrollY(), 400);
+
+ element.getBoundingClientRect = () => ({ top: 0, height: 46 });
+ setScrollY(900);
+ scroll();
+ getSpacer().getBoundingClientRect = () => ({ top: -500 });
+ assert.equal(fixedElement.getStuckScrollY(), 400);
+ });
+
+ it("reports no stuck position for a missing element", () => {
+ element.getBoundingClientRect = () => ({ top: 200, height: 46 });
+ start();
+ element.remove();
+ assert.equal(fixedElement.getStuckScrollY(), null);
+ });
+
+ it("reports no stuck position for an element that isn't sticky", () => {
+ element.style.position = "static";
+ start();
+ assert.equal(fixedElement.getStuckScrollY(), null);
+ });
+});