import React, { useRef, useState, useEffect } from "react"; import classNames from "classnames"; import { useTranslation } from "../i18n"; import { SelectOptionWithGroup } from "./SelectOption"; import { SearchBox } from "../searchbox"; import { Dropdown, DropdownKeyDownContext } from "../dropdown"; import { useDefaultValue, ChangeContext } from "../form"; import { VirtualizedList } from "../list/VirtualizedList"; import { SelectProps } from "./SelectProps"; import { Text } from "../text"; import { EmptyTip } from "../tips"; import { Button } from "../button"; import { KeyMap } from "../_util/key-map"; import { injectValue } from "../_util/inject-value"; import { useDefault } from "../_util/use-default"; import { noop } from "../_util/noop"; import { warn } from "../_util/warn"; import { useConfig } from "../_util/config-context"; import { searchFilter } from "../_util/search-filter"; import { mergeRefs } from "../util"; import { isMobile } from "../_util/is-mobile"; import { useUnmounted } from "../_util/use-unmounted"; import { getListItems } from "./util"; const defaultFilter = ( inputValue: string, { text, value }: SelectOptionWithGroup ) => { const optionText = String(typeof text === "string" ? text : value); return searchFilter(optionText, inputValue); }; const trueFilter = () => true; /** * 模拟 Select */ export const SimulateSelect = React.forwardRef(function SimulateSelect( props: SelectProps, ref: React.Ref ) { const t = useTranslation(); const { classPrefix } = useConfig(); const { virtual = true, value, onChange, options = [], groups, placeholder = t.pleaseSelect, button, size, disabled, onScrollBottom, listHeight, listWidth, bottomTips, boxStyle = {}, onOpen = noop, onClose = noop, searchable, searchPlaceholder = "", onSearch = noop, filter: customizeFilter = defaultFilter, filterRender, defaultSearchValue = "", searchValue, onSearchValueChange, autoClearSearchValue = true, clearable, refreshable, onRefresh = noop, footer, appearance = props.appearence, // eslint-disable-line react/destructuring-assignment ...dropdownProps } = useDefaultValue(props, null); // 移动端 focus 可能产生滚动导致关闭 if (isMobile && searchable) { dropdownProps.closeOnScroll = false; } const [refreshing, setRefreshing] = useState(false); const dropdownRef = useRef(null); const vListRef = useRef(null); const InputRef = useRef(null); const [inputValue, setInputValue] = useDefault( searchValue, defaultSearchValue, onSearchValueChange ); const unmountedRef = useUnmounted(); const filter = searchable ? customizeFilter : trueFilter; function focus() { if (!isMobile && searchable) { setTimeout(() => { if (InputRef.current) { InputRef.current.focus(); } }, 100); // 第一次展开时 Input 还未渲染 } } const selected = value != null ? options.find(x => x.value === value) : undefined; // 选项消失不影响已选中值显示 const [buttonOption, setButtonOption] = useState( () => { if (!selected) { return undefined; } return selected; } ); // options 中有选中项时改变 buttonOption useEffect(() => { if (selected) { setButtonOption(selected); } }, [options]); // eslint-disable-line react-hooks/exhaustive-deps // value 变化时改变 buttonOption 及搜索框状态 useEffect(() => { if (selected) { setButtonOption(selected); } else { setButtonOption(undefined); } }, [value]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { if (options.length > 10 && !searchable) { warn("Select 中 `options` 的项数较多,建议开启搜索功能"); } }, [options, searchable]); let buttonPlaceholder: React.ReactNode = placeholder; if (!appearance || appearance === "button" || appearance === "default") { buttonPlaceholder = {placeholder}; } // 按钮文字 const buttonText: React.ReactNode = // eslint-disable-next-line no-nested-ternary buttonOption ? typeof buttonOption.text === "undefined" ? buttonOption.value : buttonOption.text : buttonPlaceholder || t.pleaseSelect; const hasGroup = !!options.find(opt => !!opt.groupKey) && groups; // 筛选 const filteredOptions = options.filter(options => filter(inputValue, options) ); // 提示 let { tips } = props; if (typeof tips === "undefined" && filteredOptions.length === 0) { tips = ; } const items = getListItems({ tips: injectValue(tips)(filteredOptions), bottomTips: injectValue(bottomTips)(filteredOptions), groups, options: filteredOptions, }).map(item => { if (item.type === "option" && searchable && filterRender) { return { ...item, text: filterRender(inputValue, item.option) }; } return item; }); const selectedIndex = items.findIndex( x => x.type === "option" && x.option.value === value ); const [currentIndex, setCurrentIndex] = useState(-1); useEffect(() => { setCurrentIndex(selectedIndex); }, [selectedIndex]); const count = items.length; function handleKeyDown( source: "dropdown" | "input", event: React.KeyboardEvent, // input 托管事件时没有 context,此时一定为 open 状态 context: DropdownKeyDownContext = { open: true }, close: () => void = () => null ) { const getOptionIndex = (current: number, step: number = 1) => { let flag = 1; let index = (current + step + count) % count; let item = items[index % count]; while ( flag < count && (item.type !== "option" || item.option?.disabled) ) { flag += 1; index = (index + step + count) % count; item = items[index % count]; } return index; }; const ok = () => { const item = items[currentIndex % count]; if (item && item.type === "option" && context.open) { // 输入时不能使用空格选中 if (item.option.disabled) { return false; } onChange(item.option.value, { event, option: item.option }); close(); } return true; }; switch (event.key) { case KeyMap.Space: if (source === "input") { return false; } event.preventDefault(); return ok(); case KeyMap.Enter: return ok(); case KeyMap.Up: event.preventDefault(); if (count && context.open) { setCurrentIndex(c => { const index = getOptionIndex(c, -1); if (vListRef.current) { vListRef.current.scrollToItem(index); } return index; }); } break; case KeyMap.Down: event.preventDefault(); if (count && context.open) { setCurrentIndex(c => { const index = getOptionIndex(c); if (vListRef.current) { vListRef.current.scrollToItem(index); } return index; }); } break; case KeyMap.Esc: close(); break; } return true; } if (listWidth) { boxStyle.width = listWidth; boxStyle.minWidth = listWidth; } const select = ( { onOpen(); focus(); }} onClose={() => { onClose(); // 已经未空值不再次置回初始 if (autoClearSearchValue) { setTimeout(() => { if (!unmountedRef.current) { setInputValue("", {} as any); } }, 100); } }} onKeyDown={(event, context) => handleKeyDown("dropdown", event, context)} clearable={value != null && clearable} onClear={event => { onChange(null, { event, option: null }); setButtonOption(null); }} disabled={disabled || refreshing} {...dropdownProps} > {close => ( <> {searchable && ( { setInputValue(value, context); onSearch(value, context); }} onClear={focus} placeholder={searchPlaceholder} onKeyDown={event => handleKeyDown("input", event, undefined, close) } onSearch={onSearch} /> )} { if (item.type === "option") { const { option } = item; return { ...item, props: { disabled: option.disabled, selected: option === selected, current: currentIndex === index, onClick: event => { onChange(option.value, { event, option }); close(); }, tooltip: option.tooltip, className: hasGroup && !option.groupKey ? `${classPrefix}-list__item--single` : "", "aria-selected": option === selected, }, }; } return item; })} virtualizedRef={vListRef} type={hasGroup ? "option-group" : "option"} onScrollBottom={onScrollBottom} listHeight={listHeight} initialScrollOffsetIndex={selectedIndex} /> )} ); if (refreshable) { return (
{select}
); } return select; }); SimulateSelect.displayName = "SimulateSelect";