Skip to content

Commit 3f9d0dd

Browse files
authored
Tabs in search params (#2096)
1 parent 4ecf0e9 commit 3f9d0dd

10 files changed

Lines changed: 982 additions & 88 deletions

File tree

.changeset/lovely-feet-float.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@theguild/components': minor
3+
---
4+
5+
Extract Tabs component from Nextra

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/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"types:check": "tsc --noEmit"
4545
},
4646
"peerDependencies": {
47+
"@headlessui/react": "2.2.0",
4748
"@theguild/tailwind-config": "^0.6.3",
4849
"next": "^13 || ^14 || ^15.0.0",
4950
"react": "^18.2.0",

packages/components/src/components/legacy-package-cmd.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { ReactElement, useMemo } from 'react';
2-
import { Pre, Tabs } from 'nextra/components';
2+
import { Pre } from 'nextra/components';
3+
import { Tabs } from './tabs';
34

45
const PACKAGE_MANAGERS = ['yarn', 'npm', 'pnpm'];
56

packages/components/src/components/marketplace-search.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22

33
import { isValidElement, ReactElement, useMemo, useState } from 'react';
44
import fuzzy from 'fuzzy';
5-
import { Tabs } from 'nextra/components';
65
import { cn } from '../cn';
76
import { IMarketplaceListProps, IMarketplaceSearchProps } from '../types/components';
87
import { Heading } from './heading';
98
import { CloseIcon, SearchIcon } from './icons';
109
import { MarketplaceList } from './marketplace-list';
10+
import { Tabs } from './tabs';
1111
import { Tag, TagsContainer } from './tag';
1212

1313
const renderQueryPlaceholder = (placeholder: string | ReactElement, query: string) => {
Lines changed: 339 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,339 @@
1+
'use client';
2+
3+
import {
4+
FC,
5+
Fragment,
6+
ReactElement,
7+
ReactNode,
8+
useEffect,
9+
useId,
10+
useLayoutEffect,
11+
useRef,
12+
useState,
13+
} from 'react';
14+
import { useSearchParams } from 'next/navigation';
15+
import cn from 'clsx';
16+
import {
17+
Tab as HeadlessTab,
18+
TabProps as HeadlessTabProps,
19+
TabGroup,
20+
TabGroupProps,
21+
TabList,
22+
TabListProps,
23+
TabPanel,
24+
TabPanelProps,
25+
TabPanels,
26+
// this component is almost verbatim copied from Nextra, so we keep @headlessui/react to guarantee it works the same
27+
} from '@headlessui/react';
28+
import { useHash } from '../use-hash';
29+
30+
type TabItem = string | ReactElement;
31+
32+
type TabObjectItem = {
33+
key?: string;
34+
label: TabItem;
35+
disabled: boolean;
36+
};
37+
38+
function isTabObjectItem(item: unknown): item is TabObjectItem {
39+
return !!item && typeof item === 'object' && 'label' in item;
40+
}
41+
42+
export interface TabsProps
43+
extends Pick<TabGroupProps, 'defaultIndex' | 'selectedIndex' | 'onChange'> {
44+
items: (TabItem | TabObjectItem)[];
45+
children: ReactNode;
46+
/**
47+
* URLSearchParams key for persisting the selected tab.
48+
* @default "tab"
49+
*/
50+
searchParamKey?: string;
51+
/**
52+
* LocalStorage key for persisting the selected tab.
53+
* Set to `true` to use the default key `tabs-${id}`.
54+
* Leave empty or set to `null` to disable localStorage persistence.
55+
* Set to a string to use a custom key.
56+
*/
57+
storageKey?: string | true | null;
58+
/** Tabs CSS class name. */
59+
className?: TabListProps['className'];
60+
/** Tab CSS class name. */
61+
tabClassName?: HeadlessTabProps['className'];
62+
}
63+
64+
export const Tabs = ({
65+
items,
66+
children,
67+
searchParamKey = 'tab',
68+
storageKey = null,
69+
defaultIndex = 0,
70+
selectedIndex: _selectedIndex,
71+
onChange,
72+
className,
73+
tabClassName,
74+
}: TabsProps) => {
75+
const id = useId();
76+
77+
if (storageKey === true) {
78+
storageKey = `tabs-${id}`;
79+
}
80+
81+
let [selectedIndex, setSelectedIndex] = useState<number>(defaultIndex);
82+
if (_selectedIndex !== undefined) {
83+
selectedIndex = _selectedIndex;
84+
}
85+
86+
const tabPanelsRef = useRef<HTMLDivElement>(null!);
87+
88+
const tabIndexFromSearchParams = useActiveTabFromURL(
89+
tabPanelsRef,
90+
items,
91+
searchParamKey,
92+
setSelectedIndex,
93+
id,
94+
);
95+
96+
useActiveTabFromStorage(storageKey, items, setSelectedIndex, tabIndexFromSearchParams !== -1, id);
97+
98+
const handleChange = (index: number) => {
99+
onChange?.(index);
100+
101+
if (storageKey) {
102+
const newValue = getTabKey(items, index, id);
103+
localStorage.setItem(storageKey, newValue);
104+
105+
// the storage event only get picked up (by the listener) if the localStorage was changed in
106+
// another browser's tab/window (of the same app), but not within the context of the current tab.
107+
window.dispatchEvent(new StorageEvent('storage', { key: storageKey, newValue }));
108+
} else {
109+
setSelectedIndex(index);
110+
}
111+
112+
if (searchParamKey) {
113+
const searchParams = new URLSearchParams(window.location.search);
114+
const tabKeys = new Set(searchParams.getAll(searchParamKey));
115+
116+
// we remove only tabs from this list from search params
117+
for (let i = 0; i < items.length; i++) {
118+
const key = getTabKey(items, i, id);
119+
tabKeys.delete(key);
120+
}
121+
122+
// we add tabs from outside of this list back
123+
searchParams.delete(searchParamKey);
124+
for (const key of tabKeys) {
125+
searchParams.append(searchParamKey, key);
126+
}
127+
128+
// and finally, we add the clicked tab
129+
searchParams.append(searchParamKey, getTabKey(items, index, id));
130+
131+
window.history.replaceState(
132+
null,
133+
'',
134+
`${window.location.pathname}?${searchParams.toString()}`,
135+
);
136+
}
137+
};
138+
139+
return (
140+
<TabGroup
141+
selectedIndex={selectedIndex}
142+
defaultIndex={defaultIndex}
143+
onChange={handleChange}
144+
as={Fragment}
145+
>
146+
<TabList
147+
className={args =>
148+
cn(
149+
'nextra-scrollbar overflow-x-auto overflow-y-hidden overscroll-x-contain',
150+
'mt-4 flex w-full gap-2 border-b border-beige-200 pb-px dark:border-neutral-800',
151+
'focus-visible:hive-focus',
152+
typeof className === 'function' ? className(args) : className,
153+
)
154+
}
155+
>
156+
{items.map((item, index) => (
157+
<HeadlessTab
158+
key={index}
159+
disabled={isTabObjectItem(item) && item.disabled}
160+
className={args => {
161+
const { selected, disabled, hover, focus } = args;
162+
return cn(
163+
focus && 'hive-focus ring-inset',
164+
'cursor-pointer whitespace-nowrap',
165+
'rounded-t p-2 font-medium leading-5 transition-colors',
166+
'-mb-0.5 select-none border-b-2',
167+
selected
168+
? 'border-current outline-none'
169+
: hover
170+
? 'border-beige-200 dark:border-neutral-800'
171+
: 'border-transparent',
172+
selected
173+
? 'text-green-900 dark:text-primary'
174+
: disabled
175+
? 'pointer-events-none text-beige-400 dark:text-neutral-600'
176+
: hover
177+
? 'text-black dark:text-white'
178+
: 'text-beige-600 dark:text-beige-200',
179+
typeof tabClassName === 'function' ? tabClassName(args) : tabClassName,
180+
);
181+
}}
182+
>
183+
{isTabObjectItem(item) ? item.label : item}
184+
</HeadlessTab>
185+
))}
186+
</TabList>
187+
<TabPanels ref={tabPanelsRef}>{children}</TabPanels>
188+
</TabGroup>
189+
);
190+
};
191+
192+
export const Tab: FC<TabPanelProps> = ({
193+
children,
194+
// For SEO display all the Panel in the DOM and set `display: none;` for those that are not selected
195+
unmount = false,
196+
className,
197+
...props
198+
}) => {
199+
return (
200+
<TabPanel
201+
{...props}
202+
unmount={unmount}
203+
className={args =>
204+
cn(
205+
'mt-[1.25em] rounded',
206+
args.focus && 'hive-focus',
207+
typeof className === 'function' ? className(args) : className,
208+
)
209+
}
210+
>
211+
{children}
212+
</TabPanel>
213+
);
214+
};
215+
216+
function useActiveTabFromURL(
217+
tabPanelsRef: React.RefObject<HTMLDivElement>,
218+
items: (TabItem | TabObjectItem)[],
219+
searchParamKey: string,
220+
setSelectedIndex: (index: number) => void,
221+
id: string,
222+
) {
223+
const hash = useHash();
224+
const searchParams = useSearchParams();
225+
const tabsInSearchParams = searchParams.getAll(searchParamKey).sort();
226+
227+
const tabIndexFromSearchParams = items.findIndex((_, index) =>
228+
tabsInSearchParams.includes(getTabKey(items, index, id)),
229+
);
230+
231+
useIsomorphicLayoutEffect(() => {
232+
const tabPanel = hash
233+
? tabPanelsRef.current?.querySelector(`[role=tabpanel]:has([id="${hash}"])`)
234+
: null;
235+
236+
if (tabPanel) {
237+
let index = 0;
238+
for (const el of tabPanelsRef.current!.children) {
239+
if (el === tabPanel) {
240+
setSelectedIndex(Number(index));
241+
// Note for posterity:
242+
// This is not an infinite loop. Clearing and restoring the hash is necessary
243+
// for the browser to scroll to the element. The intermediate empty hash triggers
244+
// a hashchange event, but we don't look for a tab panel if there is no hash.
245+
246+
// Clear hash first, otherwise page isn't scrolled
247+
location.hash = '';
248+
// Execute on next tick after `selectedIndex` update
249+
requestAnimationFrame(() => (location.hash = `#${hash}`));
250+
}
251+
index++;
252+
}
253+
} else if (tabIndexFromSearchParams !== -1) {
254+
// if we don't have content to scroll to, we look at the search params
255+
setSelectedIndex(tabIndexFromSearchParams);
256+
}
257+
258+
return function cleanUpTabFromSearchParams() {
259+
const newSearchParams = new URLSearchParams(window.location.search);
260+
newSearchParams.delete(searchParamKey);
261+
window.history.replaceState(
262+
null,
263+
'',
264+
`${window.location.pathname}?${newSearchParams.toString()}`,
265+
);
266+
};
267+
// tabPanelsRef is a ref, so it's not a dependency
268+
}, [hash, tabsInSearchParams.join(',')]);
269+
270+
return tabIndexFromSearchParams;
271+
}
272+
273+
function useActiveTabFromStorage(
274+
storageKey: string | null,
275+
items: (TabItem | TabObjectItem)[],
276+
setSelectedIndex: (index: number) => void,
277+
ignoreLocalStorage: boolean,
278+
id: string,
279+
) {
280+
useIsomorphicLayoutEffect(() => {
281+
if (!storageKey || ignoreLocalStorage) {
282+
// Do not listen storage events if there is no storage key
283+
return;
284+
}
285+
286+
const setSelectedTab = (key: string) => {
287+
const index = items.findIndex((_, i) => getTabKey(items, i, id) === key);
288+
if (index !== -1) {
289+
setSelectedIndex(index);
290+
}
291+
};
292+
293+
function onStorageChange(event: StorageEvent) {
294+
if (event.key === storageKey) {
295+
const value = event.newValue;
296+
if (value) {
297+
setSelectedTab(value);
298+
}
299+
}
300+
}
301+
302+
const value = localStorage.getItem(storageKey);
303+
if (value) {
304+
setSelectedTab(value);
305+
}
306+
307+
window.addEventListener('storage', onStorageChange);
308+
return () => {
309+
window.removeEventListener('storage', onStorageChange);
310+
};
311+
}, [storageKey]);
312+
}
313+
314+
type TabKey = string & { __brand: 'TabKey' };
315+
316+
function getTabKey(items: (TabItem | TabObjectItem)[], index: number, prefix: string): TabKey {
317+
const item = items[index];
318+
const isObject = isTabObjectItem(item);
319+
// if the key is defined by user, we use it
320+
if (isObject && item.key) {
321+
return item.key as TabKey;
322+
}
323+
const label = isObject ? item.label : item;
324+
// otherwise we use the slugified label prefixed by the tab group id, if the label is a string
325+
// or the index of the item in the items array prefixed by the tab group id if the label is a ReactElement
326+
const key = typeof label === 'string' ? slugify(label) : `${prefix}-${index.toString()}`;
327+
return key as TabKey;
328+
}
329+
330+
function slugify(label: string) {
331+
return label
332+
.toLowerCase()
333+
.normalize('NFD')
334+
.replace(/[\u0300-\u036f]/g, '') // strip accents
335+
.replace(/[^a-z0-9]+/g, '-')
336+
.replace(/^-+|-+$/g, '');
337+
}
338+
339+
const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;

0 commit comments

Comments
 (0)