394 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			Vue
		
	
	
			
		
		
	
	
			394 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			Vue
		
	
	
| import TransBtn from './TransBtn';
 | |
| 
 | |
| 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';
 | |
| import { computed, defineComponent, nextTick, reactive, toRaw, watch } from 'vue';
 | |
| import List from '../vc-virtual-list';
 | |
| import useMemo from '../_util/hooks/useMemo';
 | |
| import { isPlatformMac } from './utils/platformUtil';
 | |
| 
 | |
| import type { EventHandler } from '../_util/EventInterface';
 | |
| import omit from '../_util/omit';
 | |
| import useBaseProps from './hooks/useBaseProps';
 | |
| import type { RawValueType } from './Select';
 | |
| import useSelectProps from './SelectContext';
 | |
| import type { ScrollConfig } from '../vc-virtual-list/List';
 | |
| 
 | |
| export interface RefOptionListProps {
 | |
|   onKeydown: (e?: KeyboardEvent) => void;
 | |
|   onKeyup: (e?: KeyboardEvent) => void;
 | |
|   scrollTo?: (index: number | ScrollConfig) => void;
 | |
| }
 | |
| function isTitleType(content: any) {
 | |
|   return typeof content === 'string' || typeof content === 'number';
 | |
| }
 | |
| 
 | |
| // export interface OptionListProps<OptionsType extends object[]> {
 | |
| export type OptionListProps = Record<string, never>;
 | |
| 
 | |
| /**
 | |
|  * Using virtual list of option display.
 | |
|  * Will fallback to dom if use customize render.
 | |
|  */
 | |
