diff --git a/.umirc.ts b/.umirc.ts deleted file mode 100644 index 19135896..00000000 --- a/.umirc.ts +++ /dev/null @@ -1,11 +0,0 @@ -// more config: https://d.umijs.org/config -import { defineConfig } from 'dumi'; - -export default defineConfig({ - title: 'rc-collapse', - favicon: 'https://avatars0.githubusercontent.com/u/9441414?s=200&v=4', - logo: 'https://avatars0.githubusercontent.com/u/9441414?s=200&v=4', - outputPath: '.doc', - exportStatic: {}, - styles: [], -}); diff --git a/README.md b/README.md index d712a747..898e7272 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,14 @@ ReactDOM.render(App, container); - specify whether the panel of children is collapsible or the area of collapsible. + + items + + interface.ts#ItemType + + - + collapse items content + @@ -116,6 +124,8 @@ If `accordion` is true, only one panel can be open. Opening another panel will c ### Collapse.Panel props +> **deprecated** use `items` instead, will be removed in `v4.0.0` + diff --git a/docs/demo/basic.md b/docs/demo/basic.md new file mode 100644 index 00000000..acad05fc --- /dev/null +++ b/docs/demo/basic.md @@ -0,0 +1,8 @@ +--- +title: Basic +nav: + title: Demo + path: /demo +--- + + diff --git a/docs/examples/basic.tsx b/docs/examples/basic.tsx new file mode 100644 index 00000000..4b456378 --- /dev/null +++ b/docs/examples/basic.tsx @@ -0,0 +1,27 @@ +import type { CollapseProps } from 'rc-collapse'; +import Collapse from 'rc-collapse'; +import * as React from 'react'; +import '../../assets/index.less'; + +const App = () => { + const items: CollapseProps['items'] = [ + { + label: 'title', + children: 'content', + }, + { + label: 'title 2', + children: 'content 2', + collapsible: 'disabled', + }, + { + label: 'title 3', + children: 'content 3', + onItemClick: console.log, + }, + ]; + + return ; +}; + +export default App; diff --git a/src/Collapse.tsx b/src/Collapse.tsx index 1c51e11c..36267d29 100644 --- a/src/Collapse.tsx +++ b/src/Collapse.tsx @@ -1,8 +1,9 @@ import classNames from 'classnames'; -import toArray from 'rc-util/lib/Children/toArray'; import useMergedState from 'rc-util/lib/hooks/useMergedState'; +import warning from 'rc-util/lib/warning'; import React from 'react'; -import type { CollapsePanelProps, CollapseProps, CollapsibleType } from './interface'; +import useItems from './hooks/useItems'; +import type { CollapseProps } from './interface'; import CollapsePanel from './Panel'; function getActiveKeysArray(activeKey: React.Key | React.Key[]) { @@ -22,13 +23,14 @@ const Collapse = React.forwardRef((props, ref) => style, accordion, className, - children: rawChildren, + children, collapsible, openMotion, expandIcon, activeKey: rawActiveKey, defaultActiveKey, onChange, + items, } = props; const collapseClassName = classNames(prefixCls, className); @@ -40,7 +42,7 @@ const Collapse = React.forwardRef((props, ref) => postState: getActiveKeysArray, }); - const onClickItem = (key: React.Key) => + const onItemClick = (key: React.Key) => setActiveKey(() => { if (accordion) { return activeKey[0] === key ? [] : [key]; @@ -56,65 +58,21 @@ const Collapse = React.forwardRef((props, ref) => }); // ======================== Children ======================== - const getNewChild = (child: React.ReactElement, index: number) => { - if (!child) return null; - - const key = child.key || String(index); - - const { - header, - headerClass, - destroyInactivePanel: childDestroyInactivePanel, - collapsible: childCollapsible, - onItemClick: childOnItemClick, - } = child.props; - - let isActive = false; - if (accordion) { - isActive = activeKey[0] === key; - } else { - isActive = activeKey.indexOf(key) > -1; - } - - const mergeCollapsible: CollapsibleType = childCollapsible ?? collapsible; - - const handleItemClick = (value: React.Key) => { - if (mergeCollapsible === 'disabled') return; - onClickItem(value); - childOnItemClick?.(value); - }; - - const childProps = { - key, - panelKey: key, - header, - headerClass, - isActive, - prefixCls, - destroyInactivePanel: childDestroyInactivePanel ?? destroyInactivePanel, - openMotion, - accordion, - children: child.props.children, - onItemClick: handleItemClick, - expandIcon, - collapsible: mergeCollapsible, - }; - - // https://github.com/ant-design/ant-design/issues/20479 - if (typeof child.type === 'string') { - return child; - } - - Object.keys(childProps).forEach((propName) => { - if (typeof childProps[propName] === 'undefined') { - delete childProps[propName]; - } - }); - - return React.cloneElement(child, childProps); - }; + warning( + !children, + '`children` will be removed in next major version. Please use `items` instead.', + ); - const children = toArray(rawChildren).map(getNewChild); + const mergedChildren = useItems(items, children, { + prefixCls, + accordion, + openMotion, + expandIcon, + collapsible, + destroyInactivePanel, + onItemClick, + activeKey, + }); // ======================== Render ======================== return ( @@ -124,9 +82,14 @@ const Collapse = React.forwardRef((props, ref) => style={style} role={accordion ? 'tablist' : undefined} > - {children} + {mergedChildren} ); }); -export default Object.assign(Collapse, { Panel: CollapsePanel }); +export default Object.assign(Collapse, { + /** + * @deprecated use `items` instead, will be removed in `v4.0.0` + */ + Panel: CollapsePanel, +}); diff --git a/src/hooks/useItems.tsx b/src/hooks/useItems.tsx new file mode 100644 index 00000000..2bc5db7b --- /dev/null +++ b/src/hooks/useItems.tsx @@ -0,0 +1,162 @@ +import toArray from 'rc-util/lib/Children/toArray'; +import React from 'react'; +import type { CollapsePanelProps, CollapseProps, ItemType } from '../interface'; +import CollapsePanel from '../Panel'; + +type Props = Pick & + Pick & { + activeKey: React.Key[]; + }; + +const convertItemsToNodes = (items: ItemType[], props: Props) => { + const { + prefixCls, + accordion, + collapsible, + destroyInactivePanel, + onItemClick, + activeKey, + openMotion, + expandIcon, + } = props; + + return items.map((item, index) => { + const { + children, + label, + key: rawKey, + collapsible: rawCollapsible, + onItemClick: rawOnItemClick, + destroyInactivePanel: rawDestroyInactivePanel, + ...restProps + } = item; + + // You may be puzzled why you want to convert them all into strings, me too. + // Maybe: https://github.com/react-component/collapse/blob/aac303a8b6ff30e35060b4f8fecde6f4556fcbe2/src/Collapse.tsx#L15 + const key = String(rawKey ?? index); + const mergeCollapsible = rawCollapsible ?? collapsible; + const mergeDestroyInactivePanel = rawDestroyInactivePanel ?? destroyInactivePanel; + + const handleItemClick = (value: React.Key) => { + if (mergeCollapsible === 'disabled') return; + onItemClick(value); + rawOnItemClick?.(value); + }; + + let isActive = false; + if (accordion) { + isActive = activeKey[0] === key; + } else { + isActive = activeKey.indexOf(key) > -1; + } + + return ( + + {children} + + ); + }); +}; + +/** + * @deprecated The next major version will be removed + */ +const getNewChild = ( + child: React.ReactElement, + index: number, + props: Props, +) => { + if (!child) return null; + + const { + prefixCls, + accordion, + collapsible, + destroyInactivePanel, + onItemClick, + activeKey, + openMotion, + expandIcon, + } = props; + + const key = child.key || String(index); + + const { + header, + headerClass, + destroyInactivePanel: childDestroyInactivePanel, + collapsible: childCollapsible, + onItemClick: childOnItemClick, + } = child.props; + + let isActive = false; + if (accordion) { + isActive = activeKey[0] === key; + } else { + isActive = activeKey.indexOf(key) > -1; + } + + const mergeCollapsible = childCollapsible ?? collapsible; + + const handleItemClick = (value: React.Key) => { + if (mergeCollapsible === 'disabled') return; + onItemClick(value); + childOnItemClick?.(value); + }; + + const childProps = { + key, + panelKey: key, + header, + headerClass, + isActive, + prefixCls, + destroyInactivePanel: childDestroyInactivePanel ?? destroyInactivePanel, + openMotion, + accordion, + children: child.props.children, + onItemClick: handleItemClick, + expandIcon, + collapsible: mergeCollapsible, + }; + + // https://github.com/ant-design/ant-design/issues/20479 + if (typeof child.type === 'string') { + return child; + } + + Object.keys(childProps).forEach((propName) => { + if (typeof childProps[propName] === 'undefined') { + delete childProps[propName]; + } + }); + + return React.cloneElement(child, childProps); +}; + +function useItems( + items?: ItemType[], + rawChildren?: React.ReactNode, + props?: Props, +): React.ReactElement[] { + if (Array.isArray(items)) { + return convertItemsToNodes(items, props); + } + + return toArray(rawChildren).map((child, index) => getNewChild(child, index, props)); +} + +export default useItems; diff --git a/src/index.tsx b/src/index.tsx index 8cda2ed2..8e963c68 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,4 +3,8 @@ import Collapse from './Collapse'; export type { CollapsePanelProps, CollapseProps } from './interface'; export default Collapse; + +/** + * @deprecated use `items` instead, will be removed in `v4.0.0` + */ export const { Panel } = Collapse; diff --git a/src/interface.ts b/src/interface.ts index 84540e54..838446a0 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -3,6 +3,22 @@ import type * as React from 'react'; export type CollapsibleType = 'header' | 'icon' | 'disabled'; +export interface ItemType + extends Omit< + CollapsePanelProps, + | 'header' // alias of label + | 'prefixCls' + | 'panelKey' // alias of key + | 'isActive' + | 'accordion' + | 'openMotion' + | 'expandIcon' + > { + key?: CollapsePanelProps['panelKey']; + label?: CollapsePanelProps['header']; + ref?: React.RefObject; +} + export interface CollapseProps { prefixCls?: string; activeKey?: React.Key | React.Key[]; @@ -16,6 +32,11 @@ export interface CollapseProps { expandIcon?: (props: object) => React.ReactNode; collapsible?: CollapsibleType; children?: React.ReactNode; + /** + * Collapse items content + * @since 3.6.0 + */ + items?: ItemType[]; } export interface CollapsePanelProps extends React.DOMAttributes { diff --git a/tests/__snapshots__/index.spec.tsx.snap b/tests/__snapshots__/index.spec.tsx.snap new file mode 100644 index 00000000..ddbe32ae --- /dev/null +++ b/tests/__snapshots__/index.spec.tsx.snap @@ -0,0 +1,111 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`collapse props items should work with nested 1`] = ` +
+
+ +
+
+ +
+
+ +
+
+ +
+
+`; diff --git a/tests/index.spec.tsx b/tests/index.spec.tsx index 295e7a2c..533df08b 100644 --- a/tests/index.spec.tsx +++ b/tests/index.spec.tsx @@ -3,7 +3,7 @@ import { fireEvent, render } from '@testing-library/react'; import KeyCode from 'rc-util/lib/KeyCode'; import React, { Fragment } from 'react'; import Collapse, { Panel } from '../src/index'; -import type { CollapseProps } from '../src/interface'; +import type { CollapseProps, ItemType } from '../src/interface'; describe('collapse', () => { let changeHook: jest.Mock | null; @@ -243,23 +243,15 @@ describe('collapse', () => { expect(container.querySelectorAll('.rc-collapse-content-inactive').length).toBeFalsy(); }); - describe('prop: accordion', () => { + function runAccordionTest(element: React.ReactElement) { let collapse: RenderResult; beforeEach(() => { - collapse = render( - - - first - - - second - - - third - - , - ); + collapse = render(element); + }); + + afterEach(() => { + collapse.unmount(); }); it('accordion content, should default open zero item', () => { @@ -317,6 +309,22 @@ describe('collapse', () => { expect(item).toBeTruthy(); expect(item!.getAttribute('role')).toBe('tabpanel'); }); + } + + describe('prop: accordion', () => { + runAccordionTest( + + + first + + + second + + + third + + , + ); }); describe('forceRender', () => { @@ -690,4 +698,132 @@ describe('collapse', () => { ); expect(container.querySelector('.rc-collapse-item').style.color).toBe('red'); }); + + describe('props items', () => { + const items: ItemType[] = [ + { + key: '1', + label: 'collapse 1', + children: 'first', + collapsible: 'disabled', + }, + { + key: '2', + label: 'collapse 2', + children: 'second', + extra: ExtraSpan, + }, + { + key: '3', + label: 'collapse 3', + className: 'important', + children: 'third', + }, + ]; + + runNormalTest( + test{'>'}} items={items} />, + ); + + runAccordionTest( + , + ); + + it('should work with onItemClick', () => { + const onItemClick = jest.fn(); + const { container } = render( + , + ); + fireEvent.click(container.querySelector('.rc-collapse-header')); + expect(onItemClick).toHaveBeenCalled(); + expect(onItemClick).lastCalledWith('0'); + }); + + it('should work with collapsible', () => { + const onItemClick = jest.fn(); + const onChangeFn = jest.fn(); + const { container } = render( + , + ); + + fireEvent.click(container.querySelector('.rc-collapse-header')); + expect(onItemClick).not.toHaveBeenCalled(); + + fireEvent.click( + container.querySelector('.rc-collapse-item:nth-child(2) .rc-collapse-expand-icon'), + ); + expect(onItemClick).toHaveBeenCalled(); + expect(onChangeFn).toBeCalledTimes(1); + expect(onChangeFn).lastCalledWith(['1']); + }); + + it('should work with nested', () => { + const { container } = render( + , + }, + ]} + />, + ); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('should not support expandIcon', () => { + const { container } = render( + p} + items={[ + { + label: 'title', + expandIcon: () => c, + } as any, + ]} + />, + ); + + expect(container.querySelectorAll('.custom-icon')).toHaveLength(1); + expect(container.querySelector('.custom-icon')?.innerHTML).toBe('p'); + }); + }); });