11'use client' ;
22
3- import React , { memo , useCallback , useMemo , type ComponentPropsWithRef } from 'react' ;
4-
5- import { useHash , useIsMounted , useListOverflow } from '@/components/hooks' ;
3+ import React , {
4+ memo ,
5+ useCallback ,
6+ useMemo ,
7+ useRef ,
8+ useState ,
9+ type ComponentPropsWithRef ,
10+ } from 'react' ;
11+
12+ import { useHash , useListOverflow } from '@/components/hooks' ;
613import { DropdownMenu , DropdownMenuItem } from '@/components/primitives' ;
714import { useLanguage } from '@/intl/client' ;
815import { tString } from '@/intl/translate' ;
@@ -71,6 +78,7 @@ export function DynamicTabs(props: {
7178 const router = useRouter ( ) ;
7279
7380 const hash = useHash ( ) ;
81+ const [ initialized , setInitialized ] = useState ( false ) ;
7482 const [ tabsState , setTabsState ] = useTabsState ( ) ;
7583 const activeState = useMemo ( ( ) => {
7684 const input = { id, tabs } ;
@@ -79,28 +87,33 @@ export function DynamicTabs(props: {
7987 ) ;
8088 } , [ id , tabs , tabsState ] ) ;
8189
90+ // Track if the tab has been touched by the user.
91+ const touchedRef = useRef ( false ) ;
92+
8293 // To avoid issue with hydration, we only use the state from localStorage
83- // once the component has been mounted.
94+ // once the component has been initialized (= mounted) .
8495 // Otherwise because of the streaming/suspense approach, tabs can be first-rendered at different time
8596 // and get stuck into an inconsistent state.
86- const mounted = useIsMounted ( ) ;
87- const active = mounted ? activeState : tabs [ 0 ] ;
97+ const active = initialized ? activeState : tabs [ 0 ] ;
8898
8999 // When clicking to select a tab, we:
90100 // - update the URL hash
91101 // - mark this specific ID as selected
92102 // - store the ID to auto-select other tabs with the same title
93103 const selectTab = useCallback (
94- ( tabId : string ) => {
104+ ( tabId : string , manual = true ) => {
95105 const tab = tabs . find ( ( tab ) => tab . id === tabId ) ;
96106
97107 if ( ! tab ) {
98108 return ;
99109 }
100110
101- const href = `#${ tab . id } ` ;
102- if ( window . location . hash !== href ) {
103- router . replace ( href , { scroll : false } ) ;
111+ if ( manual ) {
112+ touchedRef . current = true ;
113+ const href = `#${ tab . id } ` ;
114+ if ( window . location . hash !== href ) {
115+ router . replace ( href , { scroll : false } ) ;
116+ }
104117 }
105118
106119 setTabsState ( ( prev ) => {
@@ -125,12 +138,14 @@ export function DynamicTabs(props: {
125138 ) ;
126139
127140 // When the hash changes, we try to select the tab containing the targetted element.
128- React . useEffect ( ( ) => {
141+ React . useLayoutEffect ( ( ) => {
142+ setInitialized ( true ) ;
143+
129144 if ( hash ) {
130145 // First check if the hash matches a tab ID.
131146 const hashIsTab = tabs . some ( ( tab ) => tab . id === hash ) ;
132147 if ( hashIsTab ) {
133- selectTab ( hash ) ;
148+ selectTab ( hash , false ) ;
134149 return ;
135150 }
136151
@@ -145,10 +160,39 @@ export function DynamicTabs(props: {
145160 return ;
146161 }
147162
148- selectTab ( tabPanel . id ) ;
163+ selectTab ( tabPanel . id , false ) ;
149164 }
150165 } , [ selectTab , tabs , hash ] ) ;
151166
167+ // Scroll to active element in the tab.
168+ React . useLayoutEffect ( ( ) => {
169+ // If there is no hash or active tab, nothing to scroll.
170+ if ( ! hash || ! active ) {
171+ return ;
172+ }
173+
174+ // If the tab is touched, we don't want to scroll.
175+ if ( touchedRef . current ) {
176+ return ;
177+ }
178+
179+ // If the hash matches a tab, then the scroll is already done.
180+ const hashIsTab = tabs . some ( ( tab ) => tab . id === hash ) ;
181+ if ( hashIsTab ) {
182+ return ;
183+ }
184+
185+ const activeElement = document . getElementById ( hash ) ;
186+ if ( ! activeElement ) {
187+ return ;
188+ }
189+
190+ activeElement . scrollIntoView ( {
191+ block : 'start' ,
192+ behavior : 'instant' ,
193+ } ) ;
194+ } , [ active , tabs , hash ] ) ;
195+
152196 return (
153197 < div
154198 className = { tcls (
@@ -177,12 +221,11 @@ const TabPanel = memo(function TabPanel(props: {
177221 role = "tabpanel"
178222 id = { tab . id }
179223 aria-labelledby = { getTabButtonId ( tab . id ) }
180- className = { tcls (
181- 'scroll-mt-[calc(var(--content-scroll-margin)+var(--spacing)*12)] p-4' ,
182- isActive ? null : 'hidden'
183- ) }
224+ className = "scroll-mt-[calc(var(--content-scroll-margin)+var(--spacing)*20)]"
184225 >
185- { tab . body }
226+ < div className = "p-4" hidden = { ! isActive } >
227+ { tab . body }
228+ </ div >
186229 </ div >
187230 ) ;
188231} ) ;
0 commit comments