diff --git a/packages/docusaurus-theme-classic/src/theme/TabItem/index.tsx b/packages/docusaurus-theme-classic/src/theme/TabItem/index.tsx
index 29b3bc84f6cc..5037dc155b4f 100644
--- a/packages/docusaurus-theme-classic/src/theme/TabItem/index.tsx
+++ b/packages/docusaurus-theme-classic/src/theme/TabItem/index.tsx
@@ -7,15 +7,20 @@
import React, {type ReactNode} from 'react';
import clsx from 'clsx';
+import {useTabs} from '@docusaurus/theme-common/internal';
import type {Props} from '@theme/TabItem';
import styles from './styles.module.css';
-export default function TabItem({
+function TabItemPanel({
children,
- hidden,
className,
-}: Props): ReactNode {
+ hidden,
+}: {
+ children: ReactNode;
+ className?: string;
+ hidden?: boolean;
+}) {
return (
);
}
+
+export default function TabItem({
+ children,
+ className,
+ value,
+}: Props): ReactNode {
+ const {selectedValue, lazy} = useTabs();
+ const isSelected = value === selectedValue;
+
+ // TODO Docusaurus v4: use
?
+ if (!isSelected && lazy) {
+ return null;
+ }
+
+ return (
+
+ {children}
+
+ );
+}
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 9da082ba61c9..785a24e82008 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
@@ -9,7 +9,8 @@
// Jest doesn't allow pragma below other comments. https://github.com/facebook/jest/issues/12573
// eslint-disable-next-line header/header
-import React, {type ReactNode} from 'react';
+import React from 'react';
+import type {PropsWithChildren, ReactNode} from 'react';
import {render} from '@testing-library/react';
import '@testing-library/jest-dom';
import {ScrollControllerProvider} from '@docusaurus/theme-common/internal';
@@ -21,7 +22,7 @@ function TestProviders({
children,
pathname = '/',
}: {
- children: ReactNode;
+ children?: ReactNode;
pathname?: string;
}) {
return (
@@ -42,10 +43,12 @@ describe('Tabs', () => {
,
);
- }).toThrowErrorMatchingInlineSnapshot(
- `"Docusaurus error: Bad child : all children of the
component should be , and every should have a unique "value" prop."`,
- );
+ }).toThrowErrorMatchingInlineSnapshot(`
+ "Docusaurus error: Bad child : all children of the
component should be , and every should have a unique "value" prop.
+ If you do not want to pass on a "value" prop to the direct children of , you can also pass an explicit prop."
+ `);
});
+
it('rejects bad Tabs defaultValue', () => {
expect(() => {
render(
@@ -60,6 +63,7 @@ describe('Tabs', () => {
`"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."`,
);
});
+
it('rejects duplicate values', () => {
expect(() => {
render(
@@ -75,9 +79,36 @@ describe('Tabs', () => {
,
);
}).toThrowErrorMatchingInlineSnapshot(
- `"Docusaurus error: Duplicate values "v1, v2" found in . Every value needs to be unique."`,
+ `"Docusaurus error: Duplicate values "'v1', 'v2'" found in . Every value needs to be unique."`,
);
});
+
+ it('rejects duplicate values as prop', () => {
+ expect(() => {
+ render(
+
+
+ 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."`,
+ );
+ });
+
it('accepts valid Tabs config', () => {
expect(() => {
render(
@@ -130,6 +161,7 @@ describe('Tabs', () => {
);
}).not.toThrow(); // TODO Better Jest infrastructure to mock the Layout
});
+
// https://github.com/facebook/docusaurus/issues/5729
it('accepts dynamic Tabs with number values', () => {
expect(() => {
@@ -149,6 +181,67 @@ describe('Tabs', () => {
);
}).not.toThrow();
});
+
+ // https://github.com/facebook/docusaurus/issues/11672
+ it('rejects wrapped TabItem components when NOT using Tab values props', () => {
+ expect(() => {
+ function TabItem1({children}: PropsWithChildren) {
+ return (
+
+ {children}
+
+ );
+ }
+
+ function TabItem2({children}: PropsWithChildren) {
+ return (
+
+ {children}
+
+ );
+ }
+
+ render(
+
+
+ content1
+ content1
+
+ ,
+ );
+ }).toThrowErrorMatchingInlineSnapshot(`
+ "Docusaurus error: Bad child : all children of the component should be , and every should have a unique "value" prop.
+ If you do not want to pass on a "value" prop to the direct children of , you can also pass an explicit prop."
+ `);
+ });
+
+ // https://github.com/facebook/docusaurus/issues/11672
+ it('accepts wrapped TabItem components when using Tab values props', () => {
+ expect(() => {
+ function TabItem1({children}: PropsWithChildren) {
+ return {children};
+ }
+
+ function TabItem2({children}: PropsWithChildren) {
+ return {children};
+ }
+
+ render(
+
+
+ content1
+ content2
+
+ ,
+ );
+ }).not.toThrow();
+ });
+
it('rejects if querystring is true, but groupId falsy', () => {
expect(() => {
render(
diff --git a/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx b/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx
index 878744add0f2..89393dfc30d8 100644
--- a/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx
+++ b/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx
@@ -5,26 +5,23 @@
* LICENSE file in the root directory of this source tree.
*/
-import React, {cloneElement, type ReactElement, type ReactNode} from 'react';
+import React, {type ReactNode} from 'react';
import clsx from 'clsx';
import {ThemeClassNames} from '@docusaurus/theme-common';
import {
useScrollPositionBlocker,
+ useTabsContextValue,
useTabs,
sanitizeTabsChildren,
- type TabItemProps,
+ TabsProvider,
} from '@docusaurus/theme-common/internal';
import useIsBrowser from '@docusaurus/useIsBrowser';
import type {Props} from '@theme/Tabs';
import styles from './styles.module.css';
-function TabList({
- className,
- block,
- selectedValue,
- selectValue,
- tabValues,
-}: Props & ReturnType) {
+function TabList({className}: {className?: string}) {
+ const {selectedValue, selectValue, tabValues, block} = useTabs();
+
const tabRefs: (HTMLLIElement | null)[] = [];
const {blockElementScrollPositionUntilNextRender} =
useScrollPositionBlocker();
@@ -88,8 +85,8 @@ function TabList({
tabIndex={selectedValue === value ? 0 : -1}
aria-selected={selectedValue === value}
key={value}
- ref={(tabControl) => {
- tabRefs.push(tabControl);
+ ref={(ref) => {
+ tabRefs.push(ref);
}}
onKeyDown={handleKeydown}
onClick={handleTabChange}
@@ -109,40 +106,17 @@ function TabList({
);
}
-function TabContent({
- lazy,
- children,
- selectedValue,
-}: Props & ReturnType) {
- const childTabs = (Array.isArray(children) ? children : [children]).filter(
- Boolean,
- ) as ReactElement[];
- if (lazy) {
- const selectedTabItem = childTabs.find(
- (tabItem) => tabItem.props.value === selectedValue,
- );
- if (!selectedTabItem) {
- // fail-safe or fail-fast? not sure what's best here
- return null;
- }
- return cloneElement(selectedTabItem, {
- className: clsx('margin-top--md', selectedTabItem.props.className),
- });
- }
- return (
-
- {childTabs.map((tabItem, i) =>
- cloneElement(tabItem, {
- key: i,
- hidden: tabItem.props.value !== selectedValue,
- }),
- )}
-
- );
+function TabContent({children}: {children: ReactNode}) {
+ return {children}
;
}
-function TabsComponent(props: Props): ReactNode {
- const tabs = useTabs(props);
+function TabsContainer({
+ className,
+ children,
+}: {
+ className?: string;
+ children: ReactNode;
+}): ReactNode {
return (
-
-
+
+ {children}
);
}
export default function Tabs(props: Props): ReactNode {
const isBrowser = useIsBrowser();
+ const value = useTabsContextValue(props);
return (
-
- {sanitizeTabsChildren(props.children)}
-
+ key={String(isBrowser)}>
+
+ {sanitizeTabsChildren(props.children)}
+
+
);
}
diff --git a/packages/docusaurus-theme-common/src/internal.ts b/packages/docusaurus-theme-common/src/internal.ts
index ffb15d78e634..9d8b904cd8bd 100644
--- a/packages/docusaurus-theme-common/src/internal.ts
+++ b/packages/docusaurus-theme-common/src/internal.ts
@@ -23,7 +23,12 @@ export {
useAnnouncementBar,
} from './contexts/announcementBar';
-export {useTabs, sanitizeTabsChildren} from './utils/tabsUtils';
+export {
+ sanitizeTabsChildren,
+ TabsProvider,
+ useTabs,
+ useTabsContextValue,
+} from './utils/tabsUtils';
export type {TabValue, TabsProps, TabItemProps} from './utils/tabsUtils';
export {useNavbarMobileSidebar} from './contexts/navbarMobileSidebar';
diff --git a/packages/docusaurus-theme-common/src/utils/tabsUtils.tsx b/packages/docusaurus-theme-common/src/utils/tabsUtils.tsx
index 6757868e0696..4cb6351527ea 100644
--- a/packages/docusaurus-theme-common/src/utils/tabsUtils.tsx
+++ b/packages/docusaurus-theme-common/src/utils/tabsUtils.tsx
@@ -10,6 +10,7 @@ import React, {
useCallback,
useState,
useMemo,
+ createContext,
type ReactNode,
type ReactElement,
} from 'react';
@@ -29,12 +30,10 @@ export interface TabValue {
readonly default?: boolean;
}
-type TabItem = ReactElement | null | false | undefined;
-
export interface TabsProps {
readonly lazy?: boolean;
readonly block?: boolean;
- readonly children: TabItem[] | TabItem;
+ readonly children: ReactNode;
readonly defaultValue?: string | null;
readonly values?: readonly TabValue[];
readonly groupId?: string;
@@ -47,41 +46,45 @@ export interface TabItemProps {
readonly value: string;
readonly default?: boolean;
readonly label?: string;
- readonly hidden?: boolean;
readonly className?: string;
readonly attributes?: {[key: string]: unknown};
}
-// A very rough duck type, but good enough to guard against mistakes while
-// allowing customization
-function isTabItem(
- comp: ReactElement,
-): comp is ReactElement {
- const {props} = comp;
- return !!props && typeof props === 'object' && 'value' in props;
+export function sanitizeTabsChildren(children: ReactNode): ReactNode {
+ return React.Children.toArray(children).filter((child) => child !== '\n');
}
-export function sanitizeTabsChildren(children: TabsProps['children']) {
- return (React.Children.toArray(children)
- .filter((child) => child !== '\n')
- .map((child) => {
- if (!child || (isValidElement(child) && isTabItem(child))) {
- return child;
- }
- // child.type.name will give non-sensical values in prod because of
- // minification, but we assume it won't throw in prod.
- throw new Error(
- `Docusaurus error: Bad child <${
- // @ts-expect-error: guarding against unexpected cases
- typeof child.type === 'string' ? child.type : child.type.name
- }>: all children of the component should be , and every should have a unique "value" prop.`,
- );
- })
- ?.filter(Boolean) ?? []) as ReactElement[];
-}
+function extractChildrenTabValues(children: ReactNode): TabValue[] {
+ // ✅ => true
+ // ✅ => true
+ // ❌ => requires prop
+ function isTabItemWithValueProp(
+ comp: ReactElement,
+ ): comp is ReactElement {
+ const {props} = comp;
+ return !!props && typeof props === 'object' && 'value' in props;
+ }
-function extractChildrenTabValues(children: TabsProps['children']): TabValue[] {
- return sanitizeTabsChildren(children).map(
+ const elements = React.Children.toArray(children).flatMap((child) => {
+ // Historical case, not sure when it happens, do we really need this?
+ if (!child) {
+ return [];
+ }
+ if (isValidElement(child) && isTabItemWithValueProp(child)) {
+ return [child];
+ }
+ // child.type.name will give non-sensical values in prod because of
+ // minification, but we assume it won't throw in prod.
+ const badChildTypeName =
+ // @ts-expect-error: guarding against unexpected cases
+ typeof child.type === 'string' ? child.type : child.type.name;
+ throw new Error(
+ `Docusaurus error: Bad child <${badChildTypeName}>: all children of the component should be , and every should have a unique "value" prop.
+If you do not want to pass on a "value" prop to the direct children of , you can also pass an explicit prop.`,
+ );
+ });
+
+ return elements.map(
({props: {value, label, attributes, default: isDefault}}) => ({
value,
label,
@@ -96,7 +99,7 @@ function ensureNoDuplicateValue(values: readonly TabValue[]) {
if (dup.length > 0) {
throw new Error(
`Docusaurus error: Duplicate values "${dup
- .map((a) => a.value)
+ .map((a) => `'${a.value}'`)
.join(', ')}" found in . Every value needs to be unique.`,
);
}
@@ -221,11 +224,18 @@ function useTabStorage({groupId}: Pick) {
return [value, setValue] as const;
}
-export function useTabs(props: TabsProps): {
+type TabsContextValue = {
selectedValue: string;
selectValue: (value: string) => void;
tabValues: readonly TabValue[];
-} {
+ lazy: boolean;
+ // TODO Docusaurus v4: remove this "block" concept?
+ // TIL about it, and afaik we never used nor documented it
+ // See https://infima.dev/docs/components/tabs#block
+ block: boolean;
+};
+
+export function useTabsContextValue(props: TabsProps): TabsContextValue {
const {defaultValue, queryString = false, groupId} = props;
const tabValues = useTabValues(props);
@@ -270,5 +280,32 @@ export function useTabs(props: TabsProps): {
[setQueryString, setStorageValue, tabValues],
);
- return {selectedValue, selectValue, tabValues};
+ return {
+ selectedValue,
+ selectValue,
+ tabValues,
+ lazy: props.lazy ?? false,
+ block: props.block ?? false,
+ };
+}
+
+const TabsContext = createContext(null);
+
+export function useTabs(): TabsContextValue {
+ const contextValue = React.useContext(TabsContext);
+ if (!contextValue) {
+ throw new Error('useTabsContext() must be used within a Tabs component');
+ }
+ return contextValue;
+}
+
+export function TabsProvider(props: {
+ children: ReactNode;
+ value: TabsContextValue;
+}): ReactNode {
+ return (
+
+ {props.children}
+
+ );
}
diff --git a/project-words.txt b/project-words.txt
index 15fd2ae280aa..a4678ff2654b 100644
--- a/project-words.txt
+++ b/project-words.txt
@@ -210,6 +210,7 @@ overrideable
ozaki
ozakione
O’Shannessy
+paas
Pagefind
pagefind
Palenight
diff --git a/website/_dogfooding/_pages tests/tabs-tests.mdx b/website/_dogfooding/_pages tests/tabs-tests.mdx
index 14e92fe0d1c8..06cd35078ddb 100644
--- a/website/_dogfooding/_pages tests/tabs-tests.mdx
+++ b/website/_dogfooding/_pages tests/tabs-tests.mdx
@@ -45,3 +45,37 @@ When clicking tabs above, they should stay under cursor and we should adjust the
This is a banana 🍌
+
+## Tabs with wrappers
+
+export function Local(props) {
+ return (
+
+ Local content
+
+ );
+}
+
+export function PaaS(props) {
+ return (
+
+ PaaS content
+
+ );
+}
+
+export function InstallationTabs() {
+ return (
+
+
+
+
+ );
+}
+
+
diff --git a/website/docs/api/plugins/plugin-content-blog.mdx b/website/docs/api/plugins/plugin-content-blog.mdx
index f8a17e4347f5..b63cee98ecc8 100644
--- a/website/docs/api/plugins/plugin-content-blog.mdx
+++ b/website/docs/api/plugins/plugin-content-blog.mdx
@@ -63,7 +63,7 @@ Accepted fields:
| `blogAuthorsListComponent` | `string` | `'@theme/Blog/Pages/BlogAuthorsListPage'` | Root component of the blog authors page index. |
| `remarkPlugins` | `any[]` | `[]` | Remark plugins passed to MDX. |
| `rehypePlugins` | `any[]` | `[]` | Rehype plugins passed to MDX. |
-| `rehypePlugins` | `any[]` | `[]` | Recma plugins passed to MDX. |
+| `recmaPlugins` | `any[]` | `[]` | Recma plugins passed to MDX. |
| `beforeDefaultRemarkPlugins` | `any[]` | `[]` | Custom Remark plugins passed to MDX before the default Docusaurus Remark plugins. |
| `beforeDefaultRehypePlugins` | `any[]` | `[]` | Custom Rehype plugins passed to MDX before the default Docusaurus Rehype plugins. |
| `truncateMarker` | `RegExp` | `//` \| `\{\/\*\s*truncate\s*\*\/\}/` | Truncate marker marking where the summary ends. |
@@ -79,10 +79,10 @@ Accepted fields:
| `feedOptions.copyright` | `string` | `undefined` | Copyright message. |
| `feedOptions.xslt` | boolean \| [FeedXSLTOptions](#FeedXSLTOptions) | `undefined` | Permits to style the blog XML feeds with XSLT so that browsers render them nicely. |
| `feedOptions.language` | `string` (See [documentation](http://www.w3.org/TR/REC-html40/struct/dirlang.html#langcodes) for possible values) | `undefined` | Language metadata of the feed. |
-| `sortPosts` | 'descending' \| 'ascending' | `'descending'` | Governs the direction of blog post sorting. |
+| `sortPosts` | 'descending' \| 'ascending' | `'descending'` | Governs the direction of blog post sorting. |
| `processBlogPosts` | [ProcessBlogPostsFn](#ProcessBlogPostsFn) | `undefined` | An optional function which can be used to transform blog posts (filter, modify, delete, etc...). |
| `showLastUpdateAuthor` | `boolean` | `false` | Whether to display the author who last updated the blog post. |
-| `showLastUpdateTime` | `boolean` | `false` | Whether to display the last date the blog post was updated. This requires access to git history during the build, so will not work correctly with shallow clones (a common default for CI systems). With GitHub `actions/checkout`, use`fetch-depth: 0`. |
+| `showLastUpdateTime` | `boolean` | `false` | Whether to display the last date the blog post was updated. This requires access to git history during the build, so will not work correctly with shallow clones (a common default for CI systems). With GitHub `actions/checkout`, use `fetch-depth: 0`. When deploying to Vercel, set the environment variable `VERCEL_DEEP_CLONE=true`. |
| `tags` | `string \| false \| null \| undefined` | `tags.yml` | Path to the YAML tags file listing pre-defined tags. Relative to the blog content directory. |
| `onInlineTags` | `'ignore' \| 'log' \| 'warn' \| 'throw'` | `warn` | The plugin behavior when blog posts contain inline tags (not appearing in the list of pre-defined tags, usually `tags.yml`). |
| `onUntruncatedBlogPosts` | `'ignore' \| 'log' \| 'warn' \| 'throw'` | `warn` | The plugin behavior when blog posts do not contain a truncate marker. |
diff --git a/website/docs/api/plugins/plugin-content-docs.mdx b/website/docs/api/plugins/plugin-content-docs.mdx
index 324e2f50042b..473da3cde1d7 100644
--- a/website/docs/api/plugins/plugin-content-docs.mdx
+++ b/website/docs/api/plugins/plugin-content-docs.mdx
@@ -55,11 +55,11 @@ Accepted fields:
| `docCategoryGeneratedIndexComponent` | `string` | `'@theme/DocCategoryGeneratedIndexPage'` | Root component of the generated category index page. |
| `remarkPlugins` | `any[]` | `[]` | Remark plugins passed to MDX. |
| `rehypePlugins` | `any[]` | `[]` | Rehype plugins passed to MDX. |
-| `rehypePlugins` | `any[]` | `[]` | Recma plugins passed to MDX. |
+| `recmaPlugins` | `any[]` | `[]` | Recma plugins passed to MDX. |
| `beforeDefaultRemarkPlugins` | `any[]` | `[]` | Custom Remark plugins passed to MDX before the default Docusaurus Remark plugins. |
| `beforeDefaultRehypePlugins` | `any[]` | `[]` | Custom Rehype plugins passed to MDX before the default Docusaurus Rehype plugins. |
| `showLastUpdateAuthor` | `boolean` | `false` | Whether to display the author who last updated the doc. |
-| `showLastUpdateTime` | `boolean` | `false` | Whether to display the last date the doc was updated. This requires access to git history during the build, so will not work correctly with shallow clones (a common default for CI systems). With GitHub `actions/checkout`, use`fetch-depth: 0`. |
+| `showLastUpdateTime` | `boolean` | `false` | **Only for Markdown pages**. Whether to display the last date the doc was updated. This requires access to git history during the build, so will not work correctly with shallow clones (a common default for CI systems). With GitHub `actions/checkout`, use `fetch-depth: 0`. When deploying to Vercel, set the environment variable `VERCEL_DEEP_CLONE=true`. |
| `breadcrumbs` | `boolean` | `true` | Enable or disable the breadcrumbs on doc pages. |
| `disableVersioning` | `boolean` | `false` | Explicitly disable versioning even when multiple versions exist. This will make the site only include the current version. Will error if `includeCurrentVersion: false` and `disableVersioning: true`. |
| `includeCurrentVersion` | `boolean` | `true` | Include the current version of your docs. |
diff --git a/website/docs/api/plugins/plugin-content-pages.mdx b/website/docs/api/plugins/plugin-content-pages.mdx
index b71ef0550015..1744559f683c 100644
--- a/website/docs/api/plugins/plugin-content-pages.mdx
+++ b/website/docs/api/plugins/plugin-content-pages.mdx
@@ -40,13 +40,13 @@ Accepted fields:
| `include` | `string[]` | `['**/*.{js,jsx,ts,tsx,md,mdx}']` | Matching files will be included and processed. |
| `exclude` | `string[]` | _See example configuration_ | No route will be created for matching files. |
| `mdxPageComponent` | `string` | `'@theme/MDXPage'` | Component used by each MDX page. |
-| `remarkPlugins` | `[]` | `any[]` | Remark plugins passed to MDX. |
-| `rehypePlugins` | `[]` | `any[]` | Rehype plugins passed to MDX. |
-| `rehypePlugins` | `any[]` | `[]` | Recma plugins passed to MDX. |
+| `remarkPlugins` | `any[]` | `[]` | Remark plugins passed to MDX. |
+| `rehypePlugins` | `any[]` | `[]` | Rehype plugins passed to MDX. |
+| `recmaPlugins` | `any[]` | `[]` | Recma plugins passed to MDX. |
| `beforeDefaultRemarkPlugins` | `any[]` | `[]` | Custom Remark plugins passed to MDX before the default Docusaurus Remark plugins. |
| `beforeDefaultRehypePlugins` | `any[]` | `[]` | Custom Rehype plugins passed to MDX before the default Docusaurus Rehype plugins. |
| `showLastUpdateAuthor` | `boolean` | `false` | **Only for Markdown pages**. Whether to display the author who last updated the page. |
-| `showLastUpdateTime` | `boolean` | `false` | **Only for Markdown pages**. Whether to display the last date the page post was updated. This requires access to git history during the build, so will not work correctly with shallow clones (a common default for CI systems). With GitHub `actions/checkout`, use`fetch-depth: 0`. |
+| `showLastUpdateTime` | `boolean` | `false` | **Only for Markdown pages**. Whether to display the last date the page post was updated. This requires access to git history during the build, so will not work correctly with shallow clones (a common default for CI systems). With GitHub `actions/checkout`, use `fetch-depth: 0`. When deploying to Vercel, set the environment variable `VERCEL_DEEP_CLONE=true`. |
```mdx-code-block