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); + }); +});