item.tsx 15 KB

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