/
githubmirror
/
docusaurus
Обзор
Документация
Войти
/
githubmirror
/
docusaurus
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
packages/docusaurus-theme-common/src/utils/tabsUtils.tsx
311 строк
9 KB
Sébastien Lorber
feat(theme): Use React context for `<Tabs>`, allow custom `<TabItem>` components (#11733)
12 фев 2026, 20:28
Не верифицирован
12 фев 2026, 20:28
2c6b920
Код
Авторство
О чём код?
/** * Copyright (c) Facebook, Inc. and its affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ import React, { isValidElement, useCallback, useState, useMemo, createContext, type ReactNode, type ReactElement, } from 'react'; import {useHistory} from '@docusaurus/router'; import useIsomorphicLayoutEffect from '@docusaurus/useIsomorphicLayoutEffect'; import {useQueryStringValue} from '@docusaurus/theme-common/internal'; import {duplicates, useStorageSlot} from '../index'; /** * TabValue is the "config" of a given Tab * Provided through <Tabs> "values" prop or through the children <TabItem> props */ export interface TabValue { readonly value: string; readonly label?: string; readonly attributes?: {[key: string]: unknown}; readonly default?: boolean; } export interface TabsProps { readonly lazy?: boolean; readonly block?: boolean; readonly children: ReactNode; readonly defaultValue?: string | null; readonly values?: readonly TabValue[]; readonly groupId?: string; readonly className?: string; readonly queryString?: string | boolean; } export interface TabItemProps { readonly children: ReactNode; readonly value: string; readonly default?: boolean; readonly label?: string; readonly className?: string; readonly attributes?: {[key: string]: unknown}; } export function sanitizeTabsChildren(children: ReactNode): ReactNode { return React.Children.toArray(children).filter((child) => child !== '\n'); } function extractChildrenTabValues(children: ReactNode): TabValue[] { // ✅ <TabItem value="red"/> => true // ✅ <CustomTabItem value="red"/> => true // ❌ <RedTabItem value="tab-value"/> => requires <Tabs values> prop function isTabItemWithValueProp( comp: ReactElement, ): comp is ReactElement<TabItemProps> { const {props} = comp; return !!props && typeof props === 'object' && 'value' in props; } const elements = React.Children.toArray(children).flatMap((child) => { // Historical case, not sure when it happens, do we really need this? if (!child) { return []; } if (isValidElement(child) && isTabItemWithValueProp(child)) { return [child]; } // child.type.name will give non-sensical values in prod because of // minification, but we assume it won't throw in prod. const badChildTypeName = // @ts-expect-error: guarding against unexpected cases typeof child.type === 'string' ? child.type : child.type.name; throw new Error( `Docusaurus error: Bad <Tabs> child <${badChildTypeName}>: all children of the <Tabs> component should be <TabItem>, and every <TabItem> should have a unique "value" prop. If you do not want to pass on a "value" prop to the direct children of <Tabs>, you can also pass an explicit <Tabs values={...}> prop.`, ); }); return elements.map( ({props: {value, label, attributes, default: isDefault}}) => ({ value, label, attributes, default: isDefault, }), ); } function ensureNoDuplicateValue(values: readonly TabValue[]) { const dup = duplicates(values, (a, b) => a.value === b.value); if (dup.length > 0) { throw new Error( `Docusaurus error: Duplicate values "${dup .map((a) => `'${a.value}'`) .join(', ')}" found in <Tabs>. Every value needs to be unique.`, ); } } function useTabValues( props: Pick<TabsProps, 'values' | 'children'>, ): readonly TabValue[] { const {values: valuesProp, children} = props; return useMemo(() => { const values = valuesProp ?? extractChildrenTabValues(children); ensureNoDuplicateValue(values); return values; }, [valuesProp, children]); } function isValidValue({ value, tabValues, }: { value: string | null | undefined; tabValues: readonly TabValue[]; }) { return tabValues.some((a) => a.value === value); } function getInitialStateValue({ defaultValue, tabValues, }: { defaultValue: TabsProps['defaultValue']; tabValues: readonly TabValue[]; }): string { if (tabValues.length === 0) { throw new Error( 'Docusaurus error: the <Tabs> component requires at least one <TabItem> children component', ); } if (defaultValue) { // Warn user about passing incorrect defaultValue as prop. if (!isValidValue({value: defaultValue, tabValues})) { throw new Error( `Docusaurus error: The <Tabs> has a defaultValue "${defaultValue}" but none of its children has the corresponding value. Available values are: ${tabValues .map((a) => a.value) .join( ', ', )}. If you intend to show no default tab, use defaultValue={null} instead.`, ); } return defaultValue; } const defaultTabValue = tabValues.find((tabValue) => tabValue.default) ?? tabValues[0]; if (!defaultTabValue) { throw new Error('Unexpected error: 0 tabValues'); } return defaultTabValue.value; } function getStorageKey(groupId: string | undefined) { if (!groupId) { return null; } return `docusaurus.tab.${groupId}`; } function getQueryStringKey({ queryString = false, groupId, }: Pick<TabsProps, 'queryString' | 'groupId'>) { if (typeof queryString === 'string') { return queryString; } if (queryString === false) { return null; } if (queryString === true && !groupId) { throw new Error( `Docusaurus error: The <Tabs> component groupId prop is required if queryString=true, because this value is used as the search param name. You can also provide an explicit value such as queryString="my-search-param".`, ); } return groupId ?? null; } function useTabQueryString({ queryString = false, groupId, }: Pick<TabsProps, 'queryString' | 'groupId'>) { const history = useHistory(); const key = getQueryStringKey({queryString, groupId}); const value = useQueryStringValue(key); const setValue = useCallback( (newValue: string) => { if (!key) { return; // no-op } const searchParams = new URLSearchParams(history.location.search); searchParams.set(key, newValue); history.replace({...history.location, search: searchParams.toString()}); }, [key, history], ); return [value, setValue] as const; } function useTabStorage({groupId}: Pick<TabsProps, 'groupId'>) { const key = getStorageKey(groupId); const [value, storageSlot] = useStorageSlot(key); const setValue = useCallback( (newValue: string) => { if (!key) { return; // no-op } storageSlot.set(newValue); }, [key, storageSlot], ); return [value, setValue] as const; } type TabsContextValue = { selectedValue: string; selectValue: (value: string) => void; tabValues: readonly TabValue[]; lazy: boolean; // TODO Docusaurus v4: remove this "block" concept? // TIL about it, and afaik we never used nor documented it // See https://infima.dev/docs/components/tabs#block block: boolean; }; export function useTabsContextValue(props: TabsProps): TabsContextValue { const {defaultValue, queryString = false, groupId} = props; const tabValues = useTabValues(props); const [selectedValue, setSelectedValue] = useState(() => getInitialStateValue({defaultValue, tabValues}), ); const [queryStringValue, setQueryString] = useTabQueryString({ queryString, groupId, }); const [storageValue, setStorageValue] = useTabStorage({ groupId, }); // We sync valid querystring/storage value to state on change + hydration const valueToSync = (() => { const value = queryStringValue ?? storageValue; if (!isValidValue({value, tabValues})) { return null; } return value; })(); // Sync in a layout/sync effect is important, for useScrollPositionBlocker // See https://github.com/facebook/docusaurus/issues/8625 useIsomorphicLayoutEffect(() => { if (valueToSync) { setSelectedValue(valueToSync); } }, [valueToSync]); const selectValue = useCallback( (newValue: string) => { if (!isValidValue({value: newValue, tabValues})) { throw new Error(`Can't select invalid tab value=${newValue}`); } setSelectedValue(newValue); setQueryString(newValue); setStorageValue(newValue); }, [setQueryString, setStorageValue, tabValues], ); return { selectedValue, selectValue, tabValues, lazy: props.lazy ?? false, block: props.block ?? false, }; } const TabsContext = createContext<TabsContextValue | null>(null); export function useTabs(): TabsContextValue { const contextValue = React.useContext(TabsContext); if (!contextValue) { throw new Error('useTabsContext() must be used within a Tabs component'); } return contextValue; } export function TabsProvider(props: { children: ReactNode; value: TabsContextValue; }): ReactNode { return ( <TabsContext.Provider value={props.value}> {props.children} </TabsContext.Provider> ); }