index.tsx 39 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000
  1. import React, { Fragment, ReactNode, CSSProperties, MouseEvent, KeyboardEvent } from 'react';
  2. import ReactDOM from 'react-dom';
  3. import cls from 'classnames';
  4. import PropTypes from 'prop-types';
  5. import CascaderFoundation, {
  6. /* Corresponding to the state of react */
  7. BasicCascaderInnerData,
  8. /* Corresponding to the props of react */
  9. BasicCascaderProps,
  10. BasicTriggerRenderProps,
  11. BasicScrollPanelProps,
  12. CascaderAdapter,
  13. CascaderType
  14. } from '@douyinfe/semi-foundation/cascader/foundation';
  15. import { cssClasses, strings } from '@douyinfe/semi-foundation/cascader/constants';
  16. import { numbers as popoverNumbers } from '@douyinfe/semi-foundation/popover/constants';
  17. import { isSet, isEqual, isString, isEmpty, isFunction, isNumber, noop, flatten } from 'lodash';
  18. import '@douyinfe/semi-foundation/cascader/cascader.scss';
  19. import { IconClear, IconChevronDown } from '@douyinfe/semi-icons';
  20. import { findKeysForValues, convertDataToEntities, calcMergeType } from '@douyinfe/semi-foundation/cascader/util';
  21. import { calcCheckedKeys, normalizeKeyList, calcDisabledKeys } from '@douyinfe/semi-foundation/tree/treeUtil';
  22. import ConfigContext, { ContextValue } from '../configProvider/context';
  23. import BaseComponent, { ValidateStatus } from '../_base/baseComponent';
  24. import Input from '../input/index';
  25. import Popover, { PopoverProps } from '../popover/index';
  26. import Item, { CascaderData, Entities, Entity, Data } from './item';
  27. import Trigger from '../trigger';
  28. import Tag from '../tag';
  29. import TagInput from '../tagInput';
  30. import { isSemiIcon } from '../_utils';
  31. import { Position } from '../tooltip/index';
  32. export type { CascaderType, ShowNextType } from '@douyinfe/semi-foundation/cascader/foundation';
  33. export type { CascaderData, Entity, Data, CascaderItemProps } from './item';
  34. export interface ScrollPanelProps extends BasicScrollPanelProps {
  35. activeNode: CascaderData
  36. }
  37. export interface TriggerRenderProps extends BasicTriggerRenderProps {
  38. componentProps: CascaderProps;
  39. onClear: (e: React.MouseEvent) => void
  40. }
  41. /* The basic type of the value of Cascader */
  42. export type SimpleValueType = string | number | CascaderData;
  43. /* The value of Cascader */
  44. export type Value = SimpleValueType | Array<SimpleValueType> | Array<Array<SimpleValueType>>;
  45. export interface CascaderProps extends BasicCascaderProps {
  46. 'aria-describedby'?: React.AriaAttributes['aria-describedby'];
  47. 'aria-errormessage'?: React.AriaAttributes['aria-errormessage'];
  48. 'aria-invalid'?: React.AriaAttributes['aria-invalid'];
  49. 'aria-labelledby'?: React.AriaAttributes['aria-labelledby'];
  50. 'aria-required'?: React.AriaAttributes['aria-required'];
  51. 'aria-label'?: React.AriaAttributes['aria-label'];
  52. arrowIcon?: ReactNode;
  53. defaultValue?: Value;
  54. dropdownStyle?: CSSProperties;
  55. emptyContent?: ReactNode;
  56. motion?: boolean;
  57. treeData?: Array<CascaderData>;
  58. restTagsPopoverProps?: PopoverProps;
  59. children?: React.ReactNode;
  60. value?: Value;
  61. prefix?: ReactNode;
  62. suffix?: ReactNode;
  63. id?: string;
  64. insetLabel?: ReactNode;
  65. insetLabelId?: string;
  66. style?: CSSProperties;
  67. bottomSlot?: ReactNode;
  68. topSlot?: ReactNode;
  69. triggerRender?: (props: TriggerRenderProps) => ReactNode;
  70. onListScroll?: (e: React.UIEvent<HTMLUListElement, UIEvent>, panel: ScrollPanelProps) => void;
  71. loadData?: (selectOptions: CascaderData[]) => Promise<void>;
  72. onLoad?: (newLoadedKeys: Set<string>, data: CascaderData) => void;
  73. onChange?: (value: Value) => void;
  74. onExceed?: (checkedItem: Entity[]) => void;
  75. displayRender?: (selected: Array<string> | Entity, idx?: number) => ReactNode;
  76. onBlur?: (e: MouseEvent) => void;
  77. onFocus?: (e: MouseEvent) => void;
  78. validateStatus?: ValidateStatus;
  79. position?: Position
  80. }
  81. export interface CascaderState extends BasicCascaderInnerData {
  82. keyEntities: Entities;
  83. prevProps: CascaderProps;
  84. treeData?: Array<CascaderData>
  85. }
  86. const prefixcls = cssClasses.PREFIX;
  87. const resetkey = 0;
  88. class Cascader extends BaseComponent<CascaderProps, CascaderState> {
  89. static contextType = ConfigContext;
  90. static propTypes = {
  91. 'aria-labelledby': PropTypes.string,
  92. 'aria-invalid': PropTypes.bool,
  93. 'aria-errormessage': PropTypes.string,
  94. 'aria-describedby': PropTypes.string,
  95. 'aria-required': PropTypes.bool,
  96. 'aria-label': PropTypes.string,
  97. arrowIcon: PropTypes.node,
  98. changeOnSelect: PropTypes.bool,
  99. defaultValue: PropTypes.oneOfType([PropTypes.string, PropTypes.array]),
  100. disabled: PropTypes.bool,
  101. dropdownClassName: PropTypes.string,
  102. dropdownStyle: PropTypes.object,
  103. emptyContent: PropTypes.node,
  104. motion: PropTypes.bool,
  105. /* show search input, if passed in a function, used as custom filter */
  106. filterTreeNode: PropTypes.oneOfType([PropTypes.func, PropTypes.bool]),
  107. filterLeafOnly: PropTypes.bool,
  108. placeholder: PropTypes.string,
  109. searchPlaceholder: PropTypes.string,
  110. size: PropTypes.oneOf<CascaderType>(strings.SIZE_SET),
  111. style: PropTypes.object,
  112. className: PropTypes.string,
  113. treeData: PropTypes.arrayOf(
  114. PropTypes.shape({
  115. value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
  116. label: PropTypes.any,
  117. })
  118. ),
  119. treeNodeFilterProp: PropTypes.string,
  120. suffix: PropTypes.node,
  121. prefix: PropTypes.node,
  122. insetLabel: PropTypes.node,
  123. insetLabelId: PropTypes.string,
  124. id: PropTypes.string,
  125. displayProp: PropTypes.string,
  126. displayRender: PropTypes.func,
  127. onChange: PropTypes.func,
  128. onSearch: PropTypes.func,
  129. onSelect: PropTypes.func,
  130. onBlur: PropTypes.func,
  131. onFocus: PropTypes.func,
  132. children: PropTypes.node,
  133. getPopupContainer: PropTypes.func,
  134. zIndex: PropTypes.number,
  135. value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.array]),
  136. validateStatus: PropTypes.oneOf<CascaderProps['validateStatus']>(strings.VALIDATE_STATUS),
  137. showNext: PropTypes.oneOf([strings.SHOW_NEXT_BY_CLICK, strings.SHOW_NEXT_BY_HOVER]),
  138. stopPropagation: PropTypes.oneOfType([PropTypes.bool, PropTypes.string]),
  139. showClear: PropTypes.bool,
  140. defaultOpen: PropTypes.bool,
  141. autoAdjustOverflow: PropTypes.bool,
  142. onDropdownVisibleChange: PropTypes.func,
  143. triggerRender: PropTypes.func,
  144. onListScroll: PropTypes.func,
  145. onChangeWithObject: PropTypes.bool,
  146. bottomSlot: PropTypes.node,
  147. topSlot: PropTypes.node,
  148. multiple: PropTypes.bool,
  149. autoMergeValue: PropTypes.bool,
  150. maxTagCount: PropTypes.number,
  151. showRestTagsPopover: PropTypes.bool,
  152. restTagsPopoverProps: PropTypes.object,
  153. max: PropTypes.number,
  154. separator: PropTypes.string,
  155. onExceed: PropTypes.func,
  156. onClear: PropTypes.func,
  157. loadData: PropTypes.func,
  158. onLoad: PropTypes.func,
  159. loadedKeys: PropTypes.array,
  160. disableStrictly: PropTypes.bool,
  161. leafOnly: PropTypes.bool,
  162. enableLeafClick: PropTypes.bool,
  163. preventScroll: PropTypes.bool,
  164. position: PropTypes.string
  165. };
  166. static defaultProps = {
  167. leafOnly: false,
  168. arrowIcon: <IconChevronDown />,
  169. stopPropagation: true,
  170. motion: true,
  171. defaultOpen: false,
  172. zIndex: popoverNumbers.DEFAULT_Z_INDEX,
  173. showClear: false,
  174. autoClearSearchValue: true,
  175. changeOnSelect: false,
  176. disableStrictly: false,
  177. autoMergeValue: true,
  178. multiple: false,
  179. filterTreeNode: false,
  180. filterLeafOnly: true,
  181. showRestTagsPopover: false,
  182. restTagsPopoverProps: {},
  183. separator: ' / ',
  184. size: 'default' as const,
  185. treeNodeFilterProp: 'label' as const,
  186. displayProp: 'label' as const,
  187. treeData: [] as Array<CascaderData>,
  188. showNext: strings.SHOW_NEXT_BY_CLICK,
  189. onExceed: noop,
  190. onClear: noop,
  191. onDropdownVisibleChange: noop,
  192. onListScroll: noop,
  193. enableLeafClick: false,
  194. 'aria-label': 'Cascader',
  195. };
  196. options: any;
  197. isEmpty: boolean;
  198. inputRef: React.RefObject<typeof Input>;
  199. triggerRef: React.RefObject<HTMLDivElement>;
  200. optionsRef: React.RefObject<any>;
  201. clickOutsideHandler: any;
  202. mergeType: string;
  203. context: ContextValue;
  204. constructor(props: CascaderProps) {
  205. super(props);
  206. this.state = {
  207. disabledKeys: new Set(),
  208. isOpen: props.defaultOpen,
  209. /* By changing rePosKey, the dropdown position can be refreshed */
  210. rePosKey: resetkey,
  211. /* A data structure for storing cascader data items */
  212. keyEntities: {},
  213. /* Selected and show tick icon */
  214. selectedKeys: new Set([]),
  215. /* The key of the activated node */
  216. activeKeys: new Set([]),
  217. /* The key of the filtered node */
  218. filteredKeys: new Set([]),
  219. /* Value of input box */
  220. inputValue: '',
  221. /* Is searching */
  222. isSearching: false,
  223. /* The placeholder of input box */
  224. inputPlaceHolder: props.searchPlaceholder || props.placeholder,
  225. /* Cache props */
  226. prevProps: {},
  227. /* Is hovering */
  228. isHovering: false,
  229. /* Key of checked node, when multiple */
  230. checkedKeys: new Set([]),
  231. /* Key of half checked node, when multiple */
  232. halfCheckedKeys: new Set([]),
  233. /* Auto merged checkedKeys or leaf checkedKeys, when multiple */
  234. resolvedCheckedKeys: new Set([]),
  235. /* Keys of loaded item */
  236. loadedKeys: new Set(),
  237. /* Keys of loading item */
  238. loadingKeys: new Set(),
  239. /* Mark whether this rendering has triggered asynchronous loading of data */
  240. loading: false,
  241. showInput: false,
  242. };
  243. this.options = {};
  244. this.isEmpty = false;
  245. this.mergeType = calcMergeType(props.autoMergeValue, props.leafOnly);
  246. this.inputRef = React.createRef();
  247. this.triggerRef = React.createRef();
  248. this.optionsRef = React.createRef();
  249. this.clickOutsideHandler = null;
  250. this.foundation = new CascaderFoundation(this.adapter);
  251. }
  252. get adapter(): CascaderAdapter {
  253. const filterAdapter: Pick<CascaderAdapter, 'updateInputValue' | 'updateInputPlaceHolder' | 'focusInput'> = {
  254. updateInputValue: value => {
  255. this.setState({ inputValue: value });
  256. },
  257. updateInputPlaceHolder: value => {
  258. this.setState({ inputPlaceHolder: value });
  259. },
  260. focusInput: () => {
  261. const { preventScroll } = this.props;
  262. if (this.inputRef && this.inputRef.current) {
  263. // TODO: check the reason
  264. (this.inputRef.current as any).focus({ preventScroll });
  265. }
  266. },
  267. };
  268. const cascaderAdapter: Pick<
  269. CascaderAdapter,
  270. 'registerClickOutsideHandler' | 'unregisterClickOutsideHandler' | 'rePositionDropdown'
  271. > = {
  272. registerClickOutsideHandler: cb => {
  273. const clickOutsideHandler = (e: Event) => {
  274. const optionInstance = this.optionsRef && this.optionsRef.current;
  275. const triggerDom = this.triggerRef && this.triggerRef.current;
  276. const optionsDom = ReactDOM.findDOMNode(optionInstance);
  277. const target = e.target as Element;
  278. if (
  279. optionsDom &&
  280. (!optionsDom.contains(target) || !optionsDom.contains(target.parentNode)) &&
  281. triggerDom &&
  282. !triggerDom.contains(target)
  283. ) {
  284. cb(e);
  285. }
  286. };
  287. this.clickOutsideHandler = clickOutsideHandler;
  288. document.addEventListener('mousedown', clickOutsideHandler, false);
  289. },
  290. unregisterClickOutsideHandler: () => {
  291. document.removeEventListener('mousedown', this.clickOutsideHandler, false);
  292. },
  293. rePositionDropdown: () => {
  294. let { rePosKey } = this.state;
  295. rePosKey = rePosKey + 1;
  296. this.setState({ rePosKey });
  297. },
  298. };
  299. return {
  300. ...super.adapter,
  301. ...filterAdapter,
  302. ...cascaderAdapter,
  303. updateStates: states => {
  304. this.setState({ ...states } as CascaderState);
  305. },
  306. openMenu: () => {
  307. this.setState({ isOpen: true });
  308. },
  309. closeMenu: cb => {
  310. this.setState({ isOpen: false }, () => {
  311. cb && cb();
  312. });
  313. },
  314. updateSelection: selectedKeys => this.setState({ selectedKeys }),
  315. notifyChange: value => {
  316. this.props.onChange && this.props.onChange(value);
  317. },
  318. notifySelect: selected => {
  319. this.props.onSelect && this.props.onSelect(selected);
  320. },
  321. notifyOnSearch: input => {
  322. this.props.onSearch && this.props.onSearch(input);
  323. },
  324. notifyFocus: (...v) => {
  325. this.props.onFocus && this.props.onFocus(...v);
  326. },
  327. notifyBlur: (...v) => {
  328. this.props.onBlur && this.props.onBlur(...v);
  329. },
  330. notifyDropdownVisibleChange: visible => {
  331. this.props.onDropdownVisibleChange(visible);
  332. },
  333. toggleHovering: bool => {
  334. this.setState({ isHovering: bool });
  335. },
  336. notifyLoadData: (selectedOpt, callback) => {
  337. const { loadData } = this.props;
  338. if (loadData) {
  339. new Promise<void>(resolve => {
  340. loadData(selectedOpt).then(() => {
  341. callback();
  342. this.setState({ loading: false });
  343. resolve();
  344. });
  345. });
  346. }
  347. },
  348. notifyOnLoad: (newLoadedKeys, data) => {
  349. const { onLoad } = this.props;
  350. onLoad && onLoad(newLoadedKeys, data);
  351. },
  352. notifyListScroll: (e, { panelIndex, activeNode }) => {
  353. this.props.onListScroll(e, { panelIndex, activeNode });
  354. },
  355. notifyOnExceed: data => this.props.onExceed(data),
  356. notifyClear: () => this.props.onClear(),
  357. toggleInputShow: (showInput: boolean, cb: (...args: any) => void) => {
  358. this.setState({ showInput }, () => {
  359. cb();
  360. });
  361. },
  362. updateFocusState: (isFocus: boolean) => {
  363. this.setState({ isFocus });
  364. },
  365. };
  366. }
  367. static getDerivedStateFromProps(props: CascaderProps, prevState: CascaderState) {
  368. const { multiple, value, defaultValue, onChangeWithObject, leafOnly, autoMergeValue } = props;
  369. const { prevProps } = prevState;
  370. let keyEntities = prevState.keyEntities || {};
  371. const newState: Partial<CascaderState> = {};
  372. const needUpdate = (name: string) => {
  373. const firstInProps = isEmpty(prevProps) && name in props;
  374. const nameHasChange = prevProps && !isEqual(prevProps[name], props[name]);
  375. return firstInProps || nameHasChange;
  376. };
  377. const needUpdateData = () => {
  378. const firstInProps = !prevProps && 'treeData' in props;
  379. const treeDataHasChange = prevProps && prevProps.treeData !== props.treeData;
  380. return firstInProps || treeDataHasChange;
  381. };
  382. const getRealKeys = (realValue: Value, keyEntities: Entities) => {
  383. // normallizedValue is used to save the value in two-dimensional array format
  384. let normallizedValue: SimpleValueType[][] = [];
  385. if (Array.isArray(realValue)) {
  386. normallizedValue = Array.isArray(realValue[0])
  387. ? (realValue as SimpleValueType[][])
  388. : ([realValue] as SimpleValueType[][]);
  389. } else {
  390. if (realValue !== undefined) {
  391. normallizedValue = [[realValue]];
  392. }
  393. }
  394. // formatValuePath is used to save value of valuePath
  395. const formatValuePath: (string | number)[][] = [];
  396. normallizedValue.forEach((valueItem: SimpleValueType[]) => {
  397. const formatItem: (string | number)[] = onChangeWithObject ?
  398. (valueItem as CascaderData[]).map(i => i?.value) :
  399. valueItem as (string | number)[];
  400. formatValuePath.push(formatItem);
  401. });
  402. // formatKeys is used to save key of value
  403. const formatKeys: any[] = [];
  404. formatValuePath.forEach(v => {
  405. const formatKeyItem = findKeysForValues(v, keyEntities);
  406. !isEmpty(formatKeyItem) && formatKeys.push(formatKeyItem);
  407. });
  408. return formatKeys;
  409. };
  410. const needUpdateTreeData = needUpdate('treeData') || needUpdateData();
  411. const needUpdateValue = needUpdate('value') || (isEmpty(prevProps) && defaultValue);
  412. if (multiple) {
  413. // when value and treedata need updated
  414. if (needUpdateTreeData || needUpdateValue) {
  415. // update state.keyEntities
  416. if (needUpdateTreeData) {
  417. newState.treeData = props.treeData;
  418. keyEntities = convertDataToEntities(props.treeData);
  419. newState.keyEntities = keyEntities;
  420. }
  421. let realKeys: Array<string> | Set<string> = prevState.checkedKeys;
  422. // when data was updated
  423. if (needUpdateValue) {
  424. const realValue = needUpdate('value') ? value : defaultValue;
  425. realKeys = getRealKeys(realValue, keyEntities);
  426. } else {
  427. // needUpdateValue is false
  428. // if treeData is updated & Cascader is controlled, realKeys should be recalculated
  429. if (needUpdateTreeData && 'value' in props) {
  430. const realValue = value;
  431. realKeys = getRealKeys(realValue, keyEntities);
  432. }
  433. }
  434. if (isSet(realKeys)) {
  435. realKeys = [...realKeys];
  436. }
  437. const calRes = calcCheckedKeys(flatten(realKeys), keyEntities);
  438. const checkedKeys = new Set(calRes.checkedKeys);
  439. const halfCheckedKeys = new Set(calRes.halfCheckedKeys);
  440. // disableStrictly
  441. if (props.disableStrictly) {
  442. newState.disabledKeys = calcDisabledKeys(keyEntities);
  443. }
  444. const isLeafOnlyMerge = calcMergeType(autoMergeValue, leafOnly) === strings.LEAF_ONLY_MERGE_TYPE;
  445. newState.prevProps = props;
  446. newState.checkedKeys = checkedKeys;
  447. newState.halfCheckedKeys = halfCheckedKeys;
  448. newState.resolvedCheckedKeys = new Set(normalizeKeyList(checkedKeys, keyEntities, isLeafOnlyMerge));
  449. }
  450. }
  451. return newState;
  452. }
  453. componentDidMount() {
  454. this.foundation.init();
  455. }
  456. componentWillUnmount() {
  457. this.foundation.destroy();
  458. }
  459. componentDidUpdate(prevProps: CascaderProps) {
  460. let isOptionsChanged = false;
  461. if (!isEqual(prevProps.treeData, this.props.treeData)) {
  462. isOptionsChanged = true;
  463. this.foundation.collectOptions();
  464. }
  465. if (prevProps.value !== this.props.value && !isOptionsChanged) {
  466. this.foundation.handleValueChange(this.props.value);
  467. }
  468. }
  469. handleInputChange = (value: string) => {
  470. this.foundation.handleInputChange(value);
  471. };
  472. handleTagRemove = (e: any, tagValuePath: Array<string | number>) => {
  473. this.foundation.handleTagRemove(e, tagValuePath);
  474. };
  475. renderTagItem = (value: string | Array<string>, idx: number, type: string) => {
  476. const { keyEntities, disabledKeys } = this.state;
  477. const { size, disabled, displayProp, displayRender, disableStrictly } = this.props;
  478. const nodeKey = type === strings.IS_VALUE ? findKeysForValues(value, keyEntities)[0] : value;
  479. const isDsiabled =
  480. disabled || keyEntities[nodeKey].data.disabled || (disableStrictly && disabledKeys.has(nodeKey));
  481. if (!isEmpty(keyEntities) && !isEmpty(keyEntities[nodeKey])) {
  482. const tagCls = cls(`${prefixcls}-selection-tag`, {
  483. [`${prefixcls}-selection-tag-disabled`]: isDsiabled,
  484. });
  485. // custom render tags
  486. if (isFunction(displayRender)) {
  487. return displayRender(keyEntities[nodeKey], idx);
  488. // default render tags
  489. } else {
  490. return (
  491. <Tag
  492. size={size === 'default' ? 'large' : size}
  493. key={`tag-${nodeKey}-${idx}`}
  494. color="white"
  495. className={tagCls}
  496. closable
  497. onClose={(tagChildren, e) => {
  498. // When value has not changed, prevent clicking tag closeBtn to close tag
  499. e.preventDefault();
  500. this.handleTagRemove(e, keyEntities[nodeKey].valuePath);
  501. }}
  502. >
  503. {keyEntities[nodeKey].data[displayProp]}
  504. </Tag>
  505. );
  506. }
  507. }
  508. return null;
  509. };
  510. renderTagInput() {
  511. const { size, disabled, placeholder, maxTagCount, showRestTagsPopover, restTagsPopoverProps } = this.props;
  512. const { inputValue, checkedKeys, keyEntities, resolvedCheckedKeys } = this.state;
  513. const tagInputcls = cls(`${prefixcls}-tagInput-wrapper`);
  514. const tagValue: Array<Array<string>> = [];
  515. const realKeys = this.mergeType === strings.NONE_MERGE_TYPE ? checkedKeys : resolvedCheckedKeys;
  516. [...realKeys].forEach(checkedKey => {
  517. if (!isEmpty(keyEntities[checkedKey])) {
  518. tagValue.push(keyEntities[checkedKey].valuePath);
  519. }
  520. });
  521. return (
  522. <TagInput
  523. className={tagInputcls}
  524. ref={this.inputRef as any}
  525. disabled={disabled}
  526. size={size}
  527. // TODO Modify logic, not modify type
  528. value={(tagValue as unknown) as string[]}
  529. showRestTagsPopover={showRestTagsPopover}
  530. restTagsPopoverProps={restTagsPopoverProps}
  531. maxTagCount={maxTagCount}
  532. renderTagItem={(value, index) => this.renderTagItem(value, index, strings.IS_VALUE)}
  533. inputValue={inputValue}
  534. onInputChange={this.handleInputChange}
  535. // TODO Modify logic, not modify type
  536. onRemove={v => this.handleTagRemove(null, (v as unknown) as (string | number)[])}
  537. placeholder={placeholder}
  538. expandRestTagsOnClick={false}
  539. />
  540. );
  541. }
  542. renderInput() {
  543. const { size, disabled } = this.props;
  544. const inputcls = cls(`${prefixcls}-input`);
  545. const { inputValue, inputPlaceHolder, showInput } = this.state;
  546. const inputProps = {
  547. disabled,
  548. value: inputValue,
  549. className: inputcls,
  550. onChange: this.handleInputChange,
  551. };
  552. const wrappercls = cls({
  553. [`${prefixcls}-search-wrapper`]: true,
  554. });
  555. const displayText = this.renderDisplayText();
  556. const spanCls = cls({
  557. [`${prefixcls}-selection-placeholder`]: !displayText,
  558. [`${prefixcls}-selection-text-hide`]: showInput && inputValue,
  559. [`${prefixcls}-selection-text-inactive`]: showInput && !inputValue,
  560. });
  561. return (
  562. <div className={wrappercls}>
  563. <span className={spanCls}>{displayText ? displayText : inputPlaceHolder}</span>
  564. {showInput && <Input ref={this.inputRef as any} size={size} {...inputProps} />}
  565. </div>
  566. );
  567. }
  568. handleItemClick = (e: MouseEvent | KeyboardEvent, item: Entity | Data) => {
  569. this.foundation.handleItemClick(e, item);
  570. };
  571. handleItemHover = (e: MouseEvent, item: Entity) => {
  572. this.foundation.handleItemHover(e, item);
  573. };
  574. onItemCheckboxClick = (item: Entity | Data) => {
  575. this.foundation.onItemCheckboxClick(item);
  576. };
  577. handleListScroll = (e: React.UIEvent<HTMLUListElement, UIEvent>, ind: number) => {
  578. this.foundation.handleListScroll(e, ind);
  579. };
  580. renderContent = () => {
  581. const {
  582. inputValue,
  583. isSearching,
  584. activeKeys,
  585. selectedKeys,
  586. checkedKeys,
  587. halfCheckedKeys,
  588. loadedKeys,
  589. loadingKeys,
  590. } = this.state;
  591. const {
  592. filterTreeNode,
  593. dropdownClassName,
  594. dropdownStyle,
  595. loadData,
  596. emptyContent,
  597. separator,
  598. topSlot,
  599. bottomSlot,
  600. showNext,
  601. multiple,
  602. } = this.props;
  603. const searchable = Boolean(filterTreeNode) && isSearching;
  604. const popoverCls = cls(dropdownClassName, `${prefixcls}-popover`);
  605. const renderData = this.foundation.getRenderData();
  606. const content = (
  607. <div className={popoverCls} role="listbox" style={dropdownStyle}>
  608. {topSlot}
  609. <Item
  610. activeKeys={activeKeys}
  611. selectedKeys={selectedKeys}
  612. separator={separator}
  613. loadedKeys={loadedKeys}
  614. loadingKeys={loadingKeys}
  615. onItemClick={this.handleItemClick}
  616. onItemHover={this.handleItemHover}
  617. showNext={showNext}
  618. onItemCheckboxClick={this.onItemCheckboxClick}
  619. onListScroll={this.handleListScroll}
  620. searchable={searchable}
  621. keyword={inputValue}
  622. emptyContent={emptyContent}
  623. loadData={loadData}
  624. data={renderData}
  625. multiple={multiple}
  626. checkedKeys={checkedKeys}
  627. halfCheckedKeys={halfCheckedKeys}
  628. />
  629. {bottomSlot}
  630. </div>
  631. );
  632. return content;
  633. };
  634. renderPlusN = (hiddenTag: Array<ReactNode>) => {
  635. const { disabled, showRestTagsPopover, restTagsPopoverProps } = this.props;
  636. const plusNCls = cls(`${prefixcls}-selection-n`, {
  637. [`${prefixcls}-selection-n-disabled`]: disabled,
  638. });
  639. const renderPlusNChildren = <span className={plusNCls}>+{hiddenTag.length}</span>;
  640. return showRestTagsPopover && !disabled ? (
  641. <Popover
  642. content={hiddenTag}
  643. showArrow
  644. trigger="hover"
  645. position="top"
  646. autoAdjustOverflow
  647. {...restTagsPopoverProps}
  648. >
  649. {renderPlusNChildren}
  650. </Popover>
  651. ) : (
  652. renderPlusNChildren
  653. );
  654. };
  655. renderMultipleTags = () => {
  656. const { autoMergeValue, maxTagCount } = this.props;
  657. const { checkedKeys, resolvedCheckedKeys } = this.state;
  658. const realKeys = this.mergeType === strings.NONE_MERGE_TYPE ? checkedKeys : resolvedCheckedKeys;
  659. const displayTag: Array<ReactNode> = [];
  660. const hiddenTag: Array<ReactNode> = [];
  661. [...realKeys].forEach((checkedKey, idx) => {
  662. const notExceedMaxTagCount = !isNumber(maxTagCount) || maxTagCount >= idx + 1;
  663. const item = this.renderTagItem(checkedKey, idx, strings.IS_KEY);
  664. if (notExceedMaxTagCount) {
  665. displayTag.push(item);
  666. } else {
  667. hiddenTag.push(item);
  668. }
  669. });
  670. return (
  671. <>
  672. {displayTag}
  673. {!isEmpty(hiddenTag) && this.renderPlusN(hiddenTag)}
  674. </>
  675. );
  676. };
  677. renderDisplayText = (): ReactNode => {
  678. const { displayProp, separator, displayRender } = this.props;
  679. const { selectedKeys } = this.state;
  680. let displayText: ReactNode = '';
  681. if (selectedKeys.size) {
  682. const displayPath = this.foundation.getItemPropPath([...selectedKeys][0], displayProp);
  683. if (displayRender && typeof displayRender === 'function') {
  684. displayText = displayRender(displayPath);
  685. } else {
  686. displayText = displayPath.map((path: ReactNode, index: number) => (
  687. <Fragment key={`${path}-${index}`}>
  688. {index < displayPath.length - 1 ? (
  689. <>
  690. {path}
  691. {separator}
  692. </>
  693. ) : (
  694. path
  695. )}
  696. </Fragment>
  697. ));
  698. }
  699. }
  700. return displayText;
  701. };
  702. renderSelectContent = () => {
  703. const { placeholder, filterTreeNode, multiple } = this.props;
  704. const { checkedKeys } = this.state;
  705. const searchable = Boolean(filterTreeNode);
  706. if (!searchable) {
  707. if (multiple) {
  708. if (isEmpty(checkedKeys)) {
  709. return <span className={`${prefixcls}-selection-placeholder`}>{placeholder}</span>;
  710. }
  711. return this.renderMultipleTags();
  712. } else {
  713. const displayText = this.renderDisplayText();
  714. const spanCls = cls({
  715. [`${prefixcls}-selection-placeholder`]: !displayText,
  716. });
  717. return <span className={spanCls}>{displayText ? displayText : placeholder}</span>;
  718. }
  719. }
  720. const input = multiple ? this.renderTagInput() : this.renderInput();
  721. return input;
  722. };
  723. renderSuffix = () => {
  724. const { suffix }: any = this.props;
  725. const suffixWrapperCls = cls({
  726. [`${prefixcls}-suffix`]: true,
  727. [`${prefixcls}-suffix-text`]: suffix && isString(suffix),
  728. [`${prefixcls}-suffix-icon`]: isSemiIcon(suffix),
  729. });
  730. return (
  731. <div className={suffixWrapperCls} x-semi-prop="suffix">
  732. {suffix}
  733. </div>
  734. );
  735. };
  736. renderPrefix = () => {
  737. const { prefix, insetLabel, insetLabelId } = this.props;
  738. const labelNode: any = prefix || insetLabel;
  739. const prefixWrapperCls = cls({
  740. [`${prefixcls}-prefix`]: true,
  741. // to be doublechecked
  742. [`${prefixcls}-inset-label`]: insetLabel,
  743. [`${prefixcls}-prefix-text`]: labelNode && isString(labelNode),
  744. [`${prefixcls}-prefix-icon`]: isSemiIcon(labelNode),
  745. });
  746. return (
  747. <div className={prefixWrapperCls} id={insetLabelId} x-semi-prop="prefix,insetLabel">
  748. {labelNode}
  749. </div>
  750. );
  751. };
  752. renderCustomTrigger = () => {
  753. const { disabled, triggerRender, multiple } = this.props;
  754. const { selectedKeys, inputValue, inputPlaceHolder, resolvedCheckedKeys, checkedKeys } = this.state;
  755. let realValue;
  756. if (multiple) {
  757. if (this.mergeType === strings.NONE_MERGE_TYPE) {
  758. realValue = checkedKeys;
  759. } else {
  760. realValue = resolvedCheckedKeys;
  761. }
  762. } else {
  763. realValue = [...selectedKeys][0];
  764. }
  765. return (
  766. <Trigger
  767. value={realValue}
  768. inputValue={inputValue}
  769. onChange={this.handleInputChange}
  770. onClear={this.handleClear}
  771. placeholder={inputPlaceHolder}
  772. disabled={disabled}
  773. triggerRender={triggerRender}
  774. componentName={'Cascader'}
  775. componentProps={{ ...this.props }}
  776. />
  777. );
  778. };
  779. handleMouseOver = () => {
  780. this.foundation.toggleHoverState(true);
  781. };
  782. handleMouseLeave = () => {
  783. this.foundation.toggleHoverState(false);
  784. };
  785. handleClear = (e: MouseEvent) => {
  786. e && e.stopPropagation();
  787. this.foundation.handleClear();
  788. };
  789. /**
  790. * A11y: simulate clear button click
  791. */
  792. /* istanbul ignore next */
  793. handleClearEnterPress = (e: KeyboardEvent) => {
  794. e && e.stopPropagation();
  795. this.foundation.handleClearEnterPress(e);
  796. };
  797. showClearBtn = () => {
  798. const { showClear, disabled, multiple } = this.props;
  799. const { selectedKeys, isOpen, isHovering, checkedKeys } = this.state;
  800. const hasValue = selectedKeys.size;
  801. const multipleWithHaveValue = multiple && checkedKeys.size;
  802. return showClear && (hasValue || multipleWithHaveValue) && !disabled && (isOpen || isHovering);
  803. };
  804. renderClearBtn = () => {
  805. const clearCls = cls(`${prefixcls}-clearbtn`);
  806. const allowClear = this.showClearBtn();
  807. if (allowClear) {
  808. return (
  809. <div
  810. className={clearCls}
  811. onClick={this.handleClear}
  812. onKeyPress={this.handleClearEnterPress}
  813. role="button"
  814. tabIndex={0}
  815. >
  816. <IconClear />
  817. </div>
  818. );
  819. }
  820. return null;
  821. };
  822. renderArrow = () => {
  823. const { arrowIcon } = this.props;
  824. const showClearBtn = this.showClearBtn();
  825. if (showClearBtn) {
  826. return null;
  827. }
  828. return arrowIcon ? (
  829. <div className={cls(`${prefixcls}-arrow`)} x-semi-prop="arrowIcon">
  830. {arrowIcon}
  831. </div>
  832. ) : null;
  833. };
  834. renderSelection = () => {
  835. const {
  836. disabled,
  837. multiple,
  838. filterTreeNode,
  839. style,
  840. size,
  841. className,
  842. validateStatus,
  843. prefix,
  844. suffix,
  845. insetLabel,
  846. triggerRender,
  847. showClear,
  848. id,
  849. } = this.props;
  850. const { isOpen, isFocus, isInput, checkedKeys } = this.state;
  851. const filterable = Boolean(filterTreeNode);
  852. const useCustomTrigger = typeof triggerRender === 'function';
  853. const classNames = useCustomTrigger ?
  854. cls(className) :
  855. cls(prefixcls, className, {
  856. [`${prefixcls}-focus`]: isFocus || (isOpen && !isInput),
  857. [`${prefixcls}-disabled`]: disabled,
  858. [`${prefixcls}-single`]: true,
  859. [`${prefixcls}-filterable`]: filterable,
  860. [`${prefixcls}-error`]: validateStatus === 'error',
  861. [`${prefixcls}-warning`]: validateStatus === 'warning',
  862. [`${prefixcls}-small`]: size === 'small',
  863. [`${prefixcls}-large`]: size === 'large',
  864. [`${prefixcls}-with-prefix`]: prefix || insetLabel,
  865. [`${prefixcls}-with-suffix`]: suffix,
  866. });
  867. const mouseEvent = showClear ?
  868. {
  869. onMouseEnter: () => this.handleMouseOver(),
  870. onMouseLeave: () => this.handleMouseLeave(),
  871. } :
  872. {};
  873. const sectionCls = cls(`${prefixcls}-selection`, {
  874. [`${prefixcls}-selection-multiple`]: multiple && !isEmpty(checkedKeys),
  875. });
  876. const inner = useCustomTrigger
  877. ? this.renderCustomTrigger()
  878. : [
  879. <Fragment key={'prefix'}>{prefix || insetLabel ? this.renderPrefix() : null}</Fragment>,
  880. <Fragment key={'selection'}>
  881. <div className={sectionCls}>{this.renderSelectContent()}</div>
  882. </Fragment>,
  883. <Fragment key={'clearbtn'}>{this.renderClearBtn()}</Fragment>,
  884. <Fragment key={'suffix'}>{suffix ? this.renderSuffix() : null}</Fragment>,
  885. <Fragment key={'arrow'}>{this.renderArrow()}</Fragment>,
  886. ];
  887. /**
  888. * Reasons for disabling the a11y eslint rule:
  889. * The following attributes(aria-controls,aria-expanded) will be automatically added by Tooltip, no need to declare here
  890. */
  891. return (
  892. <div
  893. className={classNames}
  894. style={style}
  895. ref={this.triggerRef}
  896. onClick={e => this.foundation.handleClick(e)}
  897. onKeyPress={e => this.foundation.handleSelectionEnterPress(e)}
  898. aria-invalid={this.props['aria-invalid']}
  899. aria-errormessage={this.props['aria-errormessage']}
  900. aria-label={this.props['aria-label']}
  901. aria-labelledby={this.props['aria-labelledby']}
  902. aria-describedby={this.props['aria-describedby']}
  903. aria-required={this.props['aria-required']}
  904. id={id}
  905. {...mouseEvent}
  906. // eslint-disable-next-line jsx-a11y/role-has-required-aria-props
  907. role="combobox"
  908. tabIndex={0}
  909. >
  910. {inner}
  911. </div>
  912. );
  913. };
  914. render() {
  915. const {
  916. zIndex,
  917. getPopupContainer,
  918. autoAdjustOverflow,
  919. stopPropagation,
  920. mouseLeaveDelay,
  921. mouseEnterDelay,
  922. position,
  923. motion
  924. } = this.props;
  925. const { isOpen, rePosKey } = this.state;
  926. const { direction } = this.context;
  927. const content = this.renderContent();
  928. const selection = this.renderSelection();
  929. const pos = position ?? (direction === 'rtl' ? 'bottomRight' : 'bottomLeft');
  930. return (
  931. <Popover
  932. getPopupContainer={getPopupContainer}
  933. zIndex={zIndex}
  934. motion={motion}
  935. ref={this.optionsRef}
  936. content={content}
  937. visible={isOpen}
  938. trigger="custom"
  939. rePosKey={rePosKey}
  940. position={pos}
  941. autoAdjustOverflow={autoAdjustOverflow}
  942. stopPropagation={stopPropagation}
  943. mouseLeaveDelay={mouseLeaveDelay}
  944. mouseEnterDelay={mouseEnterDelay}
  945. afterClose={()=>this.foundation.updateSearching(false)}
  946. >
  947. {selection}
  948. </Popover>
  949. );
  950. }
  951. }
  952. export default Cascader;