From 9c860ce419ff90fc852a2b2d465c7ca5b07128c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Lorber?= Date: Thu, 29 Dec 2022 12:41:53 +0100 Subject: [PATCH] fix(theme): refactor Tabs, make groupId + queryString work fine together (#8486) --- .../src/theme-classic.d.ts | 30 +- .../src/theme/Layout/Provider/index.tsx | 2 - .../src/theme/Tabs/__tests__/index.test.tsx | 9 +- .../src/theme/Tabs/index.tsx | 296 +++++------------- packages/docusaurus-theme-common/package.json | 1 + .../src/contexts/tabGroupChoice.tsx | 89 ------ packages/docusaurus-theme-common/src/index.ts | 6 +- .../docusaurus-theme-common/src/internal.ts | 12 +- .../src/utils/historyUtils.ts | 30 +- .../src/utils/storageUtils.ts | 115 ++++++- .../src/utils/tabsUtils.tsx | 266 ++++++++++++++++ yarn.lock | 5 + 12 files changed, 505 insertions(+), 356 deletions(-) delete mode 100644 packages/docusaurus-theme-common/src/contexts/tabGroupChoice.tsx create mode 100644 packages/docusaurus-theme-common/src/utils/tabsUtils.tsx diff --git a/packages/docusaurus-theme-classic/src/theme-classic.d.ts b/packages/docusaurus-theme-classic/src/theme-classic.d.ts index 2a3588ef37..b89678a8f1 100644 --- a/packages/docusaurus-theme-classic/src/theme-classic.d.ts +++ b/packages/docusaurus-theme-classic/src/theme-classic.d.ts @@ -1232,39 +1232,17 @@ declare module '@theme/Mermaid' { } declare module '@theme/TabItem' { - import type {ReactNode} from 'react'; + import type {TabItemProps} from '@docusaurus/theme-common/internal'; - export interface Props { - readonly children: ReactNode; - readonly value: string; - readonly default?: boolean; - readonly label?: string; - readonly hidden?: boolean; - readonly className?: string; - readonly attributes?: {[key: string]: unknown}; - } + export interface Props extends TabItemProps {} export default function TabItem(props: Props): JSX.Element; } declare module '@theme/Tabs' { - import type {ReactElement} from 'react'; - import type {Props as TabItemProps} from '@theme/TabItem'; + import type {TabsProps} from '@docusaurus/theme-common/internal'; - export interface Props { - readonly lazy?: boolean; - readonly block?: boolean; - readonly children: readonly ReactElement[]; - readonly defaultValue?: string | null; - readonly values?: readonly { - value: string; - label?: string; - attributes?: {[key: string]: unknown}; - }[]; - readonly groupId?: string; - readonly className?: string; - readonly queryString?: string | boolean; - } + export interface Props extends TabsProps {} export default function Tabs(props: Props): JSX.Element; } diff --git a/packages/docusaurus-theme-classic/src/theme/Layout/Provider/index.tsx b/packages/docusaurus-theme-classic/src/theme/Layout/Provider/index.tsx index 4a6d316bdf..6902505547 100644 --- a/packages/docusaurus-theme-classic/src/theme/Layout/Provider/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/Layout/Provider/index.tsx @@ -9,7 +9,6 @@ import React from 'react'; import {composeProviders} from '@docusaurus/theme-common'; import { ColorModeProvider, - TabGroupChoiceProvider, AnnouncementBarProvider, DocsPreferredVersionContextProvider, ScrollControllerProvider, @@ -21,7 +20,6 @@ import type {Props} from '@theme/Layout/Provider'; const Provider = composeProviders([ ColorModeProvider, AnnouncementBarProvider, - TabGroupChoiceProvider, ScrollControllerProvider, DocsPreferredVersionContextProvider, PluginHtmlClassNameProvider, diff --git a/packages/docusaurus-theme-classic/src/theme/Tabs/__tests__/index.test.tsx b/packages/docusaurus-theme-classic/src/theme/Tabs/__tests__/index.test.tsx index 9958197cd2..f4d59c7409 100644 --- a/packages/docusaurus-theme-classic/src/theme/Tabs/__tests__/index.test.tsx +++ b/packages/docusaurus-theme-classic/src/theme/Tabs/__tests__/index.test.tsx @@ -7,10 +7,7 @@ import React, {type ReactNode} from 'react'; import renderer from 'react-test-renderer'; -import { - TabGroupChoiceProvider, - ScrollControllerProvider, -} from '@docusaurus/theme-common/internal'; +import {ScrollControllerProvider} from '@docusaurus/theme-common/internal'; import {StaticRouter} from 'react-router-dom'; import Tabs from '../index'; import TabItem from '../../TabItem'; @@ -24,9 +21,7 @@ function TestProviders({ }) { return ( - - {children} - + {children} ); } diff --git a/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx b/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx index 303d84375e..59103c8d07 100644 --- a/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx @@ -5,181 +5,27 @@ * LICENSE file in the root directory of this source tree. */ -import React, { - cloneElement, - isValidElement, - useCallback, - useEffect, - useState, - type ReactElement, -} from 'react'; +import React, {cloneElement} from 'react'; import clsx from 'clsx'; -import {useHistory, useLocation} from '@docusaurus/router'; -import {duplicates, useEvent} from '@docusaurus/theme-common'; import { useScrollPositionBlocker, - useTabGroupChoice, + useTabs, } from '@docusaurus/theme-common/internal'; import useIsBrowser from '@docusaurus/useIsBrowser'; -import type {Props as TabItemProps} from '@theme/TabItem'; import type {Props} from '@theme/Tabs'; import styles from './styles.module.css'; -// A very rough duck type, but good enough to guard against mistakes while -// allowing customization -function isTabItem( - comp: ReactElement, -): comp is ReactElement { - return 'value' in comp.props; -} - -function getSearchKey({ - queryString = false, - groupId, -}: Pick) { - if (typeof queryString === 'string') { - return queryString; - } - if (queryString === false) { - return undefined; - } - 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; -} - -function useTabQueryString({ - queryString = false, - groupId, -}: Pick) { - // TODO not re-render optimized - // See https://thisweekinreact.com/articles/useSyncExternalStore-the-underrated-react-api - const location = useLocation(); - const history = useHistory(); - - const searchKey = getSearchKey({queryString, groupId}); - - const get = useCallback(() => { - if (!searchKey) { - return undefined; - } - return new URLSearchParams(location.search).get(searchKey); - }, [searchKey, location.search]); - - const set = useCallback( - (newTabValue: string) => { - if (!searchKey) { - return; // no-op - } - const searchParams = new URLSearchParams(location.search); - searchParams.set(searchKey, newTabValue); - history.replace({...location, search: searchParams.toString()}); - }, - [searchKey, history, location], - ); - - return {get, set}; -} - -function TabsComponent(props: Props): JSX.Element { - const { - lazy, - block, - defaultValue: defaultValueProp, - values: valuesProp, - groupId, - className, - queryString = false, - } = props; - const children = React.Children.map(props.children, (child) => { - if (isValidElement(child) && isTabItem(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. - throw new Error( - `Docusaurus error: Bad child <${ - // @ts-expect-error: guarding against unexpected cases - typeof child.type === 'string' ? child.type : child.type.name - }>: all children of the component should be , and every should have a unique "value" prop.`, - ); - }); - const tabQueryString = useTabQueryString({queryString, groupId}); - const values = - valuesProp ?? - // Only pick keys that we recognize. MDX would inject some keys by default - children.map(({props: {value, label, attributes}}) => ({ - value, - label, - attributes, - })); - 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.`, - ); - } - - // Warn user about passing incorrect defaultValue as prop. - if ( - defaultValueProp !== null && - defaultValueProp !== undefined && - !values.some((a) => a.value === defaultValueProp) - ) { - throw new Error( - `Docusaurus error: The has a defaultValue "${defaultValueProp}" but none of its children has the corresponding value. Available values are: ${values - .map((a) => a.value) - .join( - ', ', - )}. If you intend to show no default tab, use defaultValue={null} instead.`, - ); - } - - const { - ready: tabGroupChoicesReady, - tabGroupChoices, - setTabGroupChoices, - } = useTabGroupChoice(); - const defaultValue = - defaultValueProp !== undefined - ? defaultValueProp - : children.find((child) => child.props.default)?.props.value ?? - children[0]!.props.value; - - const [selectedValue, setSelectedValue] = useState(defaultValue); +function TabList({ + className, + block, + selectedValue, + selectValue, + tabValues, +}: Props & ReturnType) { const tabRefs: (HTMLLIElement | null)[] = []; const {blockElementScrollPositionUntilNextRender} = useScrollPositionBlocker(); - // Lazily restore the appropriate tab selected value - // We can't read queryString/localStorage on first render - // It would trigger a React SSR/client hydration mismatch - const restoreTabSelectedValue = useEvent(() => { - // wait for localStorage values to be set (initially empty object :s) - if (tabGroupChoicesReady) { - // querystring value > localStorage value - const valueToRestore = - tabQueryString.get() ?? (groupId && tabGroupChoices[groupId]); - const isValid = - valueToRestore && - values.some((value) => value.value === valueToRestore); - if (isValid) { - setSelectedValue(valueToRestore); - } - } - }); - useEffect(() => { - // wait for localStorage values to be set (initially empty object :s) - if (tabGroupChoicesReady) { - restoreTabSelectedValue(); - } - }, [tabGroupChoicesReady, restoreTabSelectedValue]); - const handleTabChange = ( event: | React.FocusEvent @@ -188,15 +34,11 @@ function TabsComponent(props: Props): JSX.Element { ) => { const newTab = event.currentTarget; const newTabIndex = tabRefs.indexOf(newTab); - const newTabValue = values[newTabIndex]!.value; + const newTabValue = tabValues[newTabIndex]!.value; if (newTabValue !== selectedValue) { blockElementScrollPositionUntilNextRender(newTab); - setSelectedValue(newTabValue); - tabQueryString.set(newTabValue); - if (groupId != null) { - setTabGroupChoices(groupId, String(newTabValue)); - } + selectValue(newTabValue); } }; @@ -226,61 +68,79 @@ function TabsComponent(props: Props): JSX.Element { }; return ( -
-
    - {values.map(({value, label, attributes}) => ( -
  • tabRefs.push(tabControl)} - onKeyDown={handleKeydown} - onClick={handleTabChange} - {...attributes} - className={clsx( - 'tabs__item', - styles.tabItem, - attributes?.className as string, - { - 'tabs__item--active': selectedValue === value, - }, - )}> - {label ?? value} -
  • - ))} -
