diff --git a/packages/docusaurus-theme-classic/src/theme-classic.d.ts b/packages/docusaurus-theme-classic/src/theme-classic.d.ts
index be4cf3fa94e2..2a3588ef3791 100644
--- a/packages/docusaurus-theme-classic/src/theme-classic.d.ts
+++ b/packages/docusaurus-theme-classic/src/theme-classic.d.ts
@@ -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;
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`).
+
+:::