Fix scrolling hash into tabs / expandable (#3757)

This commit is contained in:
Greg Bergé
2025-10-25 15:30:52 +02:00
committed by GitHub
parent a2fb627ae1
commit 6c9893945d
2 changed files with 69 additions and 21 deletions
@@ -17,29 +17,34 @@ export function Details(props: {
}) {
const { children, id, className } = props;
const detailsRef = React.useRef<HTMLDetailsElement>(null);
const ref = React.useRef<HTMLDetailsElement>(null);
const [openFromHash, setOpenFromHash] = React.useState(false);
const hash = useHash();
/**
* Open the details element if the url hash refers to the id of the details element
* or the id of some element contained within the details element.
*/
React.useEffect(() => {
if (!hash || !detailsRef.current) {
if (!hash || !ref.current) {
return;
}
if (hash === id) {
setOpenFromHash(true);
return;
}
const activeElement = document.getElementById(hash);
setOpenFromHash(Boolean(activeElement && detailsRef.current?.contains(activeElement)));
const isOpen = Boolean(activeElement && ref.current.contains(activeElement));
setOpenFromHash(isOpen);
}, [hash, id]);
return (
<details
ref={detailsRef}
ref={ref}
id={id}
open={props.open || openFromHash}
className={tcls(
@@ -1,8 +1,15 @@
'use client';
import React, { memo, useCallback, useMemo, type ComponentPropsWithRef } from 'react';
import React, {
memo,
useCallback,
useMemo,
useRef,
useState,
type ComponentPropsWithRef,
} from 'react';
import { useHash, useIsMounted, useListOverflow } from '@/components/hooks';
import { useHash, useListOverflow } from '@/components/hooks';
import { DropdownMenu, DropdownMenuItem } from '@/components/primitives';
import { useLanguage } from '@/intl/client';
import { tString } from '@/intl/translate';
@@ -71,6 +78,7 @@ export function DynamicTabs(props: {
const router = useRouter();
const hash = useHash();
const [initialized, setInitialized] = useState(false);
const [tabsState, setTabsState] = useTabsState();
const activeState = useMemo(() => {
const input = { id, tabs };
@@ -79,28 +87,33 @@ export function DynamicTabs(props: {
);
}, [id, tabs, tabsState]);
// Track if the tab has been touched by the user.
const touchedRef = useRef(false);
// To avoid issue with hydration, we only use the state from localStorage
// once the component has been mounted.
// once the component has been initialized (=mounted).
// Otherwise because of the streaming/suspense approach, tabs can be first-rendered at different time
// and get stuck into an inconsistent state.
const mounted = useIsMounted();
const active = mounted ? activeState : tabs[0];
const active = initialized ? activeState : tabs[0];
// When clicking to select a tab, we:
// - update the URL hash
// - mark this specific ID as selected
// - store the ID to auto-select other tabs with the same title
const selectTab = useCallback(
(tabId: string) => {
(tabId: string, manual = true) => {
const tab = tabs.find((tab) => tab.id === tabId);
if (!tab) {
return;
}
const href = `#${tab.id}`;
if (window.location.hash !== href) {
router.replace(href, { scroll: false });
if (manual) {
touchedRef.current = true;
const href = `#${tab.id}`;
if (window.location.hash !== href) {
router.replace(href, { scroll: false });
}
}
setTabsState((prev) => {
@@ -125,12 +138,14 @@ export function DynamicTabs(props: {
);
// When the hash changes, we try to select the tab containing the targetted element.
React.useEffect(() => {
React.useLayoutEffect(() => {
setInitialized(true);
if (hash) {
// First check if the hash matches a tab ID.
const hashIsTab = tabs.some((tab) => tab.id === hash);
if (hashIsTab) {
selectTab(hash);
selectTab(hash, false);
return;
}
@@ -145,10 +160,39 @@ export function DynamicTabs(props: {
return;
}
selectTab(tabPanel.id);
selectTab(tabPanel.id, false);
}
}, [selectTab, tabs, hash]);
// Scroll to active element in the tab.
React.useLayoutEffect(() => {
// If there is no hash or active tab, nothing to scroll.
if (!hash || !active) {
return;
}
// If the tab is touched, we don't want to scroll.
if (touchedRef.current) {
return;
}
// If the hash matches a tab, then the scroll is already done.
const hashIsTab = tabs.some((tab) => tab.id === hash);
if (hashIsTab) {
return;
}
const activeElement = document.getElementById(hash);
if (!activeElement) {
return;
}
activeElement.scrollIntoView({
block: 'start',
behavior: 'instant',
});
}, [active, tabs, hash]);
return (
<div
className={tcls(
@@ -177,12 +221,11 @@ const TabPanel = memo(function TabPanel(props: {
role="tabpanel"
id={tab.id}
aria-labelledby={getTabButtonId(tab.id)}
className={tcls(
'scroll-mt-[calc(var(--content-scroll-margin)+var(--spacing)*12)] p-4',
isActive ? null : 'hidden'
)}
className="scroll-mt-[calc(var(--content-scroll-margin)+var(--spacing)*20)]"
>
{tab.body}
<div className="p-4" hidden={!isActive}>
{tab.body}
</div>
</div>
);
});