From 78d5a53c3501d75d3c0604951e6823e323f8e093 Mon Sep 17 00:00:00 2001 From: mturoci <64769322+mturoci@users.noreply.github.com> Date: Fri, 9 Dec 2022 17:46:09 +0100 Subject: [PATCH] feat(theme-classic): store selected tab in query string. (#8225) Co-authored-by: sebastienlorber Closes https://github.com/facebook/docusaurus/issues/7008 --- .../src/theme-classic.d.ts | 1 + .../src/theme/Tabs/__tests__/index.test.tsx | 219 +++++++++++------- .../src/theme/Tabs/index.tsx | 127 ++++++++-- .../src/contexts/tabGroupChoice.tsx | 8 +- .../src/client/ClientLifecyclesDispatcher.tsx | 25 +- .../markdown-features-tabs.mdx | 60 +++++ 6 files changed, 329 insertions(+), 111 deletions(-) diff --git a/packages/docusaurus-theme-classic/src/theme-classic.d.ts b/packages/docusaurus-theme-classic/src/theme-classic.d.ts index 032aeb01603c..3666e6a8b59f 100644 --- a/packages/docusaurus-theme-classic/src/theme-classic.d.ts +++ b/packages/docusaurus-theme-classic/src/theme-classic.d.ts @@ -1154,6 +1154,7 @@ declare module '@theme/Tabs' { }[]; readonly groupId?: string; readonly className?: string; + readonly queryString?: string | boolean; } export default function Tabs(props: Props): JSX.Element; diff --git a/packages/docusaurus-theme-classic/src/theme/Tabs/__tests__/index.test.tsx b/packages/docusaurus-theme-classic/src/theme/Tabs/__tests__/index.test.tsx index cedcbe9f4120..9958197cd29e 100644 --- a/packages/docusaurus-theme-classic/src/theme/Tabs/__tests__/index.test.tsx +++ b/packages/docusaurus-theme-classic/src/theme/Tabs/__tests__/index.test.tsx @@ -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 ( + + + {children} + + + ); +} + describe('Tabs', () => { it('rejects bad Tabs child', () => { expect(() => { renderer.create( - -
Naughty
- Good -
, + + +
Naughty
+ Good +
+
, ); }).toThrowErrorMatchingInlineSnapshot( `"Docusaurus error: Bad child
: all children of the component should be , and every should have a unique "value" prop."`, @@ -30,10 +49,12 @@ describe('Tabs', () => { it('rejects bad Tabs defaultValue', () => { expect(() => { renderer.create( - - Tab 1 - Tab 2 - , + + + Tab 1 + Tab 2 + + , ); }).toThrowErrorMatchingInlineSnapshot( `"Docusaurus error: The 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', () => { expect(() => { renderer.create( - - Tab 1 - Tab 2 - Tab 3 - Tab 4 - Tab 5 - Tab 6 - , + + + Tab 1 + Tab 2 + Tab 3 + Tab 4 + Tab 5 + Tab 6 + + , ); }).toThrowErrorMatchingInlineSnapshot( `"Docusaurus error: Duplicate values "v1, v2" found in . Every value needs to be unique."`, @@ -58,54 +81,52 @@ describe('Tabs', () => { it('accepts valid Tabs config', () => { expect(() => { renderer.create( - - - - Tab 1 - Tab 2 - - - Tab 1 - - Tab 2 - - - - - Tab 1 - - - Tab 2 - - - - Tab 1 - Tab 2 - - - Tab 1 - Tab 2 - - - - Tab 1 - - - Tab 2 - - - - , + + + Tab 1 + Tab 2 + + + Tab 1 + + Tab 2 + + + + + Tab 1 + + + Tab 2 + + + + Tab 1 + Tab 2 + + + Tab 1 + Tab 2 + + + + Tab 1 + + + Tab 2 + + + , ); }).not.toThrow(); // TODO Better Jest infrastructure to mock the Layout }); @@ -114,22 +135,60 @@ describe('Tabs', () => { expect(() => { const tabs = ['Apple', 'Banana', 'Carrot']; renderer.create( - - - ({label: t, value: 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 - defaultValue={0}> - {tabs.map((t, idx) => ( - // @ts-expect-error: for an edge-case that we didn't write types for - - {t} - - ))} - - - , + + {t} + + ))} + + , + ); + }).not.toThrow(); + }); + it('rejects if querystring is true, but groupId falsy', () => { + expect(() => { + renderer.create( + + + Val1 + Val2 + + , + ); + }).toThrow( + 'Docusaurus error: The 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( + + + Val1 + Val2 + + , + ); + }).not.toThrow(); + }); + + it('accept querystring as string, but groupId falsy', () => { + expect(() => { + renderer.create( + + + Val1 + Val2 + + , ); }).not.toThrow(); }); diff --git a/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx b/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx index 54843fa48ade..303d84375e2f 100644 --- a/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx @@ -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) { + if (typeof queryString === 'string') { + return queryString; + } + if (queryString === false) { + return undefined; + } + if (queryString === true && !groupId) { + throw new Error( + `Docusaurus error: The 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) { + // 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 component should be , and every 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 . 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 has a defaultValue "${defaultValue}" but none of its children has the corresponding value. Available values are: ${values + `Docusaurus error: The 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)); } diff --git a/packages/docusaurus-theme-common/src/contexts/tabGroupChoice.tsx b/packages/docusaurus-theme-common/src/contexts/tabGroupChoice.tsx index 530b4bc90ad4..bb4eb3a97ece 100644 --- a/packages/docusaurus-theme-common/src/contexts/tabGroupChoice.tsx +++ b/packages/docusaurus-theme-common/src/contexts/tabGroupChoice.tsx @@ -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(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], ); } diff --git a/packages/docusaurus/src/client/ClientLifecyclesDispatcher.tsx b/packages/docusaurus/src/client/ClientLifecyclesDispatcher.tsx index e9cb7f8d5f5a..c7b433686421 100644 --- a/packages/docusaurus/src/client/ClientLifecyclesDispatcher.tsx +++ b/packages/docusaurus/src/client/ClientLifecyclesDispatcher.tsx @@ -27,7 +27,26 @@ export function dispatchLifecycleAction( 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]); diff --git a/website/docs/guides/markdown-features/markdown-features-tabs.mdx b/website/docs/guides/markdown-features/markdown-features-tabs.mdx index 897f54a8a7c0..948c61ef6757 100644 --- a/website/docs/guides/markdown-features/markdown-features-tabs.mdx +++ b/website/docs/guides/markdown-features/markdown-features-tabs.mdx @@ -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 + + + Android + + + iOS + + +``` + +```mdx-code-block + + + Android + iOS + + +``` + +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 + + + Android + + + iOS + + +``` + +```mdx-code-block + + + Android + iOS + + +``` + +When the page loads, the tab query string choice will be restored in priority over the `groupId` choice (using `localStorage`). + +:::