Select.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486
  1. import _objectSpread from "@babel/runtime/helpers/esm/objectSpread2";
  2. import _extends from "@babel/runtime/helpers/esm/extends";
  3. import { createVNode as _createVNode, resolveDirective as _resolveDirective } from "vue";
  4. /**
  5. * To match accessibility requirement, we always provide an input in the component.
  6. * Other element will not set `tabindex` to avoid `onBlur` sequence problem.
  7. * For focused select, we set `aria-live="polite"` to update the accessibility content.
  8. *
  9. * ref:
  10. * - keyboard: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/listbox_role#Keyboard_interactions
  11. *
  12. * New api:
  13. * - listHeight
  14. * - listItemHeight
  15. * - component
  16. *
  17. * Remove deprecated api:
  18. * - multiple
  19. * - tags
  20. * - combobox
  21. * - firstActiveValue
  22. * - dropdownMenuStyle
  23. * - openClassName (Not list in api)
  24. *
  25. * Update:
  26. * - `backfill` only support `combobox` mode
  27. * - `combobox` mode not support `labelInValue` since it's meaningless
  28. * - `getInputElement` only support `combobox` mode
  29. * - `onChange` return OptionData instead of ReactNode
  30. * - `filterOption` `onChange` `onSelect` accept OptionData instead of ReactNode
  31. * - `combobox` mode trigger `onChange` will get `undefined` if no `value` match in Option
  32. * - `combobox` mode not support `optionLabelProp`
  33. */
  34. import BaseSelect, { baseSelectPropsWithoutPrivate, isMultiple } from './BaseSelect';
  35. import OptionList from './OptionList';
  36. import useOptions from './hooks/useOptions';
  37. import { useProvideSelectProps } from './SelectContext';
  38. import useId from './hooks/useId';
  39. import { fillFieldNames, flattenOptions, injectPropsWithOption } from './utils/valueUtil';
  40. import warningProps from './utils/warningPropsUtil';
  41. import { toArray } from './utils/commonUtil';
  42. import useFilterOptions from './hooks/useFilterOptions';
  43. import useCache from './hooks/useCache';
  44. import { computed, defineComponent, ref, shallowRef, toRef, watchEffect } from 'vue';
  45. import PropTypes from '../_util/vue-types';
  46. import { initDefaultProps } from '../_util/props-util';
  47. import useMergedState from '../_util/hooks/useMergedState';
  48. import useState from '../_util/hooks/useState';
  49. import { toReactive } from '../_util/toReactive';
  50. import omit from '../_util/omit';
  51. const OMIT_DOM_PROPS = ['inputValue'];
  52. export function selectProps() {
  53. return _extends(_extends({}, baseSelectPropsWithoutPrivate()), {
  54. prefixCls: String,
  55. id: String,
  56. backfill: {
  57. type: Boolean,
  58. default: undefined
  59. },
  60. // >>> Field Names
  61. fieldNames: Object,
  62. // >>> Search
  63. /** @deprecated Use `searchValue` instead */
  64. inputValue: String,
  65. searchValue: String,
  66. onSearch: Function,
  67. autoClearSearchValue: {
  68. type: Boolean,
  69. default: undefined
  70. },
  71. // >>> Select
  72. onSelect: Function,
  73. onDeselect: Function,
  74. // >>> Options
  75. /**
  76. * In Select, `false` means do nothing.
  77. * In TreeSelect, `false` will highlight match item.
  78. * It's by design.
  79. */
  80. filterOption: {
  81. type: [Boolean, Function],
  82. default: undefined
  83. },
  84. filterSort: Function,
  85. optionFilterProp: String,
  86. optionLabelProp: String,
  87. options: Array,
  88. defaultActiveFirstOption: {
  89. type: Boolean,
  90. default: undefined
  91. },
  92. virtual: {
  93. type: Boolean,
  94. default: undefined
  95. },
  96. listHeight: Number,
  97. listItemHeight: Number,
  98. // >>> Icon
  99. menuItemSelectedIcon: PropTypes.any,
  100. mode: String,
  101. labelInValue: {
  102. type: Boolean,
  103. default: undefined
  104. },
  105. value: PropTypes.any,
  106. defaultValue: PropTypes.any,
  107. onChange: Function,
  108. children: Array
  109. });
  110. }
  111. function isRawValue(value) {
  112. return !value || typeof value !== 'object';
  113. }
  114. export default defineComponent({
  115. compatConfig: {
  116. MODE: 3
  117. },
  118. name: 'VcSelect',
  119. inheritAttrs: false,
  120. props: initDefaultProps(selectProps(), {
  121. prefixCls: 'vc-select',
  122. autoClearSearchValue: true,
  123. listHeight: 200,
  124. listItemHeight: 20,
  125. dropdownMatchSelectWidth: true
  126. }),
  127. setup(props, _ref) {
  128. let {
  129. expose,
  130. attrs,
  131. slots
  132. } = _ref;
  133. const mergedId = useId(toRef(props, 'id'));
  134. const multiple = computed(() => isMultiple(props.mode));
  135. const childrenAsData = computed(() => !!(!props.options && props.children));
  136. const mergedFilterOption = computed(() => {
  137. if (props.filterOption === undefined && props.mode === 'combobox') {
  138. return false;
  139. }
  140. return props.filterOption;
  141. });
  142. // ========================= FieldNames =========================
  143. const mergedFieldNames = computed(() => fillFieldNames(props.fieldNames, childrenAsData.value));
  144. // =========================== Search ===========================
  145. const [mergedSearchValue, setSearchValue] = useMergedState('', {
  146. value: computed(() => props.searchValue !== undefined ? props.searchValue : props.inputValue),
  147. postState: search => search || ''
  148. });
  149. // =========================== Option ===========================
  150. const parsedOptions = useOptions(toRef(props, 'options'), toRef(props, 'children'), mergedFieldNames);
  151. const {
  152. valueOptions,
  153. labelOptions,
  154. options: mergedOptions
  155. } = parsedOptions;
  156. // ========================= Wrap Value =========================
  157. const convert2LabelValues = draftValues => {
  158. // Convert to array
  159. const valueList = toArray(draftValues);
  160. // Convert to labelInValue type
  161. return valueList.map(val => {
  162. var _a, _b;
  163. let rawValue;
  164. let rawLabel;
  165. let rawKey;
  166. let rawDisabled;
  167. // Fill label & value
  168. if (isRawValue(val)) {
  169. rawValue = val;
  170. } else {
  171. rawKey = val.key;
  172. rawLabel = val.label;
  173. rawValue = (_a = val.value) !== null && _a !== void 0 ? _a : rawKey;
  174. }
  175. const option = valueOptions.value.get(rawValue);
  176. if (option) {
  177. // Fill missing props
  178. if (rawLabel === undefined) rawLabel = option === null || option === void 0 ? void 0 : option[props.optionLabelProp || mergedFieldNames.value.label];
  179. if (rawKey === undefined) rawKey = (_b = option === null || option === void 0 ? void 0 : option.key) !== null && _b !== void 0 ? _b : rawValue;
  180. rawDisabled = option === null || option === void 0 ? void 0 : option.disabled;
  181. // Warning if label not same as provided
  182. // if (process.env.NODE_ENV !== 'production' && !isRawValue(val)) {
  183. // const optionLabel = option?.[mergedFieldNames.value.label];
  184. // if (optionLabel !== undefined && optionLabel !== rawLabel) {
  185. // warning(false, '`label` of `value` is not same as `label` in Select options.');
  186. // }
  187. // }
  188. }
  189. return {
  190. label: rawLabel,
  191. value: rawValue,
  192. key: rawKey,
  193. disabled: rawDisabled,
  194. option
  195. };
  196. });
  197. };
  198. // =========================== Values ===========================
  199. const [internalValue, setInternalValue] = useMergedState(props.defaultValue, {
  200. value: toRef(props, 'value')
  201. });
  202. // Merged value with LabelValueType
  203. const rawLabeledValues = computed(() => {
  204. var _a;
  205. const values = convert2LabelValues(internalValue.value);
  206. // combobox no need save value when it's empty
  207. if (props.mode === 'combobox' && !((_a = values[0]) === null || _a === void 0 ? void 0 : _a.value)) {
  208. return [];
  209. }
  210. return values;
  211. });
  212. // Fill label with cache to avoid option remove
  213. const [mergedValues, getMixedOption] = useCache(rawLabeledValues, valueOptions);
  214. const displayValues = computed(() => {
  215. // `null` need show as placeholder instead
  216. // https://github.com/ant-design/ant-design/issues/25057
  217. if (!props.mode && mergedValues.value.length === 1) {
  218. const firstValue = mergedValues.value[0];
  219. if (firstValue.value === null && (firstValue.label === null || firstValue.label === undefined)) {
  220. return [];
  221. }
  222. }
  223. return mergedValues.value.map(item => {
  224. var _a;
  225. return _extends(_extends({}, item), {
  226. label: (_a = typeof item.label === 'function' ? item.label() : item.label) !== null && _a !== void 0 ? _a : item.value
  227. });
  228. });
  229. });
  230. /** Convert `displayValues` to raw value type set */
  231. const rawValues = computed(() => new Set(mergedValues.value.map(val => val.value)));
  232. watchEffect(() => {
  233. var _a;
  234. if (props.mode === 'combobox') {
  235. const strValue = (_a = mergedValues.value[0]) === null || _a === void 0 ? void 0 : _a.value;
  236. if (strValue !== undefined && strValue !== null) {
  237. setSearchValue(String(strValue));
  238. }
  239. }
  240. }, {
  241. flush: 'post'
  242. });
  243. // ======================= Display Option =======================
  244. // Create a placeholder item if not exist in `options`
  245. const createTagOption = (val, label) => {
  246. const mergedLabel = label !== null && label !== void 0 ? label : val;
  247. return {
  248. [mergedFieldNames.value.value]: val,
  249. [mergedFieldNames.value.label]: mergedLabel
  250. };
  251. };
  252. // Fill tag as option if mode is `tags`
  253. const filledTagOptions = shallowRef();
  254. watchEffect(() => {
  255. if (props.mode !== 'tags') {
  256. filledTagOptions.value = mergedOptions.value;
  257. return;
  258. }
  259. // >>> Tag mode
  260. const cloneOptions = mergedOptions.value.slice();
  261. // Check if value exist in options (include new patch item)
  262. const existOptions = val => valueOptions.value.has(val);
  263. // Fill current value as option
  264. [...mergedValues.value].sort((a, b) => a.value < b.value ? -1 : 1).forEach(item => {
  265. const val = item.value;
  266. if (!existOptions(val)) {
  267. cloneOptions.push(createTagOption(val, item.label));
  268. }
  269. });
  270. filledTagOptions.value = cloneOptions;
  271. });
  272. const filteredOptions = useFilterOptions(filledTagOptions, mergedFieldNames, mergedSearchValue, mergedFilterOption, toRef(props, 'optionFilterProp'));
  273. // Fill options with search value if needed
  274. const filledSearchOptions = computed(() => {
  275. if (props.mode !== 'tags' || !mergedSearchValue.value || filteredOptions.value.some(item => item[props.optionFilterProp || 'value'] === mergedSearchValue.value)) {
  276. return filteredOptions.value;
  277. }
  278. // Fill search value as option
  279. return [createTagOption(mergedSearchValue.value), ...filteredOptions.value];
  280. });
  281. const orderedFilteredOptions = computed(() => {
  282. if (!props.filterSort) {
  283. return filledSearchOptions.value;
  284. }
  285. return [...filledSearchOptions.value].sort((a, b) => props.filterSort(a, b));
  286. });
  287. const displayOptions = computed(() => flattenOptions(orderedFilteredOptions.value, {
  288. fieldNames: mergedFieldNames.value,
  289. childrenAsData: childrenAsData.value
  290. }));
  291. // =========================== Change ===========================
  292. const triggerChange = values => {
  293. const labeledValues = convert2LabelValues(values);
  294. setInternalValue(labeledValues);
  295. if (props.onChange && (
  296. // Trigger event only when value changed
  297. labeledValues.length !== mergedValues.value.length || labeledValues.some((newVal, index) => {
  298. var _a;
  299. return ((_a = mergedValues.value[index]) === null || _a === void 0 ? void 0 : _a.value) !== (newVal === null || newVal === void 0 ? void 0 : newVal.value);
  300. }))) {
  301. const returnValues = props.labelInValue ? labeledValues.map(v => {
  302. return _extends(_extends({}, v), {
  303. originLabel: v.label,
  304. label: typeof v.label === 'function' ? v.label() : v.label
  305. });
  306. }) : labeledValues.map(v => v.value);
  307. const returnOptions = labeledValues.map(v => injectPropsWithOption(getMixedOption(v.value)));
  308. props.onChange(
  309. // Value
  310. multiple.value ? returnValues : returnValues[0],
  311. // Option
  312. multiple.value ? returnOptions : returnOptions[0]);
  313. }
  314. };
  315. // ======================= Accessibility ========================
  316. const [activeValue, setActiveValue] = useState(null);
  317. const [accessibilityIndex, setAccessibilityIndex] = useState(0);
  318. const mergedDefaultActiveFirstOption = computed(() => props.defaultActiveFirstOption !== undefined ? props.defaultActiveFirstOption : props.mode !== 'combobox');
  319. const onActiveValue = function (active, index) {
  320. let {
  321. source = 'keyboard'
  322. } = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {};
  323. setAccessibilityIndex(index);
  324. if (props.backfill && props.mode === 'combobox' && active !== null && source === 'keyboard') {
  325. setActiveValue(String(active));
  326. }
  327. };
  328. // ========================= OptionList =========================
  329. const triggerSelect = (val, selected) => {
  330. const getSelectEnt = () => {
  331. var _a;
  332. const option = getMixedOption(val);
  333. const originLabel = option === null || option === void 0 ? void 0 : option[mergedFieldNames.value.label];
  334. return [props.labelInValue ? {
  335. label: typeof originLabel === 'function' ? originLabel() : originLabel,
  336. originLabel,
  337. value: val,
  338. key: (_a = option === null || option === void 0 ? void 0 : option.key) !== null && _a !== void 0 ? _a : val
  339. } : val, injectPropsWithOption(option)];
  340. };
  341. if (selected && props.onSelect) {
  342. const [wrappedValue, option] = getSelectEnt();
  343. props.onSelect(wrappedValue, option);
  344. } else if (!selected && props.onDeselect) {
  345. const [wrappedValue, option] = getSelectEnt();
  346. props.onDeselect(wrappedValue, option);
  347. }
  348. };
  349. // Used for OptionList selection
  350. const onInternalSelect = (val, info) => {
  351. let cloneValues;
  352. // Single mode always trigger select only with option list
  353. const mergedSelect = multiple.value ? info.selected : true;
  354. if (mergedSelect) {
  355. cloneValues = multiple.value ? [...mergedValues.value, val] : [val];
  356. } else {
  357. cloneValues = mergedValues.value.filter(v => v.value !== val);
  358. }
  359. triggerChange(cloneValues);
  360. triggerSelect(val, mergedSelect);
  361. // Clean search value if single or configured
  362. if (props.mode === 'combobox') {
  363. // setSearchValue(String(val));
  364. setActiveValue('');
  365. } else if (!multiple.value || props.autoClearSearchValue) {
  366. setSearchValue('');
  367. setActiveValue('');
  368. }
  369. };
  370. // ======================= Display Change =======================
  371. // BaseSelect display values change
  372. const onDisplayValuesChange = (nextValues, info) => {
  373. triggerChange(nextValues);
  374. if (info.type === 'remove' || info.type === 'clear') {
  375. info.values.forEach(item => {
  376. triggerSelect(item.value, false);
  377. });
  378. }
  379. };
  380. // =========================== Search ===========================
  381. const onInternalSearch = (searchText, info) => {
  382. var _a;
  383. setSearchValue(searchText);
  384. setActiveValue(null);
  385. // [Submit] Tag mode should flush input
  386. if (info.source === 'submit') {
  387. const formatted = (searchText || '').trim();
  388. // prevent empty tags from appearing when you click the Enter button
  389. if (formatted) {
  390. const newRawValues = Array.from(new Set([...rawValues.value, formatted]));
  391. triggerChange(newRawValues);
  392. triggerSelect(formatted, true);
  393. setSearchValue('');
  394. }
  395. return;
  396. }
  397. if (info.source !== 'blur') {
  398. if (props.mode === 'combobox') {
  399. triggerChange(searchText);
  400. }
  401. (_a = props.onSearch) === null || _a === void 0 ? void 0 : _a.call(props, searchText);
  402. }
  403. };
  404. const onInternalSearchSplit = words => {
  405. let patchValues = words;
  406. if (props.mode !== 'tags') {
  407. patchValues = words.map(word => {
  408. const opt = labelOptions.value.get(word);
  409. return opt === null || opt === void 0 ? void 0 : opt.value;
  410. }).filter(val => val !== undefined);
  411. }
  412. const newRawValues = Array.from(new Set([...rawValues.value, ...patchValues]));
  413. triggerChange(newRawValues);
  414. newRawValues.forEach(newRawValue => {
  415. triggerSelect(newRawValue, true);
  416. });
  417. };
  418. const realVirtual = computed(() => props.virtual !== false && props.dropdownMatchSelectWidth !== false);
  419. useProvideSelectProps(toReactive(_extends(_extends({}, parsedOptions), {
  420. flattenOptions: displayOptions,
  421. onActiveValue,
  422. defaultActiveFirstOption: mergedDefaultActiveFirstOption,
  423. onSelect: onInternalSelect,
  424. menuItemSelectedIcon: toRef(props, 'menuItemSelectedIcon'),
  425. rawValues,
  426. fieldNames: mergedFieldNames,
  427. virtual: realVirtual,
  428. listHeight: toRef(props, 'listHeight'),
  429. listItemHeight: toRef(props, 'listItemHeight'),
  430. childrenAsData
  431. })));
  432. // ========================== Warning ===========================
  433. if (process.env.NODE_ENV !== 'production') {
  434. watchEffect(() => {
  435. warningProps(props);
  436. }, {
  437. flush: 'post'
  438. });
  439. }
  440. const selectRef = ref();
  441. expose({
  442. focus() {
  443. var _a;
  444. (_a = selectRef.value) === null || _a === void 0 ? void 0 : _a.focus();
  445. },
  446. blur() {
  447. var _a;
  448. (_a = selectRef.value) === null || _a === void 0 ? void 0 : _a.blur();
  449. },
  450. scrollTo(arg) {
  451. var _a;
  452. (_a = selectRef.value) === null || _a === void 0 ? void 0 : _a.scrollTo(arg);
  453. }
  454. });
  455. const pickProps = computed(() => {
  456. return omit(props, ['id', 'mode', 'prefixCls', 'backfill', 'fieldNames',
  457. // Search
  458. 'inputValue', 'searchValue', 'onSearch', 'autoClearSearchValue',
  459. // Select
  460. 'onSelect', 'onDeselect', 'dropdownMatchSelectWidth',
  461. // Options
  462. 'filterOption', 'filterSort', 'optionFilterProp', 'optionLabelProp', 'options', 'children', 'defaultActiveFirstOption', 'menuItemSelectedIcon', 'virtual', 'listHeight', 'listItemHeight',
  463. // Value
  464. 'value', 'defaultValue', 'labelInValue', 'onChange']);
  465. });
  466. return () => {
  467. return _createVNode(BaseSelect, _objectSpread(_objectSpread(_objectSpread({}, pickProps.value), attrs), {}, {
  468. "id": mergedId,
  469. "prefixCls": props.prefixCls,
  470. "ref": selectRef,
  471. "omitDomProps": OMIT_DOM_PROPS,
  472. "mode": props.mode,
  473. "displayValues": displayValues.value,
  474. "onDisplayValuesChange": onDisplayValuesChange,
  475. "searchValue": mergedSearchValue.value,
  476. "onSearch": onInternalSearch,
  477. "onSearchSplit": onInternalSearchSplit,
  478. "dropdownMatchSelectWidth": props.dropdownMatchSelectWidth,
  479. "OptionList": OptionList,
  480. "emptyOptions": !displayOptions.value.length,
  481. "activeValue": activeValue.value,
  482. "activeDescendantId": `${mergedId}_list_${accessibilityIndex.value}`
  483. }), slots);
  484. };
  485. }
  486. });