item.tsx 14 KB

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