cascader.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650
  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, index) 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(index)">
  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. },
  158. methods: Popper.methods,
  159. data: Popper.data,
  160. beforeDestroy: Popper.beforeDestroy
  161. };
  162. const InputSizeMap = {
  163. medium: 36,
  164. small: 32,
  165. mini: 28
  166. };
  167. export default {
  168. name: 'ElCascader',
  169. directives: { Clickoutside },
  170. mixins: [PopperMixin, Emitter, Locale, Migrating],
  171. inject: {
  172. elForm: {
  173. default: ''
  174. },
  175. elFormItem: {
  176. default: ''
  177. }
  178. },
  179. components: {
  180. ElInput,
  181. ElTag,
  182. ElScrollbar,
  183. ElCascaderPanel
  184. },
  185. props: {
  186. value: {},
  187. options: Array,
  188. props: Object,
  189. size: String,
  190. placeholder: {
  191. type: String,
  192. default: () => t('el.cascader.placeholder')
  193. },
  194. disabled: Boolean,
  195. clearable: Boolean,
  196. filterable: Boolean,
  197. filterMethod: Function,
  198. separator: {
  199. type: String,
  200. default: ' / '
  201. },
  202. showAllLevels: {
  203. type: Boolean,
  204. default: true
  205. },
  206. collapseTags: Boolean,
  207. debounce: {
  208. type: Number,
  209. default: 300
  210. },
  211. beforeFilter: {
  212. type: Function,
  213. default: () => (() => {})
  214. },
  215. popperClass: String
  216. },
  217. data() {
  218. return {
  219. dropDownVisible: false,
  220. checkedValue: this.value || null,
  221. inputHover: false,
  222. inputValue: null,
  223. presentText: null,
  224. presentTags: [],
  225. checkedNodes: [],
  226. filtering: false,
  227. suggestions: [],
  228. inputInitialHeight: 0,
  229. pressDeleteCount: 0
  230. };
  231. },
  232. computed: {
  233. realSize() {
  234. const _elFormItemSize = (this.elFormItem || {}).elFormItemSize;
  235. return this.size || _elFormItemSize || (this.$ELEMENT || {}).size;
  236. },
  237. tagSize() {
  238. return ['small', 'mini'].indexOf(this.realSize) > -1
  239. ? 'mini'
  240. : 'small';
  241. },
  242. isDisabled() {
  243. return this.disabled || (this.elForm || {}).disabled;
  244. },
  245. config() {
  246. const config = this.props || {};
  247. const { $attrs } = this;
  248. Object
  249. .keys(MigratingProps)
  250. .forEach(oldProp => {
  251. const { newProp, type } = MigratingProps[oldProp];
  252. let oldValue = $attrs[oldProp] || $attrs[kebabCase(oldProp)];
  253. if (isDef(oldProp) && !isDef(config[newProp])) {
  254. if (type === Boolean && oldValue === '') {
  255. oldValue = true;
  256. }
  257. config[newProp] = oldValue;
  258. }
  259. });
  260. return config;
  261. },
  262. multiple() {
  263. return this.config.multiple;
  264. },
  265. leafOnly() {
  266. return !this.config.checkStrictly;
  267. },
  268. readonly() {
  269. return !this.filterable || this.multiple;
  270. },
  271. clearBtnVisible() {
  272. if (!this.clearable || this.isDisabled || this.filtering || !this.inputHover) {
  273. return false;
  274. }
  275. return this.multiple
  276. ? !!this.checkedNodes.filter(node => !node.isDisabled).length
  277. : !!this.presentText;
  278. },
  279. panel() {
  280. return this.$refs.panel;
  281. }
  282. },
  283. watch: {
  284. disabled() {
  285. this.computePresentContent();
  286. },
  287. value(val) {
  288. if (!isEqual(val, this.checkedValue)) {
  289. this.checkedValue = val;
  290. this.computePresentContent();
  291. }
  292. },
  293. checkedValue(val) {
  294. const { value, dropDownVisible } = this;
  295. const { checkStrictly, multiple } = this.config;
  296. if (!isEqual(val, value) || isUndefined(value)) {
  297. this.computePresentContent();
  298. // hide dropdown when single mode
  299. if (!multiple && !checkStrictly && dropDownVisible) {
  300. this.toggleDropDownVisible(false);
  301. }
  302. this.$emit('input', val);
  303. this.$emit('change', val);
  304. this.dispatch('ElFormItem', 'el.form.change', [val]);
  305. }
  306. },
  307. options: {
  308. handler: function() {
  309. this.$nextTick(this.computePresentContent);
  310. },
  311. deep: true
  312. },
  313. presentText(val) {
  314. this.inputValue = val;
  315. },
  316. presentTags(val, oldVal) {
  317. if (this.multiple && (val.length || oldVal.length)) {
  318. this.$nextTick(this.updateStyle);
  319. }
  320. },
  321. filtering(val) {
  322. this.$nextTick(this.updatePopper);
  323. }
  324. },
  325. mounted() {
  326. const { input } = this.$refs;
  327. if (input && input.$el) {
  328. this.inputInitialHeight = input.$el.offsetHeight || InputSizeMap[this.realSize] || 40;
  329. }
  330. if (!isEmpty(this.value)) {
  331. this.computePresentContent();
  332. }
  333. this.filterHandler = debounce(this.debounce, () => {
  334. const { inputValue } = this;
  335. if (!inputValue) {
  336. this.filtering = false;
  337. return;
  338. }
  339. const before = this.beforeFilter(inputValue);
  340. if (before && before.then) {
  341. before.then(this.getSuggestions);
  342. } else if (before !== false) {
  343. this.getSuggestions();
  344. } else {
  345. this.filtering = false;
  346. }
  347. });
  348. addResizeListener(this.$el, this.updateStyle);
  349. },
  350. beforeDestroy() {
  351. removeResizeListener(this.$el, this.updateStyle);
  352. },
  353. methods: {
  354. getMigratingConfig() {
  355. return {
  356. props: {
  357. 'expand-trigger': 'expand-trigger is removed, use `props.expandTrigger` instead.',
  358. 'change-on-select': 'change-on-select is removed, use `props.checkStrictly` instead.',
  359. 'hover-threshold': 'hover-threshold is removed, use `props.hoverThreshold` instead'
  360. },
  361. events: {
  362. 'active-item-change': 'active-item-change is renamed to expand-change'
  363. }
  364. };
  365. },
  366. toggleDropDownVisible(visible) {
  367. if (this.isDisabled) return;
  368. const { dropDownVisible } = this;
  369. const { input } = this.$refs;
  370. visible = isDef(visible) ? visible : !dropDownVisible;
  371. if (visible !== dropDownVisible) {
  372. this.dropDownVisible = visible;
  373. if (visible) {
  374. this.$nextTick(() => {
  375. this.updatePopper();
  376. this.panel.scrollIntoView();
  377. });
  378. }
  379. input.$refs.input.setAttribute('aria-expanded', visible);
  380. this.$emit('visible-change', visible);
  381. }
  382. },
  383. handleDropdownLeave() {
  384. this.filtering = false;
  385. this.inputValue = this.presentText;
  386. },
  387. handleKeyDown(event) {
  388. switch (event.keyCode) {
  389. case KeyCode.enter:
  390. this.toggleDropDownVisible();
  391. break;
  392. case KeyCode.down:
  393. this.toggleDropDownVisible(true);
  394. this.focusFirstNode();
  395. event.preventDefault();
  396. break;
  397. case KeyCode.esc:
  398. case KeyCode.tab:
  399. this.toggleDropDownVisible(false);
  400. break;
  401. }
  402. },
  403. handleFocus(e) {
  404. this.$emit('focus', e);
  405. },
  406. handleBlur(e) {
  407. this.$emit('blur', e);
  408. },
  409. handleInput(val, event) {
  410. !this.dropDownVisible && this.toggleDropDownVisible(true);
  411. if (event && event.isComposing) return;
  412. if (val) {
  413. this.filterHandler();
  414. } else {
  415. this.filtering = false;
  416. }
  417. },
  418. handleClear() {
  419. this.presentText = '';
  420. this.panel.clearCheckedNodes();
  421. },
  422. handleExpandChange(value) {
  423. this.$nextTick(this.updatePopper.bind(this));
  424. this.$emit('expand-change', value);
  425. this.$emit('active-item-change', value); // Deprecated
  426. },
  427. focusFirstNode() {
  428. this.$nextTick(() => {
  429. const { filtering } = this;
  430. const { popper, suggestionPanel } = this.$refs;
  431. let firstNode = null;
  432. if (filtering && suggestionPanel) {
  433. firstNode = suggestionPanel.$el.querySelector('.el-cascader__suggestion-item');
  434. } else {
  435. const firstMenu = popper.querySelector('.el-cascader-menu');
  436. firstNode = firstMenu.querySelector('.el-cascader-node[tabindex="-1"]');
  437. }
  438. if (firstNode) {
  439. firstNode.focus();
  440. !filtering && firstNode.click();
  441. }
  442. });
  443. },
  444. computePresentContent() {
  445. // nextTick is required, because checked nodes may not change right now
  446. this.$nextTick(() => {
  447. if (this.config.multiple) {
  448. this.computePresentTags();
  449. this.presentText = this.presentTags.length ? ' ' : null;
  450. } else {
  451. this.computePresentText();
  452. }
  453. });
  454. },
  455. computePresentText() {
  456. const { checkedValue, config } = this;
  457. if (!isEmpty(checkedValue)) {
  458. const node = this.panel.getNodeByValue(checkedValue);
  459. if (node && (config.checkStrictly || node.isLeaf)) {
  460. this.presentText = node.getText(this.showAllLevels, this.separator);
  461. return;
  462. }
  463. }
  464. this.presentText = null;
  465. },
  466. computePresentTags() {
  467. const { isDisabled, leafOnly, showAllLevels, separator, collapseTags } = this;
  468. const checkedNodes = this.getCheckedNodes(leafOnly);
  469. const tags = [];
  470. const genTag = node => ({
  471. node,
  472. key: node.uid,
  473. text: node.getText(showAllLevels, separator),
  474. hitState: false,
  475. closable: !isDisabled && !node.isDisabled
  476. });
  477. if (checkedNodes.length) {
  478. const [first, ...rest] = checkedNodes;
  479. const restCount = rest.length;
  480. tags.push(genTag(first));
  481. if (restCount) {
  482. if (collapseTags) {
  483. tags.push({
  484. key: -1,
  485. text: `+ ${restCount}`,
  486. closable: false
  487. });
  488. } else {
  489. rest.forEach(node => tags.push(genTag(node)));
  490. }
  491. }
  492. }
  493. this.checkedNodes = checkedNodes;
  494. this.presentTags = tags;
  495. },
  496. getSuggestions() {
  497. let { filterMethod } = this;
  498. if (!isFunction(filterMethod)) {
  499. filterMethod = (node, keyword) => node.text.includes(keyword);
  500. }
  501. const suggestions = this.panel.getFlattedNodes(this.leafOnly)
  502. .filter(node => {
  503. if (node.isDisabled) return false;
  504. node.text = node.getText(this.showAllLevels, this.separator) || '';
  505. return filterMethod(node, this.inputValue);
  506. });
  507. if (this.multiple) {
  508. this.presentTags.forEach(tag => {
  509. tag.hitState = false;
  510. });
  511. } else {
  512. suggestions.forEach(node => {
  513. node.checked = isEqual(this.checkedValue, node.getValueByOption());
  514. });
  515. }
  516. this.filtering = true;
  517. this.suggestions = suggestions;
  518. this.$nextTick(this.updatePopper);
  519. },
  520. handleSuggestionKeyDown(event) {
  521. const { keyCode, target } = event;
  522. switch (keyCode) {
  523. case KeyCode.enter:
  524. target.click();
  525. break;
  526. case KeyCode.up:
  527. const prev = target.previousElementSibling;
  528. prev && prev.focus();
  529. break;
  530. case KeyCode.down:
  531. const next = target.nextElementSibling;
  532. next && next.focus();
  533. break;
  534. case KeyCode.esc:
  535. case KeyCode.tab:
  536. this.toggleDropDownVisible(false);
  537. break;
  538. }
  539. },
  540. handleDelete() {
  541. const { inputValue, pressDeleteCount, presentTags } = this;
  542. const lastIndex = presentTags.length - 1;
  543. const lastTag = presentTags[lastIndex];
  544. this.pressDeleteCount = inputValue ? 0 : pressDeleteCount + 1;
  545. if (!lastTag) return;
  546. if (this.pressDeleteCount) {
  547. if (lastTag.hitState) {
  548. this.deleteTag(lastIndex);
  549. } else {
  550. lastTag.hitState = true;
  551. }
  552. }
  553. },
  554. handleSuggestionClick(index) {
  555. const { multiple } = this;
  556. const targetNode = this.suggestions[index];
  557. if (multiple) {
  558. const { checked } = targetNode;
  559. targetNode.doCheck(!checked);
  560. this.panel.calculateMultiCheckedValue();
  561. } else {
  562. this.checkedValue = targetNode.getValueByOption();
  563. this.toggleDropDownVisible(false);
  564. }
  565. },
  566. deleteTag(index) {
  567. const { checkedValue } = this;
  568. const val = checkedValue[index];
  569. this.checkedValue = checkedValue.filter((n, i) => i !== index);
  570. this.$emit('remove-tag', val);
  571. },
  572. updateStyle() {
  573. const { $el, inputInitialHeight } = this;
  574. if (this.$isServer || !$el) return;
  575. const { suggestionPanel } = this.$refs;
  576. const inputInner = $el.querySelector('.el-input__inner');
  577. if (!inputInner) return;
  578. const tags = $el.querySelector('.el-cascader__tags');
  579. let suggestionPanelEl = null;
  580. if (suggestionPanel && (suggestionPanelEl = suggestionPanel.$el)) {
  581. const suggestionList = suggestionPanelEl.querySelector('.el-cascader__suggestion-list');
  582. suggestionList.style.minWidth = inputInner.offsetWidth + 'px';
  583. }
  584. if (tags) {
  585. const { offsetHeight } = tags;
  586. const height = Math.max(offsetHeight + 6, inputInitialHeight) + 'px';
  587. inputInner.style.height = height;
  588. this.updatePopper();
  589. }
  590. },
  591. /**
  592. * public methods
  593. */
  594. getCheckedNodes(leafOnly) {
  595. return this.panel.getCheckedNodes(leafOnly);
  596. }
  597. }
  598. };
  599. </script>