mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
fix(ui): scroll to a deep link's target once a settling body reveals
A body held back until its reads settle was hidden when the page arrived, so a link such as the pill-order anchor in the insights settings had nothing to scroll to and the page stayed at the top.
This commit is contained in:
1 parent
ecf56c6a1f
commit
27dfc6a617
2 files changed
+77
-3
No files matched your search
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { RevealWhenSettled } from "../reveal-when-settled";
|
||||
import { RevealWhenSettled, scrollToHashTarget } from "../reveal-when-settled";
|
||||
|
||||
/**
|
||||
* The body mounts at once (so its reads start) but stays out of layout until
|
||||
@@ -25,3 +25,44 @@ describe("<RevealWhenSettled>", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scrollToHashTarget", () => {
|
||||
function setup(inside: boolean) {
|
||||
const target = { scrollIntoView: vi.fn() };
|
||||
const container = { contains: vi.fn(() => inside) };
|
||||
const doc = {
|
||||
getElementById: vi.fn((id: string) =>
|
||||
id === "insights-pill-order" ? target : null,
|
||||
),
|
||||
};
|
||||
return {
|
||||
target,
|
||||
container,
|
||||
doc: doc as unknown as Pick<Document, "getElementById">,
|
||||
};
|
||||
}
|
||||
|
||||
it("scrolls to a hash target the revealed body holds", () => {
|
||||
const { target, container, doc } = setup(true);
|
||||
expect(scrollToHashTarget(container, "#insights-pill-order", doc)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(target.scrollIntoView).toHaveBeenCalledWith({ block: "start" });
|
||||
});
|
||||
|
||||
it("leaves a target outside the body to the browser", () => {
|
||||
const { target, container, doc } = setup(false);
|
||||
expect(scrollToHashTarget(container, "#insights-pill-order", doc)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(target.scrollIntoView).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing without a hash or a target", () => {
|
||||
const { container, doc } = setup(true);
|
||||
expect(scrollToHashTarget(container, "", doc)).toBe(false);
|
||||
expect(scrollToHashTarget(container, "#", doc)).toBe(false);
|
||||
expect(scrollToHashTarget(container, "#nope", doc)).toBe(false);
|
||||
expect(scrollToHashTarget(container, "#%E0%A4%A", doc)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useIsFetching, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
/**
|
||||
* Scroll to the element the URL's hash names, when it sits inside
|
||||
* `container`. Returns whether it scrolled.
|
||||
*/
|
||||
export function scrollToHashTarget(
|
||||
container: Pick<Element, "contains"> | null,
|
||||
hash: string,
|
||||
doc: Pick<Document, "getElementById">,
|
||||
): boolean {
|
||||
if (!container || hash.length < 2) return false;
|
||||
let id: string;
|
||||
try {
|
||||
id = decodeURIComponent(hash.slice(1));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const target = doc.getElementById(id);
|
||||
if (!target || !container.contains(target)) return false;
|
||||
target.scrollIntoView({ block: "start" });
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds a page body out of layout until its first reads have settled, then
|
||||
* shows it in one step.
|
||||
@@ -21,6 +43,10 @@ import { useIsFetching, useQueryClient } from "@tanstack/react-query";
|
||||
* slow or polling read elsewhere in the app can delay it but never strand
|
||||
* it. Revealing is latched: a later background refetch does not hide the
|
||||
* body again.
|
||||
*
|
||||
* A deep link into the body (`/settings/layout/insights#insights-pill-order`)
|
||||
* found its target hidden on arrival, so the browser had nothing to scroll
|
||||
* to. Once the body shows, it scrolls to the hash target itself.
|
||||
*/
|
||||
export function RevealWhenSettled({
|
||||
children,
|
||||
@@ -39,6 +65,12 @@ export function RevealWhenSettled({
|
||||
const queryClient = useQueryClient();
|
||||
const fetching = useIsFetching();
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
const bodyRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!revealed) return;
|
||||
scrollToHashTarget(bodyRef.current, window.location.hash, document);
|
||||
}, [revealed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (revealed || fetching > 0) return;
|
||||
@@ -67,6 +99,7 @@ export function RevealWhenSettled({
|
||||
<>
|
||||
{revealed ? null : fallback}
|
||||
<div
|
||||
ref={bodyRef}
|
||||
data-slot="reveal-when-settled"
|
||||
data-revealed={revealed ? "true" : "false"}
|
||||
aria-busy={revealed ? undefined : true}
|
||||
|
||||
Reference in new issue
Block a user