11'use client' ;
22
3- import { FC , Fragment , ReactElement , ReactNode , useEffect , useId , useRef , useState } from 'react' ;
3+ import {
4+ FC ,
5+ Fragment ,
6+ ReactElement ,
7+ ReactNode ,
8+ useEffect ,
9+ useId ,
10+ useMemo ,
11+ useRef ,
12+ useState ,
13+ } from 'react' ;
414import { useSearchParams } from 'next/navigation' ;
515import cn from 'clsx' ;
616import {
@@ -64,22 +74,28 @@ export const Tabs = ({
6474
6575 const tabPanelsRef = useRef < HTMLDivElement > ( null ! ) ;
6676
67- useActiveTabFromURL ( tabPanelsRef , items , searchParamKey , setSelectedIndex ) ;
77+ const ignoreLocalStorage = useActiveTabFromURL (
78+ tabPanelsRef ,
79+ items ,
80+ searchParamKey ,
81+ setSelectedIndex ,
82+ ) ;
6883 const id = useId ( ) ;
69- useActiveTabFromStorage ( storageKey ?? id , items , setSelectedIndex ) ;
84+ useActiveTabFromStorage ( storageKey ?? id , items , setSelectedIndex , ignoreLocalStorage ) ;
7085
7186 const handleChange = ( index : number ) => {
87+ onChange ?.( index ) ;
88+
7289 if ( storageKey ) {
73- const newValue = String ( index ) ;
90+ const newValue = getTabKey ( items , index ) ;
7491 localStorage . setItem ( storageKey , newValue ) ;
7592
7693 // the storage event only get picked up (by the listener) if the localStorage was changed in
7794 // another browser's tab/window (of the same app), but not within the context of the current tab.
7895 window . dispatchEvent ( new StorageEvent ( 'storage' , { key : storageKey , newValue } ) ) ;
79- return ;
96+ } else {
97+ setSelectedIndex ( index ) ;
8098 }
81- setSelectedIndex ( index ) ;
82- onChange ?.( index ) ;
8399
84100 if ( searchParamKey ) {
85101 const searchParams = new URLSearchParams ( window . location . search ) ;
@@ -179,54 +195,77 @@ function useActiveTabFromURL(
179195 const searchParams = useSearchParams ( ) ;
180196 const tabsInSearchParams = searchParams . getAll ( searchParamKey ) . sort ( ) ;
181197
198+ const tabIndexFromSearchParams =
199+ items . findIndex ( ( _ , index ) => tabsInSearchParams . includes ( getTabKey ( items , index ) ) ) ?? - 1 ;
200+
182201 useEffect ( ( ) => {
183- if ( ! hash ) return ;
202+ const tabPanel = hash
203+ ? tabPanelsRef . current ?. querySelector ( `[role=tabpanel]:has([id="${ hash } "])` )
204+ : null ;
184205
185- const tabPanel = tabPanelsRef . current ?. querySelector ( `[role=tabpanel]:has([id="${ hash } "])` ) ;
186206 if ( tabPanel ) {
187207 for ( const [ index , el ] of Object . entries ( tabPanel ) ) {
188208 if ( el === tabPanel ) {
189209 setSelectedIndex ( Number ( index ) ) ;
190210 // Note for posterity:
191211 // This is not an infinite loop. Clearing and restoring the hash is necessary
192212 // for the browser to scroll to the element. The intermediate empty hash triggers
193- // a hashchange event, but we bail out with the `if (!hash) return` in this useEffect .
213+ // a hashchange event, but we don't look for a tab panel if there is no hash .
194214
195215 // Clear hash first, otherwise page isn't scrolled
196216 location . hash = '' ;
197217 // Execute on next tick after `selectedIndex` update
198218 requestAnimationFrame ( ( ) => ( location . hash = `#${ hash } ` ) ) ;
199219 }
200220 }
201- } else if ( tabsInSearchParams ) {
221+ } else if ( tabIndexFromSearchParams ) {
202222 // if we don't have content to scroll to, we look at the search params
203- const index = items . findIndex ( ( _ , i ) => tabsInSearchParams . includes ( getTabKey ( items , i ) ) ) ;
204- if ( index !== - 1 ) setSelectedIndex ( index ) ;
223+ setSelectedIndex ( tabIndexFromSearchParams ) ;
205224 }
225+
226+ return function cleanUpTabFromSearchParams ( ) {
227+ const newSearchParams = new URLSearchParams ( window . location . search ) ;
228+ newSearchParams . delete ( searchParamKey ) ;
229+ window . history . replaceState (
230+ null ,
231+ '' ,
232+ `${ window . location . pathname } ?${ newSearchParams . toString ( ) } ` ,
233+ ) ;
234+ } ;
206235 // tabPanelsRef is a ref, so it's not a dependency
207236 // eslint-disable-next-line react-hooks/exhaustive-deps
208237 } , [ hash , tabsInSearchParams . join ( ',' ) ] ) ;
238+
239+ return tabIndexFromSearchParams ;
209240}
210241
211242function useActiveTabFromStorage (
212243 storageKey : string ,
213244 items : ( TabItem | TabObjectItem ) [ ] ,
214245 setSelectedIndex : ( index : number ) => void ,
246+ ignoreLocalStorage : boolean ,
215247) {
216248 useEffect ( ( ) => {
217- if ( ! storageKey ) {
249+ if ( ! storageKey || ignoreLocalStorage ) {
218250 // Do not listen storage events if there is no storage key
219251 return ;
220252 }
221253
222- const setSelectedTab = ( key : TabKey ) => {
254+ const setSelectedTab = ( key : string ) => {
255+ const numericIndex = Number ( key ) ;
256+ if ( ! isNaN ( numericIndex ) && numericIndex >= 0 && numericIndex < items . length ) {
257+ setSelectedIndex ( numericIndex ) ;
258+ return ;
259+ }
223260 const index = items . findIndex ( ( _ , i ) => getTabKey ( items , i ) === key ) ;
224- setSelectedIndex ( index ) ;
261+ if ( index !== - 1 ) {
262+ setSelectedIndex ( index ) ;
263+ }
225264 } ;
226265
227266 function onStorageChange ( event : StorageEvent ) {
228267 if ( event . key === storageKey ) {
229- const value = event . newValue as TabKey ;
268+ const value = event . newValue ;
230269 if ( value ) {
231270 setSelectedTab ( value ) ;
232271 }
@@ -235,7 +274,7 @@ function useActiveTabFromStorage(
235274
236275 const value = localStorage . getItem ( storageKey ) ;
237276 if ( value ) {
238- setSelectedTab ( value as TabKey ) ;
277+ setSelectedTab ( value ) ;
239278 }
240279
241280 window . addEventListener ( 'storage' , onStorageChange ) ;
0 commit comments