ant-design-vue/components/vc-select/OptionList.tsx

413 lines
12 KiB
Vue
Raw Normal View History

2020-09-30 16:21:04 +08:00
import TransBtn from './TransBtn';
import PropTypes from '../_util/vue-types';
import KeyCode from '../_util/KeyCode';
import classNames from '../_util/classNames';
import pickAttrs from '../_util/pickAttrs';
import { isValidElement } from '../_util/props-util';
import createRef from '../_util/createRef';
2021-06-26 09:35:40 +08:00
import type { VNodeChild } from 'vue';
import { computed, defineComponent, nextTick, reactive, watch } from 'vue';
2020-09-30 16:21:04 +08:00
import List from '../vc-virtual-list/List';
2021-06-26 09:35:40 +08:00
import type {
2020-10-07 22:49:01 +08:00
OptionsType as SelectOptionsType,
OptionData,
RenderNode,
OnActiveValue,
} from './interface';
2021-06-26 09:35:40 +08:00
import type { RawValueType, FlattenOptionsType } from './interface/generator';
2021-06-22 13:26:19 +08:00
import useMemo from '../_util/hooks/useMemo';
2020-10-07 22:49:01 +08:00
export interface OptionListProps {
prefixCls: string;
id: string;
options: SelectOptionsType;
flattenOptions: FlattenOptionsType<SelectOptionsType>;
height: number;
itemHeight: number;
values: Set<RawValueType>;
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;
}
2020-09-30 16:21:04 +08:00
const OptionListProps = {
prefixCls: PropTypes.string,
id: PropTypes.string,
options: PropTypes.array,
flattenOptions: PropTypes.array,
height: PropTypes.number,
itemHeight: PropTypes.number,
values: PropTypes.any,
multiple: PropTypes.looseBool,
open: PropTypes.looseBool,
defaultActiveFirstOption: PropTypes.looseBool,
2020-09-30 16:21:04 +08:00
notFoundContent: PropTypes.any,
menuItemSelectedIcon: PropTypes.any,
childrenAsData: PropTypes.looseBool,
2020-09-30 16:21:04 +08:00
searchValue: PropTypes.string,
virtual: PropTypes.looseBool,
2020-09-30 16:21:04 +08:00
onSelect: PropTypes.func,
onToggleOpen: PropTypes.func,
/** Tell Select that some value is now active to make accessibility work */
onActiveValue: PropTypes.func,
onScroll: PropTypes.func,
/** Tell Select that mouse enter the popup to force re-render */
onMouseenter: PropTypes.func,
};
/**
* Using virtual list of option display.
* Will fallback to dom if use customize render.
*/
2020-10-28 15:31:45 +08:00
const OptionList = defineComponent<OptionListProps, { state?: any }>({
2020-09-30 16:21:04 +08:00
name: 'OptionList',
inheritAttrs: false,
setup(props) {
const itemPrefixCls = computed(() => `${props.prefixCls}-item`);
2021-06-22 13:26:19 +08:00
const memoFlattenOptions = useMemo(
() => props.flattenOptions,
[() => props.open, () => props.flattenOptions],
2021-06-24 11:03:02 +08:00
next => next[0],
2021-06-22 13:26:19 +08:00
);
2020-09-30 16:21:04 +08:00
// =========================== List ===========================
const listRef = createRef();
2020-10-07 22:49:01 +08:00
const onListMouseDown: EventHandlerNonNull = event => {
2020-09-30 16:21:04 +08:00
event.preventDefault();
};
2020-10-07 22:49:01 +08:00
const scrollIntoView = (index: number) => {
2020-09-30 16:21:04 +08:00
if (listRef.current) {
listRef.current.scrollTo({ index });
}
};
// ========================== Active ==========================
2020-10-07 22:49:01 +08:00
const getEnabledActiveIndex = (index: number, offset = 1) => {
2021-06-22 13:26:19 +08:00
const len = memoFlattenOptions.value.length;
2020-09-30 16:21:04 +08:00
for (let i = 0; i < len; i += 1) {
const current = (index + i * offset + len) % len;
2021-06-22 13:26:19 +08:00
const { group, data } = memoFlattenOptions.value[current];
2020-10-07 22:49:01 +08:00
if (!group && !(data as OptionData).disabled) {
2020-09-30 16:21:04 +08:00
return current;
}
}
return -1;
};
const state = reactive({
activeIndex: getEnabledActiveIndex(0),
});
2020-10-07 22:49:01 +08:00
const setActive = (index: number, fromKeyboard = false) => {
2020-09-30 16:21:04 +08:00
state.activeIndex = index;
2020-10-07 22:49:01 +08:00
const info = { source: fromKeyboard ? ('keyboard' as const) : ('mouse' as const) };
2020-09-30 16:21:04 +08:00
// Trigger active event
2021-06-22 13:26:19 +08:00
const flattenItem = memoFlattenOptions.value[index];
2020-09-30 16:21:04 +08:00
if (!flattenItem) {
props.onActiveValue(null, -1, info);
return;
}
props.onActiveValue(flattenItem.data.value, index, info);
};
// Auto active first item when list length or searchValue changed
2020-10-07 22:49:01 +08:00
watch(
2021-06-22 13:26:19 +08:00
[() => memoFlattenOptions.value.length, () => props.searchValue],
2020-10-07 22:49:01 +08:00
() => {
setActive(props.defaultActiveFirstOption !== false ? getEnabledActiveIndex(0) : -1);
},
2020-10-08 22:51:09 +08:00
{ immediate: true },
2020-10-07 22:49:01 +08:00
);
2020-09-30 16:21:04 +08:00
// Auto scroll to item position in single mode
2020-10-07 22:49:01 +08:00
watch(
2021-06-22 13:26:19 +08:00
() => props.open,
2020-10-07 22:49:01 +08:00
() => {
2020-11-05 18:09:02 +08:00
if (!props.multiple && props.open && props.values.size === 1) {
const value = Array.from(props.values)[0];
2021-06-22 13:26:19 +08:00
const index = memoFlattenOptions.value.findIndex(({ data }) => data.value === value);
setActive(index);
2020-11-05 18:09:02 +08:00
scrollIntoView(index);
}
2020-11-04 18:59:08 +08:00
// Force trigger scrollbar visible when open
if (props.open) {
2020-11-05 18:09:02 +08:00
nextTick(() => {
2020-11-04 18:59:08 +08:00
listRef.current?.scrollTo(undefined);
2020-11-05 18:09:02 +08:00
});
2020-11-04 18:59:08 +08:00
}
2020-10-07 22:49:01 +08:00
},
2020-11-05 18:09:02 +08:00
{ immediate: true, flush: 'post' },
2020-10-07 22:49:01 +08:00
);
2020-09-30 16:21:04 +08:00
// ========================== Values ==========================
2020-10-07 22:49:01 +08:00
const onSelectValue = (value?: RawValueType) => {
2020-09-30 16:21:04 +08:00
if (value !== undefined) {
props.onSelect(value, { selected: !props.values.has(value) });
}
// Single mode should always close by select
if (!props.multiple) {
props.onToggleOpen(false);
}
};
2020-10-07 22:49:01 +08:00
function renderItem(index: number) {
2021-06-22 13:26:19 +08:00
const item = memoFlattenOptions.value[index];
2020-09-30 16:21:04 +08:00
if (!item) return null;
2020-10-07 22:49:01 +08:00
const itemData = (item.data || {}) as OptionData;
2020-09-30 16:21:04 +08:00
const { value, label, children } = itemData;
const attrs = pickAttrs(itemData, true);
const mergedLabel = props.childrenAsData ? children : label;
return item ? (
<div
2020-10-03 15:54:52 +08:00
aria-label={typeof mergedLabel === 'string' ? mergedLabel : undefined}
2020-09-30 16:21:04 +08:00
{...attrs}
key={index}
role="option"
id={`${props.id}_list_${index}`}
aria-selected={props.values.has(value)}
>
{value}
</div>
) : null;
}
return {
2021-06-22 13:26:19 +08:00
memoFlattenOptions,
2020-09-30 16:21:04 +08:00
renderItem,
listRef,
state,
onListMouseDown,
itemPrefixCls,
setActive,
onSelectValue,
2020-10-07 22:49:01 +08:00
onKeydown: (event: KeyboardEvent) => {
2020-09-30 16:21:04 +08:00
const { which } = event;
switch (which) {
// >>> Arrow keys
case KeyCode.UP:
case KeyCode.DOWN: {
let offset = 0;
if (which === KeyCode.UP) {
offset = -1;
} else if (which === KeyCode.DOWN) {
offset = 1;
}
if (offset !== 0) {
const nextActiveIndex = getEnabledActiveIndex(state.activeIndex + offset, offset);
scrollIntoView(nextActiveIndex);
setActive(nextActiveIndex, true);
}
break;
}
// >>> Select
case KeyCode.ENTER: {
// value
2021-06-22 13:26:19 +08:00
const item = memoFlattenOptions.value[state.activeIndex];
2020-09-30 16:21:04 +08:00
if (item && !item.data.disabled) {
onSelectValue(item.data.value);
} else {
onSelectValue(undefined);
}
if (props.open) {
event.preventDefault();
}
break;
}
// >>> Close
case KeyCode.ESC: {
props.onToggleOpen(false);
2021-06-22 10:47:33 +08:00
if (props.open) {
event.stopPropagation();
}
2020-09-30 16:21:04 +08:00
}
}
},
onKeyup: () => {},
2020-10-07 22:49:01 +08:00
scrollTo: (index: number) => {
2020-09-30 16:21:04 +08:00
scrollIntoView(index);
},
};
},
render() {
2020-10-10 17:01:39 +08:00
const {
renderItem,
listRef,
onListMouseDown,
itemPrefixCls,
setActive,
onSelectValue,
2021-06-22 13:26:19 +08:00
memoFlattenOptions,
2020-10-10 17:01:39 +08:00
} = this as any;
2020-09-30 16:21:04 +08:00
const {
id,
childrenAsData,
values,
height,
itemHeight,
menuItemSelectedIcon,
notFoundContent,
virtual,
onScroll,
onMouseenter,
2020-10-10 17:01:39 +08:00
} = this.$props as OptionListProps;
2020-09-30 16:21:04 +08:00
const { activeIndex } = this.state;
// ========================== Render ==========================
2021-06-22 13:26:19 +08:00
if (memoFlattenOptions.length === 0) {
2020-09-30 16:21:04 +08:00
return (
<div
role="listbox"
id={`${id}_list`}
class={`${itemPrefixCls}-empty`}
onMousedown={onListMouseDown}
>
{notFoundContent}
</div>
);
}
return (
<>
<div role="listbox" id={`${id}_list`} style={{ height: 0, width: 0, overflow: 'hidden' }}>
{renderItem(activeIndex - 1)}
{renderItem(activeIndex)}
{renderItem(activeIndex + 1)}
</div>
<List
itemKey="key"
ref={listRef}
2021-06-22 13:26:19 +08:00
data={memoFlattenOptions}
2020-09-30 16:21:04 +08:00
height={height}
itemHeight={itemHeight}
fullHeight={false}
onMousedown={onListMouseDown}
onScroll={onScroll}
virtual={virtual}
onMouseenter={onMouseenter}
2020-10-07 22:49:01 +08:00
children={({ group, groupOption, data }, itemIndex) => {
2020-09-30 16:21:04 +08:00
const { label, key } = data;
// Group
if (group) {
return (
<div class={classNames(itemPrefixCls, `${itemPrefixCls}-group`)}>
{label !== undefined ? label : key}
</div>
);
}
const {
disabled,
value,
title,
children,
style,
class: cls,
className,
...otherProps
} = data;
// Option
const selected = values.has(value);
const optionPrefixCls = `${itemPrefixCls}-option`;
const optionClassName = classNames(itemPrefixCls, optionPrefixCls, cls, className, {
[`${optionPrefixCls}-grouped`]: groupOption,
[`${optionPrefixCls}-active`]: activeIndex === itemIndex && !disabled,
[`${optionPrefixCls}-disabled`]: disabled,
[`${optionPrefixCls}-selected`]: selected,
});
const mergedLabel = childrenAsData ? children : label;
const iconVisible =
!menuItemSelectedIcon || typeof menuItemSelectedIcon === 'function' || selected;
const content = mergedLabel || value;
// https://github.com/ant-design/ant-design/issues/26717
let optionTitle =
typeof content === 'string' || typeof content === 'number'
? content.toString()
: undefined;
if (title !== undefined) {
optionTitle = title;
}
return (
<div
{...otherProps}
aria-selected={selected}
class={optionClassName}
title={optionTitle}
onMousemove={e => {
if (otherProps.onMousemove) {
otherProps.onMousemove(e);
}
2020-09-30 16:21:04 +08:00
if (activeIndex === itemIndex || disabled) {
return;
}
setActive(itemIndex);
}}
onClick={e => {
2020-09-30 16:21:04 +08:00
if (!disabled) {
onSelectValue(value);
}
if (otherProps.onClick) {
otherProps.onClick(e);
}
2020-09-30 16:21:04 +08:00
}}
style={style}
>
<div class={`${optionPrefixCls}-content`}>{content}</div>
{isValidElement(menuItemSelectedIcon) || selected}
{iconVisible && (
<TransBtn
class={`${itemPrefixCls}-option-state`}
customizeIcon={menuItemSelectedIcon}
customizeIconProps={{ isSelected: selected }}
>
{selected ? '✓' : null}
</TransBtn>
)}
</div>
);
}}
2020-10-07 22:49:01 +08:00
></List>
2020-09-30 16:21:04 +08:00
</>
);
},
2020-10-03 15:54:52 +08:00
});
2020-09-30 16:21:04 +08:00
2020-10-07 22:49:01 +08:00
OptionList.props = OptionListProps;
2020-09-30 16:21:04 +08:00
export default OptionList;