Skip to content

Commit

Permalink
UI: support usage without value on Tabs component
Browse files Browse the repository at this point in the history
  • Loading branch information
fuma-nama committed Dec 9, 2024
1 parent 9585561 commit 010da9e
Show file tree
Hide file tree
Showing 4 changed files with 152 additions and 22 deletions.
5 changes: 5 additions & 0 deletions .changeset/cool-actors-allow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'fumadocs-ui': minor
---

Tabs: support usage without `value`
27 changes: 27 additions & 0 deletions apps/docs/content/docs/ui/components/tabs.client.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
'use client';

import { useEffect, useState } from 'react';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
import { cn } from '@/utils/cn';
import { buttonVariants } from '@/components/ui/button';

export function UrlBar() {
const [url, setUrl] = useState('');
Expand All @@ -16,3 +19,27 @@ export function UrlBar() {

return <pre className="rounded-lg border bg-card p-2 text-sm">{url}</pre>;
}

export function WithoutValueTest() {
const [items, setItems] = useState(['Item 1', 'Item 2']);

return (
<>
<Tabs items={items}>
{items.map((item) => (
<Tab key={item}>{item}</Tab>
))}
</Tabs>
<button
className={cn(
buttonVariants({
variant: 'secondary',
}),
)}
onClick={() => setItems(['Item 1', 'Item 3', 'Item 2'])}
>
Change Items
</button>
</>
);
}
24 changes: 23 additions & 1 deletion apps/docs/content/docs/ui/components/tabs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ description:
preview: tabs
---

import { UrlBar } from './tabs.client';
import { UrlBar, WithoutValueTest } from './tabs.client';

## Usage

Expand All @@ -21,6 +21,28 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
</Tabs>
```

### Without `value`

Without a `value`, it detects from the children index. Note that it might cause errors on re-renders, it's not encouraged if the tabs might change.

```mdx
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';

<Tabs items={['Javascript', 'Rust']}>
<Tab>Javascript is weird</Tab>
<Tab>Rust is fast</Tab>
</Tabs>
```

#### Demo with Re-renders

<Tabs items={['Javascript', 'Rust']}>
<Tab>Javascript is weird</Tab>
<Tab>Rust is fast</Tab>
</Tabs>

<WithoutValueTest />

### Shared Value

By passing an `id` property, you can share a value across all tabs with the same
Expand Down
118 changes: 97 additions & 21 deletions packages/ui/src/components/tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {
useContext,
useRef,
useLayoutEffect,
useId,
useEffect,
} from 'react';
import { cn } from '@/utils/cn';
import * as Primitive from './ui/tabs';
Expand Down Expand Up @@ -58,9 +60,11 @@ export interface TabsProps extends BaseProps {
updateAnchor?: boolean;
}

const ValueToMapContext = createContext<Map<string, string> | undefined>(
undefined,
);
const TabsContext = createContext<{
items: string[];
valueToIdMap: Map<string, string>;
collection: CollectionType;
} | null>(null);

export function Tabs({
groupId,
Expand All @@ -72,7 +76,11 @@ export function Tabs({
}: TabsProps) {
const values = useMemo(() => items.map((item) => toValue(item)), [items]);
const [value, setValue] = useState(values[defaultIndex]);
const valueToIdMapRef = useRef(new Map<string, string>());

const valueToIdMap = useMemo(() => new Map<string, string>(), []);
// eslint-disable-next-line react-hooks/exhaustive-deps -- re-reconstruct the collection if items changed
const collection = useMemo(() => createCollection(), [items]);

const onChange: ChangeListener = (v) => {
if (values.includes(v)) setValue(v);
};
Expand All @@ -82,7 +90,7 @@ export function Tabs({

useLayoutEffect(() => {
if (!groupId) return;
const onUpdate: ChangeListener = (v) => onChangeRef.current?.(v);
const onUpdate: ChangeListener = (v) => onChangeRef.current(v);

const previous = persist
? localStorage.getItem(groupId)
Expand All @@ -99,17 +107,18 @@ export function Tabs({
const hash = window.location.hash.slice(1);
if (!hash) return;

const entry = Array.from(valueToIdMapRef.current.entries()).find(
([_, id]) => id === hash,
);

if (entry) setValue(entry[0]);
}, []);
for (const [value, id] of valueToIdMap.entries()) {
if (id === hash) {
setValue(value);
break;
}
}
}, [valueToIdMap]);

const onValueChange = useCallback(
(v: string) => {
if (updateAnchor) {
const id = valueToIdMapRef.current.get(v);
const id = valueToIdMap.get(v);

if (id) {
window.history.replaceState(null, '', `#${id}`);
Expand All @@ -127,7 +136,7 @@ export function Tabs({
setValue(v);
}
},
[groupId, persist, updateAnchor],
[valueToIdMap, groupId, persist, updateAnchor],
);

return (
Expand All @@ -144,9 +153,14 @@ export function Tabs({
</Primitive.TabsTrigger>
))}
</Primitive.TabsList>
<ValueToMapContext.Provider value={valueToIdMapRef.current}>
<TabsContext.Provider
value={useMemo(
() => ({ items, valueToIdMap, collection }),
[valueToIdMap, collection, items],
)}
>
{props.children}
</ValueToMapContext.Provider>
</TabsContext.Provider>
</Primitive.Tabs>
);
}
Expand All @@ -155,12 +169,28 @@ function toValue(v: string): string {
return v.toLowerCase().replace(/\s/, '-');
}

export function Tab({ value, className, ...props }: TabsContentProps) {
const v = toValue(value);
const valueToIdMap = useContext(ValueToMapContext);
export type TabProps = Omit<TabsContentProps, 'value'> & {
/**
* Value of tab, detect from index if unspecified.
*/
value?: TabsContentProps['value'];
};

export function Tab({ value, className, ...props }: TabProps) {
const ctx = useContext(TabsContext);
const resolvedValue =
value ??
// eslint-disable-next-line react-hooks/rules-of-hooks -- `value` is not supposed to change
ctx?.items[useCollectionIndex()];
if (!resolvedValue)
throw new Error(
'Failed to resolve tab `value`, please pass a `value` prop to the Tab component.',
);

if (props.id) {
valueToIdMap?.set(v, props.id);
const v = toValue(resolvedValue);

if (props.id && ctx) {
ctx.valueToIdMap.set(v, props.id);
}

return (
Expand All @@ -171,6 +201,52 @@ export function Tab({ value, className, ...props }: TabsContentProps) {
className,
)}
{...props}
/>
>
{props.children}
</Primitive.TabsContent>
);
}

type CollectionKey = string | symbol;
type CollectionType = ReturnType<typeof createCollection>;

function createCollection() {
return [] as CollectionKey[];
}

/**
* Inspired by Headless UI.
*
* Return the index of children, this is made possible by registering the order of render from children using React context.
* This is supposed by work with pre-rendering & pure client-side rendering.
*/
function useCollectionIndex() {
const key = useId();
const ctx = useContext(TabsContext);
if (!ctx) throw new Error('You must wrap your component in <Tabs>');

const list = ctx.collection;

function register() {
if (!list.includes(key)) list.push(key);
}

function unregister() {
const idx = list.indexOf(key);
if (idx !== -1) list.splice(idx, 1);
}

useMemo(() => {
// re-order the item to the bottom if exists
unregister();
register();
// eslint-disable-next-line -- register
}, [list]);

useEffect(() => {
return unregister;
// eslint-disable-next-line -- clean up only
}, []);

return list.indexOf(key);
}

0 comments on commit 010da9e

Please sign in to comment.