item.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410
  1. import React, { PureComponent, ReactNode } from 'react';
  2. import cls from 'classnames';
  3. import PropTypes from 'prop-types';
  4. import { cssClasses, strings } from '@douyinfe/semi-foundation/cascader/constants';
  5. import isEnterPress from '@douyinfe/semi-foundation/utils/isEnterPress';
  6. import { includes } from 'lodash';
  7. import ConfigContext, { ContextValue } from '../configProvider/context';
  8. import LocaleConsumer from '../locale/localeConsumer';
  9. import { IconChevronRight, IconTick } from '@douyinfe/semi-icons';
  10. import { Locale } from '../locale/interface';
  11. import Spin from '../spin';
  12. import Checkbox, { CheckboxEvent } from '../checkbox';
  13. import {
  14. BasicCascaderData,
  15. BasicEntity,
  16. ShowNextType,
  17. BasicData,
  18. Virtualize
  19. } from '@douyinfe/semi-foundation/cascader/foundation';
  20. import { FixedSizeList as List } from 'react-window';
  21. import VirtualRow from './virtualRow';
  22. export interface CascaderData extends BasicCascaderData {
  23. label: React.ReactNode
  24. }
  25. export interface Entity extends BasicEntity {
  26. /* children list */
  27. children?: Array<Entity>;
  28. /* treedata */
  29. data: CascaderData;
  30. /* parent data */
  31. parent?: Entity
  32. }
  33. export interface Entities {
  34. [idx: string]: Entity
  35. }
  36. export interface Data extends BasicData {
  37. data: CascaderData;
  38. searchText: React.ReactNode[]
  39. }
  40. export interface FilterRenderProps {
  41. className: string;
  42. inputValue: string;
  43. disabled: boolean;
  44. data: CascaderData[];
  45. checkStatus: {
  46. checked: boolean;
  47. halfChecked: boolean
  48. };
  49. selected: boolean;
  50. onClick: (e: React.MouseEvent) => void;
  51. onCheck: (e: React.MouseEvent) => void
  52. }
  53. export interface CascaderItemProps {
  54. activeKeys: Set<string>;
  55. selectedKeys: Set<string>;
  56. loadedKeys: Set<string>;
  57. loadingKeys: Set<string>;
  58. onItemClick: (e: React.MouseEvent | React.KeyboardEvent, item: Entity | Data) => void;
  59. onItemHover: (e: React.MouseEvent, item: Entity) => void;
  60. showNext: ShowNextType;
  61. onItemCheckboxClick: (item: Entity | Data) => void;
  62. onListScroll: (e: React.UIEvent<HTMLUListElement, UIEvent>, ind: number) => void;
  63. searchable: boolean;
  64. keyword: string;
  65. empty: boolean;
  66. emptyContent: React.ReactNode;
  67. loadData: (selectOptions: CascaderData[]) => Promise<void>;
  68. data: Array<Data | Entity>;
  69. separator: string;
  70. multiple: boolean;
  71. checkedKeys: Set<string>;
  72. halfCheckedKeys: Set<string>;
  73. filterRender?: (props: FilterRenderProps) => ReactNode;
  74. virtualize?: Virtualize;
  75. expandIcon?: ReactNode;
  76. }
  77. const prefixcls = cssClasses.PREFIX_OPTION;
  78. export default class Item extends PureComponent<CascaderItemProps> {
  79. static contextType = ConfigContext;
  80. static propTypes = {
  81. data: PropTypes.array,
  82. emptyContent: PropTypes.node,
  83. searchable: PropTypes.bool,
  84. onItemClick: PropTypes.func,
  85. onItemHover: PropTypes.func,
  86. multiple: PropTypes.bool,
  87. showNext: PropTypes.oneOf([strings.SHOW_NEXT_BY_CLICK, strings.SHOW_NEXT_BY_HOVER]),
  88. checkedKeys: PropTypes.object,
  89. halfCheckedKeys: PropTypes.object,
  90. onItemCheckboxClick: PropTypes.func,
  91. separator: PropTypes.string,
  92. keyword: PropTypes.string,
  93. virtualize: PropTypes.object,
  94. expandIcon: PropTypes.node,
  95. };
  96. static defaultProps = {
  97. empty: false,
  98. };
  99. context: ContextValue;
  100. onClick = (e: React.MouseEvent | React.KeyboardEvent, item: Entity | Data) => {
  101. const { onItemClick } = this.props;
  102. if (item.data.disabled || ('disabled' in item && item.disabled)) {
  103. return;
  104. }
  105. onItemClick(e, item);
  106. };
  107. /**
  108. * A11y: simulate item click
  109. */
  110. handleItemEnterPress = (keyboardEvent: React.KeyboardEvent, item: Entity | Data) => {
  111. if (isEnterPress(keyboardEvent)) {
  112. this.onClick(keyboardEvent, item);
  113. }
  114. }
  115. onHover = (e: React.MouseEvent, item: Entity) => {
  116. const { showNext, onItemHover } = this.props;
  117. if (item.data.disabled) {
  118. return;
  119. }
  120. if (showNext === strings.SHOW_NEXT_BY_HOVER) {
  121. onItemHover(e, item);
  122. }
  123. };
  124. onCheckboxChange = (e: CheckboxEvent, item: Entity | Data) => {
  125. const { onItemCheckboxClick } = this.props;
  126. // Prevent Checkbox's click event bubbling to trigger the li click event
  127. e.stopPropagation();
  128. if (e.nativeEvent && typeof e.nativeEvent.stopImmediatePropagation === 'function') {
  129. e.nativeEvent.stopImmediatePropagation();
  130. }
  131. onItemCheckboxClick(item);
  132. };
  133. getItemStatus = (key: string) => {
  134. const { activeKeys, selectedKeys, loadedKeys, loadingKeys } = this.props;
  135. const state = { active: false, selected: false, loading: false };
  136. if (activeKeys.has(key)) {
  137. state.active = true;
  138. }
  139. if (selectedKeys.has(key)) {
  140. state.selected = true;
  141. }
  142. if (loadingKeys.has(key) && !loadedKeys.has(key)) {
  143. state.loading = true;
  144. }
  145. return state;
  146. };
  147. renderIcon = (type: string, haveMarginLeft = false) => {
  148. const finalCls = (style: string) => {
  149. return style + (haveMarginLeft ? ` ${prefixcls}-icon-left` : '');
  150. };
  151. switch (type) {
  152. case 'child':
  153. const { expandIcon } = this.props;
  154. if (expandIcon) {
  155. return expandIcon;
  156. }
  157. return (<IconChevronRight className={finalCls(`${prefixcls}-icon ${prefixcls}-icon-expand`)} />);
  158. case 'tick':
  159. return (<IconTick className={finalCls(`${prefixcls}-icon ${prefixcls}-icon-active`)} />);
  160. case 'loading':
  161. return <Spin wrapperClassName={finalCls(`${prefixcls}-spin-icon`)} />;
  162. case 'empty':
  163. return (<span aria-hidden={true} className={finalCls(`${prefixcls}-icon ${prefixcls}-icon-empty`)} />);
  164. default:
  165. return null;
  166. }
  167. };
  168. highlight = (searchText: React.ReactNode[]) => {
  169. const content: React.ReactNode[] = [];
  170. const { keyword, separator } = this.props;
  171. searchText.forEach((item, idx) => {
  172. if (typeof item === 'string' && includes(item, keyword)) {
  173. item.split(keyword).forEach((node, index) => {
  174. if (index > 0) {
  175. content.push(
  176. <span className={`${prefixcls}-label-highlight`} key={`${index}-${idx}`}>
  177. {keyword}
  178. </span>
  179. );
  180. }
  181. content.push(node);
  182. });
  183. } else {
  184. content.push(item);
  185. }
  186. if (idx !== searchText.length - 1) {
  187. content.push(separator);
  188. }
  189. });
  190. return content;
  191. };
  192. renderFlattenOptionItem = (data: Data, index?: number, style?: any) => {
  193. const { multiple, selectedKeys, checkedKeys, halfCheckedKeys, keyword, filterRender, virtualize } = this.props;
  194. const { searchText, key, disabled, pathData } = data;
  195. const selected = selectedKeys.has(key);
  196. const className = cls(prefixcls, {
  197. [`${prefixcls}-flatten`]: true && !filterRender,
  198. [`${prefixcls}-disabled`]: disabled,
  199. [`${prefixcls}-select`]: selected && !multiple,
  200. });
  201. const onClick = e => {
  202. this.onClick(e, data);
  203. };
  204. const onKeyPress = e => this.handleItemEnterPress(e, data);
  205. const onCheck = (e: CheckboxEvent) => this.onCheckboxChange(e, data);
  206. if (filterRender) {
  207. const props = {
  208. className,
  209. inputValue: keyword,
  210. disabled,
  211. data: pathData,
  212. checkStatus: {
  213. checked: checkedKeys.has(data.key),
  214. halfChecked: halfCheckedKeys.has(data.key),
  215. },
  216. selected,
  217. onClick,
  218. onCheck
  219. };
  220. const item = filterRender(props) as any;
  221. const otherProps = virtualize ? {
  222. key,
  223. style: {
  224. ...(item.props.style ?? {}),
  225. ...style
  226. },
  227. } : { key };
  228. return React.cloneElement(item, otherProps );
  229. }
  230. return (
  231. <li
  232. role='menuitem'
  233. className={className}
  234. style={style}
  235. key={key}
  236. onClick={onClick}
  237. onKeyPress={onKeyPress}
  238. >
  239. <span className={`${prefixcls}-label`}>
  240. {!multiple && this.renderIcon('empty')}
  241. {multiple && (
  242. <Checkbox
  243. onChange={onCheck}
  244. disabled={disabled}
  245. indeterminate={halfCheckedKeys.has(data.key)}
  246. checked={checkedKeys.has(data.key)}
  247. className={`${prefixcls}-label-checkbox`}
  248. />
  249. )}
  250. {this.highlight(searchText)}
  251. </span>
  252. </li>
  253. );
  254. }
  255. renderFlattenOption = (data: Data[]) => {
  256. const { virtualize } = this.props;
  257. const content = (
  258. <ul className={`${prefixcls}-list`} key={'flatten-list'}>
  259. {virtualize ? this.renderVirtualizeList(data) : data.map(item => this.renderFlattenOptionItem(item))}
  260. </ul>
  261. );
  262. return content;
  263. };
  264. renderVirtualizeList = (visibleOptions: any) => {
  265. const { direction } = this.context;
  266. const { virtualize } = this.props;
  267. return (
  268. <List
  269. height={virtualize.height}
  270. itemCount={visibleOptions.length}
  271. itemSize={virtualize.itemSize}
  272. itemData={{ visibleOptions, renderOption: this.renderFlattenOptionItem }}
  273. width={virtualize.width ?? '100%'}
  274. style={{ direction }}
  275. >
  276. {VirtualRow}
  277. </List>
  278. );
  279. }
  280. renderItem(renderData: Array<Entity>, content: Array<React.ReactNode> = []) {
  281. const { multiple, checkedKeys, halfCheckedKeys } = this.props;
  282. let showChildItem: Entity;
  283. const ind = content.length;
  284. content.push(
  285. <ul role='menu' className={`${prefixcls}-list`} key={renderData[0].key} onScroll={e => this.props.onListScroll(e, ind)}>
  286. {renderData.map(item => {
  287. const { data, key, parentKey } = item;
  288. const { children, label, disabled, isLeaf } = data;
  289. const { active, selected, loading } = this.getItemStatus(key);
  290. const hasChild = Boolean(children) && children.length;
  291. const showExpand = hasChild || (this.props.loadData && !isLeaf);
  292. if (active && hasChild) {
  293. showChildItem = item;
  294. }
  295. const className = cls(prefixcls, {
  296. [`${prefixcls}-active`]: active && !selected,
  297. [`${prefixcls}-select`]: selected && !multiple,
  298. [`${prefixcls}-disabled`]: disabled
  299. });
  300. const otherAriaProps = parentKey ? { ['aria-owns']: `cascaderItem-${parentKey}` } : {};
  301. return (
  302. <li
  303. role='menuitem'
  304. id={`cascaderItem-${key}`}
  305. aria-expanded={active}
  306. aria-haspopup={Boolean(showExpand)}
  307. aria-disabled={disabled}
  308. {...otherAriaProps}
  309. className={className}
  310. key={key}
  311. onClick={e => {
  312. this.onClick(e, item);
  313. }}
  314. onKeyPress={e => this.handleItemEnterPress(e, item)}
  315. onMouseEnter={e => {
  316. this.onHover(e, item);
  317. }}
  318. >
  319. <span className={`${prefixcls}-label`}>
  320. {selected && !multiple && this.renderIcon('tick')}
  321. {!selected && !multiple && this.renderIcon('empty')}
  322. {multiple && (
  323. <Checkbox
  324. onChange={(e: CheckboxEvent) => this.onCheckboxChange(e, item)}
  325. disabled={disabled}
  326. indeterminate={halfCheckedKeys.has(item.key)}
  327. checked={checkedKeys.has(item.key)}
  328. className={`${prefixcls}-label-checkbox`}
  329. />
  330. )}
  331. <span>{label}</span>
  332. </span>
  333. {showExpand ? this.renderIcon(loading ? 'loading' : 'child', true) : null}
  334. </li>
  335. );
  336. })}
  337. </ul>
  338. );
  339. if (showChildItem) {
  340. content.concat(this.renderItem(showChildItem.children, content));
  341. }
  342. return content;
  343. }
  344. renderEmpty() {
  345. const { emptyContent } = this.props;
  346. if (emptyContent === null) {
  347. return null;
  348. }
  349. return (
  350. <LocaleConsumer componentName="Cascader">
  351. {(locale: Locale['Cascader']) => (
  352. <ul className={`${prefixcls} ${prefixcls}-empty`} key={'empty-list'}>
  353. <span className={`${prefixcls}-label`} x-semi-prop="emptyContent">
  354. {emptyContent || locale.emptyText}
  355. </span>
  356. </ul>
  357. )}
  358. </LocaleConsumer>
  359. );
  360. }
  361. render() {
  362. const { data, searchable } = this.props;
  363. const { direction } = this.context;
  364. const isEmpty = !data || !data.length;
  365. let content;
  366. const listsCls = cls({
  367. [`${prefixcls}-lists`]: true,
  368. [`${prefixcls}-lists-rtl`]: direction === 'rtl',
  369. [`${prefixcls}-lists-empty`]: isEmpty,
  370. });
  371. if (isEmpty) {
  372. content = this.renderEmpty();
  373. } else {
  374. content = searchable ?
  375. this.renderFlattenOption(data as Data[]) :
  376. this.renderItem(data as Entity[]);
  377. }
  378. return (
  379. <div className={listsCls}>
  380. {content}
  381. </div>
  382. );
  383. }
  384. }