index.tsx 40 KB

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