| const OptionList = defineComponent({
 | |
|   compatConfig: { MODE: 3 },
 | |
|   name: 'OptionList',
 | |
|   inheritAttrs: false,
 | |
|   setup(_, { expose, slots }) {
 | |
|     const baseProps = useBaseProps();
 | |
|     const props = useSelectProps();
 | |
|     const itemPrefixCls = computed(() => `${baseProps.prefixCls}-item`);
 | |
| 
 | |
|     const memoFlattenOptions = useMemo(
 | |
|       () => props.flattenOptions,
 | |
|       [() => baseProps.open, () => props.flattenOptions],
 | |
|       next => next[0],
 | |
|     );
 | |
| 
 | |
|     // =========================== List ===========================
 | |
|     const listRef = createRef();
 | |
| 
 | |
|     const onListMouseDown: EventHandler = event => {
 | |
|       event.preventDefault();
 | |
|     };
 | |
| 
 | |
|     const scrollIntoView = (args: number | ScrollConfig) => {
 | |
|       if (listRef.current) {
 | |
|         listRef.current.scrollTo(typeof args === 'number' ? { index: args } : args);
 | |
|       }
 | |
|     };
 | |
| 
 | |
|     // ========================== Active ==========================
 | |
|     const getEnabledActiveIndex = (index: number, offset = 1) => {
 | |
|       const len = memoFlattenOptions.value.length;
 | |
| 
 | |
|       for (let i = 0; i < len; i += 1) {
 | |
|         const current = (index + i * offset + len) % len;
 | |
| 
 | |
|         const { group, data } = memoFlattenOptions.value[current];
 | |
|         if (!group && !data.disabled) {
 | |
|           return current;
 | |
|         }
 | |
|       }
 | |
| 
 | |
|       return -1;
 | |
|     };
 | |
|     const state = reactive({
 | |
|       activeIndex: getEnabledActiveIndex(0),
 | |
|     });
 | |
| 
 | |
|     const setActive = (index: number, fromKeyboard = false) => {
 | |
|       state.activeIndex = index;
 | |
|       const info = { source: fromKeyboard ? ('keyboard' as const) : ('mouse' as const) };
 | |
| 
 | |
|       // Trigger active event
 | |
|       const flattenItem = memoFlattenOptions.value[index];
 | |
|       if (!flattenItem) {
 | |
|         props.onActiveValue(null, -1, info);
 | |
|         return;
 | |
|       }
 | |
| 
 | |
|       props.onActiveValue(flattenItem.value, index, info);
 | |
|     };
 | |
| 
 | |
|     // Auto active first item when list length or searchValue changed
 | |
| 
 | |
|     watch(
 | |
|       [() => memoFlattenOptions.value.length, () => baseProps.searchValue],
 | |
|       () => {
 | |
|         setActive(props.defaultActiveFirstOption !== false ? getEnabledActiveIndex(0) : -1);
 | |
|       },
 | |
|       { immediate: true },
 | |
|     );
 | |
| 
 | |
|     // https://github.com/ant-design/ant-design/issues/34975
 | |
|     const isSelected = (value: RawValueType) =>
 | |
|       props.rawValues.has(value) && baseProps.mode !== 'combobox';
 | |
| 
 | |
|     // Auto scroll to item position in single mode
 | |
|     watch(
 | |
|       [() => baseProps.open, () => baseProps.searchValue],
 | |
|       () => {
 | |
|         if (!baseProps.multiple && baseProps.open && props.rawValues.size === 1) {
 | |
|           const value = Array.from(props.rawValues)[0];
 | |
|           const index = toRaw(memoFlattenOptions.value).findIndex(
 | |
|             ({ data }) => data[props.fieldNames.value] === value,
 | |
|           );
 | |
|           if (index !== -1) {
 | |
|             setActive(index);
 | |
|             nextTick(() => {
 | |
|               scrollIntoView(index);
 | |
|             });
 | |
|           }
 | |
|         }
 | |
|         // Force trigger scrollbar visible when open
 | |
|         if (baseProps.open) {
 | |
|           nextTick(() => {
 | |
|             listRef.current?.scrollTo(undefined);
 | |
|           });
 | |
|         }
 | |
|       },
 | |
|       { immediate: true, flush: 'post' },
 | |
|     );
 | |
| 
 | |
|     // ========================== Values ==========================
 | |
|     const onSelectValue = (value?: RawValueType) => {
 | |
|       if (value !== undefined) {
 | |
|         props.onSelect(value, { selected: !props.rawValues.has(value) });
 | |
|       }
 | |
| 
 | |
|       // Single mode should always close by select
 | |
|       if (!baseProps.multiple) {
 | |
|         baseProps.toggleOpen(false);
 | |
|       }
 | |
|     };
 | |
|     const getLabel = (item: Record<string, any>) =>
 | |
|       typeof item.label === 'function' ? item.label() : item.label;
 | |
|     function renderItem(index: number) {
 | |
|       const item = memoFlattenOptions.value[index];
 | |
|       if (!item) return null;
 | |
| 
 | |
|       const itemData = item.data || {};
 | |
|       const { value } = itemData;
 | |
|       const { group } = item;
 | |
|       const attrs = pickAttrs(itemData, true);
 | |
|       const mergedLabel = getLabel(item);
 | |
|       return item ? (
 | |
|         <div
 | |
|           aria-label={typeof mergedLabel === 'string' && !group ? mergedLabel : null}
 | |
|           {...attrs}
 | |
|           key={index}
 | |
|           role={group ? 'presentation' : 'option'}
 | |
|           id={`${baseProps.id}_list_${index}`}
 | |
|           aria-selected={isSelected(value)}
 | |
|         >
 | |
|           {value}
 | |
|         </div>
 | |
|       ) : null;
 | |
|     }
 | |
|     const onKeydown = (event: KeyboardEvent) => {
 | |
|       const { which, ctrlKey } = event;
 | |
|       switch (which) {
 | |
|         // >>> Arrow keys & ctrl + n/p on Mac
 | |
|         case KeyCode.N:
 | |
|         case KeyCode.P:
 | |
|         case KeyCode.UP:
 | |
|         case KeyCode.DOWN: {
 | |
|           let offset = 0;
 | |
|           if (which === KeyCode.UP) {
 | |
|             offset = -1;
 | |
|           } else if (which === KeyCode.DOWN) {
 | |
|             offset = 1;
 | |
|           } else if (isPlatformMac() && ctrlKey) {
 | |
|             if (which === KeyCode.N) {
 | |
|               offset = 1;
 | |
|             } else if (which === KeyCode.P) {
 | |
|               offset = -1;
 | |
|             }
 | |
|           }
 | |
| 
 | |
|           if (offset !== 0) {
 | |
|             const nextActiveIndex = getEnabledActiveIndex(state.activeIndex + offset, offset);
 | |
|             scrollIntoView(nextActiveIndex);
 | |
|             setActive(nextActiveIndex, true);
 | |
|           }
 | |
| 
 | |
|           break;
 | |
|         }
 | |
| 
 | |
|         // >>> Select
 | |
|         case KeyCode.ENTER: {
 | |
|           // value
 | |
|           const item = memoFlattenOptions.value[state.activeIndex];
 | |
|           if (item && !item.data.disabled) {
 | |
|             onSelectValue(item.value);
 | |
|           } else {
 | |
|             onSelectValue(undefined);
 | |
|           }
 | |
| 
 | |
|           if (baseProps.open) {
 | |
|             event.preventDefault();
 | |
|           }
 | |
| 
 | |
|           break;
 | |
|         }
 | |
| 
 | |
|         // >>> Close
 | |
|         case KeyCode.ESC: {
 | |
|           baseProps.toggleOpen(false);
 | |
|           if (baseProps.open) {
 | |
|             event.stopPropagation();
 | |
|           }
 | |
|         }
 | |
|       }
 | |
|     };
 | |
|     const onKeyup = () => {};
 | |
| 
 | |
|     const scrollTo = (index: number) => {
 | |
|       scrollIntoView(index);
 | |
|     };
 | |
|     expose({
 | |
|       onKeydown,
 | |
|       onKeyup,
 | |
|       scrollTo,
 | |
|     });
 | |
|     return () => {
 | |
|       // const {
 | |
|       //   renderItem,
 | |
|       //   listRef,
 | |
|       //   onListMouseDown,
 | |
|       //   itemPrefixCls,
 | |
|       //   setActive,
 | |
|       //   onSelectValue,
 | |
|       //   memoFlattenOptions,
 | |
|       //   $slots,
 | |
|       // } = this as any;
 | |
|       const { id, notFoundContent, onPopupScroll } = baseProps;
 | |
|       const { menuItemSelectedIcon, fieldNames, virtual, listHeight, listItemHeight } = props;
 | |
| 
 | |
|       const renderOption = slots.option;
 | |
|       const { activeIndex } = state;
 | |
|       const omitFieldNameList = Object.keys(fieldNames).map(key => fieldNames[key]);
 | |
|       // ========================== Render ==========================
 | |
|       if (memoFlattenOptions.value.length === 0) {
 | |
|         return (
 | |
|           <div
 | |
|             role="listbox"
 | |
|             id={`${id}_list`}
 | |
|             class={`${itemPrefixCls.value}-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}
 | |
|             data={memoFlattenOptions.value}
 | |
|             height={listHeight}
 | |
|             itemHeight={listItemHeight}
 | |
|             fullHeight={false}
 | |
|             onMousedown={onListMouseDown}
 | |
|             onScroll={onPopupScroll}
 | |
|             virtual={virtual}
 | |
|             v-slots={{
 | |
|               default: (item, itemIndex) => {
 | |
|                 const { group, groupOption, data, value } = item;
 | |
|                 const { key } = data;
 | |
|                 const label = typeof item.label === 'function' ? item.label() : item.label;
 | |
|                 // Group
 | |
|                 if (group) {
 | |
|                   const groupTitle = data.title ?? (isTitleType(label) && label);
 | |
|                   return (
 | |
|                     <div
 | |
|                       class={classNames(itemPrefixCls.value, `${itemPrefixCls.value}-group`)}
 | |
|                       title={groupTitle}
 | |
|                     >
 | |
|                       {renderOption ? renderOption(data) : label !== undefined ? label : key}
 | |
|                     </div>
 | |
|                   );
 | |
|                 }
 | |
| 
 | |
|                 const {
 | |
|                   disabled,
 | |
|                   title,
 | |
|                   children,
 | |
|                   style,
 | |
|                   class: cls,
 | |
|                   className,
 | |
|                   ...otherProps
 | |
|                 } = data;
 | |
|                 const passedProps = omit(otherProps, omitFieldNameList);
 | |
|                 // Option
 | |
|                 const selected = isSelected(value);
 | |
| 
 | |
|                 const optionPrefixCls = `${itemPrefixCls.value}-option`;
 | |
|                 const optionClassName = classNames(
 | |
|                   itemPrefixCls.value,
 | |
|                   optionPrefixCls,
 | |
|                   cls,
 | |
|                   className,
 | |
|                   {
 | |
|                     [`${optionPrefixCls}-grouped`]: groupOption,
 | |
|                     [`${optionPrefixCls}-active`]: activeIndex === itemIndex && !disabled,
 | |
|                     [`${optionPrefixCls}-disabled`]: disabled,
 | |
|                     [`${optionPrefixCls}-selected`]: selected,
 | |
|                   },
 | |
|                 );
 | |
| 
 | |
|                 const mergedLabel = getLabel(item);
 | |
| 
 | |
|                 const iconVisible =
 | |
|                   !menuItemSelectedIcon || typeof menuItemSelectedIcon === 'function' || selected;
 | |
| 
 | |
|                 // https://github.com/ant-design/ant-design/issues/34145
 | |
|                 const content =
 | |
|                   typeof mergedLabel === 'number' ? mergedLabel : mergedLabel || value;
 | |
|                 // https://github.com/ant-design/ant-design/issues/26717
 | |
|                 let optionTitle = isTitleType(content) ? content.toString() : undefined;
 | |
|                 if (title !== undefined) {
 | |
|                   optionTitle = title;
 | |
|                 }
 | |
| 
 | |
|                 return (
 | |
|                   <div
 | |
|                     {...passedProps}
 | |
|                     aria-selected={selected}
 | |
|                     class={optionClassName}
 | |
|                     title={optionTitle}
 | |
|                     onMousemove={e => {
 | |
|                       if (otherProps.onMousemove) {
 | |
|                         otherProps.onMousemove(e);
 | |
|                       }
 | |
|                       if (activeIndex === itemIndex || disabled) {
 | |
|                         return;
 | |
|                       }
 | |
|                       setActive(itemIndex);
 | |
|                     }}
 | |
|                     onClick={e => {
 | |
|                       if (!disabled) {
 | |
|                         onSelectValue(value);
 | |
|                       }
 | |
|                       if (otherProps.onClick) {
 | |
|                         otherProps.onClick(e);
 | |
|                       }
 | |
|                     }}
 | |
|                     style={style}
 | |
|                   >
 | |
|                     <div class={`${optionPrefixCls}-content`}>
 | |
|                       {renderOption ? renderOption(data) : content}
 | |
|                     </div>
 | |
|                     {isValidElement(menuItemSelectedIcon) || selected}
 | |
|                     {iconVisible && (
 | |
|                       <TransBtn
 | |
|                         class={`${itemPrefixCls.value}-option-state`}
 | |
|                         customizeIcon={menuItemSelectedIcon}
 | |
|                         customizeIconProps={{ isSelected: selected }}
 | |
|                       >
 | |
|                         {selected ? 'â' : null}
 | |
|                       </TransBtn>
 | |
|                     )}
 | |
|                   </div>
 | |
|                 );
 | |
|               },
 | |
|             }}
 | |
|           ></List>
 | |
|         </>
 | |
|       );
 | |
|     };
 | |
|   },
 | |
| });
 | |
| 
 | |
| export default OptionList;
 |