Skip to content

Commit 4867221

Browse files
committed
wip
1 parent ca285ae commit 4867221

File tree

5 files changed

+524
-104
lines changed

5 files changed

+524
-104
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"@storybook/nextjs": "8.4.2",
3232
"@storybook/preview-api": "8.4.2",
3333
"@storybook/react": "8.4.2",
34+
"@storybook/test": "^8.4.2",
3435
"@storybook/theming": "8.4.2",
3536
"@svgr/webpack": "8.1.0",
3637
"@theguild/eslint-config": "0.13.2",

packages/components/src/components/tabs/index.client.tsx

Lines changed: 57 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
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';
414
import { useSearchParams } from 'next/navigation';
515
import cn from 'clsx';
616
import {
@@ -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

211242
function 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);

packages/components/src/components/tabs/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import { ComponentProps } from 'react';
44
import { Tabs as _Tabs, Tab } from './index.client';
55

6+
export type { TabsProps } from './index.client';
7+
68
// Workaround to fix
79
// Error: Cannot access Tab.propTypes on the server. You cannot dot into a client module from a
810
// server component. You can only pass the imported name through.

0 commit comments

Comments
 (0)