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:
mturoci 2022-12-09 17:46:09 +01:00 committed by GitHub
parent eb710af1b8
commit 5c09dbfc3d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 329 additions and 111 deletions

View file

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

View file

@ -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();
}); });

View file

@ -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));
} }

View file

@ -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],
); );
} }

View file

@ -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]);

View file

@ -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`).
:::