mirror of
https://github.com/facebook/docusaurus.git
synced 2025-05-18 19:46:57 +02:00
polish(theme-classic): guard against potential definition mistakes in Tabs (#5674)
This commit is contained in:
parent
8d92e9bcf5
commit
c8739ec28e
10 changed files with 224 additions and 23 deletions
|
@ -33,11 +33,14 @@ module.exports = {
|
|||
},
|
||||
setupFiles: ['./jest/stylelint-rule-test.js', './jest/polyfills.js'],
|
||||
moduleNameMapper: {
|
||||
// Jest can't resolve CSS imports
|
||||
'^.+\\.css$': '<rootDir>/jest/emptyModule.js',
|
||||
// TODO we need to allow Jest to resolve core Webpack aliases automatically
|
||||
'@docusaurus/router': 'react-router-dom',
|
||||
'@docusaurus/Translate': '@docusaurus/core/lib/client/exports/Translate',
|
||||
'@docusaurus/Interpolate':
|
||||
'@docusaurus/core/lib/client/exports/Interpolate',
|
||||
'@generated/codeTranslations': '<rootDir>/jest/emptyModule.js',
|
||||
'@docusaurus/(browserContext|BrowserOnly|ComponentCreator|constants|docusaurusContext|ExecutionEnvironment|Head|Interpolate|isInternalUrl|Link|Noop|renderRoutes|router|Translate|use.*)':
|
||||
'@docusaurus/core/lib/client/exports/$1',
|
||||
// Maybe point to a fixture?
|
||||
'@generated/.*': '<rootDir>/jest/emptyModule.js',
|
||||
// TODO maybe use "projects" + multiple configs if we plan to add tests to another theme?
|
||||
'@theme/(.*)': '@docusaurus/theme-classic/lib-next/theme/$1',
|
||||
},
|
||||
};
|
||||
|
|
|
@ -81,6 +81,7 @@
|
|||
"@types/react-helmet": "^6.0.0",
|
||||
"@types/react-loadable": "^5.5.3",
|
||||
"@types/react-router-config": "^5.0.1",
|
||||
"@types/react-test-renderer": "^17.0.1",
|
||||
"@types/semver": "^7.1.0",
|
||||
"@types/shelljs": "^0.8.6",
|
||||
"@types/wait-on": "^5.2.0",
|
||||
|
@ -110,6 +111,7 @@
|
|||
"prettier": "^2.4.1",
|
||||
"react": "^17.0.1",
|
||||
"react-dom": "^17.0.1",
|
||||
"react-test-renderer": "^17.0.2",
|
||||
"rimraf": "^3.0.2",
|
||||
"serve": "^12.0.1",
|
||||
"stylelint": "^13.10.0",
|
||||
|
|
|
@ -54,6 +54,7 @@
|
|||
"@types/mdx-js__react": "^1.5.4",
|
||||
"@types/parse-numeric-range": "^0.0.1",
|
||||
"@types/rtlcss": "^3.1.1",
|
||||
"react-test-renderer": "^17.0.2",
|
||||
"utility-types": "^3.10.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
|
|
@ -0,0 +1,106 @@
|
|||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import renderer from 'react-test-renderer';
|
||||
import Tabs from '../index';
|
||||
import TabItem from '../../TabItem';
|
||||
|
||||
describe('Tabs', () => {
|
||||
test('Should reject bad Tabs child', () => {
|
||||
expect(() => {
|
||||
renderer.create(
|
||||
<Tabs>
|
||||
<div>Naughty</div>
|
||||
<TabItem value="good">Good</TabItem>
|
||||
</Tabs>,
|
||||
);
|
||||
}).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."`,
|
||||
);
|
||||
});
|
||||
test('Should reject bad Tabs defaultValue', () => {
|
||||
expect(() => {
|
||||
renderer.create(
|
||||
<Tabs defaultValue="bad">
|
||||
<TabItem value="v1">Tab 1</TabItem>
|
||||
<TabItem value="v2">Tab 2</TabItem>
|
||||
</Tabs>,
|
||||
);
|
||||
}).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."`,
|
||||
);
|
||||
});
|
||||
test('Should reject duplicate values', () => {
|
||||
expect(() => {
|
||||
renderer.create(
|
||||
<Tabs>
|
||||
<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>,
|
||||
);
|
||||
}).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Docusaurus error: Duplicate values \\"v1, v2\\" found in <Tabs>. Every value needs to be unique."`,
|
||||
);
|
||||
});
|
||||
test('Should accept valid Tabs config', () => {
|
||||
expect(() => {
|
||||
renderer.create(
|
||||
<>
|
||||
<Tabs>
|
||||
<TabItem value="v1">Tab 1</TabItem>
|
||||
<TabItem value="v2">Tab 2</TabItem>
|
||||
</Tabs>
|
||||
<Tabs>
|
||||
<TabItem value="v1">Tab 1</TabItem>
|
||||
<TabItem value="v2" default>
|
||||
Tab 2
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
<Tabs defaultValue="v1">
|
||||
<TabItem value="v1" label="V1">
|
||||
Tab 1
|
||||
</TabItem>
|
||||
<TabItem value="v2" label="V2">
|
||||
Tab 2
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
<Tabs
|
||||
defaultValue="v1"
|
||||
values={[
|
||||
{value: 'v1', label: 'V1'},
|
||||
{value: 'v2', label: 'V2'},
|
||||
]}>
|
||||
<TabItem>Tab 1</TabItem>
|
||||
<TabItem>Tab 2</TabItem>
|
||||
</Tabs>
|
||||
<Tabs
|
||||
defaultValue={null}
|
||||
values={[
|
||||
{value: 'v1', label: 'V1'},
|
||||
{value: 'v2', label: 'V2'},
|
||||
]}>
|
||||
<TabItem>Tab 1</TabItem>
|
||||
<TabItem>Tab 2</TabItem>
|
||||
</Tabs>
|
||||
<Tabs defaultValue={null}>
|
||||
<TabItem value="v1" label="V1">
|
||||
Tab 1
|
||||
</TabItem>
|
||||
<TabItem value="v2" label="V2">
|
||||
Tab 2
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
</>,
|
||||
);
|
||||
}).toMatchInlineSnapshot(`[Function]`); // This is just a check that the function returns. We don't care about the actual DOM.
|
||||
});
|
||||
});
|
|
@ -5,10 +5,16 @@
|
|||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import React, {useState, cloneElement, Children, ReactElement} from 'react';
|
||||
import React, {
|
||||
useState,
|
||||
cloneElement,
|
||||
Children,
|
||||
isValidElement,
|
||||
ReactElement,
|
||||
} from 'react';
|
||||
import useIsBrowser from '@docusaurus/useIsBrowser';
|
||||
import useUserPreferencesContext from '@theme/hooks/useUserPreferencesContext';
|
||||
import {useScrollPositionBlocker} from '@docusaurus/theme-common';
|
||||
import {useScrollPositionBlocker, duplicates} from '@docusaurus/theme-common';
|
||||
import type {Props} from '@theme/Tabs';
|
||||
import type {Props as TabItemProps} from '@theme/TabItem';
|
||||
|
||||
|
@ -16,6 +22,12 @@ import clsx from 'clsx';
|
|||
|
||||
import styles from './styles.module.css';
|
||||
|
||||
// A very rough duck type, but good enough to guard against mistakes while
|
||||
// allowing customization
|
||||
function isTabItem(comp: ReactElement): comp is ReactElement<TabItemProps> {
|
||||
return typeof comp.props.value === 'string';
|
||||
}
|
||||
|
||||
function TabsComponent(props: Props): JSX.Element {
|
||||
const {
|
||||
lazy,
|
||||
|
@ -25,21 +37,48 @@ function TabsComponent(props: Props): JSX.Element {
|
|||
groupId,
|
||||
className,
|
||||
} = props;
|
||||
const children = Children.toArray(
|
||||
props.children,
|
||||
) as ReactElement<TabItemProps>[];
|
||||
const children = Children.map(props.children, (child) => {
|
||||
if (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 <Tabs> child <${
|
||||
// @ts-expect-error: guarding against unexpected cases
|
||||
typeof child.type === 'string' ? child.type : child.type.name
|
||||
}>: all children of the <Tabs> component should be <TabItem>, and every <TabItem> should have a unique "value" prop.`,
|
||||
);
|
||||
});
|
||||
const values =
|
||||
valuesProp ??
|
||||
children.map((child) => {
|
||||
return {
|
||||
value: child.props.value,
|
||||
label: child.props.label,
|
||||
};
|
||||
children.map(({props: {value, label}}) => {
|
||||
return {value, label};
|
||||
});
|
||||
const dup = duplicates(values, (a, b) => a.value === b.value);
|
||||
if (dup.length > 0) {
|
||||
throw new Error(
|
||||
`Docusaurus error: Duplicate values "${dup
|
||||
.map((a) => a.value)
|
||||
.join(', ')}" found in <Tabs>. Every value needs to be unique.`,
|
||||
);
|
||||
}
|
||||
// When defaultValueProp is null, don't show a default tab
|
||||
const defaultValue =
|
||||
defaultValueProp ??
|
||||
children.find((child) => child.props.default)?.props.value ??
|
||||
children[0]?.props.value;
|
||||
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)) {
|
||||
throw new Error(
|
||||
`Docusaurus error: The <Tabs> has a defaultValue "${defaultValue}" but none of its children has the corresponding value. Available values are: ${values
|
||||
.map((a) => a.value)
|
||||
.join(
|
||||
', ',
|
||||
)}. If you intend to show no default tab, use defaultValue={null} instead.`,
|
||||
);
|
||||
}
|
||||
|
||||
const {tabGroupChoices, setTabGroupChoices} = useUserPreferencesContext();
|
||||
const [selectedValue, setSelectedValue] = useState(defaultValue);
|
||||
|
@ -80,12 +119,12 @@ function TabsComponent(props: Props): JSX.Element {
|
|||
|
||||
switch (event.key) {
|
||||
case 'ArrowRight': {
|
||||
const nextTab = tabRefs.indexOf(event.target as HTMLLIElement) + 1;
|
||||
const nextTab = tabRefs.indexOf(event.currentTarget) + 1;
|
||||
focusElement = tabRefs[nextTab] || tabRefs[0];
|
||||
break;
|
||||
}
|
||||
case 'ArrowLeft': {
|
||||
const prevTab = tabRefs.indexOf(event.target as HTMLLIElement) - 1;
|
||||
const prevTab = tabRefs.indexOf(event.currentTarget) - 1;
|
||||
focusElement = tabRefs[prevTab] || tabRefs[tabRefs.length - 1];
|
||||
break;
|
||||
}
|
||||
|
|
|
@ -529,7 +529,7 @@ declare module '@theme/Tabs' {
|
|||
readonly lazy?: boolean;
|
||||
readonly block?: boolean;
|
||||
readonly children: readonly ReactElement<TabItemProps>[];
|
||||
readonly defaultValue?: string;
|
||||
readonly defaultValue?: string | null;
|
||||
readonly values?: readonly {value: string; label?: string}[];
|
||||
readonly groupId?: string;
|
||||
readonly className?: string;
|
||||
|
|
|
@ -59,6 +59,8 @@ export {
|
|||
useDocsPreferredVersionByPluginId,
|
||||
} from './utils/docsPreferredVersion/useDocsPreferredVersion';
|
||||
|
||||
export {duplicates} from './utils/jsUtils';
|
||||
|
||||
export {DocsPreferredVersionContextProvider} from './utils/docsPreferredVersion/DocsPreferredVersionProvider';
|
||||
|
||||
export {ThemeClassNames} from './utils/ThemeClassNames';
|
||||
|
|
23
packages/docusaurus-theme-common/src/utils/jsUtils.ts
Normal file
23
packages/docusaurus-theme-common/src/utils/jsUtils.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
// A replacement of lodash in client code
|
||||
|
||||
/**
|
||||
* Gets the duplicate values in an array.
|
||||
* @param arr The array.
|
||||
* @param comparator Compares two values and returns `true` if they are equal (duplicated).
|
||||
* @returns Value of the elements `v` that have a preceding element `u` where `comparator(u, v) === true`. Values within the returned array are not guaranteed to be unique.
|
||||
*/
|
||||
export function duplicates<T>(
|
||||
arr: readonly T[],
|
||||
comparator: (a: T, b: T) => boolean = (a, b) => a === b,
|
||||
): T[] {
|
||||
return arr.filter(
|
||||
(v, vIndex) => arr.findIndex((u) => comparator(u, v)) !== vIndex,
|
||||
);
|
||||
}
|
|
@ -131,7 +131,7 @@ It is possible to only render the default tab with `<Tabs lazy />`.
|
|||
|
||||
The first tab is displayed by default, and to override this behavior, you can specify a default tab by adding `default` to one of the tab items. You can also set the `defaultValue` prop of the `Tabs` component to the label value of your choice. For example, in the example above, either setting `default` for the `value="apple"` tab or setting `defaultValue="apple"` for the tabs forces the "Apple" tab to be open by default.
|
||||
|
||||
If `defaultValue` is provided for the `Tabs`, but it refers to an non-existing value, only the tab headings will appear until the user clicks on a tab.
|
||||
Docusaurus will throw an error if a `defaultValue` is provided for the `Tabs` but it refers to an non-existing value. If you want none of the tabs to be shown by default, use `defaultValue={null}`.
|
||||
|
||||
## Syncing tab choices {#syncing-tab-choices}
|
||||
|
||||
|
|
27
yarn.lock
27
yarn.lock
|
@ -4465,6 +4465,13 @@
|
|||
"@types/history" "*"
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react-test-renderer@^17.0.1":
|
||||
version "17.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/react-test-renderer/-/react-test-renderer-17.0.1.tgz#3120f7d1c157fba9df0118dae20cb0297ee0e06b"
|
||||
integrity sha512-3Fi2O6Zzq/f3QR9dRnlnHso9bMl7weKCviFmfF6B4LS1Uat6Hkm15k0ZAQuDz+UBq6B3+g+NM6IT2nr5QgPzCw==
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react@*", "@types/react@^17.0.2":
|
||||
version "17.0.24"
|
||||
resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.24.tgz#7e1b3f78d0fc53782543f9bce6d949959a5880bd"
|
||||
|
@ -16741,7 +16748,7 @@ react-is@^16.12.0, react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.1, react-i
|
|||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
|
||||
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
|
||||
|
||||
react-is@^17.0.1:
|
||||
"react-is@^16.12.0 || ^17.0.0", react-is@^17.0.1, react-is@^17.0.2:
|
||||
version "17.0.2"
|
||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0"
|
||||
integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==
|
||||
|
@ -16824,6 +16831,14 @@ react-router@5.2.1, react-router@^5.2.0:
|
|||
tiny-invariant "^1.0.2"
|
||||
tiny-warning "^1.0.0"
|
||||
|
||||
react-shallow-renderer@^16.13.1:
|
||||
version "16.14.1"
|
||||
resolved "https://registry.yarnpkg.com/react-shallow-renderer/-/react-shallow-renderer-16.14.1.tgz#bf0d02df8a519a558fd9b8215442efa5c840e124"
|
||||
integrity sha512-rkIMcQi01/+kxiTE9D3fdS959U1g7gs+/rborw++42m1O9FAQiNI/UNRZExVUoAOprn4umcXf+pFRou8i4zuBg==
|
||||
dependencies:
|
||||
object-assign "^4.1.1"
|
||||
react-is "^16.12.0 || ^17.0.0"
|
||||
|
||||
react-side-effect@^2.1.0:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/react-side-effect/-/react-side-effect-2.1.1.tgz#66c5701c3e7560ab4822a4ee2742dee215d72eb3"
|
||||
|
@ -16834,6 +16849,16 @@ react-simple-code-editor@^0.10.0:
|
|||
resolved "https://registry.yarnpkg.com/react-simple-code-editor/-/react-simple-code-editor-0.10.0.tgz#73e7ac550a928069715482aeb33ccba36efe2373"
|
||||
integrity sha512-bL5W5mAxSW6+cLwqqVWY47Silqgy2DKDTR4hDBrLrUqC5BXc29YVx17l2IZk5v36VcDEq1Bszu2oHm1qBwKqBA==
|
||||
|
||||
react-test-renderer@^17.0.2:
|
||||
version "17.0.2"
|
||||
resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-17.0.2.tgz#4cd4ae5ef1ad5670fc0ef776e8cc7e1231d9866c"
|
||||
integrity sha512-yaQ9cB89c17PUb0x6UfWRs7kQCorVdHlutU1boVPEsB8IDZH6n9tHxMacc3y0JoXOJUsZb/t/Mb8FUWMKaM7iQ==
|
||||
dependencies:
|
||||
object-assign "^4.1.1"
|
||||
react-is "^17.0.2"
|
||||
react-shallow-renderer "^16.13.1"
|
||||
scheduler "^0.20.2"
|
||||
|
||||
react-textarea-autosize@^8.3.2:
|
||||
version "8.3.3"
|
||||
resolved "https://registry.yarnpkg.com/react-textarea-autosize/-/react-textarea-autosize-8.3.3.tgz#f70913945369da453fd554c168f6baacd1fa04d8"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue