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 className?: string;
readonly queryString?: string | boolean;
}
export default function Tabs(props: Props): JSX.Element;

View file

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

View file

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

View file

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

View file

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

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