mirror of
https://github.com/facebook/docusaurus.git
synced 2025-04-30 10:48:05 +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' {
|
declare module '@theme/TabItem' {
|
||||||
import type {ReactNode} from 'react';
|
import type {TabItemProps} from '@docusaurus/theme-common/internal';
|
||||||
|
|
||||||
export interface Props {
|
export interface Props extends TabItemProps {}
|
||||||
readonly children: ReactNode;
|
|
||||||
readonly value: string;
|
|
||||||
readonly default?: boolean;
|
|
||||||
readonly label?: string;
|
|
||||||
readonly hidden?: boolean;
|
|
||||||
readonly className?: string;
|
|
||||||
readonly attributes?: {[key: string]: unknown};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TabItem(props: Props): JSX.Element;
|
export default function TabItem(props: Props): JSX.Element;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@theme/Tabs' {
|
declare module '@theme/Tabs' {
|
||||||
import type {ReactElement} from 'react';
|
import type {TabsProps} from '@docusaurus/theme-common/internal';
|
||||||
import type {Props as TabItemProps} from '@theme/TabItem';
|
|
||||||
|
|
||||||
export interface Props {
|
export interface Props extends TabsProps {}
|
||||||
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 default function Tabs(props: Props): JSX.Element;
|
export default function Tabs(props: Props): JSX.Element;
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,7 +9,6 @@ import React from 'react';
|
||||||
import {composeProviders} from '@docusaurus/theme-common';
|
import {composeProviders} from '@docusaurus/theme-common';
|
||||||
import {
|
import {
|
||||||
ColorModeProvider,
|
ColorModeProvider,
|
||||||
TabGroupChoiceProvider,
|
|
||||||
AnnouncementBarProvider,
|
AnnouncementBarProvider,
|
||||||
DocsPreferredVersionContextProvider,
|
DocsPreferredVersionContextProvider,
|
||||||
ScrollControllerProvider,
|
ScrollControllerProvider,
|
||||||
|
@ -21,7 +20,6 @@ import type {Props} from '@theme/Layout/Provider';
|
||||||
const Provider = composeProviders([
|
const Provider = composeProviders([
|
||||||
ColorModeProvider,
|
ColorModeProvider,
|
||||||
AnnouncementBarProvider,
|
AnnouncementBarProvider,
|
||||||
TabGroupChoiceProvider,
|
|
||||||
ScrollControllerProvider,
|
ScrollControllerProvider,
|
||||||
DocsPreferredVersionContextProvider,
|
DocsPreferredVersionContextProvider,
|
||||||
PluginHtmlClassNameProvider,
|
PluginHtmlClassNameProvider,
|
||||||
|
|
|
@ -7,10 +7,7 @@
|
||||||
|
|
||||||
import React, {type ReactNode} from 'react';
|
import React, {type ReactNode} from 'react';
|
||||||
import renderer from 'react-test-renderer';
|
import renderer from 'react-test-renderer';
|
||||||
import {
|
import {ScrollControllerProvider} from '@docusaurus/theme-common/internal';
|
||||||
TabGroupChoiceProvider,
|
|
||||||
ScrollControllerProvider,
|
|
||||||
} from '@docusaurus/theme-common/internal';
|
|
||||||
import {StaticRouter} from 'react-router-dom';
|
import {StaticRouter} from 'react-router-dom';
|
||||||
import Tabs from '../index';
|
import Tabs from '../index';
|
||||||
import TabItem from '../../TabItem';
|
import TabItem from '../../TabItem';
|
||||||
|
@ -24,9 +21,7 @@ function TestProviders({
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<StaticRouter location={{pathname}}>
|
<StaticRouter location={{pathname}}>
|
||||||
<ScrollControllerProvider>
|
<ScrollControllerProvider>{children}</ScrollControllerProvider>
|
||||||
<TabGroupChoiceProvider>{children}</TabGroupChoiceProvider>
|
|
||||||
</ScrollControllerProvider>
|
|
||||||
</StaticRouter>
|
</StaticRouter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,181 +5,27 @@
|
||||||
* LICENSE file in the root directory of this source tree.
|
* LICENSE file in the root directory of this source tree.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, {
|
import React, {cloneElement} from 'react';
|
||||||
cloneElement,
|
|
||||||
isValidElement,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useState,
|
|
||||||
type ReactElement,
|
|
||||||
} from 'react';
|
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import {useHistory, useLocation} from '@docusaurus/router';
|
|
||||||
import {duplicates, useEvent} from '@docusaurus/theme-common';
|
|
||||||
import {
|
import {
|
||||||
useScrollPositionBlocker,
|
useScrollPositionBlocker,
|
||||||
useTabGroupChoice,
|
useTabs,
|
||||||
} from '@docusaurus/theme-common/internal';
|
} from '@docusaurus/theme-common/internal';
|
||||||
import useIsBrowser from '@docusaurus/useIsBrowser';
|
import useIsBrowser from '@docusaurus/useIsBrowser';
|
||||||
import type {Props as TabItemProps} from '@theme/TabItem';
|
|
||||||
import type {Props} from '@theme/Tabs';
|
import type {Props} from '@theme/Tabs';
|
||||||
import styles from './styles.module.css';
|
import styles from './styles.module.css';
|
||||||
|
|
||||||
// A very rough duck type, but good enough to guard against mistakes while
|
function TabList({
|
||||||
// allowing customization
|
className,
|
||||||
function isTabItem(
|
block,
|
||||||
comp: ReactElement<object>,
|
selectedValue,
|
||||||
): comp is ReactElement<TabItemProps> {
|
selectValue,
|
||||||
return 'value' in comp.props;
|
tabValues,
|
||||||
}
|
}: Props & ReturnType<typeof useTabs>) {
|
||||||
|
|
||||||
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);
|
|
||||||
const tabRefs: (HTMLLIElement | null)[] = [];
|
const tabRefs: (HTMLLIElement | null)[] = [];
|
||||||
const {blockElementScrollPositionUntilNextRender} =
|
const {blockElementScrollPositionUntilNextRender} =
|
||||||
useScrollPositionBlocker();
|
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 = (
|
const handleTabChange = (
|
||||||
event:
|
event:
|
||||||
| React.FocusEvent<HTMLLIElement>
|
| React.FocusEvent<HTMLLIElement>
|
||||||
|
@ -188,15 +34,11 @@ function TabsComponent(props: Props): JSX.Element {
|
||||||
) => {
|
) => {
|
||||||
const newTab = event.currentTarget;
|
const newTab = event.currentTarget;
|
||||||
const newTabIndex = tabRefs.indexOf(newTab);
|
const newTabIndex = tabRefs.indexOf(newTab);
|
||||||
const newTabValue = values[newTabIndex]!.value;
|
const newTabValue = tabValues[newTabIndex]!.value;
|
||||||
|
|
||||||
if (newTabValue !== selectedValue) {
|
if (newTabValue !== selectedValue) {
|
||||||
blockElementScrollPositionUntilNextRender(newTab);
|
blockElementScrollPositionUntilNextRender(newTab);
|
||||||
setSelectedValue(newTabValue);
|
selectValue(newTabValue);
|
||||||
tabQueryString.set(newTabValue);
|
|
||||||
if (groupId != null) {
|
|
||||||
setTabGroupChoices(groupId, String(newTabValue));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -226,61 +68,79 @@ function TabsComponent(props: Props): JSX.Element {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx('tabs-container', styles.tabList)}>
|
<ul
|
||||||
<ul
|
role="tablist"
|
||||||
role="tablist"
|
aria-orientation="horizontal"
|
||||||
aria-orientation="horizontal"
|
className={clsx(
|
||||||
className={clsx(
|
'tabs',
|
||||||
'tabs',
|
{
|
||||||
{
|
'tabs--block': block,
|
||||||
'tabs--block': block,
|
},
|
||||||
},
|
className,
|
||||||
className,
|
)}>
|
||||||
)}>
|
{tabValues.map(({value, label, attributes}) => (
|
||||||
{values.map(({value, label, attributes}) => (
|
<li
|
||||||
<li
|
// TODO extract TabListItem
|
||||||
role="tab"
|
role="tab"
|
||||||
tabIndex={selectedValue === value ? 0 : -1}
|
tabIndex={selectedValue === value ? 0 : -1}
|
||||||
aria-selected={selectedValue === value}
|
aria-selected={selectedValue === value}
|
||||||
key={value}
|
key={value}
|
||||||
ref={(tabControl) => tabRefs.push(tabControl)}
|
ref={(tabControl) => tabRefs.push(tabControl)}
|
||||||
onKeyDown={handleKeydown}
|
onKeyDown={handleKeydown}
|
||||||
onClick={handleTabChange}
|
onClick={handleTabChange}
|
||||||
{...attributes}
|
{...attributes}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'tabs__item',
|
'tabs__item',
|
||||||
styles.tabItem,
|
styles.tabItem,
|
||||||
attributes?.className as string,
|
attributes?.className as string,
|
||||||
{
|
{
|
||||||
'tabs__item--active': selectedValue === value,
|
'tabs__item--active': selectedValue === value,
|
||||||
},
|
},
|
||||||
)}>
|
)}>
|
||||||
{label ?? value}
|
{label ?? value}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{lazy ? (
|
function TabContent({
|
||||||
cloneElement(
|
lazy,
|
||||||
children.filter(
|
children,
|
||||||
(tabItem) => tabItem.props.value === selectedValue,
|
selectedValue,
|
||||||
)[0]!,
|
}: Props & ReturnType<typeof useTabs>) {
|
||||||
{className: 'margin-top--md'},
|
if (lazy) {
|
||||||
)
|
const selectedTabItem = children.find(
|
||||||
) : (
|
(tabItem) => tabItem.props.value === selectedValue,
|
||||||
<div className="margin-top--md">
|
);
|
||||||
{children.map((tabItem, i) =>
|
if (!selectedTabItem) {
|
||||||
cloneElement(tabItem, {
|
// fail-safe or fail-fast? not sure what's best here
|
||||||
key: i,
|
return null;
|
||||||
hidden: tabItem.props.value !== selectedValue,
|
}
|
||||||
}),
|
return cloneElement(selectedTabItem, {className: 'margin-top--md'});
|
||||||
)}
|
}
|
||||||
</div>
|
return (
|
||||||
|
<div className="margin-top--md">
|
||||||
|
{children.map((tabItem, i) =>
|
||||||
|
cloneElement(tabItem, {
|
||||||
|
key: i,
|
||||||
|
hidden: tabItem.props.value !== selectedValue,
|
||||||
|
}),
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 {
|
export default function Tabs(props: Props): JSX.Element {
|
||||||
const isBrowser = useIsBrowser();
|
const isBrowser = useIsBrowser();
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -43,6 +43,7 @@
|
||||||
"parse-numeric-range": "^1.3.0",
|
"parse-numeric-range": "^1.3.0",
|
||||||
"prism-react-renderer": "^1.3.5",
|
"prism-react-renderer": "^1.3.5",
|
||||||
"tslib": "^2.4.0",
|
"tslib": "^2.4.0",
|
||||||
|
"use-sync-external-store": "^1.2.0",
|
||||||
"utility-types": "^3.10.0"
|
"utility-types": "^3.10.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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,
|
type ColorModeConfig,
|
||||||
} from './utils/useThemeConfig';
|
} from './utils/useThemeConfig';
|
||||||
|
|
||||||
export {createStorageSlot, listStorageKeys} from './utils/storageUtils';
|
export {
|
||||||
|
createStorageSlot,
|
||||||
|
useStorageSlot,
|
||||||
|
listStorageKeys,
|
||||||
|
} from './utils/storageUtils';
|
||||||
|
|
||||||
export {useContextualSearchFilters} from './utils/searchUtils';
|
export {useContextualSearchFilters} from './utils/searchUtils';
|
||||||
|
|
||||||
|
|
|
@ -42,10 +42,8 @@ export {
|
||||||
useAnnouncementBar,
|
useAnnouncementBar,
|
||||||
} from './contexts/announcementBar';
|
} from './contexts/announcementBar';
|
||||||
|
|
||||||
export {
|
export {useTabs} from './utils/tabsUtils';
|
||||||
useTabGroupChoice,
|
export type {TabValue, TabsProps, TabItemProps} from './utils/tabsUtils';
|
||||||
TabGroupChoiceProvider,
|
|
||||||
} from './contexts/tabGroupChoice';
|
|
||||||
|
|
||||||
export {useNavbarMobileSidebar} from './contexts/navbarMobileSidebar';
|
export {useNavbarMobileSidebar} from './contexts/navbarMobileSidebar';
|
||||||
export {useNavbarSecondaryMenu} from './contexts/navbarSecondaryMenu/display';
|
export {useNavbarSecondaryMenu} from './contexts/navbarSecondaryMenu/display';
|
||||||
|
@ -82,7 +80,11 @@ export {useLocationChange} from './utils/useLocationChange';
|
||||||
|
|
||||||
export {useLocalPathname} from './utils/useLocalPathname';
|
export {useLocalPathname} from './utils/useLocalPathname';
|
||||||
|
|
||||||
export {useHistoryPopHandler} from './utils/historyUtils';
|
export {
|
||||||
|
useHistoryPopHandler,
|
||||||
|
useHistorySelector,
|
||||||
|
useQueryStringValue,
|
||||||
|
} from './utils/historyUtils';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
useFilteredAndTreeifiedTOC,
|
useFilteredAndTreeifiedTOC,
|
||||||
|
|
|
@ -7,8 +7,11 @@
|
||||||
|
|
||||||
import {useEffect} from 'react';
|
import {useEffect} from 'react';
|
||||||
import {useHistory} from '@docusaurus/router';
|
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 {useEvent} from './reactUtils';
|
||||||
import type {Location, Action} from 'history';
|
|
||||||
|
import type {History, Location, Action} from 'history';
|
||||||
|
|
||||||
type HistoryBlockHandler = (location: Location, action: Action) => void | false;
|
type HistoryBlockHandler = (location: Location, action: Action) => void | false;
|
||||||
|
|
||||||
|
@ -43,3 +46,28 @@ export function useHistoryPopHandler(handler: HistoryBlockHandler): void {
|
||||||
return undefined;
|
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.
|
* 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;
|
const StorageTypes = ['localStorage', 'sessionStorage', 'none'] as const;
|
||||||
|
|
||||||
export type StorageType = typeof StorageTypes[number];
|
export type StorageType = typeof StorageTypes[number];
|
||||||
|
|
||||||
const DefaultStorageType: StorageType = 'localStorage';
|
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
|
* Will return `null` if browser storage is unavailable (like running Docusaurus
|
||||||
* in an iframe). This should NOT be called in SSR.
|
* in an iframe). This should NOT be called in SSR.
|
||||||
|
@ -58,12 +92,14 @@ export type StorageSlot = {
|
||||||
get: () => string | null;
|
get: () => string | null;
|
||||||
set: (value: string) => void;
|
set: (value: string) => void;
|
||||||
del: () => void;
|
del: () => void;
|
||||||
|
listen: (onChange: (event: StorageEvent) => void) => () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const NoopStorageSlot: StorageSlot = {
|
const NoopStorageSlot: StorageSlot = {
|
||||||
get: () => null,
|
get: () => null,
|
||||||
set: () => {},
|
set: () => {},
|
||||||
del: () => {},
|
del: () => {},
|
||||||
|
listen: () => () => {},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fail-fast, as storage APIs should not be used during the SSR process
|
// 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,
|
get: throwError,
|
||||||
set: throwError,
|
set: throwError,
|
||||||
del: throwError,
|
del: throwError,
|
||||||
|
listen: throwError,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -98,39 +135,103 @@ export function createStorageSlot(
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return createServerStorageSlot(key);
|
return createServerStorageSlot(key);
|
||||||
}
|
}
|
||||||
const browserStorage = getBrowserStorage(options?.persistence);
|
const storage = getBrowserStorage(options?.persistence);
|
||||||
if (browserStorage === null) {
|
if (storage === null) {
|
||||||
return NoopStorageSlot;
|
return NoopStorageSlot;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
get: () => {
|
get: () => {
|
||||||
try {
|
try {
|
||||||
return browserStorage.getItem(key);
|
return storage.getItem(key);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Docusaurus storage error, can't get key=${key}`, err);
|
console.error(`Docusaurus storage error, can't get key=${key}`, err);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
set: (value) => {
|
set: (newValue) => {
|
||||||
try {
|
try {
|
||||||
browserStorage.setItem(key, value);
|
const oldValue = storage.getItem(key);
|
||||||
|
storage.setItem(key, newValue);
|
||||||
|
dispatchChangeEvent({
|
||||||
|
key,
|
||||||
|
oldValue,
|
||||||
|
newValue,
|
||||||
|
storage,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(
|
console.error(
|
||||||
`Docusaurus storage error, can't set ${key}=${value}`,
|
`Docusaurus storage error, can't set ${key}=${newValue}`,
|
||||||
err,
|
err,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
del: () => {
|
del: () => {
|
||||||
try {
|
try {
|
||||||
browserStorage.removeItem(key);
|
const oldValue = storage.getItem(key);
|
||||||
|
storage.removeItem(key);
|
||||||
|
dispatchChangeEvent({key, oldValue, newValue: null, storage});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Docusaurus storage error, can't delete key=${key}`, 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,
|
* Returns a list of all the keys currently stored in browser storage,
|
||||||
* or an empty list if browser storage can't be accessed.
|
* 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:
|
dependencies:
|
||||||
use-isomorphic-layout-effect "^1.1.1"
|
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:
|
use@^3.1.0:
|
||||||
version "3.1.1"
|
version "3.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"
|
resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"
|
||||||
|
|
Loading…
Add table
Reference in a new issue