index.tsx 40 KB

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