mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
UI: support usage without value on Tabs component
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'fumadocs-ui': minor
|
||||
---
|
||||
|
||||
Tabs: support usage without `value`
|
||||
@@ -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('');
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ description:
|
||||
preview: tabs
|
||||
---
|
||||
|
||||
import { UrlBar } from './tabs.client';
|
||||
import { UrlBar, WithoutValueTest } from './tabs.client';
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
useContext,
|
||||
useRef,
|
||||
useLayoutEffect,
|
||||
useId,
|
||||
useEffect,
|
||||
} from 'react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import * as Primitive from './ui/tabs';
|
||||
@@ -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,
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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)
|
||||
@@ -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}`);
|
||||
@@ -127,7 +136,7 @@ export function Tabs({
|
||||
setValue(v);
|
||||
}
|
||||
},
|
||||
[groupId, persist, updateAnchor],
|
||||
[valueToIdMap, groupId, persist, updateAnchor],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'];
|
||||
};
|
||||
|
||||
if (props.id) {
|
||||
valueToIdMap?.set(v, props.id);
|
||||
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.',
|
||||
);
|
||||
|
||||
const v = toValue(resolvedValue);
|
||||
|
||||
if (props.id && ctx) {
|
||||
ctx.valueToIdMap.set(v, props.id);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user