import cns from 'classnames' import React, { HTMLAttributes, KeyboardEvent, PropsWithChildren, ReactNode, useCallback, useLayoutEffect, useMemo, useRef } from 'react' import { usePrefix } from '../ConfigProvider' import Dropdown, { DropdownRef } from '../Dropdown' import { CommonProps, useInputSize, useInputStyle } from '../Input/input' import { concat } from '../utils' import { useFocused, useRefs, useValue } from '../utils/hooks' import { OptionGroupStyle, OptionInfo, toggle, useActive, Value, useOptionItems, FetchFn, useSelectPrefix, isGroup } from './common' import { SelectProvider, SelectCtxValue, useSelectCtx } from './context' import { StatusIcon, ClearIcon, OptionPlaceholder, useSearchInput } from './Selector' import Overlay, { OptionsOverlayProvider } from './Overlay' import MultiInputTag, { CollapsedNumTag } from '../internal/MultiInputTag' import { WithRef } from '../utils/type' export interface Props extends CommonProps { /** * 是否支持搜索,`reserveKeyword` 表示是否在选中一个选项后保留当前的搜索关键词,`reserveKeyword` 默认为 `false` * @default false */ searchable?: boolean | { reserveKeyword?: boolean } /** 是否有清空功能 */ clearable?: boolean /** 自定义的数据获取逻辑 */ fetch?: FetchFn /** 是否折叠选中项 */ collapsed?: boolean /** 是否支持全选,不支持与 `OptionGroup` 混用 */ withSelectAll?: boolean /** `OptionGroup` 的渲染样式 */ groupStyle?: OptionGroupStyle /** 内容(`Option` / `OptionGroup`) */ children?: ReactNode /** 根元素的 HTML 属性 */ rootHtmlProps?: WithRef, HTMLDivElement> } export default function MultiSelect({ value: valueFromProps, defaultValue = [], onChange, placeholder, searchable: searchableFromProps = false, clearable = false, fetch, collapsed = true, withSelectAll = false, groupStyle = 'default', children, disabled = false, size, validateStatus, className: classNameFromProps, rootHtmlProps, ...otherProps }: Props) { const { value, setValue } = useValue({ valueFromProps, defaultValue, onChange }) const { focused, focusedHandlers } = useFocused({}, disabled) const { active, selectorRef, overlayRef, toggleActive } = useActive(disabled) const mergedSelectorRef = useRefs(rootHtmlProps?.ref, selectorRef) const searchable = !!searchableFromProps const reserveKeyword = typeof searchableFromProps === 'object' ? (searchableFromProps.reserveKeyword ?? false) : false // 是否处于搜索状态 const searchActive = searchable && active const selectClassName = useSelectPrefix() const multiSelectClassName = usePrefix('multi-select') const className = cns( useInputStyle({ size, validateStatus, disabled, focused: focused || active }), selectClassName, active && concat(selectClassName, 'active'), searchActive && concat(selectClassName, 'search-active'), multiSelectClassName, collapsed && concat(multiSelectClassName, 'collapsed'), classNameFromProps, rootHtmlProps?.className ) // 关键词输入为空时 Backspace -> 删除最后一个选中项 const handleSearchInputPress = useCallback((e: KeyboardEvent) => { if (e.key === 'Backspace' && !e.currentTarget.value && value.length > 0) { setValue(value.slice(0, -1)) } }, [value, setValue]) const { searchValue, setSearchValue, renderSearchInput, focusSearchInput } = useSearchInput(active) const searchInput = searchable && renderSearchInput({ placeholder: value.length === 0 ? placeholder : undefined, onKeyDown: handleSearchInputPress }) const handleSelectorClick = useCallback((e: React.MouseEvent) => { if (disabled) return rootHtmlProps?.onClick?.(e) if (e.defaultPrevented) return if (searchActive) { focusSearchInput() return } toggleActive() }, [disabled, rootHtmlProps, searchActive, toggleActive, focusSearchInput]) const clearIconView = !disabled && clearable && ( ) const { items, loading, getOption } = useOptionItems(active, searchValue, children, fetch) const currentOptions = value.map(getOption).filter(Boolean) as Array> // 检查是否混用了 `withSelectAll` & if (withSelectAll && items.some(it => isGroup(it))) { throw new Error('Unexpected mix of `withSelectAll` & ``.') } const tags = ( collapsed && currentOptions.length > 1 ? [ {currentOptions[0].content} , ] : currentOptions.map(option => ( {option.content} )) ) const currentOptionContent = ( currentOptions.length > 0 ? tags : {placeholder} ) // 在当前值发生变更时(可能导致 select 高度变更)去调用 dropdown 的 forcePopupAlign // 确保 dropdown 相对 select 的位置是正确的 const dropdownRef = useRef(null) useLayoutEffect(() => { if (dropdownRef.current == null) return dropdownRef.current.forcePopupAlign() }, [value]) const handleSelect = useCallback((v: T[]) => { setValue(v) if (!reserveKeyword) { setSearchValue('') } focusSearchInput() }, [setValue, setSearchValue, focusSearchInput, reserveKeyword]) const inputSize = useInputSize(size) const selectCtxValue: SelectCtxValue = useMemo(() => ({ selected: value, onSelect: handleSelect, multiple: true, size: inputSize }), [value, handleSelect, inputSize]) const overlay = ( ) return ( {currentOptionContent} {searchInput} {clearIconView} ) } function OptionTag({ disabled, value, children }: PropsWithChildren<{ value: T, disabled: boolean }>) { const { selected, onSelect } = useSelectCtx() const handleClick = useCallback( (e: React.MouseEvent) => { e.stopPropagation() // 防止点击造成 select active 变更 onSelect(toggle(selected, value)) }, [onSelect, selected, value] ) return ( {children} ) }