index.tsx 38 KB

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