+
    + {tabValues.map(({value, label, attributes}) => ( +
  • tabRefs.push(tabControl)} + onKeyDown={handleKeydown} + onClick={handleTabChange} + {...attributes} + className={clsx( + 'tabs__item', + styles.tabItem, + attributes?.className as string, + { + 'tabs__item--active': selectedValue === value, + }, + )}> + {label ?? value} +
  • + ))} +
+ ); +} - {lazy ? ( - cloneElement( - children.filter( - (tabItem) => tabItem.props.value === selectedValue, - )[0]!, - {className: 'margin-top--md'}, - ) - ) : ( -
- {children.map((tabItem, i) => - cloneElement(tabItem, { - key: i, - hidden: tabItem.props.value !== selectedValue, - }), - )} -
+function TabContent({ + lazy, + children, + selectedValue, +}: Props & ReturnType) { + if (lazy) { + const selectedTabItem = children.find( + (tabItem) => tabItem.props.value === selectedValue, + ); + if (!selectedTabItem) { + // fail-safe or fail-fast? not sure what's best here + return null; + } + return cloneElement(selectedTabItem, {className: 'margin-top--md'}); + } + return ( +
+ {children.map((tabItem, i) => + cloneElement(tabItem, { + key: i, + hidden: tabItem.props.value !== selectedValue, + }), )}
); } +function TabsComponent(props: Props): JSX.Element { + const tabs = useTabs(props); + return ( +
+ + +
+ ); +} + export default function Tabs(props: Props): JSX.Element { const isBrowser = useIsBrowser(); return ( diff --git a/packages/docusaurus-theme-common/package.json b/packages/docusaurus-theme-common/package.json index bc33c9b13b..060cac6195 100644 --- a/packages/docusaurus-theme-common/package.json +++ b/packages/docusaurus-theme-common/package.json @@ -43,6 +43,7 @@ "parse-numeric-range": "^1.3.0", "prism-react-renderer": "^1.3.5", "tslib": "^2.4.0", + "use-sync-external-store": "^1.2.0", "utility-types": "^3.10.0" }, "devDependencies": { diff --git a/packages/docusaurus-theme-common/src/contexts/tabGroupChoice.tsx b/packages/docusaurus-theme-common/src/contexts/tabGroupChoice.tsx deleted file mode 100644 index bb4eb3a97e..0000000000 --- a/packages/docusaurus-theme-common/src/contexts/tabGroupChoice.tsx +++ /dev/null @@ -1,89 +0,0 @@ -/** - * 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, { - useState, - useCallback, - useEffect, - useMemo, - useContext, - type ReactNode, -} from 'react'; -import {createStorageSlot, listStorageKeys} from '../utils/storageUtils'; -import {ReactContextError} from '../utils/reactUtils'; - -const TAB_CHOICE_PREFIX = 'docusaurus.tab.'; - -type ContextValue = { - /** A boolean that tells if choices have already been restored from storage */ - readonly ready: boolean; - /** A map from `groupId` to the `value` of the saved choice. */ - readonly tabGroupChoices: {readonly [groupId: string]: string}; - /** Set the new choice value of a group. */ - readonly setTabGroupChoices: (groupId: string, newChoice: string) => void; -}; - -const Context = React.createContext(undefined); - -function useContextValue(): ContextValue { - const [ready, setReady] = useState(false); - const [tabGroupChoices, setChoices] = useState<{ - readonly [groupId: string]: string; - }>({}); - const setChoiceSyncWithLocalStorage = useCallback( - (groupId: string, newChoice: string) => { - createStorageSlot(`${TAB_CHOICE_PREFIX}${groupId}`).set(newChoice); - }, - [], - ); - - useEffect(() => { - try { - const localStorageChoices: {[groupId: string]: string} = {}; - listStorageKeys().forEach((storageKey) => { - if (storageKey.startsWith(TAB_CHOICE_PREFIX)) { - const groupId = storageKey.substring(TAB_CHOICE_PREFIX.length); - localStorageChoices[groupId] = createStorageSlot(storageKey).get()!; - } - }); - setChoices(localStorageChoices); - } catch (err) { - console.error(err); - } - setReady(true); - }, []); - - const setTabGroupChoices = useCallback( - (groupId: string, newChoice: string) => { - setChoices((oldChoices) => ({...oldChoices, [groupId]: newChoice})); - setChoiceSyncWithLocalStorage(groupId, newChoice); - }, - [setChoiceSyncWithLocalStorage], - ); - - return useMemo( - () => ({ready, tabGroupChoices, setTabGroupChoices}), - [ready, tabGroupChoices, setTabGroupChoices], - ); -} - -export function TabGroupChoiceProvider({ - children, -}: { - children: ReactNode; -}): JSX.Element { - const value = useContextValue(); - return {children}; -} - -export function useTabGroupChoice(): ContextValue { - const context = useContext(Context); - if (context == null) { - throw new ReactContextError('TabGroupChoiceProvider'); - } - return context; -} diff --git a/packages/docusaurus-theme-common/src/index.ts b/packages/docusaurus-theme-common/src/index.ts index b736244ca6..3e5f324e7c 100644 --- a/packages/docusaurus-theme-common/src/index.ts +++ b/packages/docusaurus-theme-common/src/index.ts @@ -24,7 +24,11 @@ export { type ColorModeConfig, } from './utils/useThemeConfig'; -export {createStorageSlot, listStorageKeys} from './utils/storageUtils'; +export { + createStorageSlot, + useStorageSlot, + listStorageKeys, +} from './utils/storageUtils'; export {useContextualSearchFilters} from './utils/searchUtils'; diff --git a/packages/docusaurus-theme-common/src/internal.ts b/packages/docusaurus-theme-common/src/internal.ts index 4eaf6eaa33..4298d3e985 100644 --- a/packages/docusaurus-theme-common/src/internal.ts +++ b/packages/docusaurus-theme-common/src/internal.ts @@ -42,10 +42,8 @@ export { useAnnouncementBar, } from './contexts/announcementBar'; -export { - useTabGroupChoice, - TabGroupChoiceProvider, -} from './contexts/tabGroupChoice'; +export {useTabs} from './utils/tabsUtils'; +export type {TabValue, TabsProps, TabItemProps} from './utils/tabsUtils'; export {useNavbarMobileSidebar} from './contexts/navbarMobileSidebar'; export {useNavbarSecondaryMenu} from './contexts/navbarSecondaryMenu/display'; @@ -84,7 +82,11 @@ export {useLocationChange} from './utils/useLocationChange'; export {useLocalPathname} from './utils/useLocalPathname'; -export {useHistoryPopHandler} from './utils/historyUtils'; +export { + useHistoryPopHandler, + useHistorySelector, + useQueryStringValue, +} from './utils/historyUtils'; export { useFilteredAndTreeifiedTOC, diff --git a/packages/docusaurus-theme-common/src/utils/historyUtils.ts b/packages/docusaurus-theme-common/src/utils/historyUtils.ts index 4c8b89c834..a81663feac 100644 --- a/packages/docusaurus-theme-common/src/utils/historyUtils.ts +++ b/packages/docusaurus-theme-common/src/utils/historyUtils.ts @@ -7,8 +7,11 @@ import {useEffect} from 'react'; import {useHistory} from '@docusaurus/router'; +// @ts-expect-error: TODO temporary until React 18 upgrade +import {useSyncExternalStore} from 'use-sync-external-store/shim'; import {useEvent} from './reactUtils'; -import type {Location, Action} from 'history'; + +import type {History, Location, Action} from 'history'; type HistoryBlockHandler = (location: Location, action: Action) => void | false; @@ -43,3 +46,28 @@ export function useHistoryPopHandler(handler: HistoryBlockHandler): void { return undefined; }); } + +/** + * Permits to efficiently subscribe to a slice of the history + * See https://thisweekinreact.com/articles/useSyncExternalStore-the-underrated-react-api + * @param selector + */ +export function useHistorySelector( + selector: (history: History) => Value, +): Value { + const history = useHistory(); + return useSyncExternalStore(history.listen, () => selector(history)); +} + +/** + * Permits to efficiently subscribe to a specific querystring value + * @param key + */ +export function useQueryStringValue(key: string | null): string | null { + return useHistorySelector((history) => { + if (key === null) { + return null; + } + return new URLSearchParams(history.location.search).get(key); + }); +} diff --git a/packages/docusaurus-theme-common/src/utils/storageUtils.ts b/packages/docusaurus-theme-common/src/utils/storageUtils.ts index 08ab3be1ae..c668cfaa4f 100644 --- a/packages/docusaurus-theme-common/src/utils/storageUtils.ts +++ b/packages/docusaurus-theme-common/src/utils/storageUtils.ts @@ -5,12 +5,46 @@ * LICENSE file in the root directory of this source tree. */ +import {useCallback, useRef} from 'react'; +// @ts-expect-error: TODO temp error until React 18 upgrade +import {useSyncExternalStore} from 'use-sync-external-store/shim'; + const StorageTypes = ['localStorage', 'sessionStorage', 'none'] as const; export type StorageType = typeof StorageTypes[number]; const DefaultStorageType: StorageType = 'localStorage'; +// window.addEventListener('storage') only works for different windows... +// so for current window we have to dispatch the event manually +// Now we can listen for both cross-window / current-window storage changes! +// see https://stackoverflow.com/a/71177640/82609 +// see https://stackoverflow.com/questions/26974084/listen-for-changes-with-localstorage-on-the-same-window +function dispatchChangeEvent({ + key, + oldValue, + newValue, + storage, +}: { + key: string; + oldValue: string | null; + newValue: string | null; + storage: Storage; +}) { + const event = document.createEvent('StorageEvent'); + event.initStorageEvent( + 'storage', + false, + false, + key, + oldValue, + newValue, + window.location.href, + storage, + ); + window.dispatchEvent(event); +} + /** * Will return `null` if browser storage is unavailable (like running Docusaurus * in an iframe). This should NOT be called in SSR. @@ -58,12 +92,14 @@ export type StorageSlot = { get: () => string | null; set: (value: string) => void; del: () => void; + listen: (onChange: (event: StorageEvent) => void) => () => void; }; const NoopStorageSlot: StorageSlot = { get: () => null, set: () => {}, del: () => {}, + listen: () => () => {}, }; // Fail-fast, as storage APIs should not be used during the SSR process @@ -78,6 +114,7 @@ Please only call storage APIs in effects and event handlers.`); get: throwError, set: throwError, del: throwError, + listen: throwError, }; } @@ -98,39 +135,103 @@ export function createStorageSlot( if (typeof window === 'undefined') { return createServerStorageSlot(key); } - const browserStorage = getBrowserStorage(options?.persistence); - if (browserStorage === null) { + const storage = getBrowserStorage(options?.persistence); + if (storage === null) { return NoopStorageSlot; } return { get: () => { try { - return browserStorage.getItem(key); + return storage.getItem(key); } catch (err) { console.error(`Docusaurus storage error, can't get key=${key}`, err); return null; } }, - set: (value) => { + set: (newValue) => { try { - browserStorage.setItem(key, value); + const oldValue = storage.getItem(key); + storage.setItem(key, newValue); + dispatchChangeEvent({ + key, + oldValue, + newValue, + storage, + }); } catch (err) { console.error( - `Docusaurus storage error, can't set ${key}=${value}`, + `Docusaurus storage error, can't set ${key}=${newValue}`, err, ); } }, del: () => { try { - browserStorage.removeItem(key); + const oldValue = storage.getItem(key); + storage.removeItem(key); + dispatchChangeEvent({key, oldValue, newValue: null, storage}); } catch (err) { console.error(`Docusaurus storage error, can't delete key=${key}`, err); } }, + listen: (onChange) => { + try { + const listener = (event: StorageEvent) => { + if (event.storageArea === storage && event.key === key) { + onChange(event); + } + }; + window.addEventListener('storage', listener); + return () => window.removeEventListener('storage', listener); + } catch (err) { + console.error( + `Docusaurus storage error, can't listen for changes of key=${key}`, + err, + ); + return () => {}; + } + }, }; } +export function useStorageSlot( + key: string | null, + options?: {persistence?: StorageType}, +): [string | null, StorageSlot] { + // Not ideal but good enough: assumes storage slot config is constant + const storageSlot = useRef(() => { + if (key === null) { + return NoopStorageSlot; + } + return createStorageSlot(key, options); + }).current(); + + const listen: StorageSlot['listen'] = useCallback( + (onChange) => { + // Do not try to add a listener during SSR + if (typeof window === 'undefined') { + return () => {}; + } + return storageSlot.listen(onChange); + }, + [storageSlot], + ); + + const currentValue = useSyncExternalStore( + listen, + () => { + // TODO this check should be useless after React 18 + if (typeof window === 'undefined') { + return null; + } + return storageSlot.get(); + }, + () => null, + ); + + return [currentValue, storageSlot]; +} + /** * Returns a list of all the keys currently stored in browser storage, * or an empty list if browser storage can't be accessed. diff --git a/packages/docusaurus-theme-common/src/utils/tabsUtils.tsx b/packages/docusaurus-theme-common/src/utils/tabsUtils.tsx new file mode 100644 index 0000000000..403e023ef4 --- /dev/null +++ b/packages/docusaurus-theme-common/src/utils/tabsUtils.tsx @@ -0,0 +1,266 @@ +/** + * 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, + useEffect, + useState, + useMemo, + type ReactNode, + type ReactElement, +} from 'react'; +import {useHistory} from '@docusaurus/router'; +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: readonly ReactElement[]; + 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 hidden?: boolean; + readonly className?: string; + readonly attributes?: {[key: string]: unknown}; +} + +// A very rough duck type, but good enough to guard against mistakes while +// allowing customization +function isTabItem( + comp: ReactElement, +): comp is ReactElement { + return 'value' in comp.props; +} + +function ensureValidChildren(children: TabsProps['children']) { + return React.Children.map(children, (child) => { + if (isValidElement(child) && isTabItem(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. + throw new Error( + `Docusaurus error: Bad child <${ + // @ts-expect-error: guarding against unexpected cases + typeof child.type === 'string' ? child.type : child.type.name + }>: all children of the component should be , and every should have a unique "value" prop.`, + ); + }); +} + +function extractChildrenTabValues(children: TabsProps['children']): TabValue[] { + return ensureValidChildren(children).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; +} + +export function useTabs(props: TabsProps): { + selectedValue: string; + selectValue: (value: string) => void; + tabValues: readonly TabValue[]; +} { + 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; + })(); + useEffect(() => { + 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}; +} diff --git a/yarn.lock b/yarn.lock index fd18b1dbe2..4ca5d7db3e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15932,6 +15932,11 @@ use-latest@^1.2.1: dependencies: use-isomorphic-layout-effect "^1.1.1" +use-sync-external-store@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a" + integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA== + use@^3.1.0: version "3.1.1" resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"