mirror of
https://github.com/facebook/docusaurus.git
synced 2025-04-29 18:27:56 +02:00
fix(theme): refactor Tabs, make groupId + queryString work fine together (#8486)
This commit is contained in:
parent
70bfaae2b3
commit
266209f265
12 changed files with 505 additions and 356 deletions
|
@ -1123,39 +1123,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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
@ -82,7 +80,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,
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
266
packages/docusaurus-theme-common/src/utils/tabsUtils.tsx
Normal file
266
packages/docusaurus-theme-common/src/utils/tabsUtils.tsx
Normal 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};
|
||||
}
|
|
@ -15489,6 +15489,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"
|
||||
|
|
Loading…
Add table
Reference in a new issue