From b786f6bba655dc11e83391d6e5a9876be8b03dba Mon Sep 17 00:00:00 2001 From: tangjinzhou <415800467@qq.com> Date: Wed, 7 Oct 2020 22:49:01 +0800 Subject: [PATCH] feat: refactor vc-select --- antd-tools/getBabelCommonConfig.js | 2 +- antd-tools/getWebpackConfig.js | 3 +- babel.config.js | 2 +- components/_util/createRef.ts | 21 +- components/_util/pickAttrs.js | 40 +- components/_util/type.ts | 2 + components/_util/vnode.js | 4 +- components/vc-select2/OptGroup.tsx | 25 +- components/vc-select2/Option.tsx | 29 +- components/vc-select2/OptionList.tsx | 113 +- components/vc-select2/Select.tsx | 92 ++ components/vc-select2/SelectTrigger.tsx | 4 +- components/vc-select2/Selector/Input.tsx | 158 ++ .../vc-select2/Selector/MultipleSelector.tsx | 281 ++++ .../vc-select2/Selector/SingleSelector.tsx | 143 ++ components/vc-select2/Selector/index.tsx | 298 ++++ components/vc-select2/assets/index.less | 345 +++++ components/vc-select2/examples/common.less | 10 + .../examples/common/tbFetchSuggest.tsx | 35 + components/vc-select2/examples/single.less | 3 + components/vc-select2/examples/single.tsx | 154 ++ components/vc-select2/generate.tsx | 1284 +++++++++++++++++ .../vc-select2/hooks/useCacheDisplayValue.ts | 35 + .../vc-select2/hooks/useCacheOptions.ts | 27 + components/vc-select2/hooks/useDelayReset.ts | 32 + components/vc-select2/hooks/useLock.ts | 29 + .../hooks/useSelectTriggerControl.ts | 26 + components/vc-select2/index.js | 9 - components/vc-select2/index.ts | 7 + components/vc-select2/interface/generator.ts | 5 +- components/vc-select2/interface/index.ts | 5 +- components/vc-select2/utils/commonUtil.ts | 120 ++ components/vc-select2/utils/legacyUtil.ts | 47 + components/vc-select2/utils/valueUtil.ts | 312 ++++ .../vc-select2/utils/warningPropsUtil.ts | 160 ++ components/vc-trigger/Trigger.jsx | 2 +- components/vc-util/Dom/addEventListener.js | 13 +- components/vc-util/Dom/contains.js | 11 - components/vc-util/Dom/contains.ts | 7 + components/vc-util/{warning.js => warning.ts} | 16 +- components/vc-virtual-list/Filler.tsx | 10 +- components/vc-virtual-list/Item.tsx | 9 +- components/vc-virtual-list/List.tsx | 8 +- .../vc-virtual-list/hooks/useScrollTo.tsx | 2 +- package.json | 2 +- tsconfig.json | 2 +- webpack.config.js | 2 +- 47 files changed, 3811 insertions(+), 135 deletions(-) create mode 100644 components/vc-select2/Select.tsx create mode 100644 components/vc-select2/Selector/Input.tsx create mode 100644 components/vc-select2/Selector/MultipleSelector.tsx create mode 100644 components/vc-select2/Selector/SingleSelector.tsx create mode 100644 components/vc-select2/Selector/index.tsx create mode 100644 components/vc-select2/assets/index.less create mode 100644 components/vc-select2/examples/common.less create mode 100644 components/vc-select2/examples/common/tbFetchSuggest.tsx create mode 100644 components/vc-select2/examples/single.less create mode 100644 components/vc-select2/examples/single.tsx create mode 100644 components/vc-select2/generate.tsx create mode 100644 components/vc-select2/hooks/useCacheDisplayValue.ts create mode 100644 components/vc-select2/hooks/useCacheOptions.ts create mode 100644 components/vc-select2/hooks/useDelayReset.ts create mode 100644 components/vc-select2/hooks/useLock.ts create mode 100644 components/vc-select2/hooks/useSelectTriggerControl.ts delete mode 100644 components/vc-select2/index.js create mode 100644 components/vc-select2/index.ts create mode 100644 components/vc-select2/utils/commonUtil.ts create mode 100644 components/vc-select2/utils/legacyUtil.ts create mode 100644 components/vc-select2/utils/valueUtil.ts create mode 100644 components/vc-select2/utils/warningPropsUtil.ts delete mode 100644 components/vc-util/Dom/contains.js create mode 100644 components/vc-util/Dom/contains.ts rename components/vc-util/{warning.js => warning.ts} (60%) diff --git a/antd-tools/getBabelCommonConfig.js b/antd-tools/getBabelCommonConfig.js index b6886ea4e..80711ca4b 100644 --- a/antd-tools/getBabelCommonConfig.js +++ b/antd-tools/getBabelCommonConfig.js @@ -2,7 +2,7 @@ module.exports = function(modules) { const plugins = [ - require.resolve('@vue/babel-plugin-jsx'), + [require.resolve('@vue/babel-plugin-jsx'), { mergeProps: false }], require.resolve('@babel/plugin-proposal-optional-chaining'), require.resolve('@babel/plugin-transform-object-assign'), require.resolve('@babel/plugin-proposal-object-rest-spread'), diff --git a/antd-tools/getWebpackConfig.js b/antd-tools/getWebpackConfig.js index 5c2e646fc..bd91053d6 100644 --- a/antd-tools/getWebpackConfig.js +++ b/antd-tools/getWebpackConfig.js @@ -83,7 +83,7 @@ function getWebpackConfig(modules) { options: { presets: [require.resolve('@babel/preset-env')], plugins: [ - require.resolve('@vue/babel-plugin-jsx'), + [require.resolve('@vue/babel-plugin-jsx'), { mergeProps: false }], require.resolve('@babel/plugin-proposal-object-rest-spread'), ], }, @@ -231,7 +231,6 @@ All rights reserved. return config; } -getWebpackConfig.webpack = webpack; getWebpackConfig.svgRegex = svgRegex; getWebpackConfig.svgOptions = svgOptions; getWebpackConfig.imageOptions = imageOptions; diff --git a/babel.config.js b/babel.config.js index 10f5a0440..f4457cb2b 100644 --- a/babel.config.js +++ b/babel.config.js @@ -3,7 +3,7 @@ module.exports = { test: { presets: [['@babel/preset-env', { targets: { node: true } }]], plugins: [ - '@vue/babel-plugin-jsx', + ['@vue/babel-plugin-jsx', { mergeProps: false }], '@babel/plugin-proposal-optional-chaining', '@babel/plugin-transform-object-assign', '@babel/plugin-proposal-object-rest-spread', diff --git a/components/_util/createRef.ts b/components/_util/createRef.ts index f8a7a43de..e64a67115 100644 --- a/components/_util/createRef.ts +++ b/components/_util/createRef.ts @@ -1,4 +1,4 @@ -interface RefObject extends Function { +export interface RefObject extends Function { current?: any; } @@ -9,4 +9,23 @@ function createRef(): RefObject { return func; } +export function fillRef(ref, node: T) { + if (typeof ref === 'function') { + ref(node); + } else if (typeof ref === 'object' && ref && 'current' in ref) { + (ref as any).current = node; + } +} + +/** + * Merge refs into one ref function to support ref passing. + */ +export function composeRef(...refs: any[]) { + return (node: T) => { + refs.forEach(ref => { + fillRef(ref, node); + }); + }; +} + export default createRef; diff --git a/components/_util/pickAttrs.js b/components/_util/pickAttrs.js index 30a241893..2533eceab 100644 --- a/components/_util/pickAttrs.js +++ b/components/_util/pickAttrs.js @@ -1,23 +1,23 @@ -const attributes = `accept acceptCharset accessKey action allowFullScreen allowTransparency - alt async autoComplete autoFocus autoPlay capture cellPadding cellSpacing challenge - charSet checked classID className colSpan cols content contentEditable contextMenu - controls coords crossOrigin data dateTime default defer dir disabled download draggable - encType form formAction formEncType formMethod formNoValidate formTarget frameBorder - headers height hidden high href hrefLang htmlFor httpEquiv icon id inputMode integrity - is keyParams keyType kind label lang list loop low manifest marginHeight marginWidth max maxLength media - mediaGroup method min minLength multiple muted name noValidate nonce open - optimum pattern placeholder poster preload radioGroup readOnly rel required - reversed role rowSpan rows sandbox scope scoped scrolling seamless selected - shape size sizes span spellCheck src srcDoc srcLang srcSet start step style - summary tabIndex target title type useMap value width wmode wrap`; +const attributes = `accept acceptcharset accesskey action allowfullscreen allowtransparency +alt async autocomplete autofocus autoplay capture cellpadding cellspacing challenge +charset checked classid classname colspan cols content contenteditable contextmenu +controls coords crossorigin data datetime default defer dir disabled download draggable +enctype form formaction formenctype formmethod formnovalidate formtarget frameborder +headers height hidden high href hreflang htmlfor httpequiv icon id inputmode integrity +is keyparams keytype kind label lang list loop low manifest marginheight marginwidth max maxlength media +mediagroup method min minlength multiple muted name novalidate nonce open +optimum pattern placeholder poster preload radiogroup readonly rel required +reversed role rowspan rows sandbox scope scoped scrolling seamless selected +shape size sizes span spellcheck src srcdoc srclang srcset start step style +summary tabindex target title type usemap value width wmode wrap`; -const eventsName = `onCopy onCut onPaste onCompositionEnd onCompositionStart onCompositionUpdate onKeyDown - onKeyPress onKeyUp onFocus onBlur onChange onInput onSubmit onClick onContextMenu onDoubleClick - onDrag onDragEnd onDragEnter onDragExit onDragLeave onDragOver onDragStart onDrop onMouseDown - onMouseEnter onMouseLeave onMouseMove onMouseOut onMouseOver onMouseUp onSelect onTouchCancel - onTouchEnd onTouchMove onTouchStart onScroll onWheel onAbort onCanPlay onCanPlayThrough - onDurationChange onEmptied onEncrypted onEnded onError onLoadedData onLoadedMetadata - onLoadStart onPause onPlay onPlaying onProgress onRateChange onSeeked onSeeking onStalled onSuspend onTimeUpdate onVolumeChange onWaiting onLoad onError`; +const eventsName = `onCopy onCut onPaste onCompositionend onCompositionstart onCompositionupdate onKeydown + onKeypress onKeyup onFocus onBlur onChange onInput onSubmit onClick onContextmenu onDoubleclick onDblclick + onDrag onDragend onDragenter onDragexit onDragleave onDragover onDragstart onDrop onMousedown + onMouseenter onMouseleave onMousemove onMouseout onMouseover onMouseup onSelect onTouchcancel + onTouchend onTouchmove onTouchstart onScroll onWheel onAbort onCanplay onCanplaythrough + onDurationchange onEmptied onEncrypted onEnded onError onLoadeddata onLoadedmetadata + onLoadstart onPause onPlay onPlaying onProgress onRatechange onSeeked onSeeking onStalled onSuspend onTimeupdate onVolumechange onWaiting onLoad onError`; const propList = `${attributes} ${eventsName}`.split(/[\s\n]+/); @@ -60,7 +60,7 @@ export default function pickAttrs(props, ariaOnly = false) { // Data (mergedConfig.data && match(key, dataPrefix)) || // Attr - (mergedConfig.attr && propList.includes(key)) + (mergedConfig.attr && (propList.includes(key) || propList.includes(key.toLowerCase()))) ) { attrs[key] = props[key]; } diff --git a/components/_util/type.ts b/components/_util/type.ts index f96c9ca85..4059fa18c 100644 --- a/components/_util/type.ts +++ b/components/_util/type.ts @@ -19,6 +19,8 @@ export type LiteralUnion = T | (U & {}); export type Data = Record; +export type Key = string | number; + type DefaultFactory = (props: Data) => T | null | undefined; export interface PropOptions { diff --git a/components/_util/vnode.js b/components/_util/vnode.js index aa786c34b..4f01250be 100644 --- a/components/_util/vnode.js +++ b/components/_util/vnode.js @@ -2,7 +2,7 @@ import { filterEmpty } from './props-util'; import { cloneVNode } from 'vue'; import warning from './warning'; -export function cloneElement(vnode, nodeProps = {}, override = true) { +export function cloneElement(vnode, nodeProps = {}, override = true, mergeRef = false) { let ele = vnode; if (Array.isArray(vnode)) { ele = filterEmpty(vnode)[0]; @@ -10,7 +10,7 @@ export function cloneElement(vnode, nodeProps = {}, override = true) { if (!ele) { return null; } - const node = cloneVNode(ele, nodeProps); + const node = cloneVNode(ele, nodeProps, mergeRef); // cloneVNode内部是合并属性,这里改成覆盖属性 node.props = override ? { ...node.props, ...nodeProps } : node.props; diff --git a/components/vc-select2/OptGroup.tsx b/components/vc-select2/OptGroup.tsx index 1293870d5..65afe9e77 100644 --- a/components/vc-select2/OptGroup.tsx +++ b/components/vc-select2/OptGroup.tsx @@ -1,11 +1,14 @@ -import PropTypes from '../_util/vue-types'; -export default { - props: { - value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - label: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - }, - isSelectOptGroup: true, - render() { - return null; - }, -}; +import { FunctionalComponent } from 'vue'; + +import { OptionGroupData } from './interface'; + +export interface OptGroupProps extends Omit {} + +export interface OptionGroupFC extends FunctionalComponent { + /** Legacy for check if is a Option Group */ + isSelectOptGroup: boolean; +} + +const OptGroup: OptionGroupFC = () => null; +OptGroup.isSelectOptGroup = true; +export default OptGroup; diff --git a/components/vc-select2/Option.tsx b/components/vc-select2/Option.tsx index 6704e4c42..9b8cdcad8 100644 --- a/components/vc-select2/Option.tsx +++ b/components/vc-select2/Option.tsx @@ -1,14 +1,17 @@ -import PropTypes from '../_util/vue-types'; +import { FunctionalComponent } from 'vue'; -export default { - props: { - value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - label: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - disabled: PropTypes.bool, - title: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - }, - isSelectOption: true, - render() { - return null; - }, -}; +import { OptionCoreData } from './interface'; + +export interface OptionProps extends Omit { + /** Save for customize data */ + [prop: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any +} + +export interface OptionFC extends FunctionalComponent { + /** Legacy for check if is a Option Group */ + isSelectOption: boolean; +} + +const Option: OptionFC = () => null; +Option.isSelectOption = true; +export default Option; diff --git a/components/vc-select2/OptionList.tsx b/components/vc-select2/OptionList.tsx index 975f50c78..54fa9a558 100644 --- a/components/vc-select2/OptionList.tsx +++ b/components/vc-select2/OptionList.tsx @@ -5,8 +5,41 @@ import classNames from '../_util/classNames'; import pickAttrs from '../_util/pickAttrs'; import { isValidElement } from '../_util/props-util'; import createRef from '../_util/createRef'; -import { computed, defineComponent, reactive, watch } from 'vue'; +import { computed, defineComponent, reactive, VNodeChild, watch } from 'vue'; import List from '../vc-virtual-list/List'; +import { + OptionsType as SelectOptionsType, + OptionData, + RenderNode, + OnActiveValue, +} from './interface'; +import { RawValueType, FlattenOptionsType } from './interface/generator'; +export interface OptionListProps { + prefixCls: string; + id: string; + options: SelectOptionsType; + flattenOptions: FlattenOptionsType; + height: number; + itemHeight: number; + values: Set; + multiple: boolean; + open: boolean; + defaultActiveFirstOption?: boolean; + notFoundContent?: VNodeChild; + menuItemSelectedIcon?: RenderNode; + childrenAsData: boolean; + searchValue: string; + virtual: boolean; + + onSelect: (value: RawValueType, option: { selected: boolean }) => void; + onToggleOpen: (open?: boolean) => void; + /** Tell Select that some value is now active to make accessibility work */ + onActiveValue: OnActiveValue; + onScroll: EventHandlerNonNull; + + /** Tell Select that mouse enter the popup to force re-render */ + onMouseenter?: EventHandlerNonNull; +} const OptionListProps = { prefixCls: PropTypes.string, @@ -39,8 +72,7 @@ const OptionListProps = { * Using virtual list of option display. * Will fallback to dom if use customize render. */ -const OptionList = defineComponent({ - props: OptionListProps, +const OptionList = defineComponent({ name: 'OptionList', inheritAttrs: false, setup(props) { @@ -49,25 +81,25 @@ const OptionList = defineComponent({ // =========================== List =========================== const listRef = createRef(); - const onListMouseDown = event => { + const onListMouseDown: EventHandlerNonNull = event => { event.preventDefault(); }; - const scrollIntoView = index => { + const scrollIntoView = (index: number) => { if (listRef.current) { listRef.current.scrollTo({ index }); } }; // ========================== Active ========================== - const getEnabledActiveIndex = (index, offset = 1) => { + const getEnabledActiveIndex = (index: number, offset = 1) => { const len = props.flattenOptions.length; for (let i = 0; i < len; i += 1) { const current = (index + i * offset + len) % len; const { group, data } = props.flattenOptions[current]; - if (!group && !data.disabled) { + if (!group && !(data as OptionData).disabled) { return current; } } @@ -78,9 +110,9 @@ const OptionList = defineComponent({ activeIndex: getEnabledActiveIndex(0), }); - const setActive = (index, fromKeyboard = false) => { + const setActive = (index: number, fromKeyboard = false) => { state.activeIndex = index; - const info = { source: fromKeyboard ? 'keyboard' : 'mouse' }; + const info = { source: fromKeyboard ? ('keyboard' as const) : ('mouse' as const) }; // Trigger active event const flattenItem = props.flattenOptions[index]; @@ -94,30 +126,38 @@ const OptionList = defineComponent({ // Auto active first item when list length or searchValue changed - watch([props.flattenOptions.length, props.searchValue], () => { - setActive(props.defaultActiveFirstOption !== false ? getEnabledActiveIndex(0) : -1); - }); + watch( + computed(() => [props.flattenOptions.length, props.searchValue]), + () => { + setActive(props.defaultActiveFirstOption !== false ? getEnabledActiveIndex(0) : -1); + }, + ); // Auto scroll to item position in single mode - watch(props.open, () => { - /** - * React will skip `onChange` when component update. - * `setActive` function will call root accessibility state update which makes re-render. - * So we need to delay to let Input component trigger onChange first. - */ - const timeoutId = setTimeout(() => { - if (!props.multiple && props.open && props.values.size === 1) { - const value = Array.from(props.values)[0]; - const index = props.flattenOptions.findIndex(({ data }) => data.value === value); - setActive(index); - scrollIntoView(index); - } - }); - return () => clearTimeout(timeoutId); - }); + let timeoutId: number; + watch( + computed(() => props.open), + () => { + /** + * React will skip `onChange` when component update. + * `setActive` function will call root accessibility state update which makes re-render. + * So we need to delay to let Input component trigger onChange first. + */ + clearTimeout(timeoutId); + timeoutId = setTimeout(() => { + if (!props.multiple && props.open && props.values.size === 1) { + const value = Array.from(props.values)[0]; + const index = props.flattenOptions.findIndex(({ data }) => data.value === value); + setActive(index); + scrollIntoView(index); + } + }); + }, + { immediate: true, flush: 'post' }, + ); // ========================== Values ========================== - const onSelectValue = value => { + const onSelectValue = (value?: RawValueType) => { if (value !== undefined) { props.onSelect(value, { selected: !props.values.has(value) }); } @@ -128,11 +168,11 @@ const OptionList = defineComponent({ } }; - function renderItem(index) { + function renderItem(index: number) { const item = props.flattenOptions[index]; if (!item) return null; - const itemData = item.data || {}; + const itemData = (item.data || {}) as OptionData; const { value, label, children } = itemData; const attrs = pickAttrs(itemData, true); const mergedLabel = props.childrenAsData ? children : label; @@ -157,7 +197,7 @@ const OptionList = defineComponent({ itemPrefixCls, setActive, onSelectValue, - onKeydown: event => { + onKeydown: (event: KeyboardEvent) => { const { which } = event; switch (which) { // >>> Arrow keys @@ -204,7 +244,7 @@ const OptionList = defineComponent({ }, onKeyup: () => {}, - scrollTo: index => { + scrollTo: (index: number) => { scrollIntoView(index); }, }; @@ -256,8 +296,7 @@ const OptionList = defineComponent({ onScroll={onScroll} virtual={virtual} onMouseenter={onMouseenter} - > - {({ group, groupOption, data }, itemIndex) => { + children={({ group, groupOption, data }, itemIndex) => { const { label, key } = data; // Group @@ -339,10 +378,12 @@ const OptionList = defineComponent({ ); }} - + > ); }, }); +OptionList.props = OptionListProps; + export default OptionList; diff --git a/components/vc-select2/Select.tsx b/components/vc-select2/Select.tsx new file mode 100644 index 000000000..de323e75b --- /dev/null +++ b/components/vc-select2/Select.tsx @@ -0,0 +1,92 @@ +/** + * To match accessibility requirement, we always provide an input in the component. + * Other element will not set `tabIndex` to avoid `onBlur` sequence problem. + * For focused select, we set `aria-live="polite"` to update the accessibility content. + * + * ref: + * - keyboard: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/listbox_role#Keyboard_interactions + * + * New api: + * - listHeight + * - listItemHeight + * - component + * + * Remove deprecated api: + * - multiple + * - tags + * - combobox + * - firstActiveValue + * - dropdownMenuStyle + * - openClassName (Not list in api) + * + * Update: + * - `backfill` only support `combobox` mode + * - `combobox` mode not support `labelInValue` since it's meaningless + * - `getInputElement` only support `combobox` mode + * - `onChange` return OptionData instead of ReactNode + * - `filterOption` `onChange` `onSelect` accept OptionData instead of ReactNode + * - `combobox` mode trigger `onChange` will get `undefined` if no `value` match in Option + * - `combobox` mode not support `optionLabelProp` + */ + +import { OptionsType as SelectOptionsType } from './interface'; +import SelectOptionList from './OptionList'; +import Option from './Option'; +import OptGroup from './OptGroup'; +import { convertChildrenToData as convertSelectChildrenToData } from './utils/legacyUtil'; +import { + getLabeledValue as getSelectLabeledValue, + filterOptions as selectDefaultFilterOptions, + isValueDisabled as isSelectValueDisabled, + findValueOption as findSelectValueOption, + flattenOptions, + fillOptionsWithMissingValue, +} from './utils/valueUtil'; +import generateSelector, { SelectProps } from './generate'; +import { DefaultValueType } from './interface/generator'; +import warningProps from './utils/warningPropsUtil'; +import { defineComponent, ref } from 'vue'; +import { getSlot } from '../_util/props-util'; +import omit from 'lodash-es/omit'; + +const RefSelect = generateSelector({ + prefixCls: 'rc-select', + components: { + optionList: SelectOptionList, + }, + convertChildrenToData: convertSelectChildrenToData, + flattenOptions, + getLabeledValue: getSelectLabeledValue, + filterOptions: selectDefaultFilterOptions, + isValueDisabled: isSelectValueDisabled, + findValueOption: findSelectValueOption, + warningProps, + fillOptionsWithMissingValue, +}); + +export type ExportedSelectProps< + ValueType extends DefaultValueType = DefaultValueType +> = SelectProps; + +const Select = defineComponent>({ + setup() { + const selectRef = ref(null); + return { + selectRef, + focus: () => { + selectRef.value?.focus(); + }, + blur: () => { + selectRef.value?.blur(); + }, + }; + }, + render() { + return ; + }, +}); +Select.inheritAttrs = false; +Select.props = omit(RefSelect.props, ['children']); +Select.Option = Option; +Select.OptGroup = OptGroup; +export default Select; diff --git a/components/vc-select2/SelectTrigger.tsx b/components/vc-select2/SelectTrigger.tsx index 88fe04cca..c736b16f2 100644 --- a/components/vc-select2/SelectTrigger.tsx +++ b/components/vc-select2/SelectTrigger.tsx @@ -49,7 +49,7 @@ export interface SelectTriggerProps { prefixCls: string; disabled: boolean; visible: boolean; - popupElement: VNodeChild; + popupElement: VNodeChild | JSX.Element; animation?: string; transitionName?: string; containerWidth: number; @@ -61,7 +61,7 @@ export interface SelectTriggerProps { getPopupContainer?: RenderDOMFunc; dropdownAlign: object; empty: boolean; - getTriggerDOMNode: () => HTMLElement; + getTriggerDOMNode: () => any; } const SelectTrigger = defineComponent({ name: 'SelectTrigger', diff --git a/components/vc-select2/Selector/Input.tsx b/components/vc-select2/Selector/Input.tsx new file mode 100644 index 000000000..4278cd789 --- /dev/null +++ b/components/vc-select2/Selector/Input.tsx @@ -0,0 +1,158 @@ +import { cloneElement } from '../../_util/vnode'; +import { defineComponent, inject, VNode, VNodeChild, withDirectives } from 'vue'; +import PropTypes from '../../_util/vue-types'; +import { RefObject } from '../../_util/createRef'; +import antInput from '../../_util/antInputDirective'; + +interface InputProps { + prefixCls: string; + id: string; + inputElement: VNodeChild; + disabled: boolean; + autofocus: boolean; + autocomplete: string; + editable: boolean; + accessibilityIndex: number; + value: string; + open: boolean; + tabindex: number; + /** Pass accessibility props to input */ + attrs: object; + inputRef: RefObject; + onKeydown: EventHandlerNonNull; + onMousedown: EventHandlerNonNull; + onChange: EventHandlerNonNull; + onPaste: EventHandlerNonNull; + onCompositionstart: EventHandlerNonNull; + onCompositionend: EventHandlerNonNull; +} + +const Input = defineComponent({ + name: 'Input', + inheritAttrs: false, + setup() { + return { + VCSelectContainerEvent: inject('VCSelectContainerEvent'), + }; + }, + render() { + const { + prefixCls, + id, + inputElement, + disabled, + tabindex, + autofocus, + autocomplete, + editable, + accessibilityIndex, + value, + onKeydown, + onMousedown, + onChange, + onPaste, + onCompositionstart, + onCompositionend, + open, + inputRef, + attrs, + } = this.$props as InputProps; + let inputNode: any = withDirectives((inputElement || ) as VNode, [[antInput]]); + + const inputProps = inputNode.props || {}; + const { + onKeydown: onOriginKeyDown, + onInput: onOriginInput, + onMousedown: onOriginMouseDown, + onCompositionstart: onOriginCompositionStart, + onCompositionend: onOriginCompositionEnd, + style, + } = inputProps; + + inputNode = cloneElement(inputNode, { + id, + ref: inputRef, + disabled, + tabindex, + autocomplete: autocomplete || 'off', + type: 'search', + autofocus, + class: `${prefixCls}-selection-search-input`, + style: { ...style, opacity: editable ? null : 0 }, + role: 'combobox', + 'aria-expanded': open, + 'aria-haspopup': 'listbox', + 'aria-owns': `${id}_list`, + 'aria-autocomplete': 'list', + 'aria-controls': `${id}_list`, + 'aria-activedescendant': `${id}_list_${accessibilityIndex}`, + ...attrs, + value: editable ? value : '', + readonly: !editable, + unselectable: !editable ? 'on' : null, + onKeydown: (event: KeyboardEvent) => { + onKeydown(event); + if (onOriginKeyDown) { + onOriginKeyDown(event); + } + }, + onMousedown: (event: MouseEvent) => { + onMousedown(event); + if (onOriginMouseDown) { + onOriginMouseDown(event); + } + }, + onInput: (event: Event) => { + onChange(event); + if (onOriginInput) { + onOriginInput(event); + } + }, + onCompositionstart(event: CompositionEvent) { + onCompositionstart(event); + if (onOriginCompositionStart) { + onOriginCompositionStart(event); + } + }, + onCompositionend(event: CompositionEvent) { + onCompositionend(event); + if (onOriginCompositionEnd) { + onOriginCompositionEnd(event); + } + }, + onPaste, + onFocus: (...args: any[]) => { + this.VCSelectContainerEvent?.focus(args[0]); + }, + onBlur: (...args: any[]) => { + this.VCSelectContainerEvent?.blur(args[0]); + }, + }) as VNode; + return inputNode; + }, +}); + +Input.props = { + inputRef: PropTypes.any, + prefixCls: PropTypes.string, + id: PropTypes.string, + inputElement: PropTypes.any, + disabled: PropTypes.bool, + autofocus: PropTypes.bool, + autocomplete: PropTypes.string, + editable: PropTypes.bool, + accessibilityIndex: PropTypes.number, + value: PropTypes.string, + open: PropTypes.bool, + tabindex: PropTypes.number, + /** Pass accessibility props to input */ + attrs: PropTypes.object, + onKeydown: PropTypes.func, + onMousedown: PropTypes.func, + onChange: PropTypes.func, + onPaste: PropTypes.func, + onCompositionstart: PropTypes.func, + onCompositionend: PropTypes.func, +}; + +export default Input; diff --git a/components/vc-select2/Selector/MultipleSelector.tsx b/components/vc-select2/Selector/MultipleSelector.tsx new file mode 100644 index 000000000..4bf350451 --- /dev/null +++ b/components/vc-select2/Selector/MultipleSelector.tsx @@ -0,0 +1,281 @@ +import TransBtn from '../TransBtn'; +import { LabelValueType, RawValueType, CustomTagProps } from '../interface/generator'; +import { RenderNode } from '../interface'; +import { InnerSelectorProps } from '.'; +import Input from './Input'; +import { + computed, + defineComponent, + onMounted, + ref, + TransitionGroup, + VNodeChild, + watch, + watchEffect, + Ref, +} from 'vue'; +import classNames from '../../_util/classNames'; +import pickAttrs from '../../_util/pickAttrs'; +import PropTypes from '../../_util/vue-types'; +import getTransitionProps from '../../_util/getTransitionProps'; + +const REST_TAG_KEY = '__RC_SELECT_MAX_REST_COUNT__'; + +interface SelectorProps extends InnerSelectorProps { + // Icon + removeIcon?: RenderNode; + + // Tags + maxTagCount?: number; + maxTagTextLength?: number; + maxTagPlaceholder?: VNodeChild; + tokenSeparators?: string[]; + tagRender?: (props: CustomTagProps) => VNodeChild; + + // Motion + choiceTransitionName?: string; + + // Event + onSelect: (value: RawValueType, option: { selected: boolean }) => void; +} + +const props = { + id: PropTypes.string, + prefixCls: PropTypes.string, + values: PropTypes.array, + open: PropTypes.bool, + searchValue: PropTypes.string, + inputRef: PropTypes.any, + placeholder: PropTypes.any, + disabled: PropTypes.bool, + mode: PropTypes.string, + showSearch: PropTypes.bool, + autofocus: PropTypes.bool, + autocomplete: PropTypes.string, + accessibilityIndex: PropTypes.number, + tabindex: PropTypes.number, + + removeIcon: PropTypes.bool, + choiceTransitionName: PropTypes.string, + + maxTagCount: PropTypes.number, + maxTagTextLength: PropTypes.number, + maxTagPlaceholder: PropTypes.any.def( + (omittedValues: LabelValueType[]) => `+ ${omittedValues.length} ...`, + ), + tagRender: PropTypes.func, + + onSelect: PropTypes.func, + onInputChange: PropTypes.func, + onInputPaste: PropTypes.func, + onInputKeyDown: PropTypes.func, + onInputMouseDown: PropTypes.func, + onInputCompositionStart: PropTypes.func, + onInputCompositionEnd: PropTypes.func, +}; + +const SelectSelector = defineComponent({ + name: 'SelectSelector', + setup(props) { + let motionAppear = false; // not need use ref, because not need trigger watchEffect + const measureRef = ref(); + const inputWidth = ref(0); + + // ===================== Motion ====================== + onMounted(() => { + motionAppear = true; + }); + + // ===================== Search ====================== + const inputValue = computed(() => + props.open || props.mode === 'tags' ? props.searchValue : '', + ); + const inputEditable: Ref = computed( + () => props.mode === 'tags' || ((props.open && props.showSearch) as boolean), + ); + + // We measure width and set to the input immediately + watch( + inputValue, + () => { + inputWidth.value = measureRef.value.scrollWidth; + }, + { flush: 'pre' }, + ); + const selectionNode = ref(); + watchEffect(() => { + const { + values, + prefixCls, + removeIcon, + choiceTransitionName, + maxTagCount, + maxTagTextLength, + maxTagPlaceholder = (omittedValues: LabelValueType[]) => `+ ${omittedValues.length} ...`, + tagRender, + onSelect, + } = props; + // ==================== Selection ==================== + let displayValues: LabelValueType[] = values; + + // Cut by `maxTagCount` + let restCount: number; + if (typeof maxTagCount === 'number') { + restCount = values.length - maxTagCount; + displayValues = values.slice(0, maxTagCount); + } + + // Update by `maxTagTextLength` + if (typeof maxTagTextLength === 'number') { + displayValues = displayValues.map(({ label, ...rest }) => { + let displayLabel = label; + + if (typeof label === 'string' || typeof label === 'number') { + const strLabel = String(displayLabel); + + if (strLabel.length > maxTagTextLength) { + displayLabel = `${strLabel.slice(0, maxTagTextLength)}...`; + } + } + + return { + ...rest, + label: displayLabel, + }; + }); + } + + // Fill rest + if (restCount > 0) { + displayValues.push({ + key: REST_TAG_KEY, + label: + typeof maxTagPlaceholder === 'function' + ? maxTagPlaceholder(values.slice(maxTagCount)) + : maxTagPlaceholder, + }); + } + const transitionProps = getTransitionProps(choiceTransitionName, { + appear: motionAppear, + }); + selectionNode.value = ( + + {displayValues.map( + ({ key, label, value, disabled: itemDisabled, class: className, style }) => { + const mergedKey = key || value; + const closable = key !== REST_TAG_KEY && !itemDisabled; + const onMousedown = (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + }; + const onClose = (event?: MouseEvent) => { + if (event) event.stopPropagation(); + onSelect(value as RawValueType, { selected: false }); + }; + + return typeof tagRender === 'function' ? ( + + {tagRender({ + label, + value, + disabled: itemDisabled, + closable, + onClose, + } as CustomTagProps)} + + ) : ( + + {label} + {closable && ( + + × + + )} + + ); + }, + )} + {} + + ); + }); + + return () => { + const { + id, + prefixCls, + values, + open, + inputRef, + placeholder, + disabled, + autofocus, + autocomplete, + accessibilityIndex, + tabindex, + onInputChange, + onInputPaste, + onInputKeyDown, + onInputMouseDown, + onInputCompositionStart, + onInputCompositionEnd, + } = props; + return ( + <> + {selectionNode.value} + + + + {/* Measure Node */} + + {inputValue.value}  + + + + {!values.length && !inputValue.value && ( + {placeholder} + )} + + ); + }; + }, +}); +SelectSelector.inheritAttrs = false; +SelectSelector.props = props; +export default SelectSelector; diff --git a/components/vc-select2/Selector/SingleSelector.tsx b/components/vc-select2/Selector/SingleSelector.tsx new file mode 100644 index 000000000..d519c291a --- /dev/null +++ b/components/vc-select2/Selector/SingleSelector.tsx @@ -0,0 +1,143 @@ +import pickAttrs from '../../_util/pickAttrs'; +import Input from './Input'; +import { InnerSelectorProps } from '.'; +import { computed, defineComponent, ref, VNodeChild, watch } from 'vue'; +import PropTypes from '../../_util/vue-types'; + +interface SelectorProps extends InnerSelectorProps { + inputElement: VNodeChild; + activeValue: string; + backfill?: boolean; +} +const props = { + inputElement: PropTypes.any, + id: PropTypes.string, + prefixCls: PropTypes.string, + values: PropTypes.array, + open: PropTypes.bool, + searchValue: PropTypes.string, + inputRef: PropTypes.any, + placeholder: PropTypes.any, + disabled: PropTypes.bool, + mode: PropTypes.string, + showSearch: PropTypes.bool, + autofocus: PropTypes.bool, + autocomplete: PropTypes.string, + accessibilityIndex: PropTypes.number, + tabindex: PropTypes.number, + activeValue: PropTypes.string, + backfill: PropTypes.bool, + onInputChange: PropTypes.func, + onInputPaste: PropTypes.func, + onInputKeyDown: PropTypes.func, + onInputMouseDown: PropTypes.func, + onInputCompositionStart: PropTypes.func, + onInputCompositionEnd: PropTypes.func, +}; +const SingleSelector = defineComponent({ + name: 'SingleSelector', + setup(props) { + const inputChanged = ref(false); + + const combobox = computed(() => props.mode === 'combobox'); + const inputEditable = computed(() => combobox.value || props.showSearch); + + const inputValue = computed(() => { + let inputValue: string = props.searchValue || ''; + if (combobox.value && props.activeValue && !inputChanged.value) { + inputValue = props.activeValue; + } + return inputValue; + }); + watch( + computed(() => [combobox.value, props.activeValue]), + () => { + if (combobox.value) { + inputChanged.value = false; + } + }, + ); + + // Not show text when closed expect combobox mode + const hasTextInput = computed(() => + props.mode !== 'combobox' && !props.open ? false : !!inputValue.value, + ); + + const title = computed(() => { + const item = props.values[0]; + return item && (typeof item.label === 'string' || typeof item.label === 'number') + ? item.label.toString() + : undefined; + }); + + return () => { + const { + inputElement, + prefixCls, + id, + values, + inputRef, + disabled, + autofocus, + autocomplete, + accessibilityIndex, + open, + placeholder, + tabindex, + onInputKeyDown, + onInputMouseDown, + onInputChange, + onInputPaste, + onInputCompositionStart, + onInputCompositionEnd, + } = props; + const item = values[0]; + return ( + <> + + { + inputChanged.value = true; + onInputChange(e as any); + }} + onPaste={onInputPaste} + onCompositionstart={onInputCompositionStart} + onCompositionend={onInputCompositionEnd} + tabindex={tabindex} + attrs={pickAttrs(props, true)} + /> + + + {/* Display value */} + {!combobox.value && item && !hasTextInput.value && ( + + {Array.isArray(item.label) ? item.label.map(la => la) : item.label} + + )} + + {/* Display placeholder */} + {!item && !hasTextInput.value && ( + {placeholder} + )} + + ); + }; + }, +}); +SingleSelector.props = props; +SingleSelector.inheritAttrs = false; + +export default SingleSelector; diff --git a/components/vc-select2/Selector/index.tsx b/components/vc-select2/Selector/index.tsx new file mode 100644 index 000000000..90e88e52d --- /dev/null +++ b/components/vc-select2/Selector/index.tsx @@ -0,0 +1,298 @@ +/** + * Cursor rule: + * 1. Only `showSearch` enabled + * 2. Only `open` is `true` + * 3. When typing, set `open` to `true` which hit rule of 2 + * + * Accessibility: + * - https://www.w3.org/TR/wai-aria-practices/examples/combobox/aria1.1pattern/listbox-combo.html + */ + +import KeyCode from '../../_util/KeyCode'; +import MultipleSelector from './MultipleSelector'; +import SingleSelector from './SingleSelector'; +import { LabelValueType, RawValueType, CustomTagProps } from '../interface/generator'; +import { RenderNode, Mode } from '../interface'; +import useLock from '../hooks/useLock'; +import { defineComponent, VNode, VNodeChild } from 'vue'; +import createRef, { RefObject } from '../../_util/createRef'; +import PropTypes from '../../_util/vue-types copy'; + +export interface InnerSelectorProps { + prefixCls: string; + id: string; + mode: Mode; + inputRef: RefObject; + placeholder?: VNodeChild; + disabled?: boolean; + autofocus?: boolean; + autocomplete?: string; + values: LabelValueType[]; + showSearch?: boolean; + searchValue: string; + accessibilityIndex: number; + open: boolean; + tabindex?: number; + onInputKeyDown: EventHandlerNonNull; + onInputMouseDown: EventHandlerNonNull; + onInputChange: EventHandlerNonNull; + onInputPaste: EventHandlerNonNull; + onInputCompositionStart: EventHandlerNonNull; + onInputCompositionEnd: EventHandlerNonNull; +} + +export interface SelectorProps { + id: string; + prefixCls: string; + showSearch?: boolean; + open: boolean; + /** Display in the Selector value, it's not same as `value` prop */ + values: LabelValueType[]; + multiple: boolean; + mode: Mode; + searchValue: string; + activeValue: string; + inputElement: JSX.Element; + + autofocus?: boolean; + accessibilityIndex: number; + tabindex?: number; + disabled?: boolean; + placeholder?: VNodeChild; + removeIcon?: RenderNode; + + // Tags + maxTagCount?: number; + maxTagTextLength?: number; + maxTagPlaceholder?: VNodeChild; + tagRender?: (props: CustomTagProps) => VNode; + + /** Check if `tokenSeparators` contains `\n` or `\r\n` */ + tokenWithEnter?: boolean; + + // Motion + choiceTransitionName?: string; + + onToggleOpen: (open?: boolean) => void; + /** `onSearch` returns go next step boolean to check if need do toggle open */ + onSearch: (searchText: string, fromTyping: boolean, isCompositing: boolean) => boolean; + onSearchSubmit: (searchText: string) => void; + onSelect: (value: RawValueType, option: { selected: boolean }) => void; + onInputKeyDown?: EventHandlerNonNull; + + /** + * @private get real dom for trigger align. + * This may be removed after React provides replacement of `findDOMNode` + */ + domRef: () => HTMLDivElement; +} + +const Selector = defineComponent({ + name: 'Selector', + setup(props) { + const inputRef = createRef(); + let compositionStatus = false; + + // ====================== Input ====================== + const [getInputMouseDown, setInputMouseDown] = useLock(0); + + const onInternalInputKeyDown = (event: KeyboardEvent) => { + const { which } = event; + + if (which === KeyCode.UP || which === KeyCode.DOWN) { + event.preventDefault(); + } + + if (props.onInputKeyDown) { + props.onInputKeyDown(event); + } + + if (which === KeyCode.ENTER && props.mode === 'tags' && !compositionStatus && !props.open) { + // When menu isn't open, OptionList won't trigger a value change + // So when enter is pressed, the tag's input value should be emitted here to let selector know + props.onSearchSubmit((event.target as HTMLInputElement).value); + } + + if (![KeyCode.SHIFT, KeyCode.TAB, KeyCode.BACKSPACE, KeyCode.ESC].includes(which)) { + props.onToggleOpen(true); + } + }; + + /** + * We can not use `findDOMNode` sine it will get warning, + * have to use timer to check if is input element. + */ + const onInternalInputMouseDown = () => { + setInputMouseDown(true); + }; + + // When paste come, ignore next onChange + let pastedText = null; + + const triggerOnSearch = (value: string) => { + if (props.onSearch(value, true, compositionStatus) !== false) { + props.onToggleOpen(true); + } + }; + + const onInputCompositionStart = () => { + compositionStatus = true; + }; + + const onInputCompositionEnd = () => { + compositionStatus = false; + }; + + const onInputChange = (event: { target: { value: any } }) => { + let { + target: { value }, + } = event; + + // Pasted text should replace back to origin content + if (props.tokenWithEnter && pastedText && /[\r\n]/.test(pastedText)) { + // CRLF will be treated as a single space for input element + const replacedText = pastedText.replace(/\r\n/g, ' ').replace(/[\r\n]/g, ' '); + value = value.replace(replacedText, pastedText); + } + + pastedText = null; + + triggerOnSearch(value); + }; + + const onInputPaste = (e: ClipboardEvent) => { + const { clipboardData } = e; + const value = clipboardData.getData('text'); + + pastedText = value; + }; + + const onClick = ({ target }) => { + if (target !== inputRef.current) { + // Should focus input if click the selector + const isIE = (document.body.style as any).msTouchAction !== undefined; + if (isIE) { + setTimeout(() => { + inputRef.current.focus(); + }); + } else { + inputRef.current.focus(); + } + } + }; + + const onMousedown = (event: MouseEvent) => { + const inputMouseDown = getInputMouseDown(); + if (event.target !== inputRef.current && !inputMouseDown) { + event.preventDefault(); + } + + if ((props.mode !== 'combobox' && (!props.showSearch || !inputMouseDown)) || !props.open) { + if (props.open) { + props.onSearch('', true, false); + } + props.onToggleOpen(); + } + }; + + return { + focus: () => { + inputRef.current.focus(); + }, + blur: () => { + inputRef.current.blur(); + }, + onMousedown, + onClick, + onInputPaste, + inputRef, + onInternalInputKeyDown, + onInternalInputMouseDown, + onInputChange, + onInputCompositionEnd, + onInputCompositionStart, + }; + }, + render() { + const { prefixCls, domRef, multiple } = this.$props as SelectorProps; + const { + onMousedown, + onClick, + inputRef, + onInputPaste, + onInternalInputKeyDown, + onInternalInputMouseDown, + onInputChange, + onInputCompositionStart, + onInputCompositionEnd, + } = this; + const sharedProps = { + inputRef, + onInputKeyDown: onInternalInputKeyDown, + onInputMouseDown: onInternalInputMouseDown, + onInputChange, + onInputPaste, + onInputCompositionStart, + onInputCompositionEnd, + }; + const selectNode = multiple ? ( + + ) : ( + + ); + return ( +
+ {selectNode} +
+ ); + }, +}); + +Selector.inheritAttrs = false; +Selector.props = { + id: PropTypes.string, + prefixCls: PropTypes.string, + showSearch: PropTypes.bool, + open: PropTypes.bool, + /** Display in the Selector value, it's not same as `value` prop */ + values: PropTypes.array, + multiple: PropTypes.bool, + mode: PropTypes.string, + searchValue: PropTypes.string, + activeValue: PropTypes.string, + inputElement: PropTypes.any, + + autofocus: PropTypes.bool, + accessibilityIndex: PropTypes.number, + tabindex: PropTypes.number, + disabled: PropTypes.bool, + placeholder: PropTypes.any, + removeIcon: PropTypes.any, + + // Tags + maxTagCount: PropTypes.number, + maxTagTextLength: PropTypes.number, + maxTagPlaceholder: PropTypes.any, + tagRender: PropTypes.func, + + /** Check if `tokenSeparators` contains `\n` or `\r\n` */ + tokenWithEnter: PropTypes.bool, + + // Motion + choiceTransitionName: PropTypes.string, + + onToggleOpen: PropTypes.func, + /** `onSearch` returns go next step boolean to check if need do toggle open */ + onSearch: PropTypes.func, + onSearchSubmit: PropTypes.func, + onSelect: PropTypes.func, + onInputKeyDown: PropTypes.func, + + /** + * @private get real dom for trigger align. + * This may be removed after React provides replacement of `findDOMNode` + */ + domRef: PropTypes.func, +}; + +export default Selector; diff --git a/components/vc-select2/assets/index.less b/components/vc-select2/assets/index.less new file mode 100644 index 000000000..cbd4a1309 --- /dev/null +++ b/components/vc-select2/assets/index.less @@ -0,0 +1,345 @@ +@select-prefix: ~'rc-select'; + +* { + box-sizing: border-box; +} + +.search-input-without-border() { + .@{select-prefix}-selection-search-input { + border: none; + outline: none; + background: rgba(255, 0, 0, 0.2); + width: 100%; + } +} + +.@{select-prefix} { + display: inline-block; + font-size: 12px; + width: 100px; + position: relative; + + &-disabled { + &, + & input { + cursor: not-allowed; + } + + .@{select-prefix}-selector { + opacity: 0.3; + } + } + + &-show-arrow&-loading { + .@{select-prefix}-arrow { + &-icon::after { + box-sizing: border-box; + width: 12px; + height: 12px; + border-radius: 100%; + border: 2px solid #999; + border-top-color: transparent; + border-bottom-color: transparent; + transform: none; + margin-top: 4px; + + animation: rcSelectLoadingIcon 0.5s infinite; + } + } + } + + // ============== Selector =============== + .@{select-prefix}-selection-placeholder { + opacity: 0.4; + } + + // ============== Search =============== + .@{select-prefix}-selection-search-input { + appearance: none; + + &::-webkit-search-cancel-button { + display: none; + appearance: none; + } + } + + // --------------- Single ---------------- + &-single { + .@{select-prefix}-selector { + display: flex; + position: relative; + + .@{select-prefix}-selection-search { + width: 100%; + + &-input { + width: 100%; + } + } + + .@{select-prefix}-selection-item, + .@{select-prefix}-selection-placeholder { + position: absolute; + top: 1px; + left: 3px; + pointer-events: none; + } + } + + // Not customize + &:not(.@{select-prefix}-customize-input) { + .@{select-prefix}-selector { + padding: 1px; + border: 1px solid #000; + + .search-input-without-border(); + } + } + } + + // -------------- Multiple --------------- + &-multiple .@{select-prefix}-selector { + display: flex; + flex-wrap: wrap; + padding: 1px; + border: 1px solid #000; + + .@{select-prefix}-selection-item { + flex: none; + background: #bbb; + border-radius: 4px; + margin-right: 2px; + padding: 0 8px; + + &-disabled { + cursor: not-allowed; + opacity: 0.5; + } + } + + .@{select-prefix}-selection-search { + position: relative; + + &-input, + &-mirror { + padding: 1px; + font-family: system-ui; + } + + &-mirror { + position: absolute; + z-index: 999; + white-space: nowrap; + position: none; + left: 0; + top: 0; + visibility: hidden; + } + } + + .search-input-without-border(); + } + + // ================ Icons ================ + &-allow-clear { + &.@{select-prefix}-multiple .@{select-prefix}-selector { + padding-right: 20px; + } + + .@{select-prefix}-clear { + position: absolute; + right: 20px; + top: 0; + } + } + + &-show-arrow { + &.@{select-prefix}-multiple .@{select-prefix}-selector { + padding-right: 20px; + } + + .@{select-prefix}-arrow { + pointer-events: none; + position: absolute; + right: 5px; + top: 0; + + &-icon::after { + content: ''; + border: 5px solid transparent; + width: 0; + height: 0; + display: inline-block; + border-top-color: #999; + transform: translateY(5px); + } + } + } + + // =============== Focused =============== + &-focused { + .@{select-prefix}-selector { + border-color: blue !important; + } + } + + // ============== Dropdown =============== + &-dropdown { + border: 1px solid green; + min-height: 100px; + position: absolute; + background: #fff; + + &-hidden { + display: none; + } + } + + // =============== Option ================ + &-item { + font-size: 16px; + line-height: 1.5; + padding: 4px 16px; + + // >>> Group + &-group { + color: #999; + font-weight: bold; + font-size: 80%; + } + + // >>> Option + &-option { + position: relative; + + &-grouped { + padding-left: 24px; + } + + .@{select-prefix}-item-option-state { + position: absolute; + right: 0; + top: 4px; + pointer-events: none; + } + + // ------- Active ------- + &-active { + background: green; + } + + // ------ Disabled ------ + &-disabled { + color: #999; + } + } + + // >>> Empty + &-empty { + text-align: center; + color: #999; + } + } +} + +.@{select-prefix}-selection__choice-zoom { + transition: all 0.3s; +} + +.@{select-prefix}-selection__choice-zoom-appear { + opacity: 0; + transform: scale(0.5); + + &&-active { + opacity: 1; + transform: scale(1); + } +} +.@{select-prefix}-selection__choice-zoom-leave { + opacity: 1; + transform: scale(1); + + &&-active { + opacity: 0; + transform: scale(0.5); + } +} + +.effect() { + animation-duration: 0.3s; + animation-fill-mode: both; + transform-origin: 0 0; +} + +.@{select-prefix}-dropdown { + &-slide-up-enter, + &-slide-up-appear { + .effect(); + opacity: 0; + animation-timing-function: cubic-bezier(0.08, 0.82, 0.17, 1); + animation-play-state: paused; + } + + &-slide-up-leave { + .effect(); + opacity: 1; + animation-timing-function: cubic-bezier(0.6, 0.04, 0.98, 0.34); + animation-play-state: paused; + } + + &-slide-up-enter&-slide-up-enter-active&-placement-bottomLeft, + &-slide-up-appear&-slide-up-appear-active&-placement-bottomLeft { + animation-name: rcSelectDropdownSlideUpIn; + animation-play-state: running; + } + + &-slide-up-leave&-slide-up-leave-active&-placement-bottomLeft { + animation-name: rcSelectDropdownSlideUpOut; + animation-play-state: running; + } + + &-slide-up-enter&-slide-up-enter-active&-placement-topLeft, + &-slide-up-appear&-slide-up-appear-active&-placement-topLeft { + animation-name: rcSelectDropdownSlideDownIn; + animation-play-state: running; + } + + &-slide-up-leave&-slide-up-leave-active&-placement-topLeft { + animation-name: rcSelectDropdownSlideDownOut; + animation-play-state: running; + } +} + +@keyframes rcSelectDropdownSlideUpIn { + 0% { + opacity: 0; + transform-origin: 0% 0%; + transform: scaleY(0); + } + 100% { + opacity: 1; + transform-origin: 0% 0%; + transform: scaleY(1); + } +} +@keyframes rcSelectDropdownSlideUpOut { + 0% { + opacity: 1; + transform-origin: 0% 0%; + transform: scaleY(1); + } + 100% { + opacity: 0; + transform-origin: 0% 0%; + transform: scaleY(0); + } +} + +@keyframes rcSelectLoadingIcon { + 0% { + transform: rotate(0); + } + 100% { + transform: rotate(360deg); + } +} diff --git a/components/vc-select2/examples/common.less b/components/vc-select2/examples/common.less new file mode 100644 index 000000000..d78a563b4 --- /dev/null +++ b/components/vc-select2/examples/common.less @@ -0,0 +1,10 @@ +// input { +// // height: 24px; +// // line-height: 24px; +// border: 1px solid #333; +// border-radius: 4px; +// } + +// button { +// border: 1px solid #333; +// } diff --git a/components/vc-select2/examples/common/tbFetchSuggest.tsx b/components/vc-select2/examples/common/tbFetchSuggest.tsx new file mode 100644 index 000000000..8b9f90698 --- /dev/null +++ b/components/vc-select2/examples/common/tbFetchSuggest.tsx @@ -0,0 +1,35 @@ +import jsonp from 'jsonp'; +import querystring from 'querystring'; + +let timeout; +let currentValue; + +export function fetch(value, callback) { + if (timeout) { + clearTimeout(timeout); + timeout = null; + } + currentValue = value; + + function fake() { + const str = querystring.encode({ + code: 'utf-8', + q: value, + }); + jsonp(`http://suggest.taobao.com/sug?${str}`, (err, d) => { + if (currentValue === value) { + const { result } = d; + const data = []; + result.forEach(r => { + data.push({ + value: r[0], + text: r[0], + }); + }); + callback(data); + } + }); + } + + timeout = setTimeout(fake, 300); +} diff --git a/components/vc-select2/examples/single.less b/components/vc-select2/examples/single.less new file mode 100644 index 000000000..165cd09a7 --- /dev/null +++ b/components/vc-select2/examples/single.less @@ -0,0 +1,3 @@ +.test-option { + font-weight: bolder; +} diff --git a/components/vc-select2/examples/single.tsx b/components/vc-select2/examples/single.tsx new file mode 100644 index 000000000..29fa21c21 --- /dev/null +++ b/components/vc-select2/examples/single.tsx @@ -0,0 +1,154 @@ +import { defineComponent } from 'vue'; +/* eslint-disable no-console */ + +import Select, { Option } from '..'; +import '../assets/index.less'; +import './single.less'; + +const Test = defineComponent({ + data() { + return { + destroy: false, + value: '9', + }; + }, + methods: { + onChange(e) { + let value; + if (e && e.target) { + ({ value } = e.target); + } else { + value = e; + } + console.log('onChange', value); + + this.value = value; + }, + + onDestroy() { + this.destroy = 1; + }, + + onBlur(v) { + console.log('onBlur', v); + }, + + onFocus() { + console.log('onFocus'); + }, + + onSearch(val) { + console.log('Search:', val); + }, + }, + + render() { + const { value, destroy } = this; + if (destroy) { + return null; + } + + return ( +
+
{ + e.preventDefault(); + }} + > + Prevent Default +
+ +

Single Select

+ +
+ +
+

native select

+ + +

RTL Select

+ +
+ +
+ +

+ +

+
+ ); + }, +}); + +export default Test; +/* eslint-enable */ diff --git a/components/vc-select2/generate.tsx b/components/vc-select2/generate.tsx new file mode 100644 index 000000000..cff3bb3d3 --- /dev/null +++ b/components/vc-select2/generate.tsx @@ -0,0 +1,1284 @@ +/** + * To match accessibility requirement, we always provide an input in the component. + * Other element will not set `tabindex` to avoid `onBlur` sequence problem. + * For focused select, we set `aria-live="polite"` to update the accessibility content. + * + * ref: + * - keyboard: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/listbox_role#Keyboard_interactions + */ + +import KeyCode from '../_util/KeyCode'; +import classNames from '../_util/classNames'; +import Selector from './Selector'; +import SelectTrigger from './SelectTrigger'; +import { RenderNode, Mode, RenderDOMFunc, OnActiveValue } from './interface'; +import { + GetLabeledValue, + FilterOptions, + FilterFunc, + DefaultValueType, + RawValueType, + LabelValueType, + Key, + DisplayLabelValueType, + FlattenOptionsType, + SingleType, + OnClear, + INTERNAL_PROPS_MARK, + SelectSource, + CustomTagProps, +} from './interface/generator'; +import { OptionListProps } from './OptionList'; +import { toInnerValue, toOuterValues, removeLastEnabledValue, getUUID } from './utils/commonUtil'; +import TransBtn from './TransBtn'; +import useLock from './hooks/useLock'; +import useDelayReset from './hooks/useDelayReset'; +import { getSeparatedContent } from './utils/valueUtil'; +import useSelectTriggerControl from './hooks/useSelectTriggerControl'; +import useCacheDisplayValue from './hooks/useCacheDisplayValue'; +import useCacheOptions from './hooks/useCacheOptions'; +import { + computed, + CSSProperties, + DefineComponent, + defineComponent, + onBeforeUnmount, + onMounted, + provide, + ref, + VNode, + VNodeChild, + watch, +} from 'vue'; +import createRef from '../_util/createRef'; +import PropTypes from '../_util/vue-types'; + +const DEFAULT_OMIT_PROPS = [ + 'children', + 'removeIcon', + 'placeholder', + 'autofocus', + 'maxTagCount', + 'maxTagTextLength', + 'maxTagPlaceholder', + 'choiceTransitionName', + 'onInputKeyDown', +]; + +export interface SelectProps { + prefixCls?: string; + id?: string; + class?: string; + style?: CSSProperties; + + // Options + options?: OptionsType; + children?: VNode[] | JSX.Element[]; + mode?: Mode; + + // Value + value?: ValueType; + defaultValue?: ValueType; + labelInValue?: boolean; + + // Search + inputValue?: string; + searchValue?: string; + optionFilterProp?: string; + /** + * In Select, `false` means do nothing. + * In TreeSelect, `false` will highlight match item. + * It's by design. + */ + filterOption?: boolean | FilterFunc; + showSearch?: boolean; + autoClearSearchValue?: boolean; + onSearch?: (value: string) => void; + onClear?: OnClear; + + // Icons + allowClear?: boolean; + clearIcon?: VNodeChild; + showArrow?: boolean; + inputIcon?: RenderNode; + removeIcon?: VNodeChild; + menuItemSelectedIcon?: RenderNode; + + // Dropdown + open?: boolean; + defaultOpen?: boolean; + listHeight?: number; + listItemHeight?: number; + dropdownStyle?: CSSProperties; + dropdownClassName?: string; + dropdownMatchSelectWidth?: boolean | number; + virtual?: boolean; + dropdownRender?: (menu: VNodeChild) => VNodeChild; + dropdownAlign?: any; + animation?: string; + transitionName?: string; + getPopupContainer?: RenderDOMFunc; + direction?: string; + + // Others + disabled?: boolean; + loading?: boolean; + autofocus?: boolean; + defaultActiveFirstOption?: boolean; + notFoundContent?: VNodeChild; + placeholder?: VNodeChild; + backfill?: boolean; + getInputElement?: () => VNodeChild; + optionLabelProp?: string; + maxTagTextLength?: number; + maxTagCount?: number; + maxTagPlaceholder?: VNodeChild | ((omittedValues: LabelValueType[]) => VNodeChild); + tokenSeparators?: string[]; + tagRender?: (props: CustomTagProps) => VNodeChild; + showAction?: ('focus' | 'click')[]; + tabindex?: number; + + // Events + onKeyup?: EventHandlerNonNull; + onKeydown?: EventHandlerNonNull; + onPopupScroll?: EventHandlerNonNull; + onDropdownVisibleChange?: (open: boolean) => void; + onSelect?: (value: SingleType, option: OptionsType[number]) => void; + onDeselect?: (value: SingleType, option: OptionsType[number]) => void; + onInputKeyDown?: EventHandlerNonNull; + onClick?: EventHandlerNonNull; + onChange?: (value: ValueType, option: OptionsType[number] | OptionsType) => void; + onBlur?: EventHandlerNonNull; + onFocus?: EventHandlerNonNull; + onMousedown?: EventHandlerNonNull; + onMouseenter?: EventHandlerNonNull; + onMouseleave?: EventHandlerNonNull; + + // Motion + choiceTransitionName?: string; + + // Internal props + /** + * Only used in current version for internal event process. + * Do not use in production environment. + */ + internalProps?: { + mark?: string; + onClear?: OnClear; + skipTriggerChange?: boolean; + skipTriggerSelect?: boolean; + onRawSelect?: (value: RawValueType, option: OptionsType[number], source: SelectSource) => void; + onRawDeselect?: ( + value: RawValueType, + option: OptionsType[number], + source: SelectSource, + ) => void; + }; +} + +export interface GenerateConfig { + prefixCls: string; + components: { + optionList: DefineComponent & { options: OptionsType }>; + }; + /** Convert jsx tree into `OptionsType` */ + convertChildrenToData: (children: VNodeChild | JSX.Element) => OptionsType; + /** Flatten nest options into raw option list */ + flattenOptions: (options: OptionsType, props: any) => FlattenOptionsType; + /** Convert single raw value into { label, value } format. Will be called by each value */ + getLabeledValue: GetLabeledValue>; + filterOptions: FilterOptions; + findValueOption: // Need still support legacy ts api + | ((values: RawValueType[], options: FlattenOptionsType) => OptionsType) + // New API add prevValueOptions support + | (( + values: RawValueType[], + options: FlattenOptionsType, + info?: { prevValueOptions?: OptionsType[] }, + ) => OptionsType); + /** Check if a value is disabled */ + isValueDisabled: (value: RawValueType, options: FlattenOptionsType) => boolean; + warningProps?: (props: any) => void; + fillOptionsWithMissingValue?: ( + options: OptionsType, + value: DefaultValueType, + optionLabelProp: string, + labelInValue: boolean, + ) => OptionsType; + omitDOMProps?: (props: object) => object; +} +type ValueType = DefaultValueType; +/** + * This function is in internal usage. + * Do not use it in your prod env since we may refactor this. + */ +export default function generateSelector< + OptionsType extends { + value?: RawValueType; + label?: VNodeChild; + key?: Key; + disabled?: boolean; + }[] +>(config: GenerateConfig): DefineComponent { + const { + prefixCls: defaultPrefixCls, + components: { optionList: OptionList }, + convertChildrenToData, + flattenOptions, + getLabeledValue, + filterOptions, + isValueDisabled, + findValueOption, + warningProps, + fillOptionsWithMissingValue, + omitDOMProps, + } = config; + const Select = defineComponent>({ + name: 'Select', + setup(props: SelectProps) { + const useInternalProps = computed(() => props.internalProps.mark === INTERNAL_PROPS_MARK); + + const containerRef = ref(null); + const triggerRef = ref(null); + const selectorRef = ref(null); + const listRef = ref(null); + const tokenWithEnter = computed(() => + (props.tokenSeparators || []).some(tokenSeparator => + ['\n', '\r\n'].includes(tokenSeparator), + ), + ); + + /** Used for component focused management */ + const [mockFocused, setMockFocused, cancelSetMockFocused] = useDelayReset(); + + const mergedId = computed(() => props.id || `rc_select_${getUUID()}`); + + // optionLabelProp + let mergedOptionLabelProp = computed(() => { + let mergedOptionLabelProp = props.optionLabelProp; + if (mergedOptionLabelProp === undefined) { + mergedOptionLabelProp = props.options ? 'label' : 'children'; + } + return mergedOptionLabelProp; + }); + + // labelInValue + const mergedLabelInValue = computed(() => + props.mode === 'combobox' ? false : props.labelInValue, + ); + + const isMultiple = computed(() => props.mode === 'tags' || props.mode === 'multiple'); + + const mergedShowSearch = computed(() => + props.showSearch !== undefined + ? props.showSearch + : isMultiple.value || props.mode === 'combobox', + ); + + // ============================== Ref =============================== + const selectorDomRef = createRef(); + + const mergedValue = ref(undefined); + watch( + computed(() => [props.value, props.defaultValue]), + () => { + mergedValue.value = props.value !== undefined ? props.value : props.defaultValue; + }, + { immediate: true }, + ); + // ============================= Value ============================== + + /** Unique raw values */ + const mergedRawValue = computed(() => + toInnerValue(mergedValue.value, { + labelInValue: mergedLabelInValue.value, + combobox: props.mode === 'combobox', + }), + ); + /** We cache a set of raw values to speed up check */ + const rawValues = computed(() => new Set(mergedRawValue.value)); + + // ============================= Option ============================= + // Set by option list active, it will merge into search input when mode is `combobox` + const activeValue = ref(null); + const setActiveValue = (val: string) => { + activeValue.value = val; + }; + const innerSearchValue = ref(''); + const setInnerSearchValue = (val: string) => { + innerSearchValue.value = val; + }; + + const mergedSearchValue = computed(() => { + let mergedSearchValue = innerSearchValue.value; + if (props.mode === 'combobox' && mergedValue.value !== undefined) { + mergedSearchValue = mergedValue.value as string; + } else if (props.searchValue !== undefined) { + mergedSearchValue = props.searchValue; + } else if (props.inputValue) { + mergedSearchValue = props.inputValue; + } + return mergedSearchValue; + }); + + const mergedOptions = computed( + (): OptionsType => { + let newOptions = props.options; + if (newOptions === undefined) { + newOptions = convertChildrenToData(props.children); + } + + /** + * `tags` should fill un-list item. + * This is not cool here since TreeSelect do not need this + */ + if (props.mode === 'tags' && fillOptionsWithMissingValue) { + newOptions = fillOptionsWithMissingValue( + newOptions, + mergedValue.value, + mergedOptionLabelProp.value, + props.labelInValue, + ); + } + + return newOptions || ([] as OptionsType); + }, + ); + + const mergedFlattenOptions = computed(() => flattenOptions(mergedOptions.value, props)); + + const getValueOption = useCacheOptions(mergedRawValue.value, mergedFlattenOptions); + + // Display options for OptionList + const displayOptions = computed(() => { + if (!mergedSearchValue.value || !mergedShowSearch) { + return [...mergedOptions.value] as OptionsType; + } + const { optionFilterProp = 'value', mode, filterOption } = props; + const filteredOptions: OptionsType = filterOptions( + mergedSearchValue.value, + mergedOptions.value, + { + optionFilterProp, + filterOption: + mode === 'combobox' && filterOption === undefined ? () => true : filterOption, + }, + ); + if ( + mode === 'tags' && + filteredOptions.every(opt => opt[optionFilterProp] !== mergedSearchValue.value) + ) { + filteredOptions.unshift({ + value: mergedSearchValue.value, + label: mergedSearchValue.value, + key: '__RC_SELECT_TAG_PLACEHOLDER__', + }); + } + + return filteredOptions; + }); + + const displayFlattenOptions = computed(() => flattenOptions(displayOptions.value, props)); + + watch( + mergedSearchValue, + () => { + if (listRef.value && listRef.value.scrollTo) { + listRef.value.scrollTo(0); + } + }, + { flush: 'post' }, + ); + + // ============================ Selector ============================ + let displayValues = computed(() => { + const tmpValues = mergedRawValue.value.map((val: RawValueType) => { + const valueOptions = getValueOption([val]); + const displayValue = getLabeledValue(val, { + options: valueOptions, + prevValue: mergedValue.value, + labelInValue: mergedLabelInValue.value, + optionLabelProp: mergedOptionLabelProp.value, + }); + + return { + ...displayValue, + disabled: isValueDisabled(val, valueOptions), + }; + }); + + if ( + !props.mode && + tmpValues.length === 1 && + tmpValues[0].value === null && + tmpValues[0].label === null + ) { + return []; + } + + return tmpValues; + }); + + // Polyfill with cache label + displayValues = useCacheDisplayValue(displayValues); + + const triggerSelect = (newValue: RawValueType, isSelect: boolean, source: SelectSource) => { + const newValueOption = getValueOption([newValue]); + const outOption = findValueOption([newValue], newValueOption)[0]; + + if (!props.internalProps.skipTriggerSelect) { + // Skip trigger `onSelect` or `onDeselect` if configured + const selectValue = (mergedLabelInValue.value + ? getLabeledValue(newValue, { + options: newValueOption, + prevValue: mergedValue.value, + labelInValue: mergedLabelInValue.value, + optionLabelProp: mergedOptionLabelProp.value, + }) + : newValue) as SingleType; + + if (isSelect && props.onSelect) { + props.onSelect(selectValue, outOption); + } else if (!isSelect && props.onDeselect) { + props.onDeselect(selectValue, outOption); + } + } + + // Trigger internal event + if (useInternalProps.value) { + if (isSelect && props.internalProps.onRawSelect) { + props.internalProps.onRawSelect(newValue, outOption, source); + } else if (!isSelect && props.internalProps.onRawDeselect) { + props.internalProps.onRawDeselect(newValue, outOption, source); + } + } + }; + + // We need cache options here in case user update the option list + const prevValueOptions = ref([]); + const setPrevValueOptions = (val: any[]) => { + prevValueOptions.value = val; + }; + const triggerChange = (newRawValues: RawValueType[]) => { + if (useInternalProps.value && props.internalProps.skipTriggerChange) { + return; + } + const newRawValuesOptions = getValueOption(newRawValues); + const outValues = toOuterValues>(Array.from(newRawValues), { + labelInValue: mergedLabelInValue.value, + options: newRawValuesOptions, + getLabeledValue, + prevValue: mergedValue.value, + optionLabelProp: mergedOptionLabelProp.value, + }); + + const outValue: ValueType = (isMultiple.value ? outValues : outValues[0]) as ValueType; + // Skip trigger if prev & current value is both empty + if ( + props.onChange && + (mergedRawValue.value.length !== 0 || (outValues as []).length !== 0) + ) { + const outOptions = findValueOption(newRawValues, newRawValuesOptions, { + prevValueOptions: prevValueOptions.value, + }); + + // We will cache option in case it removed by ajax + setPrevValueOptions( + outOptions.map((option, index) => { + const clone = { ...option }; + Object.defineProperty(clone, '_INTERNAL_OPTION_VALUE_', { + get: () => newRawValues[index], + }); + return clone; + }), + ); + + props.onChange(outValue, isMultiple.value ? outOptions : outOptions[0]); + } + mergedValue.value = outValue; + }; + + const onInternalSelect = ( + newValue: RawValueType, + { selected, source }: { selected: boolean; source: 'option' | 'selection' }, + ) => { + const { autoClearSearchValue = true } = props; + if (props.disabled) { + return; + } + + let newRawValue: Set; + + if (isMultiple.value) { + newRawValue = new Set(mergedRawValue.value); + if (selected) { + newRawValue.add(newValue); + } else { + newRawValue.delete(newValue); + } + } else { + newRawValue = new Set(); + newRawValue.add(newValue); + } + + // Multiple always trigger change and single should change if value changed + if ( + isMultiple.value || + (!isMultiple.value && Array.from(mergedRawValue.value)[0] !== newValue) + ) { + triggerChange(Array.from(newRawValue)); + } + + // Trigger `onSelect`. Single mode always trigger select + triggerSelect(newValue, !isMultiple.value || selected, source); + + // Clean search value if single or configured + if (props.mode === 'combobox') { + setInnerSearchValue(String(newValue)); + setActiveValue(''); + } else if (!isMultiple.value || autoClearSearchValue) { + setInnerSearchValue(''); + setActiveValue(''); + } + }; + + const onInternalOptionSelect = (newValue: RawValueType, info: { selected: boolean }) => { + onInternalSelect(newValue, { ...info, source: 'option' }); + }; + + const onInternalSelectionSelect = (newValue: RawValueType, info: { selected: boolean }) => { + onInternalSelect(newValue, { ...info, source: 'selection' }); + }; + + // ============================== Open ============================== + + const innerOpen = ref(undefined); + let mergedOpen = ref(undefined); + const setInnerOpen = (val: boolean) => { + innerOpen.value = val; + mergedOpen.value = val; + }; + watch( + computed(() => [props.defaultOpen, props.open]), + () => { + setInnerOpen(props.open !== undefined ? props.open : props.defaultOpen); + }, + ); + + // Not trigger `open` in `combobox` when `notFoundContent` is empty + const emptyListContent = computed( + () => !props.notFoundContent && !displayOptions.value.length, + ); + watch( + computed( + () => + props.disabled || + (emptyListContent.value && mergedOpen.value && props.mode === 'combobox'), + ), + val => { + debugger; + if (val) { + mergedOpen.value = false; + } + }, + ); + + const triggerOpen = computed(() => (emptyListContent.value ? false : mergedOpen.value)); + + const onToggleOpen = (newOpen?: boolean) => { + const nextOpen = newOpen !== undefined ? newOpen : !mergedOpen.value; + + if (innerOpen.value !== nextOpen && !props.disabled) { + setInnerOpen(nextOpen); + + if (props.onDropdownVisibleChange) { + props.onDropdownVisibleChange(nextOpen); + } + } + }; + + useSelectTriggerControl( + [containerRef.value, triggerRef.value && triggerRef.value.getPopupElement()], + triggerOpen, + onToggleOpen, + ); + + // ============================= Search ============================= + const triggerSearch = (searchText: string, fromTyping: boolean, isCompositing: boolean) => { + let ret = true; + let newSearchText = searchText; + setActiveValue(null); + + // Check if match the `tokenSeparators` + const patchLabels: string[] = isCompositing + ? null + : getSeparatedContent(searchText, props.tokenSeparators); + let patchRawValues: RawValueType[] = patchLabels; + + if (props.mode === 'combobox') { + // Only typing will trigger onChange + if (fromTyping) { + triggerChange([newSearchText]); + } + } else if (patchLabels) { + newSearchText = ''; + + if (props.mode !== 'tags') { + patchRawValues = patchLabels + .map(label => { + const item = mergedFlattenOptions.value.find( + ({ data }) => data[mergedOptionLabelProp.value] === label, + ); + return item ? item.data.value : null; + }) + .filter((val: RawValueType) => val !== null); + } + + const newRawValues = Array.from( + new Set([...mergedRawValue.value, ...patchRawValues]), + ); + triggerChange(newRawValues); + newRawValues.forEach(newRawValue => { + triggerSelect(newRawValue, true, 'input'); + }); + + // Should close when paste finish + onToggleOpen(false); + + // Tell Selector that break next actions + ret = false; + } + + setInnerSearchValue(newSearchText); + + if (props.onSearch && mergedSearchValue.value !== newSearchText) { + props.onSearch(newSearchText); + } + + return ret; + }; + + // Only triggered when menu is closed & mode is tags + // If menu is open, OptionList will take charge + // If mode isn't tags, press enter is not meaningful when you can't see any option + const onSearchSubmit = (searchText: string) => { + const newRawValues = Array.from( + new Set([...mergedRawValue.value, searchText]), + ); + triggerChange(newRawValues); + newRawValues.forEach(newRawValue => { + triggerSelect(newRawValue, true, 'input'); + }); + setInnerSearchValue(''); + }; + + // Close dropdown when disabled change + + watch( + computed(() => props.disabled), + () => { + if (innerOpen.value && !!props.disabled) { + setInnerOpen(false); + } + }, + ); + + // Close will clean up single mode search text + watch(mergedOpen, () => { + if (innerOpen.value && !!props.disabled) { + setInnerOpen(false); + } + }); + // ============================ Keyboard ============================ + /** + * We record input value here to check if can press to clean up by backspace + * - null: Key is not down, this is reset by key up + * - true: Search text is empty when first time backspace down + * - false: Search text is not empty when first time backspace down + */ + const [getClearLock, setClearLock] = useLock(); + + // KeyDown + const onInternalKeyDown = (event: KeyboardEvent) => { + const clearLock = getClearLock(); + const { which } = event; + + // We only manage open state here, close logic should handle by list component + if (!mergedOpen.value && which === KeyCode.ENTER) { + onToggleOpen(true); + } + + setClearLock(!!mergedSearchValue.value); + + // Remove value by `backspace` + if ( + which === KeyCode.BACKSPACE && + !clearLock && + isMultiple.value && + !mergedSearchValue.value && + mergedRawValue.value.length + ) { + const removeInfo = removeLastEnabledValue(displayValues.value, mergedRawValue.value); + + if (removeInfo.removedValue !== null) { + triggerChange(removeInfo.values); + triggerSelect(removeInfo.removedValue, false, 'input'); + } + } + + if (mergedOpen.value && listRef.value) { + listRef.value.onKeydown(event); + } + + if (props.onKeydown) { + props.onKeydown(event); + } + }; + + // KeyUp + const onInternalKeyUp = (event: Event) => { + if (mergedOpen.value && listRef.value) { + listRef.value.onKeyup(event); + } + + if (props.onKeyup) { + props.onKeyup(event); + } + }; + + // ========================== Focus / Blur ========================== + /** Record real focus status */ + const focusRef = ref(false); + + const onContainerFocus = (...args: any[]) => { + setMockFocused(true); + + if (!props.disabled) { + if (props.onFocus && !focusRef.value) { + props.onFocus(args[0]); + } + + // `showAction` should handle `focus` if set + if (props.showAction.includes('focus')) { + onToggleOpen(true); + } + } + + focusRef.value = true; + }; + + const onContainerBlur = (...args: any[]) => { + setMockFocused(false, () => { + focusRef.value = false; + onToggleOpen(false); + }); + + if (props.disabled) { + return; + } + + if (mergedSearchValue.value) { + // `tags` mode should move `searchValue` into values + if (props.mode === 'tags') { + triggerSearch('', false, false); + triggerChange(Array.from(new Set([...mergedRawValue.value, mergedSearchValue.value]))); + } else if (props.mode === 'multiple') { + // `multiple` mode only clean the search value but not trigger event + setInnerSearchValue(''); + } + } + + if (props.onBlur) { + props.onBlur(args[0]); + } + }; + provide('VCSelectContainerEvent', { + focus: onContainerFocus, + blur: onContainerBlur, + }); + const activeTimeoutIds: number[] = []; + + onMounted(() => { + activeTimeoutIds.forEach(timeoutId => clearTimeout(timeoutId)); + activeTimeoutIds.splice(0, activeTimeoutIds.length); + }); + onBeforeUnmount(() => { + activeTimeoutIds.forEach(timeoutId => clearTimeout(timeoutId)); + activeTimeoutIds.splice(0, activeTimeoutIds.length); + }); + + const onInternalMouseDown = (event: MouseEvent) => { + const { target } = event; + const popupElement: HTMLDivElement = triggerRef.value && triggerRef.value.getPopupElement(); + + // We should give focus back to selector if clicked item is not focusable + if (popupElement && popupElement.contains(target as HTMLElement)) { + const timeoutId = setTimeout(() => { + const index = activeTimeoutIds.indexOf(timeoutId); + if (index !== -1) { + activeTimeoutIds.splice(index, 1); + } + + cancelSetMockFocused(); + + if (!popupElement.contains(document.activeElement)) { + selectorRef.value.focus(); + } + }); + + activeTimeoutIds.push(timeoutId); + } + + if (props.onMousedown) { + props.onMousedown(event); + } + }; + + // ========================= Accessibility ========================== + const accessibilityIndex = ref(0); + const mergedDefaultActiveFirstOption = computed(() => + props.defaultActiveFirstOption !== undefined + ? props.defaultActiveFirstOption + : props.mode !== 'combobox', + ); + + const onActiveValue: OnActiveValue = (active, index, { source = 'keyboard' } = {}) => { + accessibilityIndex.value = index; + + if ( + props.backfill && + props.mode === 'combobox' && + active !== null && + source === 'keyboard' + ) { + setActiveValue(String(active)); + } + }; + + // ============================= Popup ============================== + const containerWidth = ref(null); + + watch(triggerOpen, () => { + if (triggerOpen.value) { + const newWidth = Math.ceil(containerRef.value.offsetWidth); + if (containerWidth !== newWidth) { + containerWidth.value = newWidth; + } + } + }); + const focus = () => { + selectorRef.value.focus(); + }; + const blur = () => { + selectorRef.value.blur(); + }; + return { + focus, + blur, + tokenWithEnter, + mockFocused, + mergedId, + containerWidth, + onActiveValue, + accessibilityIndex, + mergedDefaultActiveFirstOption, + onInternalMouseDown, + onContainerFocus, + onContainerBlur, + onInternalKeyDown, + isMultiple, + mergedOpen, + displayOptions, + displayFlattenOptions, + rawValues, + onInternalOptionSelect, + onToggleOpen, + mergedSearchValue, + useInternalProps, + triggerChange, + triggerSearch, + mergedRawValue, + mergedShowSearch, + onInternalKeyUp, + triggerOpen, + mergedOptions, + onInternalSelectionSelect, + selectorDomRef, + displayValues, + activeValue, + onSearchSubmit, + containerRef, + listRef, + }; + }, + methods: { + // We need force update here since popup dom is render async + onPopupMouseEnter() { + this.$forceUpdate(); + }, + }, + render() { + const { + tokenWithEnter, + mockFocused, + mergedId, + containerWidth, + onActiveValue, + accessibilityIndex, + mergedDefaultActiveFirstOption, + onInternalMouseDown, + onInternalKeyDown, + isMultiple, + mergedOpen, + displayOptions, + displayFlattenOptions, + rawValues, + onInternalOptionSelect, + onToggleOpen, + mergedSearchValue, + onPopupMouseEnter, + useInternalProps, + triggerChange, + triggerSearch, + mergedRawValue, + mergedShowSearch, + onInternalKeyUp, + triggerOpen, + mergedOptions, + onInternalSelectionSelect, + selectorDomRef, + displayValues, + activeValue, + onSearchSubmit, + } = this; + const { + prefixCls = defaultPrefixCls, + class: className, + children, + options, + + mode, + // Icons + allowClear, + clearIcon, + showArrow, + inputIcon, + menuItemSelectedIcon, + + // Others + disabled, + loading, + notFoundContent = 'Not Found', + getInputElement, + getPopupContainer, + + // Dropdown + listHeight = 200, + listItemHeight = 20, + animation, + transitionName, + virtual, + dropdownStyle, + dropdownClassName, + dropdownMatchSelectWidth, + dropdownRender, + dropdownAlign, + direction, + + tagRender, + + // Events + onPopupScroll, + + onClear, + + internalProps = {}, + + ...restProps + } = this.$props as SelectProps; + + // ============================= Input ============================== + // Only works in `combobox` + const customizeInputElement: VNodeChild = + (mode === 'combobox' && getInputElement && getInputElement()) || null; + + const domProps = omitDOMProps ? omitDOMProps(restProps) : restProps; + DEFAULT_OMIT_PROPS.forEach(prop => { + delete domProps[prop]; + }); + const popupNode = ( + + ); + + // ============================= Clear ============================== + let clearNode: VNode | JSX.Element; + const onClearMouseDown = () => { + // Trigger internal `onClear` event + if (useInternalProps && internalProps.onClear) { + internalProps.onClear(); + } + + if (onClear) { + onClear(); + } + + triggerChange([]); + triggerSearch('', false, false); + }; + + if (!disabled && allowClear && (mergedRawValue.length || mergedSearchValue)) { + clearNode = ( + + × + + ); + } + + // ============================= Arrow ============================== + const mergedShowArrow = + showArrow !== undefined ? showArrow : loading || (!isMultiple && mode !== 'combobox'); + let arrowNode: VNode | JSX.Element; + + if (mergedShowArrow) { + arrowNode = ( + + ); + } + + // ============================ Warning ============================= + if (process.env.NODE_ENV !== 'production' && warningProps) { + warningProps(this.$props); + } + + // ============================= Render ============================= + const mergedClassName = classNames(prefixCls, className, { + [`${prefixCls}-focused`]: mockFocused, + [`${prefixCls}-multiple`]: isMultiple, + [`${prefixCls}-single`]: !isMultiple, + [`${prefixCls}-allow-clear`]: allowClear, + [`${prefixCls}-show-arrow`]: mergedShowArrow, + [`${prefixCls}-disabled`]: disabled, + [`${prefixCls}-loading`]: loading, + [`${prefixCls}-open`]: mergedOpen, + [`${prefixCls}-customize-input`]: customizeInputElement, + [`${prefixCls}-show-search`]: mergedShowSearch, + }); + + return ( +
+ {mockFocused && !mergedOpen && ( + + {/* Merge into one string to make screen reader work as expect */} + {`${mergedRawValue.join(', ')}`} + + )} + selectorDomRef.current as any} + > + + + + {arrowNode} + {clearNode} +
+ ); + }, + }); + Select.inheritAttrs = false; + Select.props = { + prefixCls: PropTypes.string, + id: PropTypes.string, + class: PropTypes.string, + style: PropTypes.any, + + // Options + options: PropTypes.array, + children: PropTypes.array.def([]), + mode: PropTypes.string, + + // Value + value: PropTypes.any, + defaultValue: PropTypes.any, + labelInValue: PropTypes.bool, + + // Search + inputValue: PropTypes.string, + searchValue: PropTypes.string, + optionFilterProp: PropTypes.string.def('value'), + /** + * In Select, `false` means do nothing. + * In TreeSelect, `false` will highlight match item. + * It's by design. + */ + filterOption: PropTypes.any, + showSearch: PropTypes.bool, + autoClearSearchValue: PropTypes.bool, + onSearch: PropTypes.func, + onClear: PropTypes.func, + + // Icons + allowClear: PropTypes.bool, + clearIcon: PropTypes.any, + showArrow: { + type: Boolean, + default: undefined, + }, + inputIcon: PropTypes.any, + removeIcon: PropTypes.any, + menuItemSelectedIcon: PropTypes.func, + + // Dropdown + open: PropTypes.bool, + defaultOpen: PropTypes.bool, + listHeight: PropTypes.number.def(200), + listItemHeight: PropTypes.number.def(20), + dropdownStyle: PropTypes.object, + dropdownClassName: PropTypes.string, + dropdownMatchSelectWidth: PropTypes.oneOfType([Boolean, Number]).def(true), + virtual: PropTypes.bool, + dropdownRender: PropTypes.func, + dropdownAlign: PropTypes.any, + animation: PropTypes.string, + transitionName: PropTypes.string, + getPopupContainer: PropTypes.func, + direction: PropTypes.string, + + // Others + disabled: PropTypes.bool, + loading: PropTypes.bool, + autofocus: PropTypes.bool, + defaultActiveFirstOption: PropTypes.bool, + notFoundContent: PropTypes.any.def('Not Found'), + placeholder: PropTypes.any, + backfill: PropTypes.bool, + getInputElement: PropTypes.func, + optionLabelProp: PropTypes.string, + maxTagTextLength: PropTypes.number, + maxTagCount: PropTypes.number, + maxTagPlaceholder: PropTypes.any, + tokenSeparators: PropTypes.array, + tagRender: PropTypes.func, + showAction: PropTypes.array.def([]), + tabindex: PropTypes.number, + + // Events + onKeyup: PropTypes.func, + onKeydown: PropTypes.func, + onPopupScroll: PropTypes.func, + onDropdownVisibleChange: PropTypes.func, + onSelect: PropTypes.func, + onDeselect: PropTypes.func, + onInputKeyDown: PropTypes.func, + onClick: PropTypes.func, + onChange: PropTypes.func, + onBlur: PropTypes.func, + onFocus: PropTypes.func, + onMousedown: PropTypes.func, + onMouseenter: PropTypes.func, + onMouseleave: PropTypes.func, + + // Motion + choiceTransitionName: PropTypes.string, + + // Internal props + /** + * Only used in current version for internal event process. + * Do not use in production environment. + */ + internalProps: PropTypes.object.def({}), + }; + return Select; +} diff --git a/components/vc-select2/hooks/useCacheDisplayValue.ts b/components/vc-select2/hooks/useCacheDisplayValue.ts new file mode 100644 index 000000000..0c3dccebf --- /dev/null +++ b/components/vc-select2/hooks/useCacheDisplayValue.ts @@ -0,0 +1,35 @@ +import { computed, ComputedRef, Ref } from 'vue'; +import { DisplayLabelValueType } from '../interface/generator'; + +export default function useCacheDisplayValue( + values: Ref, +): ComputedRef { + let prevValues = [...values.value]; + + const mergedValues = computed(() => { + // Create value - label map + const valueLabels = new Map(); + prevValues.forEach(({ value, label }) => { + if (value !== label) { + valueLabels.set(value, label); + } + }); + + const resultValues = values.value.map(item => { + const cacheLabel = valueLabels.get(item.value); + if (item.value === item.label && cacheLabel) { + return { + ...item, + label: cacheLabel, + }; + } + + return item; + }); + + prevValues = resultValues; + return resultValues; + }); + + return mergedValues; +} diff --git a/components/vc-select2/hooks/useCacheOptions.ts b/components/vc-select2/hooks/useCacheOptions.ts new file mode 100644 index 000000000..08857d9ab --- /dev/null +++ b/components/vc-select2/hooks/useCacheOptions.ts @@ -0,0 +1,27 @@ +import { computed, Ref, VNodeChild } from 'vue'; +import { RawValueType, FlattenOptionsType, Key } from '../interface/generator'; + +export default function useCacheOptions< + OptionsType extends { + value?: RawValueType; + label?: VNodeChild; + key?: Key; + disabled?: boolean; + }[] +>(_values: RawValueType[], options: Ref) { + const optionMap = computed(() => { + const map: Map[number]> = new Map(); + options.value.forEach(item => { + const { + data: { value }, + } = item; + map.set(value, item); + }); + return map; + }); + + const getValueOption = (vals: RawValueType[]): FlattenOptionsType => + vals.map(value => optionMap.value.get(value)).filter(Boolean); + + return getValueOption; +} diff --git a/components/vc-select2/hooks/useDelayReset.ts b/components/vc-select2/hooks/useDelayReset.ts new file mode 100644 index 000000000..a83589bbd --- /dev/null +++ b/components/vc-select2/hooks/useDelayReset.ts @@ -0,0 +1,32 @@ +import { onBeforeUpdate, Ref, ref } from 'vue'; + +/** + * Similar with `useLock`, but this hook will always execute last value. + * When set to `true`, it will keep `true` for a short time even if `false` is set. + */ +export default function useDelayReset( + timeout = 10, +): [Ref, (val: boolean, callback?: () => void) => void, () => void] { + const bool = ref(false); + let delay: number; + + const cancelLatest = () => { + window.clearTimeout(delay); + }; + + onBeforeUpdate(() => { + cancelLatest(); + }); + const delaySetBool = (value: boolean, callback: () => void) => { + cancelLatest(); + + delay = window.setTimeout(() => { + bool.value = value; + if (callback) { + callback(); + } + }, timeout); + }; + + return [bool, delaySetBool, cancelLatest]; +} diff --git a/components/vc-select2/hooks/useLock.ts b/components/vc-select2/hooks/useLock.ts new file mode 100644 index 000000000..6aec5b727 --- /dev/null +++ b/components/vc-select2/hooks/useLock.ts @@ -0,0 +1,29 @@ +import { onBeforeUpdate } from 'vue'; + +/** + * Locker return cached mark. + * If set to `true`, will return `true` in a short time even if set `false`. + * If set to `false` and then set to `true`, will change to `true`. + * And after time duration, it will back to `null` automatically. + */ +export default function useLock(duration = 250): [() => boolean | null, (lock: boolean) => void] { + let lock: boolean | null = null; + let timeout: number; + + onBeforeUpdate(() => { + window.clearTimeout(timeout); + }); + + function doLock(locked: boolean) { + if (locked || lock === null) { + lock = locked; + } + + window.clearTimeout(timeout); + timeout = window.setTimeout(() => { + lock = null; + }, duration); + } + + return [() => lock, doLock]; +} diff --git a/components/vc-select2/hooks/useSelectTriggerControl.ts b/components/vc-select2/hooks/useSelectTriggerControl.ts new file mode 100644 index 000000000..f3e42c5de --- /dev/null +++ b/components/vc-select2/hooks/useSelectTriggerControl.ts @@ -0,0 +1,26 @@ +import { onBeforeUnmount, onMounted, Ref } from 'vue'; + +export default function useSelectTriggerControl( + elements: (HTMLElement | undefined)[], + open: Ref, + triggerOpen: (open: boolean) => void, +) { + function onGlobalMouseDown(event: MouseEvent) { + const target = event.target as HTMLElement; + if ( + open.value && + elements.every(element => element && !element.contains(target) && element !== target) + ) { + // Should trigger close + triggerOpen(false); + } + } + + onMounted(() => { + window.addEventListener('mousedown', onGlobalMouseDown); + }); + + onBeforeUnmount(() => { + window.removeEventListener('mousedown', onGlobalMouseDown); + }); +} diff --git a/components/vc-select2/index.js b/components/vc-select2/index.js deleted file mode 100644 index cfa3695ae..000000000 --- a/components/vc-select2/index.js +++ /dev/null @@ -1,9 +0,0 @@ -// based on vc-select 9.2.2 -import Select from './Select'; -import Option from './Option'; -import { SelectPropTypes } from './PropTypes'; -import OptGroup from './OptGroup'; -Select.Option = Option; -Select.OptGroup = OptGroup; -export { Select, Option, OptGroup, SelectPropTypes }; -export default Select; diff --git a/components/vc-select2/index.ts b/components/vc-select2/index.ts new file mode 100644 index 000000000..f1a12a1a3 --- /dev/null +++ b/components/vc-select2/index.ts @@ -0,0 +1,7 @@ +import Select, { ExportedSelectProps } from './Select'; +import Option from './Option'; +import OptGroup from './OptGroup'; +type SelectProps = ExportedSelectProps; +export { Option, OptGroup, SelectProps }; + +export default Select; diff --git a/components/vc-select2/interface/generator.ts b/components/vc-select2/interface/generator.ts index 12a4b9a83..259f34812 100644 --- a/components/vc-select2/interface/generator.ts +++ b/components/vc-select2/interface/generator.ts @@ -1,5 +1,4 @@ import * as Vue from 'vue'; -import { SelectProps, RefSelectProps } from '../generate'; export type SelectSource = 'option' | 'selection' | 'input'; @@ -8,9 +7,9 @@ export const INTERNAL_PROPS_MARK = 'RC_SELECT_INTERNAL_PROPS_MARK'; // =================================== Shared Type =================================== export type Key = string | number; -export type RawValueType = string | number; +export type RawValueType = string | number | null; -export interface LabelValueType { +export interface LabelValueType extends Record { key?: Key; value?: RawValueType; label?: Vue.VNodeChild; diff --git a/components/vc-select2/interface/index.ts b/components/vc-select2/interface/index.ts index 85d475212..a541f8ede 100644 --- a/components/vc-select2/interface/index.ts +++ b/components/vc-select2/interface/index.ts @@ -1,4 +1,5 @@ import * as Vue from 'vue'; +import { VNode } from 'vue'; import { Key, RawValueType } from './generator'; export type RenderDOMFunc = (props: any) => HTMLElement; @@ -19,12 +20,11 @@ export interface OptionCoreData { disabled?: boolean; value: Key; title?: string; - className?: string; class?: string; style?: Vue.CSSProperties; label?: Vue.VNodeChild; /** @deprecated Only works when use `children` as option data */ - children?: Vue.VNodeChild; + children?: VNode[] | JSX.Element[]; } export interface OptionData extends OptionCoreData { @@ -36,7 +36,6 @@ export interface OptionGroupData { key?: Key; label?: Vue.VNodeChild; options: OptionData[]; - className?: string; class?: string; style?: Vue.CSSProperties; diff --git a/components/vc-select2/utils/commonUtil.ts b/components/vc-select2/utils/commonUtil.ts new file mode 100644 index 000000000..f9ab32704 --- /dev/null +++ b/components/vc-select2/utils/commonUtil.ts @@ -0,0 +1,120 @@ +import { + RawValueType, + GetLabeledValue, + LabelValueType, + DefaultValueType, + FlattenOptionsType, +} from '../interface/generator'; + +export function toArray(value: T | T[]): T[] { + if (Array.isArray(value)) { + return value; + } + return value !== undefined ? [value] : []; +} + +/** + * Convert outer props value into internal value + */ +export function toInnerValue( + value: DefaultValueType, + { labelInValue, combobox }: { labelInValue: boolean; combobox: boolean }, +): RawValueType[] { + if (value === undefined || (value === '' && combobox)) { + return []; + } + + const values = Array.isArray(value) ? value : [value]; + + if (labelInValue) { + return (values as LabelValueType[]).map(({ key, value: val }: LabelValueType) => + val !== undefined ? val : key, + ); + } + + return values as RawValueType[]; +} + +/** + * Convert internal value into out event value + */ +export function toOuterValues( + valueList: RawValueType[], + { + optionLabelProp, + labelInValue, + prevValue, + options, + getLabeledValue, + }: { + optionLabelProp: string; + labelInValue: boolean; + getLabeledValue: GetLabeledValue; + options: FOT; + prevValue: DefaultValueType; + }, +): RawValueType[] | LabelValueType[] | DefaultValueType { + let values: DefaultValueType = valueList; + + if (labelInValue) { + values = values.map(val => + getLabeledValue(val, { + options, + prevValue, + labelInValue, + optionLabelProp, + }), + ); + } + + return values; +} + +export function removeLastEnabledValue< + T extends { disabled?: boolean }, + P extends RawValueType | object +>(measureValues: T[], values: P[]): { values: P[]; removedValue: P } { + const newValues = [...values]; + + let removeIndex: number; + for (removeIndex = measureValues.length - 1; removeIndex >= 0; removeIndex -= 1) { + if (!measureValues[removeIndex].disabled) { + break; + } + } + + let removedValue = null; + + if (removeIndex !== -1) { + removedValue = newValues[removeIndex]; + newValues.splice(removeIndex, 1); + } + + return { + values: newValues, + removedValue, + }; +} + +export const isClient = + typeof window !== 'undefined' && window.document && window.document.documentElement; + +/** Is client side and not jsdom */ +export const isBrowserClient = process.env.NODE_ENV !== 'test' && isClient; + +let uuid = 0; +/** Get unique id for accessibility usage */ +export function getUUID(): number | string { + let retId: string | number; + + // Test never reach + /* istanbul ignore if */ + if (isBrowserClient) { + retId = uuid; + uuid += 1; + } else { + retId = 'TEST_OR_SSR'; + } + + return retId; +} diff --git a/components/vc-select2/utils/legacyUtil.ts b/components/vc-select2/utils/legacyUtil.ts new file mode 100644 index 000000000..81fbae365 --- /dev/null +++ b/components/vc-select2/utils/legacyUtil.ts @@ -0,0 +1,47 @@ +import { flattenChildren, isValidElement } from '../../_util/props-util'; +import { VNode } from 'vue'; +import { OptionData, OptionGroupData, OptionsType } from '../interface'; + +function convertNodeToOption(node: VNode): OptionData { + const { + key, + children, + props: { value, ...restProps }, + } = node as VNode & { + children: { default?: () => any }; + }; + const child = children.default ? children.default() : undefined; + return { key, value: value !== undefined ? value : key, children: child, ...restProps }; +} + +export function convertChildrenToData(nodes: any[], optionOnly = false): OptionsType { + const dd = flattenChildren(nodes) + .map((node: VNode, index: number): OptionData | OptionGroupData | null => { + if (!isValidElement(node) || !node.type) { + return null; + } + + const { + type: { isSelectOptGroup }, + key, + children, + props, + } = node as VNode & { + type: { isSelectOptGroup?: boolean }; + children: { default?: () => any }; + }; + + if (optionOnly || !isSelectOptGroup) { + return convertNodeToOption(node); + } + const child = children.default ? children.default() : undefined; + return { + key: `__RC_SELECT_GRP__${key === null ? index : key}__`, + label: key, + ...props, + options: convertChildrenToData(child || []), + } as any; + }) + .filter(data => data); + return dd; +} diff --git a/components/vc-select2/utils/valueUtil.ts b/components/vc-select2/utils/valueUtil.ts new file mode 100644 index 000000000..412d81204 --- /dev/null +++ b/components/vc-select2/utils/valueUtil.ts @@ -0,0 +1,312 @@ +import { warning } from '../../vc-util/warning'; +import { VNodeChild } from 'vue'; +import { + OptionsType as SelectOptionsType, + OptionData, + OptionGroupData, + FlattenOptionData, +} from '../interface'; +import { + LabelValueType, + FilterFunc, + RawValueType, + GetLabeledValue, + DefaultValueType, +} from '../interface/generator'; + +import { toArray } from './commonUtil'; + +function getKey(data: OptionData | OptionGroupData, index: number) { + const { key } = data; + let value: RawValueType; + + if ('value' in data) { + ({ value } = data); + } + + if (key !== null && key !== undefined) { + return key; + } + if (value !== undefined) { + return value; + } + return `rc-index-key-${index}`; +} + +/** + * Flat options into flatten list. + * We use `optionOnly` here is aim to avoid user use nested option group. + * Here is simply set `key` to the index if not provided. + */ +export function flattenOptions(options: SelectOptionsType): FlattenOptionData[] { + const flattenList: FlattenOptionData[] = []; + + function dig(list: SelectOptionsType, isGroupOption: boolean) { + list.forEach(data => { + if (isGroupOption || !('options' in data)) { + // Option + flattenList.push({ + key: getKey(data, flattenList.length), + groupOption: isGroupOption, + data, + }); + } else { + // Option Group + flattenList.push({ + key: getKey(data, flattenList.length), + group: true, + data, + }); + + dig(data.options, true); + } + }); + } + + dig(options, false); + + return flattenList; +} + +/** + * Inject `props` into `option` for legacy usage + */ +function injectPropsWithOption(option: T): T { + const newOption = { ...option }; + if (!('props' in newOption)) { + Object.defineProperty(newOption, 'props', { + get() { + warning( + false, + 'Return type is option instead of Option instance. Please read value directly instead of reading from `props`.', + ); + return newOption; + }, + }); + } + + return newOption; +} + +export function findValueOption( + values: RawValueType[], + options: FlattenOptionData[], + { prevValueOptions = [] }: { prevValueOptions?: OptionData[] } = {}, +): OptionData[] { + const optionMap: Map = new Map(); + + options.forEach(flattenItem => { + if (!flattenItem.group) { + const data = flattenItem.data as OptionData; + // Check if match + optionMap.set(data.value, data); + } + }); + + return values.map(val => { + let option = optionMap.get(val); + + // Fallback to try to find prev options + if (!option) { + option = { + // eslint-disable-next-line no-underscore-dangle + ...prevValueOptions.find(opt => opt._INTERNAL_OPTION_VALUE_ === val), + }; + } + + return injectPropsWithOption(option); + }); +} + +export const getLabeledValue: GetLabeledValue = ( + value, + { options, prevValue, labelInValue, optionLabelProp }, +) => { + const item = findValueOption([value], options)[0]; + const result: LabelValueType = { + value, + }; + + let prevValItem: LabelValueType; + const prevValues = toArray(prevValue); + if (labelInValue) { + prevValItem = prevValues.find((prevItem: LabelValueType) => { + if (typeof prevItem === 'object' && 'value' in prevItem) { + return prevItem.value === value; + } + // [Legacy] Support `key` as `value` + return prevItem.key === value; + }) as LabelValueType; + } + + if (prevValItem && typeof prevValItem === 'object' && 'label' in prevValItem) { + result.label = prevValItem.label; + + if ( + item && + typeof prevValItem.label === 'string' && + typeof item[optionLabelProp] === 'string' && + prevValItem.label.trim() !== item[optionLabelProp].trim() + ) { + warning(false, '`label` of `value` is not same as `label` in Select options.'); + } + } else if (item && optionLabelProp in item) { + result.label = item[optionLabelProp]; + } else { + result.label = value; + } + + // Used for motion control + result.key = result.value; + + return result; +}; + +function toRawString(content: VNodeChild): string { + return toArray(content).join(''); +} + +/** Filter single option if match the search text */ +function getFilterFunction(optionFilterProp: string) { + return (searchValue: string, option: OptionData | OptionGroupData) => { + const lowerSearchText = searchValue.toLowerCase(); + + // Group label search + if ('options' in option) { + return toRawString(option.label) + .toLowerCase() + .includes(lowerSearchText); + } + + // Option value search + const rawValue = option[optionFilterProp]; + const value = toRawString(rawValue).toLowerCase(); + return value.includes(lowerSearchText); + }; +} + +/** Filter options and return a new options by the search text */ +export function filterOptions( + searchValue: string, + options: SelectOptionsType, + { + optionFilterProp, + filterOption, + }: { optionFilterProp: string; filterOption: boolean | FilterFunc }, +) { + const filteredOptions: SelectOptionsType = []; + let filterFunc: FilterFunc; + + if (filterOption === false) { + return options; + } + if (typeof filterOption === 'function') { + filterFunc = filterOption; + } else { + filterFunc = getFilterFunction(optionFilterProp); + } + + options.forEach(item => { + // Group should check child options + if ('options' in item) { + // Check group first + const matchGroup = filterFunc(searchValue, item); + if (matchGroup) { + filteredOptions.push(item); + } else { + // Check option + const subOptions = item.options.filter(subItem => filterFunc(searchValue, subItem)); + if (subOptions.length) { + filteredOptions.push({ + ...item, + options: subOptions, + }); + } + } + + return; + } + + if (filterFunc(searchValue, injectPropsWithOption(item))) { + filteredOptions.push(item); + } + }); + + return filteredOptions; +} + +export function getSeparatedContent(text: string, tokens: string[]): string[] { + if (!tokens || !tokens.length) { + return null; + } + + let match = false; + + function separate(str: string, [token, ...restTokens]: string[]) { + if (!token) { + return [str]; + } + + const list = str.split(token); + match = match || list.length > 1; + + return list + .reduce((prevList, unitStr) => [...prevList, ...separate(unitStr, restTokens)], []) + .filter(unit => unit); + } + + const list = separate(text, tokens); + return match ? list : null; +} + +export function isValueDisabled(value: RawValueType, options: FlattenOptionData[]): boolean { + const option = findValueOption([value], options)[0]; + return option.disabled; +} + +/** + * `tags` mode should fill un-list item into the option list + */ +export function fillOptionsWithMissingValue( + options: SelectOptionsType, + value: DefaultValueType, + optionLabelProp: string, + labelInValue: boolean, +): SelectOptionsType { + const values = toArray(value) + .slice() + .sort(); + const cloneOptions = [...options]; + + // Convert options value to set + const optionValues = new Set(); + options.forEach(opt => { + if (opt.options) { + opt.options.forEach((subOpt: OptionData) => { + optionValues.add(subOpt.value); + }); + } else { + optionValues.add((opt as OptionData).value); + } + }); + + // Fill missing value + values.forEach(item => { + const val: RawValueType = labelInValue + ? (item as LabelValueType).value + : (item as RawValueType); + + if (!optionValues.has(val)) { + cloneOptions.push( + labelInValue + ? { + [optionLabelProp]: (item as LabelValueType).label, + value: val, + } + : { value: val }, + ); + } + }); + + return cloneOptions; +} diff --git a/components/vc-select2/utils/warningPropsUtil.ts b/components/vc-select2/utils/warningPropsUtil.ts new file mode 100644 index 000000000..005a3e1ee --- /dev/null +++ b/components/vc-select2/utils/warningPropsUtil.ts @@ -0,0 +1,160 @@ +import warning, { noteOnce } from '../../vc-util/warning'; +import { SelectProps } from '..'; +import { convertChildrenToData } from './legacyUtil'; +import { OptionData, OptionGroupData } from '../interface'; +import { toArray } from './commonUtil'; +import { RawValueType, LabelValueType } from '../interface/generator'; +import { isValidElement } from '../../_util/props-util'; +import { VNode } from 'vue'; + +function warningProps(props: SelectProps) { + const { + mode, + options, + children, + backfill, + allowClear, + placeholder, + getInputElement, + showSearch, + onSearch, + defaultOpen, + autofocus, + labelInValue, + value, + inputValue, + optionLabelProp, + } = props; + + const multiple = mode === 'multiple' || mode === 'tags'; + const mergedShowSearch = showSearch !== undefined ? showSearch : multiple || mode === 'combobox'; + const mergedOptions = options || convertChildrenToData(children); + + // `tags` should not set option as disabled + warning( + mode !== 'tags' || + mergedOptions.every((opt: { disabled?: boolean } & OptionGroupData) => !opt.disabled), + 'Please avoid setting option to disabled in tags mode since user can always type text as tag.', + ); + + // `combobox` & `tags` should option be `string` type + if (mode === 'tags' || mode === 'combobox') { + const hasNumberValue = mergedOptions.some(item => { + if (item.options) { + return item.options.some( + (opt: OptionData) => typeof ('value' in opt ? opt.value : opt.key) === 'number', + ); + } + return typeof ('value' in item ? item.value : item.key) === 'number'; + }); + + warning( + !hasNumberValue, + '`value` of Option should not use number type when `mode` is `tags` or `combobox`.', + ); + } + + // `combobox` should not use `optionLabelProp` + warning( + mode !== 'combobox' || !optionLabelProp, + '`combobox` mode not support `optionLabelProp`. Please set `value` on Option directly.', + ); + + // Only `combobox` support `backfill` + warning(mode === 'combobox' || !backfill, '`backfill` only works with `combobox` mode.'); + + // Only `combobox` support `getInputElement` + warning( + mode === 'combobox' || !getInputElement, + '`getInputElement` only work with `combobox` mode.', + ); + + // Customize `getInputElement` should not use `allowClear` & `placeholder` + noteOnce( + mode !== 'combobox' || !getInputElement || !allowClear || !placeholder, + 'Customize `getInputElement` should customize clear and placeholder logic instead of configuring `allowClear` and `placeholder`.', + ); + + // `onSearch` should use in `combobox` or `showSearch` + if (onSearch && !mergedShowSearch && mode !== 'combobox' && mode !== 'tags') { + warning(false, '`onSearch` should work with `showSearch` instead of use alone.'); + } + + noteOnce( + !defaultOpen || autofocus, + '`defaultOpen` makes Select open without focus which means it will not close by click outside. You can set `autofocus` if needed.', + ); + + if (value !== undefined && value !== null) { + const values = toArray(value); + warning( + !labelInValue || + values.every(val => typeof val === 'object' && ('key' in val || 'value' in val)), + '`value` should in shape of `{ value: string | number, label?: ReactNode }` when you set `labelInValue` to `true`', + ); + + warning( + !multiple || Array.isArray(value), + '`value` should be array when `mode` is `multiple` or `tags`', + ); + } + + // Syntactic sugar should use correct children type + if (children) { + let invalidateChildType = null; + children.some( + ( + node: VNode & { + children: { default?: () => any }; + }, + ) => { + if (!isValidElement(node) || !node.type) { + return false; + } + + const { type } = node as { type: { isSelectOption?: boolean; isSelectOptGroup?: boolean } }; + + if (type.isSelectOption) { + return false; + } + if (type.isSelectOptGroup) { + const childs = node.children?.default() || []; + const allChildrenValid = childs.every((subNode: VNode) => { + if ( + !isValidElement(subNode) || + !node.type || + (subNode.type as { isSelectOption?: boolean }).isSelectOption + ) { + return true; + } + invalidateChildType = subNode.type; + return false; + }); + + if (allChildrenValid) { + return false; + } + return true; + } + invalidateChildType = type; + return true; + }, + ); + + if (invalidateChildType) { + warning( + false, + `\`children\` should be \`Select.Option\` or \`Select.OptGroup\` instead of \`${invalidateChildType.displayName || + invalidateChildType.name || + invalidateChildType}\`.`, + ); + } + + warning( + inputValue === undefined, + '`inputValue` is deprecated, please use `searchValue` instead.', + ); + } +} + +export default warningProps; diff --git a/components/vc-trigger/Trigger.jsx b/components/vc-trigger/Trigger.jsx index ee42820e2..dde7d228b 100644 --- a/components/vc-trigger/Trigger.jsx +++ b/components/vc-trigger/Trigger.jsx @@ -401,7 +401,7 @@ export default defineComponent({ prefixCls, destroyPopupOnHide, visible: sPopupVisible, - point: alignPoint && point, + point: alignPoint ? point : null, action, align, animation: popupAnimation, diff --git a/components/vc-util/Dom/addEventListener.js b/components/vc-util/Dom/addEventListener.js index 4f93f9d79..505252516 100644 --- a/components/vc-util/Dom/addEventListener.js +++ b/components/vc-util/Dom/addEventListener.js @@ -1,5 +1,12 @@ -import addDOMEventListener from 'add-dom-event-listener'; - export default function addEventListenerWrap(target, eventType, cb, option) { - return addDOMEventListener(target, eventType, cb, option); + if (target.addEventListener) { + target.addEventListener(eventType, cb, option); + } + return { + remove: () => { + if (target.removeEventListener) { + target.removeEventListener(eventType, cb); + } + }, + }; } diff --git a/components/vc-util/Dom/contains.js b/components/vc-util/Dom/contains.js deleted file mode 100644 index 0c4c465eb..000000000 --- a/components/vc-util/Dom/contains.js +++ /dev/null @@ -1,11 +0,0 @@ -export default function contains(root, n) { - let node = n; - while (node) { - if (node === root) { - return true; - } - node = node.parentNode; - } - - return false; -} diff --git a/components/vc-util/Dom/contains.ts b/components/vc-util/Dom/contains.ts new file mode 100644 index 000000000..5eb1ebcea --- /dev/null +++ b/components/vc-util/Dom/contains.ts @@ -0,0 +1,7 @@ +export default function contains(root: Node | null | undefined, n?: Node) { + if (!root) { + return false; + } + + return root.contains(n); +} diff --git a/components/vc-util/warning.js b/components/vc-util/warning.ts similarity index 60% rename from components/vc-util/warning.js rename to components/vc-util/warning.ts index b76fce037..66bb2139d 100644 --- a/components/vc-util/warning.js +++ b/components/vc-util/warning.ts @@ -1,14 +1,14 @@ /* eslint-disable no-console */ -let warned = {}; +let warned: Record = {}; -export function warning(valid, message) { +export function warning(valid: boolean, message: string) { // Support uglify if (process.env.NODE_ENV !== 'production' && !valid && console !== undefined) { console.error(`Warning: ${message}`); } } -export function note(valid, message) { +export function note(valid: boolean, message: string) { // Support uglify if (process.env.NODE_ENV !== 'production' && !valid && console !== undefined) { console.warn(`Note: ${message}`); @@ -19,18 +19,22 @@ export function resetWarned() { warned = {}; } -export function call(method, valid, message) { +export function call( + method: (valid: boolean, message: string) => void, + valid: boolean, + message: string, +) { if (!valid && !warned[message]) { method(false, message); warned[message] = true; } } -export function warningOnce(valid, message) { +export function warningOnce(valid: boolean, message: string) { call(warning, valid, message); } -export function noteOnce(valid, message) { +export function noteOnce(valid: boolean, message: string) { call(note, valid, message); } diff --git a/components/vc-virtual-list/Filler.tsx b/components/vc-virtual-list/Filler.tsx index 3add1a86b..6471cb2d1 100644 --- a/components/vc-virtual-list/Filler.tsx +++ b/components/vc-virtual-list/Filler.tsx @@ -1,6 +1,6 @@ import classNames from '../_util/classNames'; import ResizeObserver from '../vc-resize-observer'; -import { CSSProperties, FunctionalComponent } from 'vue'; +import { CSSProperties, FunctionalComponent, PropType } from 'vue'; interface FillerProps { prefixCls?: string; @@ -59,5 +59,13 @@ const Filter: FunctionalComponent = ( Filter.displayName = 'Filter'; Filter.inheritAttrs = false; +Filter.props = { + prefixCls: String, + /** Virtual filler height. Should be `count * itemMinHeight` */ + height: Number, + /** Set offset of visible items. Should be the top of start item position */ + offset: Number, + onInnerResize: Function as PropType<() => void>, +}; export default Filter; diff --git a/components/vc-virtual-list/Item.tsx b/components/vc-virtual-list/Item.tsx index 7350b37fa..76119ab80 100644 --- a/components/vc-virtual-list/Item.tsx +++ b/components/vc-virtual-list/Item.tsx @@ -1,4 +1,4 @@ -import { cloneVNode, FunctionalComponent } from 'vue'; +import { cloneVNode, FunctionalComponent, PropType } from 'vue'; export interface ItemProps { setRef: (element: HTMLElement) => void; @@ -13,5 +13,10 @@ const Item: FunctionalComponent = ({ setRef }, { slots }) => { }) : children; }; - +Item.props = { + setRef: { + type: Function as PropType<(element: HTMLElement) => void>, + default: () => {}, + }, +}; export default Item; diff --git a/components/vc-virtual-list/List.tsx b/components/vc-virtual-list/List.tsx index eceb50063..f27628856 100644 --- a/components/vc-virtual-list/List.tsx +++ b/components/vc-virtual-list/List.tsx @@ -79,6 +79,8 @@ const List = defineComponent({ virtual: PropTypes.bool, children: PropTypes.func, onScroll: PropTypes.func, + onMousedown: PropTypes.func, + onMouseenter: PropTypes.func, }, setup(props) { // ================================= MISC ================================= @@ -255,7 +257,7 @@ const List = defineComponent({ const removeEventListener = () => { if (componentRef.value) { componentRef.value.removeEventListener('wheel', onRawWheel); - componentRef.value.removeEventListener('DOMMouseScroll', onFireFoxScroll); + componentRef.value.removeEventListener('DOMMouseScroll' as any, onFireFoxScroll); componentRef.value.removeEventListener('MozMousePixelScroll', onMozMousePixelScroll); } }; @@ -264,7 +266,7 @@ const List = defineComponent({ if (componentRef.value) { removeEventListener(); componentRef.value.addEventListener('wheel', onRawWheel); - componentRef.value.addEventListener('DOMMouseScroll', onFireFoxScroll); + componentRef.value.addEventListener('DOMMouseScroll' as any, onFireFoxScroll); componentRef.value.addEventListener('MozMousePixelScroll', onMozMousePixelScroll); } }); @@ -331,7 +333,7 @@ const List = defineComponent({ style, class: className, ...restProps - } = { ...this.$props, ...this.$attrs }; + } = { ...this.$props, ...this.$attrs } as any; const mergedClassName = classNames(prefixCls, className); const { scrollTop, mergedData } = this.state; const { scrollHeight, offset, start, end } = this.calRes; diff --git a/components/vc-virtual-list/hooks/useScrollTo.tsx b/components/vc-virtual-list/hooks/useScrollTo.tsx index 36126a595..06c818a78 100644 --- a/components/vc-virtual-list/hooks/useScrollTo.tsx +++ b/components/vc-virtual-list/hooks/useScrollTo.tsx @@ -15,7 +15,7 @@ export default function useScrollTo( ) { let scroll: number | null = null; - return arg => { + return (arg: any) => { raf.cancel(scroll!); const data = state.mergedData; const itemHeight = props.itemHeight; diff --git a/package.json b/package.json index 7fa1981df..6a7723369 100644 --- a/package.json +++ b/package.json @@ -86,7 +86,7 @@ "@types/raf": "^3.4.0", "@typescript-eslint/eslint-plugin": "^4.1.0", "@typescript-eslint/parser": "^4.1.0", - "@vue/babel-plugin-jsx": "^1.0.0-rc.2", + "@vue/babel-plugin-jsx": "^1.0.0-rc.3", "@vue/cli-plugin-eslint": "^4.0.0", "@vue/compiler-sfc": "^3.0.0", "@vue/eslint-config-prettier": "^6.0.0", diff --git a/tsconfig.json b/tsconfig.json index 554fa1f17..8286f8a82 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,7 +5,7 @@ "ant-design-vue": ["components/index.tsx"], "ant-design-vue/es/*": ["components/*"] }, - "strictNullChecks": true, + "strictNullChecks": false, "moduleResolution": "node", "esModuleInterop": true, "experimentalDecorators": true, diff --git a/webpack.config.js b/webpack.config.js index 4991a84af..33531b51d 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -33,7 +33,7 @@ const babelConfig = { style: true, }, ], - ['@vue/babel-plugin-jsx'], + ['@vue/babel-plugin-jsx', { mergeProps: false }], '@babel/plugin-proposal-optional-chaining', '@babel/plugin-transform-object-assign', '@babel/plugin-proposal-object-rest-spread',