index.tsx 43 KB

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