import cx from 'classnames'; import { Component, createRef } from 'react'; import Popover from '../popover'; import TagList, { ISelectTagListProps } from './TagList'; import Option from './Option'; import Search from './Search'; import { DisabledContext, IDisabledContext } from '../disabled'; import WindowEventHandler from '../utils/component/WindowEventHandler'; import Icon from '../icon'; import { TextMark } from '../text-mark'; import { InlineLoading } from '../loading/InlineLoading'; import { Pop } from '../pop'; import { I18nReceiver as Receiver, II18nLocaleSelect } from '../i18n'; import memoize from '../utils/memorize-one'; import uniqueId from '../utils/uniqueId'; import { filterReviver, reviveSelectItem } from './reviver'; // 允许创建的临时 key const uniqueKey = '__ZENT_SELECT_CREATABLE_KEY__'; const SELECT_CREATABLE_KEY = uniqueId(uniqueKey); export interface ISelectItem { key: Key; text: React.ReactNode; disabled?: boolean; type?: 'header' | 'divider' | 'reviver'; reviver?: (item: ISelectItem) => ISelectItem | null; } export interface IOptionRenderer< Key extends string | number = string | number, Item extends ISelectItem = ISelectItem > { (item: Item, index: number): React.ReactNode; } export interface ISelectKeywordChangeMeta { source: 'user-clear' | 'user-change' | 'popup-close' | 'option-create'; } export type ISelectSize = 'xs' | 's' | 'm' | 'l' | 'xl'; export interface ISelectCommonProps< Key extends string | number = string | number, Item extends ISelectItem = ISelectItem > { keyword?: string; onKeywordChange?: (keyword: string, meta: ISelectKeywordChangeMeta) => void; options: Item[]; isEqual?: (a: Item, b: Item) => boolean; placeholder?: string; notFoundContent?: string; inline?: boolean; width?: React.CSSProperties['width']; size?: ISelectSize; popupWidth?: React.CSSProperties['width']; filter?: ((keyword: string, item: Item) => boolean) | false; highlight?: (keyword: string, item: Item) => Item; disabled?: boolean; open?: boolean; onOpenChange?: (open: boolean) => void; renderValue?: (value: Item) => React.ReactNode; renderOptionList?: ( options: Item[], renderOption: IOptionRenderer ) => React.ReactNode; renderOptionContent?: (value: Item) => React.ReactNode; clearable?: boolean; loading?: boolean; creatable?: boolean; onCreate?: (text: string) => Promise; isValidNewOption?: (keyword: string, options: Item[]) => boolean; collapsable?: boolean; collapseAt?: number; hideCollapsePop?: boolean; className?: string; disableSearch?: boolean; renderCollapsedContent?: (collapsedValue: Item[]) => React.ReactNode; } export interface ISelectSingleProps< Key extends string | number = string | number, Item extends ISelectItem = ISelectItem > extends ISelectCommonProps { multiple?: false; value?: Item | null; onChange?: (value: Item | null) => void; } export interface ISelectMultiProps< Key extends string | number = string | number, Item extends ISelectItem = ISelectItem > extends ISelectCommonProps { multiple: true; value?: Item[]; onChange?: (value: Item[]) => void; renderTagList?: (props: ISelectTagListProps) => React.ReactNode; } export type ISelectProps< Key extends string | number = string | number, Item extends ISelectItem = ISelectItem > = ISelectMultiProps | ISelectSingleProps; export interface ISelectState< Key extends string | number = string | number, Item extends ISelectItem = ISelectItem > { open: boolean; active: boolean; keyword: string; /** * This is the value used for rendering even when componnet is in controlled mode */ value: null | Item | Item[]; activeIndex: null | number; prevOptions: Item[]; creating: boolean; // Keep track of trigger DOM node width triggerWidth: React.CSSProperties['width']; } function defaultIsEqual< Key extends string | number = string | number, Item extends ISelectItem = ISelectItem >(a: Item, b: Item) { return a.key === b.key; } function defaultFilter< Key extends string | number = string | number, Item extends ISelectItem = ISelectItem >(keyword: string, option: Item): boolean { if (typeof option.text !== 'string') { return true; } return option.text.toLowerCase().includes(keyword.toLowerCase()); } function defaultRenderOptionList< Key extends string | number = string | number, Item extends ISelectItem = ISelectItem >(options: Item[], renderOption: IOptionRenderer) { return options.map(renderOption); } // 获取creatable下需要增加的options function getExtraOptions< Key extends string | number = string | number, Item extends ISelectItem = ISelectItem >(value: Item | Item[] | undefined) { if (!Array.isArray(value)) { if (value?.key?.toString()?.indexOf(uniqueKey) > -1) { return [value]; } return []; } return value.reduce((v, next) => { if (next?.key?.toString()?.indexOf(uniqueKey) > -1) { return [...v, next]; } return v; }, []); } function isSelectable< Key extends string | number = string | number, Item extends ISelectItem = ISelectItem >(item: Item) { return !!item && !item.disabled && !item.type; } function findNextSelectableOption< Key extends string | number = string | number, Item extends ISelectItem = ISelectItem >(options: Item[], start: number): number | null { for (let i = start; i < options.length; i += 1) { if (isSelectable(options[i])) { return i; } } return null; } function findPrevSelectableOption< Key extends string | number = string | number, Item extends ISelectItem = ISelectItem >(options: Item[], start: number) { for (let i = start; i >= 0; i -= 1) { if (isSelectable(options[i])) { return i; } } return null; } function defaultHighlight< Key extends string | number = string | number, Item extends ISelectItem = ISelectItem >(keyword: string, option: Item): React.ReactNode { if (typeof option.text !== 'string') { return option.text; } return ( ); } const DEFAULT_LOADING = (
); function defaultIsValidNewOption( keyword: string, options: ISelectItem[] ): boolean { return options.every( it => (typeof it.text === 'string' ? it.text.toLowerCase() : it.text) !== keyword.toLowerCase() ); } const DEFAULT_TRIGGER_WIDTH = 240; const DEFAULT_SIZE_WIDTH = 116; const DEFAULT_PADDING_WIDTH = 8; const SIZE_MAP = { xs: DEFAULT_SIZE_WIDTH, s: DEFAULT_SIZE_WIDTH * 2 + DEFAULT_PADDING_WIDTH, m: DEFAULT_SIZE_WIDTH * 3 + DEFAULT_PADDING_WIDTH * 2, l: DEFAULT_SIZE_WIDTH * 4 + DEFAULT_PADDING_WIDTH * 3, xl: DEFAULT_SIZE_WIDTH * 5 + DEFAULT_PADDING_WIDTH * 4, }; export class Select< Key extends string | number = string | number, Item extends ISelectItem = ISelectItem > extends Component, ISelectState> { static defaultProps = { isEqual: defaultIsEqual, renderOptionList: defaultRenderOptionList, filter: defaultFilter, isValidNewOption: defaultIsValidNewOption, highlight: defaultHighlight, size: 's', multiple: false, clearable: false, loading: false, creatable: false, }; static contextType = DisabledContext; static reviveValue = reviveSelectItem; context!: IDisabledContext; triggerRef = createRef(); popoverRef = createRef(); inputRef = createRef(); constructor(props: ISelectProps) { super(props); let value: null | Item | Item[]; if (props.multiple) { value = filterReviver(props.value ?? []); } else { value = filterReviver(props.value ?? null); } const { keyword, width, options, size } = props; this.state = { keyword: keyword ?? '', value, open: false, active: false, activeIndex: null, prevOptions: options, creating: false, triggerWidth: width ?? (SIZE_MAP[size] || DEFAULT_TRIGGER_WIDTH), }; this.tryReviveOption(props); } static getDerivedStateFromProps< Key extends string | number = string | number, Item extends ISelectItem = ISelectItem >( props: ISelectProps, state: ISelectState ): Partial> | null { const nextState: Partial> = { prevOptions: props.options, }; if (typeof props.keyword === 'string') { nextState.keyword = props.keyword; } if (typeof props.open === 'boolean') { nextState.open = props.open; nextState.active = props.open; } if (props.multiple) { if (Array.isArray(props.value)) { nextState.value = filterReviver(props.value); } } else { if ('value' in props) { nextState.value = filterReviver(props.value ?? null); } } if (props.options !== state.prevOptions && state.activeIndex !== null) { if (!props.options.length) { nextState.activeIndex = null; } else { if (state.activeIndex >= props.options.length) { nextState.activeIndex = props.options.length - 1; } } } return nextState; } componentDidMount() { if ('popupWidth' in this.props) { return; } const { size, width } = this.props; const sizeWidth = SIZE_MAP[size] || DEFAULT_TRIGGER_WIDTH; const useWidth = typeof width === 'number' ? width : sizeWidth; const triggerWidth = this.triggerRef.current?.offsetWidth || useWidth; this.setState({ triggerWidth, }); } componentDidUpdate(prevProps: ISelectProps) { if ( this.props.options !== prevProps.options || this.props.value !== prevProps.value ) { this.tryReviveOption(this.props); } } get disabled() { const { disabled = this.context.value } = this.props; return disabled; } tryReviveOption(props: ISelectProps) { const { options } = props; if (props.multiple) { const value = props.value ?? []; let revived = false; const newValue = value.map(v => { if (v.type === 'reviver') { for (const opt of options) { const revivedOpt = v.reviver?.(opt); if (revivedOpt) { revived = true; return revivedOpt as Item; } } } return v; }); if (revived) { if (props.onChange) { props.onChange(newValue); } else { this.setState({ value: newValue }); } } } else if (props.multiple === false) { const value = props.value ?? null; if (value?.type === 'reviver') { let revivedOpt: Item | null = null; for (const opt of options) { revivedOpt = value.reviver?.(opt) as Item; if (revivedOpt) { break; } } if (revivedOpt) { if (props.onChange) { props.onChange?.(revivedOpt); } else { this.setState({ value: revivedOpt }); } } } } } onVisibleChange = (open: boolean) => { if (this.disabled) { return; } const { onOpenChange } = this.props; if (onOpenChange) { onOpenChange(open); } else { this.setState({ open, active: open, activeIndex: null, }); } // 关闭时清空搜索内容 if (open === false) { this.resetKeyword('popup-close'); } }; onSelect = (item: Item) => { if (!item || item.disabled || item.type || this.disabled) { return; } const { onCreate } = this.props; const isCreate = item.key === SELECT_CREATABLE_KEY; if (isCreate && onCreate) { this.onCreateClick(); return; } isCreate && this.resetKeyword('option-create'); const valueItem = isCreate ? { ...item, key: uniqueId(uniqueKey) } : item; if (this.props.multiple === true) { const { onChange, isEqual } = this.props; const value = this.state.value as Item[]; const valueIndex = value.findIndex(it => isEqual(it, item)); this.focusSearchInput(); const nextValue = valueIndex >= 0 ? value.filter((_it, index) => index !== valueIndex) : value.concat([valueItem]); if (onChange) { onChange(nextValue); } else { this.setState({ value: nextValue }); } } else { this.onVisibleChange(false); const { onChange } = this.props; if (onChange) { onChange(valueItem); } else { this.setState({ value: valueItem }); } } }; onKeywordChange: React.ChangeEventHandler = e => { if (this.disabled) { return; } this.setKeyword(e.target.value, 'user-change'); }; resetKeyword(source: ISelectKeywordChangeMeta['source']) { this.setKeyword('', source); } setKeyword(keyword: string, source: ISelectKeywordChangeMeta['source']) { const { onKeywordChange } = this.props; if (onKeywordChange) { onKeywordChange(keyword, { source }); } else { this.setState({ keyword, }); } } onRemove = (item: Item) => { if (this.disabled) { return; } const { value } = this.state; const { onChange, isEqual } = this.props as ISelectMultiProps; const nextValue = (value as Item[]).filter(it => !isEqual(item, it)); this.focusSearchInput(); if (onChange) { onChange(nextValue); } else { this.setState({ value: nextValue, }); } }; onOptionMouseEnter = (index: number) => { if (this.disabled) { return; } this.setState({ activeIndex: index, }); }; onOptionMouseLeave = (index: number) => { if (this.disabled) { return; } this.setState(state => state.activeIndex === index ? { activeIndex: null, } : null ); }; selectCurrentIndex = () => { if (this.disabled) { return; } const { activeIndex, keyword, value } = this.state; const { creatable, options: _options, filter, isValidNewOption, } = this.props; const options = this.filterOptions( keyword, _options, filter, creatable, isValidNewOption, value ); if (activeIndex !== null) { this.onSelect(options[activeIndex]); } else { // 没有activeIndex且第一项为create,则自动创建 if (options.length && options[0]?.key === SELECT_CREATABLE_KEY) { this.onSelect(options[0]); } } }; renderOption: IOptionRenderer = (option: Item, index: number) => { const { isEqual, multiple, renderOptionContent, highlight, filter } = this.props; const { value, activeIndex, creating } = this.state; const selected = !!value && (multiple ? (value as Item[]).findIndex(it => isEqual(it, option)) >= 0 : isEqual(value as Item, option)); let optionContent: React.ReactNode = null; let loading = false; if (option.key === SELECT_CREATABLE_KEY) { loading = creating; optionContent = ( {i18n => ( {i18n.create} {option.text} )} ); } else if (renderOptionContent) { optionContent = renderOptionContent(option); } else { const keyword = this.state.keyword.trim(); optionContent = filter !== false && keyword.length > 0 ? highlight?.(keyword, option) : option.text; } return ( ); }; globalClick = (e: MouseEvent) => { if ( this.disabled || this.state.open || !this.state.active || !this.triggerRef.current || !this.popoverRef.current ) { return; } if (!this.triggerRef.current?.contains(e.target as Element)) { this.setState({ active: false, }); } }; onIndexChange = (delta: 1 | -1) => { if (this.disabled) { return; } this.setState( (state, { options: _options, creatable, filter, isValidNewOption }) => { const options = this.filterOptions( state.keyword, _options, filter, creatable, isValidNewOption, state.value ); let nextIndex: number; if (state.activeIndex === null) { if (delta < 0) { nextIndex = options.length - 1; } else { nextIndex = 0; } } else { nextIndex = (state.activeIndex + delta) % options.length; } if (nextIndex >= options.length) { nextIndex = options.length - 1; } if (nextIndex < 0) { nextIndex = 0; } if (!isSelectable(options[nextIndex])) { let enabled: number | null; if (delta > 0) { enabled = findNextSelectableOption(options, nextIndex); } else { enabled = findPrevSelectableOption(options, nextIndex); } if (!enabled) { return null; } nextIndex = enabled; } if (state.activeIndex === nextIndex) { return null; } return { activeIndex: nextIndex, }; } ); }; renderValue(i18n: II18nLocaleSelect) { const { placeholder, renderValue, multiple } = this.props; const { open } = this.state; if (multiple) { const value = this.state.value as Item[]; if (value?.length > 0) { return this.renderTagList(value, i18n); } if (open) { return null; } } else { if (open) { return null; } const value = this.state.value as Item | null; if (value) { return renderValue ? ( renderValue(value) ) : ( {value.text} ); } } return {placeholder}; } renderTagCollapsedTrigger(value: Item[]) { return ( +{value.length} ); } renderTagList(value: Item[], i18n: II18nLocaleSelect) { const { renderValue, renderTagList, collapsable, hideCollapsePop, collapseAt = 1, renderCollapsedContent, } = this.props as ISelectMultiProps; const tagsValue = collapsable ? value.slice(0, collapseAt) : value; const collapsedValue = value.slice(collapseAt); return ( <> {typeof renderTagList === 'function' ? ( renderTagList({ list: value, onRemove: this.onRemove, renderValue: renderValue as any, }) ) : ( )} {collapsable && collapsedValue.length > 0 && (!hideCollapsePop ? (
{typeof renderCollapsedContent === 'function' ? renderCollapsedContent(collapsedValue) : collapsedValue.map((item, index) => { return ( {renderValue ? renderValue(item) : item.text} {index !== collapsedValue.length - 1 && i18n.tagSeparator} ); })}
} > {this.renderTagCollapsedTrigger(collapsedValue)}
) : ( this.renderTagCollapsedTrigger(collapsedValue) ))} ); } getSearchPlaceholder(): string { const { placeholder } = this.props; if (this.props.multiple) { if ((this.state.value as Item[]).length) { return ''; } return placeholder ?? ''; } const value = this.state.value as Item | null; if (!value || typeof value.text !== 'string') { return placeholder ?? ''; } return value.text; } onClear = (e: React.MouseEvent) => { e.stopPropagation(); const { keyword } = this.state; this.focusSearchInput(); if (keyword) { this.resetKeyword('user-clear'); return; } if (this.props.multiple) { const { onChange } = this.props as ISelectMultiProps; const value: Item[] = []; if (onChange) { onChange(value); } else { this.setState({ value, }); } } else { const { onChange } = this.props as ISelectSingleProps; const value = null; if (onChange) { onChange(value); } else { this.setState({ value, }); } } }; onCreateClick = () => { const { onCreate, multiple } = this.props; const { keyword } = this.state; if (onCreate) { this.setState({ creating: true }); onCreate(keyword.trim()) .then(() => { if (multiple) { this.focusSearchInput(); } else { this.onVisibleChange(false); } this.resetKeyword('option-create'); }) .finally(() => { this.setState({ creating: false }); }); } }; filterOptions = memoize( ( keyword: string, options: Item[] = [], filter: ((keyword: string, item: Item) => boolean) | false, creatable: boolean, isValidNewOption: (keyword: string, options: Item[]) => boolean, value: Item | Item[] | undefined ): Item[] => { const extraOptions = creatable ? getExtraOptions(value) : []; const mergedOptions = [...options, ...extraOptions]; const filtered = filter !== false && keyword ? mergedOptions.filter(it => filter?.(keyword, it)) : mergedOptions; const pendingCreateOption = creatable && keyword && isValidNewOption?.(keyword, mergedOptions) ? [ { key: SELECT_CREATABLE_KEY, text: keyword, }, ] : []; return (pendingCreateOption as Item[]).concat(filtered); } ); focusSearchInput = () => { // 命令式聚焦搜索框 this.inputRef?.current?.focus(); }; renderPopoverContent(i18n: II18nLocaleSelect): React.ReactNode { const { notFoundContent, renderOptionList, loading, creatable, options, filter, isValidNewOption, } = this.props; const keyword = this.state.keyword.trim(); const value = this.state.value; if (loading) { return DEFAULT_LOADING; } const filtered = this.filterOptions( keyword, options, filter, creatable, isValidNewOption, value ); return filtered?.length ? ( renderOptionList(filtered, this.renderOption) ) : (
{notFoundContent ?? i18n.empty}
); } render() { const { keyword, open: visible, active, value, triggerWidth } = this.state; const { inline, width, clearable, multiple, popupWidth, collapsable, className, disableSearch, size, collapseAt, } = this.props; const notEmpty = multiple ? Array.isArray(value) && value.length > 0 : value; const showClear = clearable && !this.disabled && (keyword || notEmpty); return ( <> {i18n => (
{this.renderValue(i18n)} {showClear && ( )} {!disableSearch && visible && ( )}
{this.renderPopoverContent(i18n)}
)}
); } } export default Select;