From 010da9e8eee03aaa348eadb9f6aa33636124fb70 Mon Sep 17 00:00:00 2001 From: Fuma Nama Date: Mon, 9 Dec 2024 22:30:30 +0800 Subject: [PATCH] UI: support usage without `value` on Tabs component --- .changeset/cool-actors-allow.md | 5 + .../docs/ui/components/tabs.client.tsx | 27 ++++ apps/docs/content/docs/ui/components/tabs.mdx | 24 +++- packages/ui/src/components/tabs.tsx | 118 ++++++++++++++---- 4 files changed, 152 insertions(+), 22 deletions(-) create mode 100644 .changeset/cool-actors-allow.md diff --git a/.changeset/cool-actors-allow.md b/.changeset/cool-actors-allow.md new file mode 100644 index 000000000..7dd99f2e0 --- /dev/null +++ b/.changeset/cool-actors-allow.md @@ -0,0 +1,5 @@ +--- +'fumadocs-ui': minor +--- + +Tabs: support usage without `value` diff --git a/apps/docs/content/docs/ui/components/tabs.client.tsx b/apps/docs/content/docs/ui/components/tabs.client.tsx index 84b83543a..11daf50a8 100644 --- a/apps/docs/content/docs/ui/components/tabs.client.tsx +++ b/apps/docs/content/docs/ui/components/tabs.client.tsx @@ -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
{url}
; } + +export function WithoutValueTest() { + const [items, setItems] = useState(['Item 1', 'Item 2']); + + return ( + <> + + {items.map((item) => ( + {item} + ))} + + + + ); +} diff --git a/apps/docs/content/docs/ui/components/tabs.mdx b/apps/docs/content/docs/ui/components/tabs.mdx index 77ca1cee4..1f0c525f6 100644 --- a/apps/docs/content/docs/ui/components/tabs.mdx +++ b/apps/docs/content/docs/ui/components/tabs.mdx @@ -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'; ``` +### 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'; + + + Javascript is weird + Rust is fast + +``` + +#### Demo with Re-renders + + + Javascript is weird + Rust is fast + + + + ### Shared Value By passing an `id` property, you can share a value across all tabs with the same diff --git a/packages/ui/src/components/tabs.tsx b/packages/ui/src/components/tabs.tsx index d4b46bdd2..6ef62af50 100644 --- a/packages/ui/src/components/tabs.tsx +++ b/packages/ui/src/components/tabs.tsx @@ -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 | undefined>( - undefined, -); +const TabsContext = createContext<{ + items: string[]; + valueToIdMap: Map; + 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()); + + const valueToIdMap = useMemo(() => new Map(), []); + // 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({ ))} - + ({ items, valueToIdMap, collection }), + [valueToIdMap, collection, items], + )} + > {props.children} - + ); } @@ -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 & { + /** + * 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 ( @@ -171,6 +201,52 @@ export function Tab({ value, className, ...props }: TabsContentProps) { className, )} {...props} - /> + > + {props.children} + ); } + +type CollectionKey = string | symbol; +type CollectionType = ReturnType; + +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 '); + + 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); +}