mirror of
https://github.com/facebook/docusaurus.git
synced 2025-06-02 19:03:38 +02:00
feat(theme-classic): store selected tab in query string. (#8225)
Co-authored-by: sebastienlorber <lorber.sebastien@gmail.com> Closes https://github.com/facebook/docusaurus/issues/7008
This commit is contained in:
parent
eb710af1b8
commit
5c09dbfc3d
6 changed files with 329 additions and 111 deletions
|
@ -1263,6 +1263,7 @@ declare module '@theme/Tabs' {
|
||||||
}[];
|
}[];
|
||||||
readonly groupId?: string;
|
readonly groupId?: string;
|
||||||
readonly className?: string;
|
readonly className?: string;
|
||||||
|
readonly queryString?: string | boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Tabs(props: Props): JSX.Element;
|
export default function Tabs(props: Props): JSX.Element;
|
||||||
|
|
|
@ -5,23 +5,42 @@
|
||||||
* LICENSE file in the root directory of this source tree.
|
* LICENSE file in the root directory of this source tree.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, {type ReactNode} from 'react';
|
||||||
import renderer from 'react-test-renderer';
|
import renderer from 'react-test-renderer';
|
||||||
import {
|
import {
|
||||||
TabGroupChoiceProvider,
|
TabGroupChoiceProvider,
|
||||||
ScrollControllerProvider,
|
ScrollControllerProvider,
|
||||||
} from '@docusaurus/theme-common/internal';
|
} from '@docusaurus/theme-common/internal';
|
||||||
|
import {StaticRouter} from 'react-router-dom';
|
||||||
import Tabs from '../index';
|
import Tabs from '../index';
|
||||||
import TabItem from '../../TabItem';
|
import TabItem from '../../TabItem';
|
||||||
|
|
||||||
|
function TestProviders({
|
||||||
|
children,
|
||||||
|
pathname = '/',
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
pathname?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<StaticRouter location={{pathname}}>
|
||||||
|
<ScrollControllerProvider>
|
||||||
|
<TabGroupChoiceProvider>{children}</TabGroupChoiceProvider>
|
||||||
|
</ScrollControllerProvider>
|
||||||
|
</StaticRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
describe('Tabs', () => {
|
describe('Tabs', () => {
|
||||||
it('rejects bad Tabs child', () => {
|
it('rejects bad Tabs child', () => {
|
||||||
expect(() => {
|
expect(() => {
|
||||||
renderer.create(
|
renderer.create(
|
||||||
<Tabs>
|
<TestProviders>
|
||||||
<div>Naughty</div>
|
<Tabs>
|
||||||
<TabItem value="good">Good</TabItem>
|
<div>Naughty</div>
|
||||||
</Tabs>,
|
<TabItem value="good">Good</TabItem>
|
||||||
|
</Tabs>
|
||||||
|
</TestProviders>,
|
||||||
);
|
);
|
||||||
}).toThrowErrorMatchingInlineSnapshot(
|
}).toThrowErrorMatchingInlineSnapshot(
|
||||||
`"Docusaurus error: Bad <Tabs> child <div>: all children of the <Tabs> component should be <TabItem>, and every <TabItem> should have a unique "value" prop."`,
|
`"Docusaurus error: Bad <Tabs> child <div>: all children of the <Tabs> component should be <TabItem>, and every <TabItem> should have a unique "value" prop."`,
|
||||||
|
@ -30,10 +49,12 @@ describe('Tabs', () => {
|
||||||
it('rejects bad Tabs defaultValue', () => {
|
it('rejects bad Tabs defaultValue', () => {
|
||||||
expect(() => {
|
expect(() => {
|
||||||
renderer.create(
|
renderer.create(
|
||||||
<Tabs defaultValue="bad">
|
<TestProviders>
|
||||||
<TabItem value="v1">Tab 1</TabItem>
|
<Tabs defaultValue="bad">
|
||||||
<TabItem value="v2">Tab 2</TabItem>
|
<TabItem value="v1">Tab 1</TabItem>
|
||||||
</Tabs>,
|
<TabItem value="v2">Tab 2</TabItem>
|
||||||
|
</Tabs>
|
||||||
|
</TestProviders>,
|
||||||
);
|
);
|
||||||
}).toThrowErrorMatchingInlineSnapshot(
|
}).toThrowErrorMatchingInlineSnapshot(
|
||||||
`"Docusaurus error: The <Tabs> has a defaultValue "bad" but none of its children has the corresponding value. Available values are: v1, v2. If you intend to show no default tab, use defaultValue={null} instead."`,
|
`"Docusaurus error: The <Tabs> has a defaultValue "bad" but none of its children has the corresponding value. Available values are: v1, v2. If you intend to show no default tab, use defaultValue={null} instead."`,
|
||||||
|
@ -42,14 +63,16 @@ describe('Tabs', () => {
|
||||||
it('rejects duplicate values', () => {
|
it('rejects duplicate values', () => {
|
||||||
expect(() => {
|
expect(() => {
|
||||||
renderer.create(
|
renderer.create(
|
||||||
<Tabs>
|
<TestProviders>
|
||||||
<TabItem value="v1">Tab 1</TabItem>
|
<Tabs>
|
||||||
<TabItem value="v2">Tab 2</TabItem>
|
<TabItem value="v1">Tab 1</TabItem>
|
||||||
<TabItem value="v3">Tab 3</TabItem>
|
<TabItem value="v2">Tab 2</TabItem>
|
||||||
<TabItem value="v4">Tab 4</TabItem>
|
<TabItem value="v3">Tab 3</TabItem>
|
||||||
<TabItem value="v1">Tab 5</TabItem>
|
<TabItem value="v4">Tab 4</TabItem>
|
||||||
<TabItem value="v2">Tab 6</TabItem>
|
<TabItem value="v1">Tab 5</TabItem>
|
||||||
</Tabs>,
|
<TabItem value="v2">Tab 6</TabItem>
|
||||||
|
</Tabs>
|
||||||
|
</TestProviders>,
|
||||||
);
|
);
|
||||||
}).toThrowErrorMatchingInlineSnapshot(
|
}).toThrowErrorMatchingInlineSnapshot(
|
||||||
`"Docusaurus error: Duplicate values "v1, v2" found in <Tabs>. Every value needs to be unique."`,
|
`"Docusaurus error: Duplicate values "v1, v2" found in <Tabs>. Every value needs to be unique."`,
|
||||||
|
@ -58,54 +81,52 @@ describe('Tabs', () => {
|
||||||
it('accepts valid Tabs config', () => {
|
it('accepts valid Tabs config', () => {
|
||||||
expect(() => {
|
expect(() => {
|
||||||
renderer.create(
|
renderer.create(
|
||||||
<ScrollControllerProvider>
|
<TestProviders>
|
||||||
<TabGroupChoiceProvider>
|
<Tabs>
|
||||||
<Tabs>
|
<TabItem value="v1">Tab 1</TabItem>
|
||||||
<TabItem value="v1">Tab 1</TabItem>
|
<TabItem value="v2">Tab 2</TabItem>
|
||||||
<TabItem value="v2">Tab 2</TabItem>
|
</Tabs>
|
||||||
</Tabs>
|
<Tabs>
|
||||||
<Tabs>
|
<TabItem value="v1">Tab 1</TabItem>
|
||||||
<TabItem value="v1">Tab 1</TabItem>
|
<TabItem value="v2" default>
|
||||||
<TabItem value="v2" default>
|
Tab 2
|
||||||
Tab 2
|
</TabItem>
|
||||||
</TabItem>
|
</Tabs>
|
||||||
</Tabs>
|
<Tabs defaultValue="v1">
|
||||||
<Tabs defaultValue="v1">
|
<TabItem value="v1" label="V1">
|
||||||
<TabItem value="v1" label="V1">
|
Tab 1
|
||||||
Tab 1
|
</TabItem>
|
||||||
</TabItem>
|
<TabItem value="v2" label="V2">
|
||||||
<TabItem value="v2" label="V2">
|
Tab 2
|
||||||
Tab 2
|
</TabItem>
|
||||||
</TabItem>
|
</Tabs>
|
||||||
</Tabs>
|
<Tabs
|
||||||
<Tabs
|
defaultValue="v1"
|
||||||
defaultValue="v1"
|
values={[
|
||||||
values={[
|
{value: 'v1', label: 'V1'},
|
||||||
{value: 'v1', label: 'V1'},
|
{value: 'v2', label: 'V2'},
|
||||||
{value: 'v2', label: 'V2'},
|
]}>
|
||||||
]}>
|
<TabItem value="v1">Tab 1</TabItem>
|
||||||
<TabItem value="v1">Tab 1</TabItem>
|
<TabItem value="v2">Tab 2</TabItem>
|
||||||
<TabItem value="v2">Tab 2</TabItem>
|
</Tabs>
|
||||||
</Tabs>
|
<Tabs
|
||||||
<Tabs
|
defaultValue={null}
|
||||||
defaultValue={null}
|
values={[
|
||||||
values={[
|
{value: 'v1', label: 'V1'},
|
||||||
{value: 'v1', label: 'V1'},
|
{value: 'v2', label: 'V2'},
|
||||||
{value: 'v2', label: 'V2'},
|
]}>
|
||||||
]}>
|
<TabItem value="v1">Tab 1</TabItem>
|
||||||
<TabItem value="v1">Tab 1</TabItem>
|
<TabItem value="v2">Tab 2</TabItem>
|
||||||
<TabItem value="v2">Tab 2</TabItem>
|
</Tabs>
|
||||||
</Tabs>
|
<Tabs defaultValue={null}>
|
||||||
<Tabs defaultValue={null}>
|
<TabItem value="v1" label="V1">
|
||||||
<TabItem value="v1" label="V1">
|
Tab 1
|
||||||
Tab 1
|
</TabItem>
|
||||||
</TabItem>
|
<TabItem value="v2" label="V2">
|
||||||
<TabItem value="v2" label="V2">
|
Tab 2
|
||||||
Tab 2
|
</TabItem>
|
||||||
</TabItem>
|
</Tabs>
|
||||||
</Tabs>
|
</TestProviders>,
|
||||||
</TabGroupChoiceProvider>
|
|
||||||
</ScrollControllerProvider>,
|
|
||||||
);
|
);
|
||||||
}).not.toThrow(); // TODO Better Jest infrastructure to mock the Layout
|
}).not.toThrow(); // TODO Better Jest infrastructure to mock the Layout
|
||||||
});
|
});
|
||||||
|
@ -114,22 +135,60 @@ describe('Tabs', () => {
|
||||||
expect(() => {
|
expect(() => {
|
||||||
const tabs = ['Apple', 'Banana', 'Carrot'];
|
const tabs = ['Apple', 'Banana', 'Carrot'];
|
||||||
renderer.create(
|
renderer.create(
|
||||||
<ScrollControllerProvider>
|
<TestProviders>
|
||||||
<TabGroupChoiceProvider>
|
<Tabs
|
||||||
<Tabs
|
// @ts-expect-error: for an edge-case that we didn't write types for
|
||||||
|
values={tabs.map((t, idx) => ({label: t, value: idx}))}
|
||||||
|
// @ts-expect-error: for an edge-case that we didn't write types for
|
||||||
|
defaultValue={0}>
|
||||||
|
{tabs.map((t, idx) => (
|
||||||
// @ts-expect-error: for an edge-case that we didn't write types for
|
// @ts-expect-error: for an edge-case that we didn't write types for
|
||||||
values={tabs.map((t, idx) => ({label: t, value: idx}))}
|
<TabItem key={idx} value={idx}>
|
||||||
// @ts-expect-error: for an edge-case that we didn't write types for
|
{t}
|
||||||
defaultValue={0}>
|
</TabItem>
|
||||||
{tabs.map((t, idx) => (
|
))}
|
||||||
// @ts-expect-error: for an edge-case that we didn't write types for
|
</Tabs>
|
||||||
<TabItem key={idx} value={idx}>
|
</TestProviders>,
|
||||||
{t}
|
);
|
||||||
</TabItem>
|
}).not.toThrow();
|
||||||
))}
|
});
|
||||||
</Tabs>
|
it('rejects if querystring is true, but groupId falsy', () => {
|
||||||
</TabGroupChoiceProvider>
|
expect(() => {
|
||||||
</ScrollControllerProvider>,
|
renderer.create(
|
||||||
|
<TestProviders>
|
||||||
|
<Tabs queryString>
|
||||||
|
<TabItem value="val1">Val1</TabItem>
|
||||||
|
<TabItem value="val2">Val2</TabItem>
|
||||||
|
</Tabs>
|
||||||
|
</TestProviders>,
|
||||||
|
);
|
||||||
|
}).toThrow(
|
||||||
|
'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".',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accept querystring=true when groupId is defined', () => {
|
||||||
|
expect(() => {
|
||||||
|
renderer.create(
|
||||||
|
<TestProviders>
|
||||||
|
<Tabs queryString groupId="my-group-id">
|
||||||
|
<TabItem value="val1">Val1</TabItem>
|
||||||
|
<TabItem value="val2">Val2</TabItem>
|
||||||
|
</Tabs>
|
||||||
|
</TestProviders>,
|
||||||
|
);
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accept querystring as string, but groupId falsy', () => {
|
||||||
|
expect(() => {
|
||||||
|
renderer.create(
|
||||||
|
<TestProviders>
|
||||||
|
<Tabs queryString="qsKey">
|
||||||
|
<TabItem value="val1">Val1</TabItem>
|
||||||
|
<TabItem value="val2">Val2</TabItem>
|
||||||
|
</Tabs>
|
||||||
|
</TestProviders>,
|
||||||
);
|
);
|
||||||
}).not.toThrow();
|
}).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
|
@ -6,21 +6,23 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, {
|
import React, {
|
||||||
useState,
|
|
||||||
cloneElement,
|
cloneElement,
|
||||||
isValidElement,
|
isValidElement,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
type ReactElement,
|
type ReactElement,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import useIsBrowser from '@docusaurus/useIsBrowser';
|
import {useHistory, useLocation} from '@docusaurus/router';
|
||||||
import {duplicates} from '@docusaurus/theme-common';
|
import {duplicates, useEvent} from '@docusaurus/theme-common';
|
||||||
import {
|
import {
|
||||||
useScrollPositionBlocker,
|
useScrollPositionBlocker,
|
||||||
useTabGroupChoice,
|
useTabGroupChoice,
|
||||||
} from '@docusaurus/theme-common/internal';
|
} from '@docusaurus/theme-common/internal';
|
||||||
import type {Props} from '@theme/Tabs';
|
import useIsBrowser from '@docusaurus/useIsBrowser';
|
||||||
import type {Props as TabItemProps} from '@theme/TabItem';
|
import type {Props as TabItemProps} from '@theme/TabItem';
|
||||||
|
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
|
// A very rough duck type, but good enough to guard against mistakes while
|
||||||
|
@ -31,6 +33,57 @@ function isTabItem(
|
||||||
return 'value' in comp.props;
|
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 {
|
function TabsComponent(props: Props): JSX.Element {
|
||||||
const {
|
const {
|
||||||
lazy,
|
lazy,
|
||||||
|
@ -39,6 +92,7 @@ function TabsComponent(props: Props): JSX.Element {
|
||||||
values: valuesProp,
|
values: valuesProp,
|
||||||
groupId,
|
groupId,
|
||||||
className,
|
className,
|
||||||
|
queryString = false,
|
||||||
} = props;
|
} = props;
|
||||||
const children = React.Children.map(props.children, (child) => {
|
const children = React.Children.map(props.children, (child) => {
|
||||||
if (isValidElement(child) && isTabItem(child)) {
|
if (isValidElement(child) && isTabItem(child)) {
|
||||||
|
@ -53,6 +107,7 @@ function TabsComponent(props: Props): JSX.Element {
|
||||||
}>: all children of the <Tabs> component should be <TabItem>, and every <TabItem> should have a unique "value" prop.`,
|
}>: 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 =
|
const values =
|
||||||
valuesProp ??
|
valuesProp ??
|
||||||
// Only pick keys that we recognize. MDX would inject some keys by default
|
// Only pick keys that we recognize. MDX would inject some keys by default
|
||||||
|
@ -69,16 +124,15 @@ function TabsComponent(props: Props): JSX.Element {
|
||||||
.join(', ')}" found in <Tabs>. Every value needs to be unique.`,
|
.join(', ')}" found in <Tabs>. Every value needs to be unique.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// When defaultValueProp is null, don't show a default tab
|
|
||||||
const defaultValue =
|
// Warn user about passing incorrect defaultValue as prop.
|
||||||
defaultValueProp === null
|
if (
|
||||||
? defaultValueProp
|
defaultValueProp !== null &&
|
||||||
: defaultValueProp ??
|
defaultValueProp !== undefined &&
|
||||||
children.find((child) => child.props.default)?.props.value ??
|
!values.some((a) => a.value === defaultValueProp)
|
||||||
children[0]!.props.value;
|
) {
|
||||||
if (defaultValue !== null && !values.some((a) => a.value === defaultValue)) {
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Docusaurus error: The <Tabs> has a defaultValue "${defaultValue}" but none of its children has the corresponding value. Available values are: ${values
|
`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)
|
.map((a) => a.value)
|
||||||
.join(
|
.join(
|
||||||
', ',
|
', ',
|
||||||
|
@ -86,22 +140,45 @@ function TabsComponent(props: Props): JSX.Element {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const {tabGroupChoices, setTabGroupChoices} = useTabGroupChoice();
|
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 [selectedValue, setSelectedValue] = useState(defaultValue);
|
||||||
const tabRefs: (HTMLLIElement | null)[] = [];
|
const tabRefs: (HTMLLIElement | null)[] = [];
|
||||||
const {blockElementScrollPositionUntilNextRender} =
|
const {blockElementScrollPositionUntilNextRender} =
|
||||||
useScrollPositionBlocker();
|
useScrollPositionBlocker();
|
||||||
|
|
||||||
if (groupId != null) {
|
// Lazily restore the appropriate tab selected value
|
||||||
const relevantTabGroupChoice = tabGroupChoices[groupId];
|
// We can't read queryString/localStorage on first render
|
||||||
if (
|
// It would trigger a React SSR/client hydration mismatch
|
||||||
relevantTabGroupChoice != null &&
|
const restoreTabSelectedValue = useEvent(() => {
|
||||||
relevantTabGroupChoice !== selectedValue &&
|
// wait for localStorage values to be set (initially empty object :s)
|
||||||
values.some((value) => value.value === relevantTabGroupChoice)
|
if (tabGroupChoicesReady) {
|
||||||
) {
|
// querystring value > localStorage value
|
||||||
setSelectedValue(relevantTabGroupChoice);
|
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:
|
||||||
|
@ -116,7 +193,7 @@ function TabsComponent(props: Props): JSX.Element {
|
||||||
if (newTabValue !== selectedValue) {
|
if (newTabValue !== selectedValue) {
|
||||||
blockElementScrollPositionUntilNextRender(newTab);
|
blockElementScrollPositionUntilNextRender(newTab);
|
||||||
setSelectedValue(newTabValue);
|
setSelectedValue(newTabValue);
|
||||||
|
tabQueryString.set(newTabValue);
|
||||||
if (groupId != null) {
|
if (groupId != null) {
|
||||||
setTabGroupChoices(groupId, String(newTabValue));
|
setTabGroupChoices(groupId, String(newTabValue));
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,6 +19,8 @@ import {ReactContextError} from '../utils/reactUtils';
|
||||||
const TAB_CHOICE_PREFIX = 'docusaurus.tab.';
|
const TAB_CHOICE_PREFIX = 'docusaurus.tab.';
|
||||||
|
|
||||||
type ContextValue = {
|
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. */
|
/** A map from `groupId` to the `value` of the saved choice. */
|
||||||
readonly tabGroupChoices: {readonly [groupId: string]: string};
|
readonly tabGroupChoices: {readonly [groupId: string]: string};
|
||||||
/** Set the new choice value of a group. */
|
/** Set the new choice value of a group. */
|
||||||
|
@ -28,6 +30,7 @@ type ContextValue = {
|
||||||
const Context = React.createContext<ContextValue | undefined>(undefined);
|
const Context = React.createContext<ContextValue | undefined>(undefined);
|
||||||
|
|
||||||
function useContextValue(): ContextValue {
|
function useContextValue(): ContextValue {
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
const [tabGroupChoices, setChoices] = useState<{
|
const [tabGroupChoices, setChoices] = useState<{
|
||||||
readonly [groupId: string]: string;
|
readonly [groupId: string]: string;
|
||||||
}>({});
|
}>({});
|
||||||
|
@ -51,6 +54,7 @@ function useContextValue(): ContextValue {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
}
|
}
|
||||||
|
setReady(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setTabGroupChoices = useCallback(
|
const setTabGroupChoices = useCallback(
|
||||||
|
@ -62,8 +66,8 @@ function useContextValue(): ContextValue {
|
||||||
);
|
);
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({tabGroupChoices, setTabGroupChoices}),
|
() => ({ready, tabGroupChoices, setTabGroupChoices}),
|
||||||
[tabGroupChoices, setTabGroupChoices],
|
[ready, tabGroupChoices, setTabGroupChoices],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -27,7 +27,26 @@ export function dispatchLifecycleAction<K extends keyof ClientModule>(
|
||||||
return () => callbacks.forEach((cb) => cb?.());
|
return () => callbacks.forEach((cb) => cb?.());
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollAfterNavigation(location: Location) {
|
function scrollAfterNavigation({
|
||||||
|
location,
|
||||||
|
previousLocation,
|
||||||
|
}: {
|
||||||
|
location: Location;
|
||||||
|
previousLocation: Location | null;
|
||||||
|
}) {
|
||||||
|
if (!previousLocation) {
|
||||||
|
return; // no-op: use native browser feature
|
||||||
|
}
|
||||||
|
|
||||||
|
const samePathname = location.pathname === previousLocation.pathname;
|
||||||
|
const sameHash = location.hash === previousLocation.hash;
|
||||||
|
const sameSearch = location.search === previousLocation.search;
|
||||||
|
|
||||||
|
// Query-string changes: do not scroll to top/hash
|
||||||
|
if (samePathname && sameHash && !sameSearch) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const {hash} = location;
|
const {hash} = location;
|
||||||
if (!hash) {
|
if (!hash) {
|
||||||
window.scrollTo(0, 0);
|
window.scrollTo(0, 0);
|
||||||
|
@ -49,9 +68,7 @@ function ClientLifecyclesDispatcher({
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (previousLocation !== location) {
|
if (previousLocation !== location) {
|
||||||
if (previousLocation) {
|
scrollAfterNavigation({location, previousLocation});
|
||||||
scrollAfterNavigation(location);
|
|
||||||
}
|
|
||||||
dispatchLifecycleAction('onRouteDidUpdate', {previousLocation, location});
|
dispatchLifecycleAction('onRouteDidUpdate', {previousLocation, location});
|
||||||
}
|
}
|
||||||
}, [previousLocation, location]);
|
}, [previousLocation, location]);
|
||||||
|
|
|
@ -318,3 +318,63 @@ li[role='tab'][data-value='apple'] {
|
||||||
```
|
```
|
||||||
|
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
## Query string {#query-string}
|
||||||
|
|
||||||
|
It is possible to persist the selected tab into the url search parameters. This enables deep linking: the ability to share or bookmark a link to a specific tab, that will be pre-selected when the page loads.
|
||||||
|
|
||||||
|
Use the `queryString` prop to enable this feature and define the search param name to use.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// highlight-next-line
|
||||||
|
<Tabs queryString="current-os">
|
||||||
|
<TabItem value="android" label="Android">
|
||||||
|
Android
|
||||||
|
</TabItem>
|
||||||
|
<TabItem value="ios" label="iOS">
|
||||||
|
iOS
|
||||||
|
</TabItem>
|
||||||
|
</Tabs>
|
||||||
|
```
|
||||||
|
|
||||||
|
```mdx-code-block
|
||||||
|
<BrowserWindow>
|
||||||
|
<Tabs queryString='current-os'>
|
||||||
|
<TabItem value="android" label="Android">Android</TabItem>
|
||||||
|
<TabItem value="ios" label="iOS">iOS</TabItem>
|
||||||
|
</Tabs>
|
||||||
|
</BrowserWindow>
|
||||||
|
```
|
||||||
|
|
||||||
|
As soon as a tab is clicked, a search parameter is added at the end of the url: `?current-os=android` or `?current-os=ios`.
|
||||||
|
|
||||||
|
:::tip
|
||||||
|
|
||||||
|
`queryString` can be used together with `groupId`.
|
||||||
|
|
||||||
|
For convenience, when the `queryString` prop is `true`, the `groupId` value will be used as a fallback.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// highlight-next-line
|
||||||
|
<Tabs groupId="current-os" queryString>
|
||||||
|
<TabItem value="android" label="Android">
|
||||||
|
Android
|
||||||
|
</TabItem>
|
||||||
|
<TabItem value="ios" label="iOS">
|
||||||
|
iOS
|
||||||
|
</TabItem>
|
||||||
|
</Tabs>
|
||||||
|
```
|
||||||
|
|
||||||
|
```mdx-code-block
|
||||||
|
<BrowserWindow>
|
||||||
|
<Tabs queryString groupId="current-os">
|
||||||
|
<TabItem value="android" label="Android">Android</TabItem>
|
||||||
|
<TabItem value="ios" label="iOS">iOS</TabItem>
|
||||||
|
</Tabs>
|
||||||
|
</BrowserWindow>
|
||||||
|
```
|
||||||
|
|
||||||
|
When the page loads, the tab query string choice will be restored in priority over the `groupId` choice (using `localStorage`).
|
||||||
|
|
||||||
|
:::
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue