You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
643 lines
20 KiB
643 lines
20 KiB
/**
|
|
* 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 BaseSelect, { baseSelectPropsWithoutPrivate, isMultiple } from './BaseSelect';
|
|
import type { DisplayValueType, BaseSelectRef, BaseSelectProps } from './BaseSelect';
|
|
import OptionList from './OptionList';
|
|
import useOptions from './hooks/useOptions';
|
|
import type { SelectContextProps } from './SelectContext';
|
|
import { useProvideSelectProps } from './SelectContext';
|
|
import useId from './hooks/useId';
|
|
import { fillFieldNames, flattenOptions, injectPropsWithOption } from './utils/valueUtil';
|
|
import warningProps from './utils/warningPropsUtil';
|
|
import { toArray } from './utils/commonUtil';
|
|
import useFilterOptions from './hooks/useFilterOptions';
|
|
import useCache from './hooks/useCache';
|
|
import type { Key, VueNode } from '../_util/type';
|
|
import { computed, defineComponent, ref, toRef, watchEffect } from 'vue';
|
|
import type { ExtractPropTypes, PropType } from 'vue';
|
|
import PropTypes from '../_util/vue-types';
|
|
import { initDefaultProps } from '../_util/props-util';
|
|
import useMergedState from '../_util/hooks/useMergedState';
|
|
import useState from '../_util/hooks/useState';
|
|
import { toReactive } from '../_util/toReactive';
|
|
import omit from '../_util/omit';
|
|
|
|
const OMIT_DOM_PROPS = ['inputValue'];
|
|
|
|
export type OnActiveValue = (
|
|
active: RawValueType,
|
|
index: number,
|
|
info?: { source?: 'keyboard' | 'mouse' },
|
|
) => void;
|
|
|
|
export type OnInternalSelect = (value: RawValueType, info: { selected: boolean }) => void;
|
|
|
|
export type RawValueType = string | number;
|
|
export interface LabelInValueType {
|
|
label: any;
|
|
value: RawValueType;
|
|
/** @deprecated `key` is useless since it should always same as `value` */
|
|
key?: Key;
|
|
}
|
|
|
|
export type DraftValueType =
|
|
| RawValueType
|
|
| LabelInValueType
|
|
| DisplayValueType
|
|
| (RawValueType | LabelInValueType | DisplayValueType)[];
|
|
|
|
export type FilterFunc<OptionType> = (inputValue: string, option?: OptionType) => boolean;
|
|
|
|
export interface FieldNames {
|
|
value?: string;
|
|
label?: string;
|
|
options?: string;
|
|
}
|
|
|
|
export interface BaseOptionType {
|
|
disabled?: boolean;
|
|
[name: string]: any;
|
|
}
|
|
|
|
export interface DefaultOptionType extends BaseOptionType {
|
|
label?: any;
|
|
value?: string | number | null;
|
|
children?: Omit<DefaultOptionType, 'children'>[];
|
|
}
|
|
|
|
export type SelectHandler<ValueType = any, OptionType extends BaseOptionType = DefaultOptionType> =
|
|
| ((value: RawValueType | LabelInValueType, option: OptionType) => void)
|
|
| ((value: ValueType, option: OptionType) => void);
|
|
|
|
export function selectProps<
|
|
ValueType = any,
|
|
OptionType extends BaseOptionType = DefaultOptionType,
|
|
>() {
|
|
return {
|
|
...baseSelectPropsWithoutPrivate(),
|
|
prefixCls: String,
|
|
id: String,
|
|
|
|
backfill: { type: Boolean, default: undefined },
|
|
|
|
// >>> Field Names
|
|
fieldNames: Object as PropType<FieldNames>,
|
|
|
|
// >>> Search
|
|
/** @deprecated Use `searchValue` instead */
|
|
inputValue: String,
|
|
searchValue: String,
|
|
onSearch: Function as PropType<(value: string) => void>,
|
|
autoClearSearchValue: { type: Boolean, default: undefined },
|
|
|
|
// >>> Select
|
|
onSelect: Function as PropType<SelectHandler<ValueType, OptionType>>,
|
|
onDeselect: Function as PropType<SelectHandler<ValueType, OptionType>>,
|
|
|
|
// >>> Options
|
|
/**
|
|
* In Select, `false` means do nothing.
|
|
* In TreeSelect, `false` will highlight match item.
|
|
* It's by design.
|
|
*/
|
|
filterOption: {
|
|
type: [Boolean, Function] as PropType<boolean | FilterFunc<OptionType>>,
|
|
default: undefined,
|
|
},
|
|
filterSort: Function as PropType<(optionA: OptionType, optionB: OptionType) => number>,
|
|
optionFilterProp: String,
|
|
optionLabelProp: String,
|
|
options: Array as PropType<OptionType[]>,
|
|
defaultActiveFirstOption: { type: Boolean, default: undefined },
|
|
virtual: { type: Boolean, default: undefined },
|
|
listHeight: Number,
|
|
listItemHeight: Number,
|
|
|
|
// >>> Icon
|
|
menuItemSelectedIcon: PropTypes.any,
|
|
|
|
mode: String as PropType<'combobox' | 'multiple' | 'tags'>,
|
|
labelInValue: { type: Boolean, default: undefined },
|
|
value: PropTypes.any,
|
|
defaultValue: PropTypes.any,
|
|
onChange: Function as PropType<(value: ValueType, option: OptionType | OptionType[]) => void>,
|
|
children: Array as PropType<VueNode[]>,
|
|
};
|
|
}
|
|
|
|
export type SelectProps = Partial<ExtractPropTypes<ReturnType<typeof selectProps>>>;
|
|
|
|
function isRawValue(value: DraftValueType): value is RawValueType {
|
|
return !value || typeof value !== 'object';
|
|
}
|
|
|
|
export default defineComponent({
|
|
name: 'Select',
|
|
inheritAttrs: false,
|
|
props: initDefaultProps(selectProps(), {
|
|
prefixCls: 'vc-select',
|
|
autoClearSearchValue: true,
|
|
listHeight: 200,
|
|
listItemHeight: 20,
|
|
}),
|
|
setup(props, { expose, attrs, slots }) {
|
|
const mergedId = useId(toRef(props, 'id'));
|
|
const multiple = computed(() => isMultiple(props.mode));
|
|
const childrenAsData = computed(() => !!(!props.options && props.children));
|
|
|
|
const mergedFilterOption = computed(() => {
|
|
if (props.filterOption === undefined && props.mode === 'combobox') {
|
|
return false;
|
|
}
|
|
return props.filterOption;
|
|
});
|
|
|
|
// ========================= FieldNames =========================
|
|
const mergedFieldNames = computed(() => fillFieldNames(props.fieldNames, childrenAsData.value));
|
|
|
|
// =========================== Search ===========================
|
|
const [mergedSearchValue, setSearchValue] = useMergedState('', {
|
|
value: computed(() =>
|
|
props.searchValue !== undefined ? props.searchValue : props.inputValue,
|
|
),
|
|
postState: search => search || '',
|
|
});
|
|
|
|
// =========================== Option ===========================
|
|
const parsedOptions = useOptions(
|
|
toRef(props, 'options'),
|
|
toRef(props, 'children'),
|
|
mergedFieldNames,
|
|
);
|
|
const { valueOptions, labelOptions, options: mergedOptions } = parsedOptions;
|
|
|
|
// ========================= Wrap Value =========================
|
|
const convert2LabelValues = (draftValues: DraftValueType) => {
|
|
// Convert to array
|
|
const valueList = toArray(draftValues);
|
|
|
|
// Convert to labelInValue type
|
|
return valueList.map(val => {
|
|
let rawValue: RawValueType;
|
|
let rawLabel: any;
|
|
let rawKey: Key;
|
|
let rawDisabled: boolean | undefined;
|
|
|
|
// Fill label & value
|
|
if (isRawValue(val)) {
|
|
rawValue = val;
|
|
} else {
|
|
rawKey = val.key;
|
|
rawLabel = val.label;
|
|
rawValue = val.value ?? rawKey;
|
|
}
|
|
|
|
const option = valueOptions.value.get(rawValue);
|
|
if (option) {
|
|
// Fill missing props
|
|
if (rawLabel === undefined)
|
|
rawLabel = option?.[props.optionLabelProp || mergedFieldNames.value.label];
|
|
if (rawKey === undefined) rawKey = option?.key ?? rawValue;
|
|
rawDisabled = option?.disabled;
|
|
|
|
// Warning if label not same as provided
|
|
// if (process.env.NODE_ENV !== 'production' && !isRawValue(val)) {
|
|
// const optionLabel = option?.[mergedFieldNames.value.label];
|
|
// if (optionLabel !== undefined && optionLabel !== rawLabel) {
|
|
// warning(false, '`label` of `value` is not same as `label` in Select options.');
|
|
// }
|
|
// }
|
|
}
|
|
|
|
return {
|
|
label: rawLabel,
|
|
value: rawValue,
|
|
key: rawKey,
|
|
disabled: rawDisabled,
|
|
option,
|
|
};
|
|
});
|
|
};
|
|
|
|
// =========================== Values ===========================
|
|
const [internalValue, setInternalValue] = useMergedState(props.defaultValue, {
|
|
value: toRef(props, 'value'),
|
|
});
|
|
|
|
// Merged value with LabelValueType
|
|
const rawLabeledValues = computed(() => {
|
|
const values = convert2LabelValues(internalValue.value);
|
|
|
|
// combobox no need save value when it's empty
|
|
if (props.mode === 'combobox' && !values[0]?.value) {
|
|
return [];
|
|
}
|
|
|
|
return values;
|
|
});
|
|
|
|
// Fill label with cache to avoid option remove
|
|
const [mergedValues, getMixedOption] = useCache(rawLabeledValues, valueOptions);
|
|
|
|
const displayValues = computed(() => {
|
|
// `null` need show as placeholder instead
|
|
// https://github.com/ant-design/ant-design/issues/25057
|
|
if (!props.mode && mergedValues.value.length === 1) {
|
|
const firstValue = mergedValues.value[0];
|
|
if (
|
|
firstValue.value === null &&
|
|
(firstValue.label === null || firstValue.label === undefined)
|
|
) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
return mergedValues.value.map(item => ({
|
|
...item,
|
|
label: item.label ?? item.value,
|
|
}));
|
|
});
|
|
|
|
/** Convert `displayValues` to raw value type set */
|
|
const rawValues = computed(() => new Set(mergedValues.value.map(val => val.value)));
|
|
|
|
watchEffect(
|
|
() => {
|
|
if (props.mode === 'combobox') {
|
|
const strValue = mergedValues.value[0]?.value;
|
|
|
|
if (strValue !== undefined && strValue !== null) {
|
|
setSearchValue(String(strValue));
|
|
}
|
|
}
|
|
},
|
|
{ flush: 'post' },
|
|
);
|
|
|
|
// ======================= Display Option =======================
|
|
// Create a placeholder item if not exist in `options`
|
|
const createTagOption = (val: RawValueType, label?: any) => {
|
|
const mergedLabel = label ?? val;
|
|
return {
|
|
[mergedFieldNames.value.value]: val,
|
|
[mergedFieldNames.value.label]: mergedLabel,
|
|
} as DefaultOptionType;
|
|
};
|
|
|
|
// Fill tag as option if mode is `tags`
|
|
const filledTagOptions = computed(() => {
|
|
if (props.mode !== 'tags') {
|
|
return mergedOptions.value;
|
|
}
|
|
|
|
// >>> Tag mode
|
|
const cloneOptions = [...mergedOptions.value];
|
|
|
|
// Check if value exist in options (include new patch item)
|
|
const existOptions = (val: RawValueType) => valueOptions.value.has(val);
|
|
|
|
// Fill current value as option
|
|
[...mergedValues.value]
|
|
.sort((a, b) => (a.value < b.value ? -1 : 1))
|
|
.forEach(item => {
|
|
const val = item.value;
|
|
|
|
if (!existOptions(val)) {
|
|
cloneOptions.push(createTagOption(val, item.label));
|
|
}
|
|
});
|
|
|
|
return cloneOptions;
|
|
});
|
|
|
|
const filteredOptions = useFilterOptions(
|
|
filledTagOptions,
|
|
mergedFieldNames,
|
|
mergedSearchValue,
|
|
mergedFilterOption,
|
|
toRef(props, 'optionFilterProp'),
|
|
);
|
|
|
|
// Fill options with search value if needed
|
|
const filledSearchOptions = computed(() => {
|
|
if (
|
|
props.mode !== 'tags' ||
|
|
!mergedSearchValue.value ||
|
|
filteredOptions.value.some(
|
|
item => item[props.optionFilterProp || 'value'] === mergedSearchValue.value,
|
|
)
|
|
) {
|
|
return filteredOptions.value;
|
|
}
|
|
|
|
// Fill search value as option
|
|
return [createTagOption(mergedSearchValue.value), ...filteredOptions.value];
|
|
});
|
|
|
|
const orderedFilteredOptions = computed(() => {
|
|
if (!props.filterSort) {
|
|
return filledSearchOptions.value;
|
|
}
|
|
|
|
return [...filledSearchOptions.value].sort((a, b) => props.filterSort(a, b));
|
|
});
|
|
|
|
const displayOptions = computed(() =>
|
|
flattenOptions(orderedFilteredOptions.value, {
|
|
fieldNames: mergedFieldNames.value,
|
|
childrenAsData: childrenAsData.value,
|
|
}),
|
|
);
|
|
|
|
// =========================== Change ===========================
|
|
const triggerChange = (values: DraftValueType) => {
|
|
const labeledValues = convert2LabelValues(values);
|
|
setInternalValue(labeledValues);
|
|
|
|
if (
|
|
props.onChange &&
|
|
// Trigger event only when value changed
|
|
(labeledValues.length !== mergedValues.value.length ||
|
|
labeledValues.some((newVal, index) => mergedValues.value[index]?.value !== newVal?.value))
|
|
) {
|
|
const returnValues = props.labelInValue ? labeledValues : labeledValues.map(v => v.value);
|
|
const returnOptions = labeledValues.map(v =>
|
|
injectPropsWithOption(getMixedOption(v.value)),
|
|
);
|
|
|
|
props.onChange(
|
|
// Value
|
|
multiple.value ? returnValues : returnValues[0],
|
|
// Option
|
|
multiple.value ? returnOptions : returnOptions[0],
|
|
);
|
|
}
|
|
};
|
|
|
|
// ======================= Accessibility ========================
|
|
const [activeValue, setActiveValue] = useState<string>(null);
|
|
const [accessibilityIndex, setAccessibilityIndex] = useState(0);
|
|
const mergedDefaultActiveFirstOption = computed(() =>
|
|
props.defaultActiveFirstOption !== undefined
|
|
? props.defaultActiveFirstOption
|
|
: props.mode !== 'combobox',
|
|
);
|
|
|
|
const onActiveValue: OnActiveValue = (active, index, { source = 'keyboard' } = {}) => {
|
|
setAccessibilityIndex(index);
|
|
|
|
if (props.backfill && props.mode === 'combobox' && active !== null && source === 'keyboard') {
|
|
setActiveValue(String(active));
|
|
}
|
|
};
|
|
|
|
// ========================= OptionList =========================
|
|
const triggerSelect = (val: RawValueType, selected: boolean) => {
|
|
const getSelectEnt = (): [RawValueType | LabelInValueType, DefaultOptionType] => {
|
|
const option = getMixedOption(val);
|
|
return [
|
|
props.labelInValue
|
|
? {
|
|
label: option?.[mergedFieldNames.value.label],
|
|
value: val,
|
|
key: option.key ?? val,
|
|
}
|
|
: val,
|
|
injectPropsWithOption(option),
|
|
];
|
|
};
|
|
|
|
if (selected && props.onSelect) {
|
|
const [wrappedValue, option] = getSelectEnt();
|
|
props.onSelect(wrappedValue, option);
|
|
} else if (!selected && props.onDeselect) {
|
|
const [wrappedValue, option] = getSelectEnt();
|
|
props.onDeselect(wrappedValue, option);
|
|
}
|
|
};
|
|
|
|
// Used for OptionList selection
|
|
const onInternalSelect = (val, info) => {
|
|
let cloneValues: (RawValueType | DisplayValueType)[];
|
|
|
|
// Single mode always trigger select only with option list
|
|
const mergedSelect = multiple.value ? info.selected : true;
|
|
|
|
if (mergedSelect) {
|
|
cloneValues = multiple.value ? [...mergedValues.value, val] : [val];
|
|
} else {
|
|
cloneValues = mergedValues.value.filter(v => v.value !== val);
|
|
}
|
|
|
|
triggerChange(cloneValues);
|
|
triggerSelect(val, mergedSelect);
|
|
|
|
// Clean search value if single or configured
|
|
if (props.mode === 'combobox') {
|
|
// setSearchValue(String(val));
|
|
setActiveValue('');
|
|
} else if (!multiple.value || props.autoClearSearchValue) {
|
|
setSearchValue('');
|
|
setActiveValue('');
|
|
}
|
|
};
|
|
|
|
// ======================= Display Change =======================
|
|
// BaseSelect display values change
|
|
const onDisplayValuesChange: BaseSelectProps['onDisplayValuesChange'] = (nextValues, info) => {
|
|
triggerChange(nextValues);
|
|
|
|
if (info.type === 'remove' || info.type === 'clear') {
|
|
info.values.forEach(item => {
|
|
triggerSelect(item.value, false);
|
|
});
|
|
}
|
|
};
|
|
|
|
// =========================== Search ===========================
|
|
const onInternalSearch: BaseSelectProps['onSearch'] = (searchText, info) => {
|
|
setSearchValue(searchText);
|
|
setActiveValue(null);
|
|
|
|
// [Submit] Tag mode should flush input
|
|
if (info.source === 'submit') {
|
|
const formatted = (searchText || '').trim();
|
|
// prevent empty tags from appearing when you click the Enter button
|
|
if (formatted) {
|
|
const newRawValues = Array.from(new Set<RawValueType>([...rawValues.value, formatted]));
|
|
triggerChange(newRawValues);
|
|
triggerSelect(formatted, true);
|
|
setSearchValue('');
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (info.source !== 'blur') {
|
|
if (props.mode === 'combobox') {
|
|
triggerChange(searchText);
|
|
}
|
|
|
|
props.onSearch?.(searchText);
|
|
}
|
|
};
|
|
|
|
const onInternalSearchSplit: BaseSelectProps['onSearchSplit'] = words => {
|
|
let patchValues: RawValueType[] = words;
|
|
|
|
if (props.mode !== 'tags') {
|
|
patchValues = words
|
|
.map(word => {
|
|
const opt = labelOptions.value.get(word);
|
|
return opt?.value;
|
|
})
|
|
.filter(val => val !== undefined);
|
|
}
|
|
|
|
const newRawValues = Array.from(new Set<RawValueType>([...rawValues.value, ...patchValues]));
|
|
triggerChange(newRawValues);
|
|
newRawValues.forEach(newRawValue => {
|
|
triggerSelect(newRawValue, true);
|
|
});
|
|
};
|
|
const realVirtual = computed(
|
|
() => props.virtual !== false && props.dropdownMatchSelectWidth !== false,
|
|
);
|
|
useProvideSelectProps(
|
|
toReactive({
|
|
...parsedOptions,
|
|
flattenOptions: displayOptions,
|
|
onActiveValue,
|
|
defaultActiveFirstOption: mergedDefaultActiveFirstOption,
|
|
onSelect: onInternalSelect,
|
|
menuItemSelectedIcon: toRef(props, 'menuItemSelectedIcon'),
|
|
rawValues,
|
|
fieldNames: mergedFieldNames,
|
|
virtual: realVirtual,
|
|
listHeight: toRef(props, 'listHeight'),
|
|
listItemHeight: toRef(props, 'listItemHeight'),
|
|
childrenAsData,
|
|
} as unknown as SelectContextProps),
|
|
);
|
|
|
|
// ========================== Warning ===========================
|
|
if (process.env.NODE_ENV !== 'production') {
|
|
watchEffect(
|
|
() => {
|
|
warningProps(props);
|
|
},
|
|
{ flush: 'post' },
|
|
);
|
|
}
|
|
const selectRef = ref<BaseSelectRef>();
|
|
expose({
|
|
focus() {
|
|
selectRef.value?.focus();
|
|
},
|
|
blur() {
|
|
selectRef.value?.blur();
|
|
},
|
|
scrollTo(arg) {
|
|
selectRef.value?.scrollTo(arg);
|
|
},
|
|
} as BaseSelectRef);
|
|
const pickProps = computed(() => {
|
|
return omit(props, [
|
|
'id',
|
|
'mode',
|
|
'prefixCls',
|
|
'backfill',
|
|
'fieldNames',
|
|
|
|
// Search
|
|
'inputValue',
|
|
'searchValue',
|
|
'onSearch',
|
|
'autoClearSearchValue',
|
|
|
|
// Select
|
|
'onSelect',
|
|
'onDeselect',
|
|
'dropdownMatchSelectWidth',
|
|
|
|
// Options
|
|
'filterOption',
|
|
'filterSort',
|
|
'optionFilterProp',
|
|
'optionLabelProp',
|
|
'options',
|
|
'children',
|
|
'defaultActiveFirstOption',
|
|
'menuItemSelectedIcon',
|
|
'virtual',
|
|
'listHeight',
|
|
'listItemHeight',
|
|
|
|
// Value
|
|
'value',
|
|
'defaultValue',
|
|
'labelInValue',
|
|
'onChange',
|
|
]);
|
|
});
|
|
return () => {
|
|
return (
|
|
<BaseSelect
|
|
{...pickProps.value}
|
|
{...attrs}
|
|
// >>> MISC
|
|
id={mergedId}
|
|
prefixCls={props.prefixCls}
|
|
ref={selectRef}
|
|
omitDomProps={OMIT_DOM_PROPS}
|
|
mode={props.mode}
|
|
// >>> Values
|
|
displayValues={displayValues.value}
|
|
onDisplayValuesChange={onDisplayValuesChange}
|
|
// >>> Search
|
|
searchValue={mergedSearchValue.value}
|
|
onSearch={onInternalSearch}
|
|
onSearchSplit={onInternalSearchSplit}
|
|
dropdownMatchSelectWidth={props.dropdownMatchSelectWidth}
|
|
// >>> OptionList
|
|
OptionList={OptionList}
|
|
emptyOptions={!displayOptions.value.length}
|
|
// >>> Accessibility
|
|
activeValue={activeValue.value}
|
|
activeDescendantId={`${mergedId}_list_${accessibilityIndex.value}`}
|
|
v-slots={slots}
|
|
/>
|
|
);
|
|
};
|
|
},
|
|
});
|