fix(theme): refactor Tabs, make groupId + queryString work fine together (#8486)

This commit is contained in:
Sébastien Lorber 2022-12-29 12:41:53 +01:00 committed by GitHub
parent 949158d35b
commit 9c860ce419
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 505 additions and 356 deletions

View file

@ -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<TabItemProps>[];
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;
}

View file

@ -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,

View file

@ -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 (
<StaticRouter location={{pathname}}>
<ScrollControllerProvider>
<TabGroupChoiceProvider>{children}</TabGroupChoiceProvider>
</ScrollControllerProvider>
<ScrollControllerProvider>{children}</ScrollControllerProvider>
</StaticRouter>
);
}

View file

@ -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<object>,
): comp is ReactElement<TabItemProps> {
return 'value' in comp.props;
}
function getSearchKey({
queryString = false,
groupId,
}: Pick<Props, 'queryString' | 'groupId'>) {
if (typeof queryString === 'string') {
return queryString;
}
if (queryString === false) {
return undefined;
}
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;
}
function useTabQueryString({
queryString = false,
groupId,
}: Pick<Props, 'queryString' | 'groupId'>) {
// 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 <Tabs> child <${
// @ts-expect-error: guarding against unexpected cases
typeof child.type === 'string' ? child.type : child.type.name
}>: all children of the <Tabs> component should be <TabItem>, and every <TabItem> 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 <Tabs>. 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 <Tabs> 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<typeof useTabs>) {
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<HTMLLIElement>
@ -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 (
<div className={clsx('tabs-container', styles.tabList)}>
<ul
role="tablist"
aria-orientation="horizontal"
className={clsx(
'tabs',
{
'tabs--block': block,
},
className,
)}>
{values.map(({value, label, attributes}) => (
<li
role="tab"
tabIndex={selectedValue === value ? 0 : -1}
aria-selected={selectedValue === value}
key={value}
ref={(tabControl) => 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}
</li>
))}
</ul>
<ul
role="tablist"
aria-orientation="horizontal"
className={clsx(
'tabs',
{
'tabs--block': block,
},
className,
)}>
{tabValues.map(({value, label, attributes}) => (
<li
// TODO extract TabListItem
role="tab"
tabIndex={selectedValue === value ? 0 : -1}
aria-selected={selectedValue === value}
key={value}
ref={(tabControl) => 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}
</li>
))}
</ul>
);
}
{lazy ? (
cloneElement(
children.filter(
(tabItem) => tabItem.props.value === selectedValue,
)[0]!,
{className: 'margin-top--md'},
)
) : (
<div className="margin-top--md">
{children.map((tabItem, i) =>
cloneElement(tabItem, {
key: i,
hidden: tabItem.props.value !== selectedValue,
}),
)}
</div>
function TabContent({
lazy,
children,
selectedValue,
}: Props & ReturnType<typeof useTabs>) {
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 (
<div className="margin-top--md">
{children.map((tabItem, i) =>
cloneElement(tabItem, {
key: i,
hidden: tabItem.props.value !== selectedValue,
}),
)}
</div>
);
}
function TabsComponent(props: Props): JSX.Element {
const tabs = useTabs(props);
return (
<div className={clsx('tabs-container', styles.tabList)}>
<TabList {...props} {...tabs} />
<TabContent {...props} {...tabs} />
</div>
);
}
export default function Tabs(props: Props): JSX.Element {
const isBrowser = useIsBrowser();
return (

View file

@ -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": {

View file

@ -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<ContextValue | undefined>(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 <Context.Provider value={value}>{children}</Context.Provider>;
}
export function useTabGroupChoice(): ContextValue {
const context = useContext(Context);
if (context == null) {
throw new ReactContextError('TabGroupChoiceProvider');
}
return context;
}

View file

@ -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';

View file

@ -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,

View file

@ -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<Value>(
selector: (history: History<unknown>) => 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);
});
}

View file

@ -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.

View file

@ -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 <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: readonly ReactElement<TabItemProps>[];
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<object>,
): comp is ReactElement<TabItemProps> {
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 <Tabs> child <${
// @ts-expect-error: guarding against unexpected cases
typeof child.type === 'string' ? child.type : child.type.name
}>: all children of the <Tabs> component should be <TabItem>, and every <TabItem> 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 <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;
}
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};
}

View file

@ -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"