Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions packages/docusaurus-theme-classic/src/theme/TabItem/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div
role="tabpanel"
Expand All @@ -25,3 +30,23 @@ export default function TabItem({
</div>
);
}

export default function TabItem({
children,
className,
value,
}: Props): ReactNode {
const {selectedValue, lazy} = useTabs();
const isSelected = value === selectedValue;

// TODO Docusaurus v4: use <Activity> ?
if (!isSelected && lazy) {
return null;
}

return (
<TabItemPanel className={className} hidden={!isSelected}>
{children}
</TabItemPanel>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -21,7 +22,7 @@ function TestProviders({
children,
pathname = '/',
}: {
children: ReactNode;
children?: ReactNode;
pathname?: string;
}) {
return (
Expand All @@ -42,10 +43,12 @@ describe('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."`,
);
}).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.
If you do not want to pass on a "value" prop to the direct children of <Tabs>, you can also pass an explicit <Tabs values={...}> prop."
`);
});

it('rejects bad Tabs defaultValue', () => {
expect(() => {
render(
Expand All @@ -60,6 +63,7 @@ describe('Tabs', () => {
`"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."`,
);
});

it('rejects duplicate values', () => {
expect(() => {
render(
Expand All @@ -75,9 +79,36 @@ describe('Tabs', () => {
</TestProviders>,
);
}).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."`,
);
});

it('rejects duplicate values as prop', () => {
expect(() => {
render(
<TestProviders>
<Tabs
values={[
{value: 'v1', label: 'V1'},
{value: 'v2', label: 'V2'},
{value: 'v3', label: 'V3'},
{value: 'v1', label: 'V1 different label'},
{value: 'v2', label: 'V3 different label'},
]}>
<TabItem value="v1">Tab 1</TabItem>
<TabItem value="v2">Tab 2</TabItem>
<TabItem value="v3">Tab 3</TabItem>
<TabItem value="v4">Tab 4</TabItem>
<TabItem value="v1">Tab 5</TabItem>
<TabItem value="v2">Tab 6</TabItem>
</Tabs>
</TestProviders>,
);
}).toThrowErrorMatchingInlineSnapshot(
`"Docusaurus error: Duplicate values "'v1', 'v2'" found in <Tabs>. Every value needs to be unique."`,
);
});

it('accepts valid Tabs config', () => {
expect(() => {
render(
Expand Down Expand Up @@ -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(() => {
Expand All @@ -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 (
<TabItem value="item1" label="Item 1">
{children}
</TabItem>
);
}

function TabItem2({children}: PropsWithChildren) {
return (
<TabItem value="item2" label="Item 2">
{children}
</TabItem>
);
}

render(
<TestProviders>
<Tabs>
<TabItem1>content1</TabItem1>
<TabItem2>content1</TabItem2>
</Tabs>
</TestProviders>,
);
}).toThrowErrorMatchingInlineSnapshot(`
"Docusaurus error: Bad <Tabs> child <TabItem1>: all children of the <Tabs> component should be <TabItem>, and every <TabItem> should have a unique "value" prop.
If you do not want to pass on a "value" prop to the direct children of <Tabs>, you can also pass an explicit <Tabs values={...}> prop."
`);
});

// https://github.com/facebook/docusaurus/issues/11672
it('accepts wrapped TabItem components when using Tab values props', () => {
expect(() => {
function TabItem1({children}: PropsWithChildren) {
return <TabItem value="item1">{children}</TabItem>;
}

function TabItem2({children}: PropsWithChildren) {
return <TabItem value="item2">{children}</TabItem>;
}

render(
<TestProviders>
<Tabs
defaultValue="item1"
values={[
{label: 'Item 1', value: 'item1'},
{label: 'Item 2', value: 'item2'},
]}>
<TabItem1>content1</TabItem1>
<TabItem2>content2</TabItem2>
</Tabs>
</TestProviders>,
);
}).not.toThrow();
});

it('rejects if querystring is true, but groupId falsy', () => {
expect(() => {
render(
Expand Down
81 changes: 31 additions & 50 deletions packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof useTabs>) {
function TabList({className}: {className?: string}) {
const {selectedValue, selectValue, tabValues, block} = useTabs();

const tabRefs: (HTMLLIElement | null)[] = [];
const {blockElementScrollPositionUntilNextRender} =
useScrollPositionBlocker();
Expand Down Expand Up @@ -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}
Expand All @@ -109,40 +106,17 @@ function TabList({
);
}

function TabContent({
lazy,
children,
selectedValue,
}: Props & ReturnType<typeof useTabs>) {
const childTabs = (Array.isArray(children) ? children : [children]).filter(
Boolean,
) as ReactElement<TabItemProps>[];
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 (
<div className="margin-top--md">
{childTabs.map((tabItem, i) =>
cloneElement(tabItem, {
key: i,
hidden: tabItem.props.value !== selectedValue,
}),
)}
</div>
);
function TabContent({children}: {children: ReactNode}) {
return <div className="margin-top--md">{children}</div>;
}

function TabsComponent(props: Props): ReactNode {
const tabs = useTabs(props);
function TabsContainer({
className,
children,
}: {
className?: string;
children: ReactNode;
}): ReactNode {
return (
<div
className={clsx(
Expand All @@ -152,21 +126,28 @@ function TabsComponent(props: Props): ReactNode {
'tabs-container',
styles.tabList,
)}>
<TabList {...tabs} {...props} />
<TabContent {...tabs} {...props} />
<TabList
// Surprising but historical
// className is applied on TabList, not on TabsContainer
className={className}
/>
<TabContent>{children}</TabContent>
</div>
);
}

export default function Tabs(props: Props): ReactNode {
const isBrowser = useIsBrowser();
const value = useTabsContextValue(props);
return (
<TabsComponent
<TabsProvider
value={value}
// Remount tabs after hydration
// Temporary fix for https://github.com/facebook/docusaurus/issues/5653
key={String(isBrowser)}
{...props}>
{sanitizeTabsChildren(props.children)}
</TabsComponent>
key={String(isBrowser)}>
<TabsContainer className={props.className}>
{sanitizeTabsChildren(props.children)}
</TabsContainer>
</TabsProvider>
);
}
7 changes: 6 additions & 1 deletion packages/docusaurus-theme-common/src/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading
Loading