import React, { useState, useRef, useEffect, useCallback } from "react"; import { ListOnScrollProps } from "react-window"; import { Popover, TriggerProps } from "../popover"; import { DropdownBox, CommonDropdownProps } from "../dropdown"; import { StyledProps } from "../_type"; import { SelectOptionWithGroup } from "../select"; import { VirtualizedList as List, VirtualizedListItem, } from "../list/VirtualizedList"; import { ControlledProps, useDefaultValue } from "../form"; import { EmptyTip } from "../tips"; import { useDefault } from "../_util/use-default"; import { KeyMap } from "../_util/key-map"; import { useConfig } from "../util"; import { useIsomorphicLayoutEffect } from "../_util/use-isomorphic-layout-effect"; import { highlight } from "../_util/text-highlight"; export interface AutoCompleteProps extends ControlledProps, CommonDropdownProps, StyledProps { /** * 下拉选项列表 */ options?: SelectOptionWithGroup[]; /** * 分组 */ groups?: { [groupKey: string]: React.ReactNode; }; /** * `options` 为空时展示,可使用字符串或 [StatusTip](/component/tips) 相关组件 */ tips?: React.ReactNode; /** * 展示为高亮的关键词 */ keyword?: string; /** * 要包裹的输入组件 */ children?: ( ref: React.Ref, context: { close: () => void } ) => React.ReactNode; /** * `options` 滚动至底部的回调 */ onScrollBottom?: (props: ListOnScrollProps) => void; } export function AutoComplete(props: AutoCompleteProps) { const { className, style = {}, onChange, children, options = [], groups = {}, keyword, onScrollBottom, closeOnScroll = true, open, defaultOpen, onOpenChange, ...popoverProps } = useDefaultValue(props); const { classPrefix } = useConfig(); const [isSelected, setIsSelected] = useState(false); const inputRef = useRef(null); const [isOpened, setIsOpened] = useDefault(open, defaultOpen, onOpenChange); const [dropdownWidth, setDropdownWidth] = useState(undefined); const [currentIndex, setCurrentIndex] = useState(-1); const listRef = useRef(null); // 内容变化时更新位置 const scheduleUpdateRef = useRef(null); useIsomorphicLayoutEffect( () => () => { if (scheduleUpdateRef.current) { scheduleUpdateRef.current(); } }, [options] ); const hasGroup = !!options.find(opt => !!opt.groupKey); let { tips = } = props; if (options.length === 0) { tips = typeof tips === "string" ? : tips; } else { tips = null; } const items = getListItems({ tips, groups, options, keyword, }); const count = items.length; const getOptionIndex = useCallback( (current: number, step: number = 1) => { let flag = 1; let index = (current + step + count) % count; let item = items[index % count]; while (flag < count && (!item.option || item.option.disabled)) { flag += 1; index = (index + step + count) % count; item = items[index % count]; } return index; }, [items, count] ); useEffect(() => { if (!inputRef.current) return null; function handleKeyDown(event: KeyboardEvent) { const { option } = items[currentIndex % count] || {}; switch (event.key) { case KeyMap.Up: event.preventDefault(); setCurrentIndex(c => { const index = getOptionIndex(c, -1); if (listRef.current && count > 0) { listRef.current.scrollToItem(index); } return index; }); break; case KeyMap.Down: event.preventDefault(); setCurrentIndex(c => { const index = getOptionIndex(c); if (listRef.current && count > 0) { listRef.current.scrollToItem(index); } return index; }); break; case KeyMap.Enter: if (isOpened && option) { if (option.disabled) break; setIsOpened(false); onChange(option.value, { event: event as any }); } else { setIsOpened(true); } break; case KeyMap.Esc: setIsOpened(false); inputRef.current.blur(); break; default: setCurrentIndex(-1); break; } } inputRef.current.addEventListener("keydown", handleKeyDown); return () => { if (inputRef.current) { inputRef.current.removeEventListener("keydown", handleKeyDown); } }; }, [ currentIndex, inputRef, isOpened, onChange, options, setIsOpened, items, count, getOptionIndex, ]); const boxStyle = { width: dropdownWidth, ...style }; return ( { scheduleUpdateRef.current = scheduleUpdate; return ( { if (item.type === "option") { const { option } = item; return { ...item, props: { disabled: option.disabled, current: currentIndex === index, onClick: event => { event.stopPropagation(); inputRef.current.focus(); onChange(option.value, { event }); setIsSelected(true); setCurrentIndex(index); }, tooltip: option.tooltip, className: hasGroup && !option.groupKey ? `${classPrefix}-list__item--single` : "", }, }; } return item; })} onScrollBottom={onScrollBottom} /> ); }} > {children( dom => { inputRef.current = dom; if (dom) { setDropdownWidth(dom.offsetWidth); } }, { close: () => setIsOpened(false) } )} ); } AutoComplete.displayName = "AutoComplete"; export function AutoCompleteTrigger({ visible, setVisible, openDelay = 0, closeDelay = 0, render, setIsSelected, }: TriggerProps & { setIsSelected: (s: boolean) => void }) { const commonProps = { onFocus: () => setVisible(true, openDelay), onChange: () => { setIsSelected(false); if (!visible) { setVisible(true, openDelay); } }, onClick: () => { setIsSelected(false); if (!visible) { setVisible(true, openDelay); } }, onBlur: () => setVisible(false, closeDelay), }; return render({ overlayProps: { ...commonProps, tabIndex: 1000 }, childrenProps: commonProps, }); } AutoComplete.defaultLabelAlign = "middle"; function getListItems({ tips, options, groups, keyword, }: Pick< AutoCompleteProps, "tips" | "options" | "groups" | "keyword" >): VirtualizedListItem[] { const items: VirtualizedListItem[] = []; // tips if (tips) { items.push({ type: "tips", key: "__auto_complete_tips", text: tips }); } options.forEach((option, index) => { if ( option.groupKey && (index === 0 || option.groupKey !== options[index - 1].groupKey) ) { items.push({ type: "group", key: `${option.groupKey}-${option.value}`, text: groups[option.groupKey], }); } items.push({ type: "option", key: option.value, text: highlight( typeof option.text === "undefined" ? option.value : option.text, keyword ), option, }); }); return items; }