mirror of
https://github.com/facebook/docusaurus.git
synced 2025-06-06 04:42:40 +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 className?: string;
|
||||
readonly queryString?: string | boolean;
|
||||
}
|
||||
|
||||
export default function Tabs(props: Props): JSX.Element;
|
||||
|
|
|
@ -5,23 +5,42 @@
|
|||
* 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 {
|
||||
TabGroupChoiceProvider,
|
||||
ScrollControllerProvider,
|
||||
} from '@docusaurus/theme-common/internal';
|
||||
import {StaticRouter} from 'react-router-dom';
|
||||
import Tabs from '../index';
|
||||
import TabItem from '../../TabItem';
|
||||
|
||||
function TestProviders({
|
||||
children,
|
||||
pathname = '/',
|
||||
}: {
|
||||
children: ReactNode;
|
||||
pathname?: string;
|
||||
}) {
|
||||
return (
|
||||
<StaticRouter location={{pathname}}>
|
||||
<ScrollControllerProvider>
|
||||
<TabGroupChoiceProvider>{children}</TabGroupChoiceProvider>
|
||||
</ScrollControllerProvider>
|
||||
</StaticRouter>
|
||||
);
|
||||
}
|
||||
|
||||
describe('Tabs', () => {
|
||||
it('rejects bad Tabs child', () => {
|
||||
expect(() => {
|
||||
renderer.create(
|
||||
<TestProviders>
|
||||
<Tabs>
|
||||
<div>Naughty</div>
|
||||
<TabItem value="good">Good</TabItem>
|
||||
</Tabs>,
|
||||
</Tabs>
|
||||
</TestProviders>,
|
||||
);
|
||||
}).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."`,
|
||||
|
@ -30,10 +49,12 @@ describe('Tabs', () => {
|
|||
it('rejects bad Tabs defaultValue', () => {
|
||||
expect(() => {
|
||||
renderer.create(
|
||||
<TestProviders>
|
||||
<Tabs defaultValue="bad">
|
||||
<TabItem value="v1">Tab 1</TabItem>
|
||||
<TabItem value="v2">Tab 2</TabItem>
|
||||
</Tabs>,
|
||||
</Tabs>
|
||||
</TestProviders>,
|
||||
);
|
||||
}).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."`,
|
||||
|
@ -42,6 +63,7 @@ describe('Tabs', () => {
|
|||
it('rejects duplicate values', () => {
|
||||
expect(() => {
|
||||
renderer.create(
|
||||
<TestProviders>
|
||||
<Tabs>
|
||||
<TabItem value="v1">Tab 1</TabItem>
|
||||
<TabItem value="v2">Tab 2</TabItem>
|
||||
|
@ -49,7 +71,8 @@ describe('Tabs', () => {
|
|||
<TabItem value="v4">Tab 4</TabItem>
|
||||
<TabItem value="v1">Tab 5</TabItem>
|
||||
<TabItem value="v2">Tab 6</TabItem>
|
||||
</Tabs>,
|
||||
</Tabs>
|
||||
</TestProviders>,
|
||||
);
|
||||
}).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Docusaurus error: Duplicate values "v1, v2" found in <Tabs>. Every value needs to be unique."`,
|
||||
|
@ -58,8 +81,7 @@ describe('Tabs', () => {
|
|||
it('accepts valid Tabs config', () => {
|
||||
expect(() => {
|
||||
renderer.create(
|
||||
<ScrollControllerProvider>
|
||||
<TabGroupChoiceProvider>
|
||||
<TestProviders>
|
||||
<Tabs>
|
||||
<TabItem value="v1">Tab 1</TabItem>
|
||||
<TabItem value="v2">Tab 2</TabItem>
|
||||
|
@ -104,8 +126,7 @@ describe('Tabs', () => {
|
|||
Tab 2
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
</TabGroupChoiceProvider>
|
||||
</ScrollControllerProvider>,
|
||||
</TestProviders>,
|
||||
);
|
||||
}).not.toThrow(); // TODO Better Jest infrastructure to mock the Layout
|
||||
});
|
||||
|
@ -114,8 +135,7 @@ describe('Tabs', () => {
|
|||
expect(() => {
|
||||
const tabs = ['Apple', 'Banana', 'Carrot'];
|
||||
renderer.create(
|
||||
<ScrollControllerProvider>
|
||||
<TabGroupChoiceProvider>
|
||||
<TestProviders>
|
||||
<Tabs
|
||||
// @ts-expect-error: for an edge-case that we didn't write types for
|
||||
values={tabs.map((t, idx) => ({label: t, value: idx}))}
|
||||
|
@ -128,8 +148,47 @@ describe('Tabs', () => {
|
|||
</TabItem>
|
||||
))}
|
||||
</Tabs>
|
||||
</TabGroupChoiceProvider>
|
||||
</ScrollControllerProvider>,
|
||||
</TestProviders>,
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
it('rejects if querystring is true, but groupId falsy', () => {
|
||||
expect(() => {
|
||||
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();
|
||||
});
|
||||
|
|
|
@ -6,21 +6,23 @@
|
|||
*/
|
||||
|
||||
import React, {
|
||||
useState,
|
||||
cloneElement,
|
||||
isValidElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
type ReactElement,
|
||||
} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import useIsBrowser from '@docusaurus/useIsBrowser';
|
||||
import {duplicates} from '@docusaurus/theme-common';
|
||||
import {useHistory, useLocation} from '@docusaurus/router';
|
||||
import {duplicates, useEvent} from '@docusaurus/theme-common';
|
||||
import {
|
||||
useScrollPositionBlocker,
|
||||
useTabGroupChoice,
|
||||
} 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} from '@theme/Tabs';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
// A very rough duck type, but good enough to guard against mistakes while
|
||||
|
@ -31,6 +33,57 @@ function isTabItem(
|
|||
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,
|
||||
|
@ -39,6 +92,7 @@ function TabsComponent(props: Props): JSX.Element {
|
|||
values: valuesProp,
|
||||
groupId,
|
||||
className,
|
||||
queryString = false,
|
||||
} = props;
|
||||
const children = React.Children.map(props.children, (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.`,
|
||||
);
|
||||
});
|
||||
const tabQueryString = useTabQueryString({queryString, groupId});
|
||||
const values =
|
||||
valuesProp ??
|
||||
// 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.`,
|
||||
);
|
||||
}
|
||||
// When defaultValueProp is null, don't show a default tab
|
||||
const defaultValue =
|
||||
defaultValueProp === null
|
||||
? defaultValueProp
|
||||
: defaultValueProp ??
|
||||
children.find((child) => child.props.default)?.props.value ??
|
||||
children[0]!.props.value;
|
||||
if (defaultValue !== null && !values.some((a) => a.value === defaultValue)) {
|
||||
|
||||
// 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 "${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)
|
||||
.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 tabRefs: (HTMLLIElement | null)[] = [];
|
||||
const {blockElementScrollPositionUntilNextRender} =
|
||||
useScrollPositionBlocker();
|
||||
|
||||
if (groupId != null) {
|
||||
const relevantTabGroupChoice = tabGroupChoices[groupId];
|
||||
if (
|
||||
relevantTabGroupChoice != null &&
|
||||
relevantTabGroupChoice !== selectedValue &&
|
||||
values.some((value) => value.value === relevantTabGroupChoice)
|
||||
) {
|
||||
setSelectedValue(relevantTabGroupChoice);
|
||||
// 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:
|
||||
|
@ -116,7 +193,7 @@ function TabsComponent(props: Props): JSX.Element {
|
|||
if (newTabValue !== selectedValue) {
|
||||
blockElementScrollPositionUntilNextRender(newTab);
|
||||
setSelectedValue(newTabValue);
|
||||
|
||||
tabQueryString.set(newTabValue);
|
||||
if (groupId != null) {
|
||||
setTabGroupChoices(groupId, String(newTabValue));
|
||||
}
|
||||
|
|
|
@ -19,6 +19,8 @@ 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. */
|
||||
|
@ -28,6 +30,7 @@ type ContextValue = {
|
|||
const Context = React.createContext<ContextValue | undefined>(undefined);
|
||||
|
||||
function useContextValue(): ContextValue {
|
||||
const [ready, setReady] = useState(false);
|
||||
const [tabGroupChoices, setChoices] = useState<{
|
||||
readonly [groupId: string]: string;
|
||||
}>({});
|
||||
|
@ -51,6 +54,7 @@ function useContextValue(): ContextValue {
|
|||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
setReady(true);
|
||||
}, []);
|
||||
|
||||
const setTabGroupChoices = useCallback(
|
||||
|
@ -62,8 +66,8 @@ function useContextValue(): ContextValue {
|
|||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({tabGroupChoices, setTabGroupChoices}),
|
||||
[tabGroupChoices, setTabGroupChoices],
|
||||
() => ({ready, tabGroupChoices, setTabGroupChoices}),
|
||||
[ready, tabGroupChoices, setTabGroupChoices],
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -27,7 +27,26 @@ export function dispatchLifecycleAction<K extends keyof ClientModule>(
|
|||
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;
|
||||
if (!hash) {
|
||||
window.scrollTo(0, 0);
|
||||
|
@ -49,9 +68,7 @@ function ClientLifecyclesDispatcher({
|
|||
}): JSX.Element {
|
||||
useLayoutEffect(() => {
|
||||
if (previousLocation !== location) {
|
||||
if (previousLocation) {
|
||||
scrollAfterNavigation(location);
|
||||
}
|
||||
scrollAfterNavigation({location, previousLocation});
|
||||
dispatchLifecycleAction('onRouteDidUpdate', {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