Skip to content

Commit cb42eb2

Browse files
committed
Bring Tabs and useHash from Nextra
1 parent e9adb97 commit cb42eb2

File tree

4 files changed

+264
-1
lines changed

4 files changed

+264
-1
lines changed

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

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

33
import { isValidElement, ReactElement, useMemo, useState } from 'react';
44
import fuzzy from 'fuzzy';
5-
import { Tabs } from 'nextra/components';
5+
import { Tabs } from './tabs';
66
import { cn } from '../cn';
77
import { IMarketplaceListProps, IMarketplaceSearchProps } from '../types/components';
88
import { Heading } from './heading';
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
'use client';
2+
3+
import { FC, Fragment, ReactElement, ReactNode, useEffect, useRef, useState } from 'react';
4+
import cn from 'clsx';
5+
import {
6+
Tab as HeadlessTab,
7+
TabProps as HeadlessTabProps,
8+
TabGroup,
9+
TabGroupProps,
10+
TabList,
11+
TabListProps,
12+
TabPanel,
13+
TabPanelProps,
14+
TabPanels,
15+
// this component is almost verbatim copied from Nextra, so keep @headlessui/react to guarantee it works the same
16+
} from '@headlessui/react';
17+
import { useHash } from '../use-hash';
18+
19+
type TabItem = string | ReactElement;
20+
21+
type TabObjectItem = {
22+
label: TabItem;
23+
disabled: boolean;
24+
};
25+
26+
function isTabObjectItem(item: unknown): item is TabObjectItem {
27+
return !!item && typeof item === 'object' && 'label' in item;
28+
}
29+
30+
export interface TabsProps
31+
extends Pick<TabGroupProps, 'defaultIndex' | 'selectedIndex' | 'onChange'> {
32+
items: (TabItem | TabObjectItem)[];
33+
children: ReactNode;
34+
/** LocalStorage key for persisting the selected tab. */
35+
storageKey?: string;
36+
/** Tabs CSS class name. */
37+
className?: TabListProps['className'];
38+
/** Tab CSS class name. */
39+
tabClassName?: HeadlessTabProps['className'];
40+
}
41+
42+
export const Tabs = ({
43+
items,
44+
children,
45+
storageKey,
46+
defaultIndex = 0,
47+
selectedIndex: _selectedIndex,
48+
onChange,
49+
className,
50+
tabClassName,
51+
}: TabsProps) => {
52+
const [selectedIndex, setSelectedIndex] = useState(defaultIndex);
53+
const hash = useHash();
54+
const tabPanelsRef = useRef<HTMLDivElement>(null!);
55+
56+
useEffect(() => {
57+
if (_selectedIndex !== undefined) {
58+
setSelectedIndex(_selectedIndex);
59+
}
60+
}, [_selectedIndex]);
61+
62+
useEffect(() => {
63+
if (!hash) return;
64+
const tabPanel = tabPanelsRef.current.querySelector(`[role=tabpanel]:has([id="${hash}"])`);
65+
if (!tabPanel) return;
66+
67+
for (const [index, el] of Object.entries(tabPanelsRef.current.children)) {
68+
if (el === tabPanel) {
69+
setSelectedIndex(Number(index));
70+
// Clear hash first, otherwise page isn't scrolled
71+
location.hash = '';
72+
// Execute on next tick after `selectedIndex` update
73+
requestAnimationFrame(() => {
74+
location.hash = `#${hash}`;
75+
});
76+
}
77+
}
78+
}, [hash]);
79+
80+
useEffect(() => {
81+
if (!storageKey) {
82+
// Do not listen storage events if there is no storage key
83+
return;
84+
}
85+
86+
function fn(event: StorageEvent) {
87+
if (event.key === storageKey) {
88+
setSelectedIndex(Number(event.newValue));
89+
}
90+
}
91+
92+
const index = Number(localStorage.getItem(storageKey));
93+
setSelectedIndex(Number.isNaN(index) ? 0 : index);
94+
95+
window.addEventListener('storage', fn);
96+
return () => {
97+
window.removeEventListener('storage', fn);
98+
};
99+
}, []); // eslint-disable-line react-hooks/exhaustive-deps -- only on mount
100+
101+
const handleChange = (index: number) => {
102+
if (storageKey) {
103+
const newValue = String(index);
104+
localStorage.setItem(storageKey, newValue);
105+
106+
// the storage event only get picked up (by the listener) if the localStorage was changed in
107+
// another browser's tab/window (of the same app), but not within the context of the current tab.
108+
window.dispatchEvent(new StorageEvent('storage', { key: storageKey, newValue }));
109+
return;
110+
}
111+
setSelectedIndex(index);
112+
onChange?.(index);
113+
};
114+
115+
return (
116+
<TabGroup
117+
selectedIndex={selectedIndex}
118+
defaultIndex={defaultIndex}
119+
onChange={handleChange}
120+
as={Fragment}
121+
>
122+
<TabList
123+
className={args =>
124+
cn(
125+
'nextra-scrollbar x:overflow-x-auto x:overscroll-x-contain x:overflow-y-hidden',
126+
'x:mt-4 x:flex x:w-full x:gap-2 x:border-b x:border-gray-200 x:pb-px x:dark:border-neutral-800',
127+
'x:focus-visible:nextra-focus',
128+
typeof className === 'function' ? className(args) : className,
129+
)
130+
}
131+
>
132+
{items.map((item, index) => (
133+
<HeadlessTab
134+
key={index}
135+
disabled={isTabObjectItem(item) && item.disabled}
136+
className={args => {
137+
const { selected, disabled, hover, focus } = args;
138+
return cn(
139+
focus && 'x:nextra-focus x:ring-inset',
140+
'x:whitespace-nowrap x:cursor-pointer',
141+
'x:rounded-t x:p-2 x:font-medium x:leading-5 x:transition-colors',
142+
'x:-mb-0.5 x:select-none x:border-b-2',
143+
selected
144+
? 'x:border-current x:outline-none'
145+
: hover
146+
? 'x:border-gray-200 x:dark:border-neutral-800'
147+
: 'x:border-transparent',
148+
selected
149+
? 'x:text-primary-600'
150+
: disabled
151+
? 'x:text-gray-400 x:dark:text-neutral-600 x:pointer-events-none'
152+
: hover
153+
? 'x:text-black x:dark:text-white'
154+
: 'x:text-gray-600 x:dark:text-gray-200',
155+
typeof tabClassName === 'function' ? tabClassName(args) : tabClassName,
156+
);
157+
}}
158+
>
159+
{isTabObjectItem(item) ? item.label : item}
160+
</HeadlessTab>
161+
))}
162+
</TabList>
163+
<TabPanels ref={tabPanelsRef}>{children}</TabPanels>
164+
</TabGroup>
165+
);
166+
};
167+
168+
export const Tab: FC<TabPanelProps> = ({
169+
children,
170+
// For SEO display all the Panel in the DOM and set `display: none;` for those that are not selected
171+
unmount = false,
172+
className,
173+
...props
174+
}) => {
175+
return (
176+
<TabPanel
177+
{...props}
178+
unmount={unmount}
179+
className={args =>
180+
cn(
181+
'x:rounded x:mt-[1.25em]',
182+
args.focus && 'x:nextra-focus',
183+
typeof className === 'function' ? className(args) : className,
184+
)
185+
}
186+
>
187+
{children}
188+
</TabPanel>
189+
);
190+
};
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
'use no memo';
2+
3+
import { ComponentProps } from 'react';
4+
import { Tabs as _Tabs, Tab } from './index.client';
5+
6+
// Workaround to fix
7+
// Error: Cannot access Tab.propTypes on the server. You cannot dot into a client module from a
8+
// server component. You can only pass the imported name through.
9+
/**
10+
* A built-in component for creating tabbed content, helping organize related information in a
11+
* compact, interactive layout.
12+
*
13+
* @example
14+
* <Tabs items={['pnpm', 'npm', 'yarn']}>
15+
* <Tabs.Tab>**pnpm**: Fast, disk space efficient package manager.</Tabs.Tab>
16+
* <Tabs.Tab>**npm** is a package manager for the JavaScript programming language.</Tabs.Tab>
17+
* <Tabs.Tab>**Yarn** is a software packaging system.</Tabs.Tab>
18+
* </Tabs>
19+
*
20+
* @usage
21+
* ```mdx
22+
* import { Tabs } from '@theguild/components'
23+
*
24+
* <Tabs items={['pnpm', 'npm', 'yarn']}>
25+
* <Tabs.Tab>**pnpm**: Fast, disk space efficient package manager.</Tabs.Tab>
26+
* <Tabs.Tab>**npm** is a package manager for the JavaScript programming language.</Tabs.Tab>
27+
* <Tabs.Tab>**Yarn** is a software packaging system.</Tabs.Tab>
28+
* </Tabs>
29+
* ```
30+
*
31+
* ### Default Selected Index
32+
*
33+
* You can use the `defaultIndex` prop to set the default tab index:
34+
*
35+
* ```mdx /defaultIndex="1"/
36+
* import { Tabs } from '@theguild/components'
37+
*
38+
* <Tabs items={['pnpm', 'npm', 'yarn']} defaultIndex="1">
39+
* ...
40+
* </Tabs>
41+
* ```
42+
*
43+
* And you will have `npm` as the default tab:
44+
*
45+
* <Tabs items={['pnpm', 'npm', 'yarn']} defaultIndex="1">
46+
* <Tabs.Tab>**pnpm**: Fast, disk space efficient package manager.</Tabs.Tab>
47+
* <Tabs.Tab>**npm** is a package manager for the JavaScript programming language.</Tabs.Tab>
48+
* <Tabs.Tab>**Yarn** is a software packaging system.</Tabs.Tab>
49+
* </Tabs>
50+
*/
51+
export const Tabs = Object.assign((props: ComponentProps<typeof _Tabs>) => <_Tabs {...props} />, {
52+
Tab,
53+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
'use client';
2+
3+
// don't need to memoize string `hash` value
4+
'use no memo';
5+
6+
import { useEffect, useState } from 'react';
7+
8+
export function useHash() {
9+
const [hash, setHash] = useState('');
10+
11+
useEffect(() => {
12+
const handleHashChange = () => setHash(location.hash.replace('#', ''));
13+
handleHashChange();
14+
15+
window.addEventListener('hashchange', handleHashChange);
16+
return () => window.removeEventListener('hashchange', handleHashChange);
17+
}, []);
18+
19+
return hash;
20+
}

0 commit comments

Comments
 (0)