/** * 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 "values" prop or through the children 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[] { // ✅ => true // ✅ => true // ❌ => requires prop function isTabItemWithValueProp( comp: ReactElement, ): comp is ReactElement { 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 child <${badChildTypeName}>: all children of the component should be , and every should have a unique "value" prop. If you do not want to pass on a "value" prop to the direct children of , you can also pass an explicit 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 . Every value needs to be unique.`, ); } } function useTabValues( props: Pick, ): 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 component requires at least one children component', ); } if (defaultValue) { // Warn user about passing incorrect defaultValue as prop. if (!isValidValue({value: defaultValue, tabValues})) { throw new Error( `Docusaurus error: The 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) { if (typeof queryString === 'string') { return queryString; } if (queryString === false) { return null; } if (queryString === true && !groupId) { throw new Error( `Docusaurus error: The 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) { 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) { 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(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 ( {props.children} ); }