fix(stream): preserve FilterChips horizontal scroll across remount
PublicLayout wraps the routed page in <AnimatePresence> keyed by pathname+search, so changing ?type=… fully unmounts the page and creates a fresh FilterChips. A useRef-based save/restore therefore reset on every filter switch. Persist the scrollLeft in a module-level value that survives the unmount, restore synchronously on mount, and keep an ~1.5s post-mount watch window for the iOS Safari sticky relayout that asynchronously snaps scrollLeft back to 0. Also gate the inactive-chip hover color behind [@media(hover:hover)] so iOS sticky-hover no longer leaves a faint gold tint on the last-tapped filter.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useLayoutEffect, useRef } from "react";
|
||||
import { useI18n } from "../../i18n";
|
||||
import { typeFilterLabel } from "../../resourceTypeLabels";
|
||||
|
||||
@@ -19,10 +19,29 @@ export type FilterChipsProps = {
|
||||
onTypeChange: (next: string) => void;
|
||||
};
|
||||
|
||||
// Persist the horizontal scroll position OUTSIDE the component lifecycle.
|
||||
// `PublicLayout` wraps every page in an `AnimatePresence` keyed by
|
||||
// `pathname + search`, so changing the `?type=…` query unmounts the current
|
||||
// `FilterChips` and mounts a fresh one. A `useRef` would reset on every
|
||||
// filter switch. A module-level value survives the re-mount and lets the
|
||||
// new instance restore the user's last scroll position synchronously on
|
||||
// first paint.
|
||||
let lastScrollLeft = 0;
|
||||
|
||||
export function FilterChips({ type, onTypeChange }: FilterChipsProps) {
|
||||
const { t } = useI18n();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Restore the saved scroll position before paint so the bar never flashes
|
||||
// at scrollLeft=0 after a filter-driven re-mount.
|
||||
useLayoutEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
if (lastScrollLeft > 0 && el.scrollLeft !== lastScrollLeft) {
|
||||
el.scrollLeft = lastScrollLeft;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Let a mouse wheel scroll the row horizontally when it overflows — desktop
|
||||
// mice have no horizontal wheel and the scrollbar is hidden, so otherwise the
|
||||
// last filters are unreachable. Touch/trackpad scroll natively.
|
||||
@@ -38,13 +57,80 @@ export function FilterChips({ type, onTypeChange }: FilterChipsProps) {
|
||||
return () => el.removeEventListener("wheel", onWheel);
|
||||
}, []);
|
||||
|
||||
// Save the position only on user-initiated input (touchend, pointerup,
|
||||
// wheel). The sibling `ScrollToTop` calls `window.scrollTo` after the
|
||||
// re-mount, which on iOS Safari can collapse this sticky row's
|
||||
// scrollLeft to 0 — that fires a `scroll` event too, but it's not a
|
||||
// user gesture and we must not let it overwrite the saved value.
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const save = () => {
|
||||
lastScrollLeft = el.scrollLeft;
|
||||
};
|
||||
const saveDeferred = () => {
|
||||
// Wait one frame so the momentum scroll has had a chance to update.
|
||||
window.requestAnimationFrame(save);
|
||||
};
|
||||
el.addEventListener("touchend", saveDeferred, { passive: true });
|
||||
el.addEventListener("pointerup", saveDeferred, { passive: true });
|
||||
el.addEventListener("wheel", saveDeferred, { passive: true });
|
||||
return () => {
|
||||
el.removeEventListener("touchend", saveDeferred);
|
||||
el.removeEventListener("pointerup", saveDeferred);
|
||||
el.removeEventListener("wheel", saveDeferred);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// After mount, watch for the iOS Safari quirk that asynchronously resets
|
||||
// scrollLeft to 0 (triggered by the page-level scroll-to-top that runs
|
||||
// after a filter-driven re-mount). Re-apply the saved value for ~1.5s,
|
||||
// skipping while the user is actively touching so a fresh scroll gesture
|
||||
// isn't yanked back.
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
if (lastScrollLeft <= 0) return;
|
||||
const target = lastScrollLeft;
|
||||
let cancelled = false;
|
||||
let touching = false;
|
||||
const onTouchStart = () => {
|
||||
touching = true;
|
||||
};
|
||||
const onTouchEnd = () => {
|
||||
touching = false;
|
||||
};
|
||||
const deadline = performance.now() + 1500;
|
||||
const apply = () => {
|
||||
if (cancelled || !el || touching) return;
|
||||
if (el.scrollLeft === 0 && target > 0) el.scrollLeft = target;
|
||||
};
|
||||
const tick = () => {
|
||||
if (cancelled) return;
|
||||
apply();
|
||||
if (performance.now() < deadline) window.requestAnimationFrame(tick);
|
||||
};
|
||||
window.requestAnimationFrame(tick);
|
||||
el.addEventListener("scroll", apply, { passive: true });
|
||||
el.addEventListener("touchstart", onTouchStart, { passive: true });
|
||||
el.addEventListener("touchend", onTouchEnd, { passive: true });
|
||||
el.addEventListener("touchcancel", onTouchEnd, { passive: true });
|
||||
return () => {
|
||||
cancelled = true;
|
||||
el.removeEventListener("scroll", apply);
|
||||
el.removeEventListener("touchstart", onTouchStart);
|
||||
el.removeEventListener("touchend", onTouchEnd);
|
||||
el.removeEventListener("touchcancel", onTouchEnd);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const tabClass = (active: boolean) =>
|
||||
[
|
||||
"relative flex h-[52px] shrink-0 items-center whitespace-nowrap px-3 pb-4 pt-3 text-[15px] leading-6 outline-none transition-colors md:h-auto md:px-1 md:py-3 md:leading-none",
|
||||
"border-b-0 md:border-b-2",
|
||||
active
|
||||
? "border-ark-gold font-medium text-ark-gold"
|
||||
: "border-transparent text-[#97989A] hover:text-ark-gold/80 md:text-neutral-400",
|
||||
: "border-transparent text-[#97989A] [@media(hover:hover)]:hover:text-ark-gold/80 md:text-neutral-400",
|
||||
].join(" ");
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user