cascader.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663
  1. <template>
  2. <div
  3. ref="reference"
  4. :class="[
  5. 'el-cascader',
  6. realSize && `el-cascader--${realSize}`,
  7. { 'is-disabled': isDisabled }
  8. ]"
  9. v-clickoutside="() => toggleDropDownVisible(false)"
  10. @mouseenter="inputHover = true"
  11. @mouseleave="inputHover = false"
  12. @click="() => toggleDropDownVisible(readonly ? undefined : true)"
  13. @keydown="handleKeyDown">
  14. <el-input
  15. ref="input"
  16. v-model="multiple ? presentText : inputValue"
  17. :size="realSize"
  18. :placeholder="placeholder"
  19. :readonly="readonly"
  20. :disabled="isDisabled"
  21. :validate-event="false"
  22. :class="{ 'is-focus': dropDownVisible }"
  23. @focus="handleFocus"
  24. @blur="handleBlur"
  25. @input="handleInput">
  26. <template slot="suffix">
  27. <i
  28. v-if="clearBtnVisible"
  29. key="clear"
  30. class="el-input__icon el-icon-circle-close"
  31. @click.stop="handleClear"></i>
  32. <i
  33. v-else
  34. key="arrow-down"
  35. :class="[
  36. 'el-input__icon',
  37. 'el-icon-arrow-down',
  38. dropDownVisible && 'is-reverse'
  39. ]"
  40. @click.stop="toggleDropDownVisible()"></i>
  41. </template>
  42. </el-input>
  43. <div v-if="multiple" class="el-cascader__tags">
  44. <el-tag
  45. v-for="tag in presentTags"
  46. :key="tag.key"
  47. type="info"
  48. :size="tagSize"
  49. :hit="tag.hitState"
  50. :closable="tag.closable"
  51. disable-transitions
  52. @close="deleteTag(tag)">
  53. <span>{{ tag.text }}</span>
  54. </el-tag>
  55. <input
  56. v-if="filterable && !isDisabled"
  57. v-model.trim="inputValue"
  58. type="text"
  59. class="el-cascader__search-input"
  60. :placeholder="presentTags.length ? '' : placeholder"
  61. @input="e => handleInput(inputValue, e)"
  62. @click.stop="toggleDropDownVisible(true)"
  63. @keydown.delete="handleDelete">
  64. </div>
  65. <transition name="el-zoom-in-top" @after-leave="handleDropdownLeave">
  66. <div
  67. v-show="dropDownVisible"
  68. ref="popper"
  69. :class="['el-popper', 'el-cascader__dropdown', popperClass]">
  70. <el-cascader-panel
  71. ref="panel"
  72. v-show="!filtering"
  73. v-model="checkedValue"
  74. :options="options"
  75. :props="config"
  76. :border="false"
  77. :render-label="$scopedSlots.default"
  78. @expand-change="handleExpandChange"
  79. @close="toggleDropDownVisible(false)"></el-cascader-panel>
  80. <el-scrollbar
  81. ref="suggestionPanel"
  82. v-if="filterable"
  83. v-show="filtering"
  84. tag="ul"
  85. class="el-cascader__suggestion-panel"
  86. view-class="el-cascader__suggestion-list"
  87. @keydown.native="handleSuggestionKeyDown">
  88. <template v-if="suggestions.length">
  89. <li
  90. v-for="(item, index) in suggestions"
  91. :key="item.uid"
  92. :class="[
  93. 'el-cascader__suggestion-item',
  94. item.checked && 'is-checked'
  95. ]"
  96. :tabindex="-1"
  97. @click="handleSuggestionClick(index)">
  98. <span>{{ item.text }}</span>
  99. <i v-if="item.checked" class="el-icon-check"></i>
  100. </li>
  101. </template>
  102. <slot v-else name="empty">
  103. <li class="el-cascader__empty-text">{{ t('el.cascader.noMatch') }}</li>
  104. </slot>
  105. </el-scrollbar>
  106. </div>
  107. </transition>
  108. </div>
  109. </template>
  110. <script>
  111. import Popper from 'element-ui/src/utils/vue-popper';
  112. import Clickoutside from 'element-ui/src/utils/clickoutside';
  113. import Emitter from 'element-ui/src/mixins/emitter';
  114. import Locale from 'element-ui/src/mixins/locale';
  115. import Migrating from 'element-ui/src/mixins/migrating';
  116. import ElInput from 'element-ui/packages/input';
  117. import ElTag from 'element-ui/packages/tag';
  118. import ElScrollbar from 'element-ui/packages/scrollbar';
  119. import ElCascaderPanel from 'element-ui/packages/cascader-panel';
  120. import AriaUtils from 'element-ui/src/utils/aria-utils';
  121. import { t } from 'element-ui/src/locale';
  122. import { isEqual, isEmpty, kebabCase } from 'element-ui/src/utils/util';
  123. import { isUndefined, isFunction } from 'element-ui/src/utils/types';
  124. import { isDef } from 'element-ui/src/utils/shared';
  125. import { addResizeListener, removeResizeListener } from 'element-ui/src/utils/resize-event';
  126. import debounce from 'throttle-debounce/debounce';
  127. const { keys: KeyCode } = AriaUtils;
  128. const MigratingProps = {
  129. expandTrigger: {
  130. newProp: 'expandTrigger',
  131. type: String
  132. },
  133. changeOnSelect: {
  134. newProp: 'checkStrictly',
  135. type: Boolean
  136. },
  137. hoverThreshold: {
  138. newProp: 'hoverThreshold',
  139. type: Number
  140. }
  141. };
  142. const PopperMixin = {
  143. props: {
  144. placement: {
  145. type: String,
  146. default: 'bottom-start'
  147. },
  148. appendToBody: Popper.props.appendToBody,
  149. visibleArrow: {
  150. type: Boolean,
  151. default: true
  152. },
  153. arrowOffset: Popper.props.arrowOffset,
  154. offset: Popper.props.offset,
  155. boundariesPadding: Popper.props.boundariesPadding,
  156. popperOptions: Popper.props.popperOptions,
  157. transformOrigin: Popper.props.transformOrigin
  158. },
  159. methods: Popper.methods,
  160. data: Popper.data,
  161. beforeDestroy: Popper.beforeDestroy
  162. };
  163. const InputSizeMap = {
  164. medium: 36,
  165. small: 32,
  166. mini: 28
  167. };
  168. export default {
  169. name: 'ElCascader',
  170. directives: { Clickoutside },
  171. mixins: [PopperMixin, Emitter, Locale, Migrating],
  172. inject: {
  173. elForm: {
  174. default: ''
  175. },
  176. elFormItem: {
  177. default: ''
  178. }
  179. },
  180. components: {
  181. ElInput,
  182. ElTag,
  183. ElScrollbar,
  184. ElCascaderPanel
  185. },
  186. props: {
  187. value: {},
  188. options: Array,
  189. props: Object,
  190. size: String,
  191. placeholder: {
  192. type: String,
  193. default: () => t('el.cascader.placeholder')
  194. },
  195. disabled: Boolean,
  196. clearable: Boolean,
  197. filterable: Boolean,
  198. filterMethod: Function,
  199. separator: {
  200. type: String,
  201. default: ' / '
  202. },
  203. showAllLevels: {
  204. type: Boolean,
  205. default: true
  206. },
  207. collapseTags: Boolean,
  208. debounce: {
  209. type: Number,
  210. default: 300
  211. },
  212. beforeFilter: {
  213. type: Function,
  214. default: () => (() => {})
  215. },
  216. popperClass: String
  217. },
  218. data() {
  219. return {
  220. dropDownVisible: false,
  221. checkedValue: this.value,
  222. inputHover: false,
  223. inputValue: null,
  224. presentText: null,
  225. presentTags: [],
  226. checkedNodes: [],
  227. filtering: false,
  228. suggestions: [],
  229. inputInitialHeight: 0,
  230. pressDeleteCount: 0
  231. };
  232. },
  233. computed: {
  234. realSize() {
  235. const _elFormItemSize = (this.elFormItem || {}).elFormItemSize;
  236. return this.size || _elFormItemSize || (this.$ELEMENT || {}).size;
  237. },
  238. tagSize() {
  239. return ['small', 'mini'].indexOf(this.realSize) > -1
  240. ? 'mini'
  241. : 'small';
  242. },
  243. isDisabled() {
  244. return this.disabled || (this.elForm || {}).disabled;
  245. },
  246. config() {
  247. const config = this.props || {};
  248. const { $attrs } = this;
  249. Object
  250. .keys(MigratingProps)
  251. .forEach(oldProp => {
  252. const { newProp, type } = MigratingProps[oldProp];
  253. let oldValue = $attrs[oldProp] || $attrs[kebabCase(oldProp)];
  254. if (isDef(oldProp) && !isDef(config[newProp])) {
  255. if (type === Boolean && oldValue === '') {
  256. oldValue = true;
  257. }
  258. config[newProp] = oldValue;
  259. }
  260. });
  261. return config;
  262. },
  263. multiple() {
  264. return this.config.multiple;
  265. },
  266. leafOnly() {
  267. return !this.config.checkStrictly;
  268. },
  269. readonly() {
  270. return !this.filterable || this.multiple;
  271. },
  272. clearBtnVisible() {
  273. if (!this.clearable || this.isDisabled || this.filtering || !this.inputHover) {
  274. return false;
  275. }
  276. return this.multiple
  277. ? !!this.checkedNodes.filter(node => !node.isDisabled).length
  278. : !!this.presentText;
  279. },
  280. panel() {
  281. return this.$refs.panel;
  282. }
  283. },
  284. watch: {
  285. disabled() {
  286. this.computePresentContent();
  287. },
  288. value(val) {
  289. if (!isEqual(val, this.checkedValue)) {
  290. this.checkedValue = val;
  291. this.computePresentContent();
  292. }
  293. },
  294. checkedValue(val) {
  295. const { value, dropDownVisible } = this;
  296. const { checkStrictly, multiple } = this.config;
  297. if (!isEqual(val, value) || isUndefined(value)) {
  298. this.computePresentContent();
  299. // hide dropdown when single mode
  300. if (!multiple && !checkStrictly && dropDownVisible) {
  301. this.toggleDropDownVisible(false);
  302. }
  303. this.$emit('input', val);
  304. this.$emit('change', val);
  305. this.dispatch('ElFormItem', 'el.form.change', [val]);
  306. }
  307. },
  308. options: {
  309. handler: function() {
  310. this.$nextTick(this.computePresentContent);
  311. },
  312. deep: true
  313. },
  314. presentText(val) {
  315. this.inputValue = val;
  316. },
  317. presentTags(val, oldVal) {
  318. if (this.multiple && (val.length || oldVal.length)) {
  319. this.$nextTick(this.updateStyle);
  320. }
  321. },
  322. filtering(val) {
  323. this.$nextTick(this.updatePopper);
  324. }
  325. },
  326. mounted() {
  327. const { input } = this.$refs;
  328. if (input && input.$el) {
  329. this.inputInitialHeight = input.$el.offsetHeight || InputSizeMap[this.realSize] || 40;
  330. }
  331. if (!this.isEmptyValue(this.value)) {
  332. this.computePresentContent();
  333. }
  334. this.filterHandler = debounce(this.debounce, () => {
  335. const { inputValue } = this;
  336. if (!inputValue) {
  337. this.filtering = false;
  338. return;
  339. }
  340. const before = this.beforeFilter(inputValue);
  341. if (before && before.then) {
  342. before.then(this.getSuggestions);
  343. } else if (before !== false) {
  344. this.getSuggestions();
  345. } else {
  346. this.filtering = false;
  347. }
  348. });
  349. addResizeListener(this.$el, this.updateStyle);
  350. },
  351. beforeDestroy() {
  352. removeResizeListener(this.$el, this.updateStyle);
  353. },
  354. methods: {
  355. getMigratingConfig() {
  356. return {
  357. props: {
  358. 'expand-trigger': 'expand-trigger is removed, use `props.expandTrigger` instead.',
  359. 'change-on-select': 'change-on-select is removed, use `props.checkStrictly` instead.',
  360. 'hover-threshold': 'hover-threshold is removed, use `props.hoverThreshold` instead'
  361. },
  362. events: {
  363. 'active-item-change': 'active-item-change is renamed to expand-change'
  364. }
  365. };
  366. },
  367. toggleDropDownVisible(visible) {
  368. if (this.isDisabled) return;
  369. const { dropDownVisible } = this;
  370. const { input } = this.$refs;
  371. visible = isDef(visible) ? visible : !dropDownVisible;
  372. if (visible !== dropDownVisible) {
  373. this.dropDownVisible = visible;
  374. if (visible) {
  375. this.$nextTick(() => {
  376. this.updatePopper();
  377. this.panel.scrollIntoView();
  378. });
  379. }
  380. input.$refs.input.setAttribute('aria-expanded', visible);
  381. this.$emit('visible-change', visible);
  382. }
  383. },
  384. handleDropdownLeave() {
  385. this.filtering = false;
  386. this.inputValue = this.presentText;
  387. this.doDestroy();
  388. },
  389. handleKeyDown(event) {
  390. switch (event.keyCode) {
  391. case KeyCode.enter:
  392. this.toggleDropDownVisible();
  393. break;
  394. case KeyCode.down:
  395. this.toggleDropDownVisible(true);
  396. this.focusFirstNode();
  397. event.preventDefault();
  398. break;
  399. case KeyCode.esc:
  400. case KeyCode.tab:
  401. this.toggleDropDownVisible(false);
  402. break;
  403. }
  404. },
  405. handleFocus(e) {
  406. this.$emit('focus', e);
  407. },
  408. handleBlur(e) {
  409. this.$emit('blur', e);
  410. },
  411. handleInput(val, event) {
  412. !this.dropDownVisible && this.toggleDropDownVisible(true);
  413. if (event && event.isComposing) return;
  414. if (val) {
  415. this.filterHandler();
  416. } else {
  417. this.filtering = false;
  418. }
  419. },
  420. handleClear() {
  421. this.presentText = '';
  422. this.panel.clearCheckedNodes();
  423. },
  424. handleExpandChange(value) {
  425. this.$nextTick(this.updatePopper.bind(this));
  426. this.$emit('expand-change', value);
  427. this.$emit('active-item-change', value); // Deprecated
  428. },
  429. focusFirstNode() {
  430. this.$nextTick(() => {
  431. const { filtering } = this;
  432. const { popper, suggestionPanel } = this.$refs;
  433. let firstNode = null;
  434. if (filtering && suggestionPanel) {
  435. firstNode = suggestionPanel.$el.querySelector('.el-cascader__suggestion-item');
  436. } else {
  437. const firstMenu = popper.querySelector('.el-cascader-menu');
  438. firstNode = firstMenu.querySelector('.el-cascader-node[tabindex="-1"]');
  439. }
  440. if (firstNode) {
  441. firstNode.focus();
  442. !filtering && firstNode.click();
  443. }
  444. });
  445. },
  446. computePresentContent() {
  447. // nextTick is required, because checked nodes may not change right now
  448. this.$nextTick(() => {
  449. if (this.config.multiple) {
  450. this.computePresentTags();
  451. this.presentText = this.presentTags.length ? ' ' : null;
  452. } else {
  453. this.computePresentText();
  454. }
  455. });
  456. },
  457. isEmptyValue(val) {
  458. const { multiple } = this;
  459. const { emitPath } = this.panel.config;
  460. if (multiple || emitPath) {
  461. return isEmpty(val);
  462. }
  463. return false;
  464. },
  465. computePresentText() {
  466. const { checkedValue, config } = this;
  467. if (!this.isEmptyValue(checkedValue)) {
  468. const node = this.panel.getNodeByValue(checkedValue);
  469. if (node && (config.checkStrictly || node.isLeaf)) {
  470. this.presentText = node.getText(this.showAllLevels, this.separator);
  471. return;
  472. }
  473. }
  474. this.presentText = null;
  475. },
  476. computePresentTags() {
  477. const { isDisabled, leafOnly, showAllLevels, separator, collapseTags } = this;
  478. const checkedNodes = this.getCheckedNodes(leafOnly);
  479. const tags = [];
  480. const genTag = node => ({
  481. node,
  482. key: node.uid,
  483. text: node.getText(showAllLevels, separator),
  484. hitState: false,
  485. closable: !isDisabled && !node.isDisabled
  486. });
  487. if (checkedNodes.length) {
  488. const [first, ...rest] = checkedNodes;
  489. const restCount = rest.length;
  490. tags.push(genTag(first));
  491. if (restCount) {
  492. if (collapseTags) {
  493. tags.push({
  494. key: -1,
  495. text: `+ ${restCount}`,
  496. closable: false
  497. });
  498. } else {
  499. rest.forEach(node => tags.push(genTag(node)));
  500. }
  501. }
  502. }
  503. this.checkedNodes = checkedNodes;
  504. this.presentTags = tags;
  505. },
  506. getSuggestions() {
  507. let { filterMethod } = this;
  508. if (!isFunction(filterMethod)) {
  509. filterMethod = (node, keyword) => node.text.includes(keyword);
  510. }
  511. const suggestions = this.panel.getFlattedNodes(this.leafOnly)
  512. .filter(node => {
  513. if (node.isDisabled) return false;
  514. node.text = node.getText(this.showAllLevels, this.separator) || '';
  515. return filterMethod(node, this.inputValue);
  516. });
  517. if (this.multiple) {
  518. this.presentTags.forEach(tag => {
  519. tag.hitState = false;
  520. });
  521. } else {
  522. suggestions.forEach(node => {
  523. node.checked = isEqual(this.checkedValue, node.getValueByOption());
  524. });
  525. }
  526. this.filtering = true;
  527. this.suggestions = suggestions;
  528. this.$nextTick(this.updatePopper);
  529. },
  530. handleSuggestionKeyDown(event) {
  531. const { keyCode, target } = event;
  532. switch (keyCode) {
  533. case KeyCode.enter:
  534. target.click();
  535. break;
  536. case KeyCode.up:
  537. const prev = target.previousElementSibling;
  538. prev && prev.focus();
  539. break;
  540. case KeyCode.down:
  541. const next = target.nextElementSibling;
  542. next && next.focus();
  543. break;
  544. case KeyCode.esc:
  545. case KeyCode.tab:
  546. this.toggleDropDownVisible(false);
  547. break;
  548. }
  549. },
  550. handleDelete() {
  551. const { inputValue, pressDeleteCount, presentTags } = this;
  552. const lastIndex = presentTags.length - 1;
  553. const lastTag = presentTags[lastIndex];
  554. this.pressDeleteCount = inputValue ? 0 : pressDeleteCount + 1;
  555. if (!lastTag) return;
  556. if (this.pressDeleteCount) {
  557. if (lastTag.hitState) {
  558. this.deleteTag(lastTag);
  559. } else {
  560. lastTag.hitState = true;
  561. }
  562. }
  563. },
  564. handleSuggestionClick(index) {
  565. const { multiple } = this;
  566. const targetNode = this.suggestions[index];
  567. if (multiple) {
  568. const { checked } = targetNode;
  569. targetNode.doCheck(!checked);
  570. this.panel.calculateMultiCheckedValue();
  571. } else {
  572. this.checkedValue = targetNode.getValueByOption();
  573. this.toggleDropDownVisible(false);
  574. }
  575. },
  576. deleteTag(tag) {
  577. const { checkedValue } = this;
  578. const current = tag.node.getValueByOption();
  579. const val = checkedValue.find(n => isEqual(n, current));
  580. this.checkedValue = checkedValue.filter(n => !isEqual(n, current));
  581. this.$emit('remove-tag', val);
  582. },
  583. updateStyle() {
  584. const { $el, inputInitialHeight } = this;
  585. if (this.$isServer || !$el) return;
  586. const { suggestionPanel } = this.$refs;
  587. const inputInner = $el.querySelector('.el-input__inner');
  588. if (!inputInner) return;
  589. const tags = $el.querySelector('.el-cascader__tags');
  590. let suggestionPanelEl = null;
  591. if (suggestionPanel && (suggestionPanelEl = suggestionPanel.$el)) {
  592. const suggestionList = suggestionPanelEl.querySelector('.el-cascader__suggestion-list');
  593. suggestionList.style.minWidth = inputInner.offsetWidth + 'px';
  594. }
  595. if (tags) {
  596. const offsetHeight = Math.round(tags.getBoundingClientRect().height);
  597. const height = Math.max(offsetHeight + 6, inputInitialHeight) + 'px';
  598. inputInner.style.height = height;
  599. if (this.dropDownVisible) {
  600. this.updatePopper();
  601. }
  602. }
  603. },
  604. /**
  605. * public methods
  606. */
  607. getCheckedNodes(leafOnly) {
  608. return this.panel.getCheckedNodes(leafOnly);
  609. }
  610. }
  611. };
  612. </script>