import type { DCloneHTMLElement, DId, DSize } from '../../utils/types';
import type { DDropdownItem } from '../dropdown';
import type { DFormControl } from '../form';
import type { DVirtualScrollPerformance, DVirtualScrollRef } from '../virtual-scroll';
import { isNull, isUndefined } from 'lodash';
import React, { useState, useCallback, useMemo, useRef, useImperativeHandle } from 'react';
import { useEventCallback, useId } from '@react-devui/hooks';
import { CloseOutlined, LoadingOutlined, PlusOutlined } from '@react-devui/icons';
import { findNested, getClassName } from '@react-devui/utils';
import { useGeneralContext, useDValue } from '../../hooks';
import { cloneHTMLElement, getVerticalSidePosition, registerComponentMate, TTANSITION_DURING_POPUP, WINDOW_SPACE } from '../../utils';
import { DComboboxKeyboard } from '../_keyboard';
import { DSelectbox } from '../_selectbox';
import { DTransition } from '../_transition';
import { DCheckbox } from '../checkbox';
import { DDropdown } from '../dropdown';
import { useFormControl } from '../form';
import { ROOT_DATA, useComponentConfig, usePrefixConfig, useTranslation } from '../root';
import { DTag } from '../tag';
import { DVirtualScroll } from '../virtual-scroll';
const IS_CREATE = Symbol();
export interface DSelectRef {
updatePosition: () => void;
}
export interface DSelectItem<V extends DId> {
label: string;
value: V;
disabled?: boolean;
children?: DSelectItem<V>[];
}
export interface DSelectProps<V extends DId, T extends DSelectItem<V>> extends Omit<React.HTMLAttributes<HTMLDivElement>, 'children'> {
dRef?: {
input?: React.ForwardedRef<HTMLInputElement>;
};
dFormControl?: DFormControl;
dList: T[];
dModel?: V | null | V[];
dVisible?: boolean;
dInitialVisible?: boolean;
dPlaceholder?: string;
dSize?: DSize;
dLoading?: boolean;
dSearchable?: boolean;
dSearchValue?: string;
dClearable?: boolean;
dDisabled?: boolean;
dMultiple?: boolean;
dMonospaced?: boolean;
dVirtual?: boolean;
dCustomItem?: (item: T) => React.ReactNode;
dCustomSelected?: (select: T) => string;
dCustomSearch?: {
filter?: (value: string, item: T) => boolean;
sort?: (a: T, b: T) => number;
};
dCreateItem?: (value: string) => T | undefined;
dPopupClassName?: string;
dInputRender?: DCloneHTMLElement<React.InputHTMLAttributes<HTMLInputElement>>;
onModelChange?: (value: any, item: any) => void;
onVisibleChange?: (visible: boolean) => void;
onSearchValueChange?: (value: string) => void;
onClear?: () => void;
onCreateItem?: (item: T) => void;
onScrollBottom?: () => void;
afterVisibleChange?: (visible: boolean) => void;
}
const { COMPONENT_NAME } = registerComponentMate({ COMPONENT_NAME: 'DSelect' as const });
function Select<V extends DId, T extends DSelectItem<V>>(
props: DSelectProps<V, T>,
ref: React.ForwardedRef<DSelectRef>
): JSX.Element | null {
const {
dRef,
dFormControl,
dList,
dModel,
dVisible,
dInitialVisible = false,
dPlaceholder,
dSize,
dLoading = false,
dSearchable = false,
dSearchValue,
dClearable = false,
dDisabled = false,
dMultiple = false,
dMonospaced = true,
dVirtual = false,
dCustomItem,
dCustomSelected,
dCustomSearch,
dCreateItem,
dPopupClassName,
dInputRender,
onModelChange,
onVisibleChange,
onClear,
onSearchValueChange,
onCreateItem,
onScrollBottom,
afterVisibleChange,
...restProps
} = useComponentConfig(COMPONENT_NAME, props);
const dPrefix = usePrefixConfig();
const { gSize, gDisabled } = useGeneralContext();
const boxRef = useRef<HTMLDivElement>(null);
const popupRef = useRef<HTMLDivElement>(null);
const vsRef = useRef<DVirtualScrollRef<T>>(null);
const [t] = useTranslation();
const uniqueId = useId();
const listId = `${dPrefix}select-list-${uniqueId}`;
const getGroupId = (val: V) => `${dPrefix}select-group-${val}-${uniqueId}`;
const getItemId = (val: V) => `${dPrefix}select-item-${val}-${uniqueId}`;
const itemsMap = useMemo(() => {
const items = new Map<V, T>();
const reduceArr = (arr: T[]) => {
for (const item of arr) {
items.set(item.value, item);
if (item.children) {
reduceArr(item.children as T[]);
}
}
};
reduceArr(dList);
return items;
}, [dList]);
const [searchValue, changeSearchValue] = useDValue<string>('', dSearchValue, onSearchValueChange);
const canSelectItem = useCallback((item: T) => !item.disabled && !item.children, []);
const [focusVisible, setFocusVisible] = useState(false);
const [visible, changeVisible] = useDValue<boolean>(dInitialVisible, dVisible, onVisibleChange);
const formControlInject = useFormControl(dFormControl);
const [_select, changeSelect] = useDValue<V | null | V[]>(
dMultiple ? [] : null,
dModel,
(value) => {
if (onModelChange) {
if (dMultiple) {
onModelChange(
value,
(value as V[]).map((v) => itemsMap.get(v))
);
} else {
onModelChange(value, isNull(value) ? null : itemsMap.get(value as V));
}
}
},
undefined,
formControlInject
);
const select = useMemo(() => (dMultiple ? new Set(_select as V[]) : (_select as V | null)), [_select, dMultiple]);
const size = dSize ?? gSize;
const disabled = (dDisabled || gDisabled || dFormControl?.control.disabled) ?? false;
const hasSearch = searchValue.length > 0;
const hasSelected = dMultiple ? (select as Set<V>).size > 0 : !isNull(select);
const _filterFn = dCustomSearch?.filter;
const filterFn = useCallback(
(item: T) => {
const defaultFilterFn = (item: T) => {
return item.label.includes(searchValue);
};
return _filterFn ? _filterFn(searchValue, item) : defaultFilterFn(item);
},
[_filterFn, searchValue]
);
const sortFn = dCustomSearch?.sort;
const searchList = useMemo(() => {
if (!hasSearch) {
return [];
}
let createItem = dCreateItem?.(searchValue);
if (createItem) {
createItem = {
...createItem,
[IS_CREATE]: true,
};
}
let searchList: (T & { [IS_CREATE]?: boolean })[] = [];
dList.forEach((item) => {
if (!item.children) {
if (createItem && item.value === createItem.value) {
createItem = undefined;
}
if (filterFn(item)) {
searchList.push(item);
}
} else {
const groupList: T[] = [];
(item.children as T[]).forEach((groupItem) => {
if (createItem && groupItem.value === createItem.value) {
createItem = undefined;
}
if (filterFn(groupItem)) {
groupList.push(groupItem);
}
});
searchList = searchList.concat(groupList);
}
});
if (sortFn) {
searchList.sort(sortFn);
}
if (createItem) {
searchList.unshift(createItem);
}
return searchList;
}, [dCreateItem, dList, filterFn, hasSearch, searchValue, sortFn]);
const list = hasSearch ? searchList : dList;
const [_noSearchFocusItem, setNoSearchFocusItem] = useState<T | undefined>();
const noSearchFocusItem = (() => {
let focusItem: T | undefined;
if (_noSearchFocusItem) {
focusItem = itemsMap.get(_noSearchFocusItem.value);
if (focusItem && canSelectItem(focusItem)) {
return focusItem;
}
}
if (hasSelected) {
focusItem = findNested(dList, (item) =>
canSelectItem(item) && dMultiple ? (select as Set<V>).has(item.value) : (select as V) === item.value
);
}
if (isUndefined(focusItem)) {
focusItem = findNested(dList, (item) => canSelectItem(item));
}
return focusItem;
})();
const [_searchFocusItem, setSearchFocusItem] = useState<(T & { [IS_CREATE]?: boolean | undefined }) | undefined>();
const searchFocusItem = (() => {
if (_searchFocusItem && findNested(searchList, (item) => canSelectItem(item) && item.value === _searchFocusItem.value)) {
return _searchFocusItem;
}
if (hasSearch) {
return findNested(searchList, (item) => canSelectItem(item));
}
})();
const focusItem = hasSearch ? searchFocusItem : noSearchFocusItem;
const changeFocusItem = (item?: T) => {
if (!isUndefined(item)) {
hasSearch ? setSearchFocusItem(item) : setNoSearchFocusItem(item);
}
};
const createItem = (item?: T) => {
if (!isUndefined(item)) {
const newItem = { ...item };
delete newItem[IS_CREATE];
onCreateItem?.(newItem);
}
};
const changeSelectByClick = (val: V) => {
if (dMultiple) {
changeSelect((draft) => {
const index = (draft as V[]).findIndex((v) => v === val);
if (index !== -1) {
(draft as V[]).splice(index, 1);
} else {
(draft as V[]).push(val);
}
});
} else {
changeSelect(val);
changeVisible(false);
}
};
const [popupPositionStyle, setPopupPositionStyle] = useState<React.CSSProperties>({
top: '-200vh',
left: '-200vw',
});
const [transformOrigin, setTransformOrigin] = useState<string>();
const updatePosition = useEventCallback(() => {
if (visible && boxRef.current && popupRef.current) {
if (dMonospaced) {
const width = Math.min(boxRef.current.offsetWidth, ROOT_DATA.pageSize.width - WINDOW_SPACE * 2);
const height = popupRef.current.offsetHeight;
const { top, left, transformOrigin } = getVerticalSidePosition(
boxRef.current,
{ width, height },
{
placement: 'bottom',
inWindow: WINDOW_SPACE,
}
);
setPopupPositionStyle({
top,
left,
width,
});
setTransformOrigin(transformOrigin);
} else {
const boxWidth = boxRef.current.offsetWidth;
const height = popupRef.current.offsetHeight;
const maxWidth = ROOT_DATA.pageSize.width - WINDOW_SPACE * 2;
const width = Math.min(Math.max(popupRef.current.scrollWidth, boxWidth), maxWidth);
const { top, left, transformOrigin } = getVerticalSidePosition(
boxRef.current,
{ width, height },
{
placement: 'bottom-left',
inWindow: WINDOW_SPACE,
}
);
setPopupPositionStyle({
top,
left,
minWidth: Math.min(boxWidth, maxWidth),
maxWidth,
});
setTransformOrigin(transformOrigin);
}
}
});
useImperativeHandle(
ref,
() => ({
updatePosition,
}),
[updatePosition]
);
const [selectedNode, suffixNode, selectedLabel] = (() => {
let selectedNode: React.ReactNode = null;
let suffixNode: React.ReactNode = null;
let selectedLabel: string | undefined;
if (dMultiple) {
const selectedItems: T[] = [];
for (const v of _select as V[]) {
const item = itemsMap.get(v);
if (item) {
selectedItems.push(item);
} else {
console.warn(`Can't find item that value field is ${v}!`);
}
}
suffixNode = (
<DDropdown
dList={selectedItems.map<DDropdownItem<V>>((item) => {
const { label: itemLabel, value: itemValue, disabled: itemDisabled } = item;
const text = dCustomSelected ? dCustomSelected(item) : itemLabel;
return {
id: itemValue,
label: text,
type: 'item',
disabled: itemDisabled,
};
})}
onItemClick={(id: V) => {
changeSelectByClick(id);
return false;
}}
>
<DTag className={`${dPrefix}select__multiple-count`} tabIndex={-1} dSize={size}>
{(select as Set<V>).size}
</DTag>
</DDropdown>
);
selectedNode = selectedItems.map((item) => (
<DTag key={item.value} className={`${dPrefix}select__multiple-tag`} dSize={size}>
{dCustomSelected ? dCustomSelected(item) : item.label}
{!(item.disabled || disabled) && (
<div
className={`${dPrefix}select__close`}
role="button"
aria-label={t('Close')}
onClick={(e) => {
e.stopPropagation();
changeSelectByClick(item.value);
}}
>
<CloseOutlined />
</div>
)}
</DTag>
));
} else {
if (!isNull(select)) {
const item = itemsMap.get(select as V);
if (item) {
selectedLabel = item.label;
selectedNode = dCustomSelected ? dCustomSelected(item) : selectedLabel;
} else {
console.warn(`Can't find item that value field is ${select}!`);
}
}
}
return [selectedNode, suffixNode, selectedLabel];
})();
const vsPerformance = useMemo<DVirtualScrollPerformance<T>>(
() => ({
dList: list,
dItemSize: 32,
dItemNested: (item) => ({
list: item.children as T[],
emptySize: 32,
inAriaSetsize: false,
}),
dItemKey: (item) => item.value,
dFocusable: canSelectItem,
}),
[canSelectItem, list]
);
return (
<DSelectbox
{...restProps}
onClick={(e) => {
restProps.onClick?.(e);
changeVisible((draft) => (dSearchable ? true : !draft));
}}
dRef={{
box: boxRef,
input: dRef?.input,
}}
dClassNamePrefix="select"
dFormControl={dFormControl}
dVisible={visible}
dContent={hasSelected && selectedNode}
dContentTitle={selectedLabel}
dPlaceholder={dPlaceholder}
dSuffix={suffixNode}
dSize={size}
dLoading={dLoading}
dSearchable={dSearchable}
dClearable={dClearable}
dDisabled={disabled}
dUpdatePosition={{
fn: updatePosition,
popupRef,
extraScrollRefs: [],
}}
dInputRender={(el) => (
<DComboboxKeyboard
dVisible={visible}
dEditable={dSearchable}
dHasSub={false}
onVisibleChange={changeVisible}
onFocusChange={(key) => {
switch (key) {
case 'next':
changeFocusItem(vsRef.current?.scrollToStep(1));
break;
case 'prev':
changeFocusItem(vsRef.current?.scrollToStep(-1));
break;
case 'first':
changeFocusItem(vsRef.current?.scrollToStart());
break;
case 'last':
changeFocusItem(vsRef.current?.scrollToEnd());
break;
default:
break;
}
}}
>
{({ render: renderComboboxKeyboard }) => {
const input = renderComboboxKeyboard(
cloneHTMLElement(el, {
value: searchValue,
'aria-controls': listId,
onBlur: (e) => {
el.props.onBlur?.(e);
changeVisible(false);
},
onKeyDown: (e) => {
el.props.onKeyDown?.(e);
if ((e.code === 'Enter' || (!dSearchable && e.code === 'Space')) && visible && focusItem) {
e.preventDefault();
if (focusItem[IS_CREATE]) {
createItem(focusItem);
}
changeSelectByClick(focusItem.value);
}
},
onChange: (e) => {
el.props.onChange?.(e);
const val = e.currentTarget.value;
if (dSearchable) {
changeSearchValue(val);
}
},
})
);
return isUndefined(dInputRender) ? input : dInputRender(input);
}}
</DComboboxKeyboard>
)}
onFocusVisibleChange={setFocusVisible}
onClear={() => {
onClear?.();
if (dMultiple) {
changeSelect([]);
} else {
changeSelect(null);
}
}}
>
{({ renderPopup }) => (
<DTransition
dIn={visible}
dDuring={TTANSITION_DURING_POPUP}
onEnter={updatePosition}
afterEnter={() => {
afterVisibleChange?.(true);
}}
afterLeave={() => {
afterVisibleChange?.(false);
}}
>
{(state) => {
let transitionStyle: React.CSSProperties = {};
switch (state) {
case 'enter':
transitionStyle = { transform: 'scaleY(0.7)', opacity: 0 };
break;
case 'entering':
transitionStyle = {
transition: ['transform', 'opacity'].map((attr) => `${attr} ${TTANSITION_DURING_POPUP}ms ease-out`).join(', '),
transformOrigin,
};
break;
case 'leaving':
transitionStyle = {
transform: 'scaleY(0.7)',
opacity: 0,
transition: ['transform', 'opacity'].map((attr) => `${attr} ${TTANSITION_DURING_POPUP}ms ease-in`).join(', '),
transformOrigin,
};
break;
case 'leaved':
transitionStyle = { display: 'none' };
break;
default:
break;
}
return renderPopup(
<div
ref={popupRef}
className={getClassName(dPopupClassName, `${dPrefix}select__popup`)}
style={{
...popupPositionStyle,
...transitionStyle,
}}
>
{dLoading && (
<div
className={getClassName(`${dPrefix}select__loading`, {
[`${dPrefix}select__loading--empty`]: list.length === 0,
})}
>
<LoadingOutlined dSize={list.length === 0 ? 18 : 24} dSpin />
</div>
)}
<DVirtualScroll
{...vsPerformance}
ref={vsRef}
dFillNode={<li></li>}
dItemRender={(item, index, { aria, vsList }, parent) => {
const { label: itemLabel, value: itemValue, disabled: itemDisabled, children } = item;
const node = dCustomItem ? dCustomItem(item) : itemLabel;
if (children) {
return (
<ul
key={itemValue}
className={`${dPrefix}select__option-group`}
role="group"
aria-labelledby={getGroupId(itemValue)}
>
<li
key={itemValue}
id={getGroupId(itemValue)}
className={`${dPrefix}select__option-group-label`}
role="presentation"
>
<div className={`${dPrefix}select__option-content`}>{node}</div>
</li>
{vsList}
</ul>
);
}
let isSelected = false;
if (dMultiple) {
isSelected = (select as Set<V>).has(itemValue);
} else {
isSelected = (select as V | null) === itemValue;
}
return (
<li
{...aria}
key={itemValue}
id={getItemId(itemValue)}
className={getClassName(`${dPrefix}select__option`, {
'is-selected': !dMultiple && isSelected,
'is-disabled': itemDisabled,
})}
style={{ paddingLeft: parent.length === 0 ? undefined : 12 + 8 }}
title={(item[IS_CREATE] ? t('Create') + ' ' : '') + itemLabel}
role="option"
aria-selected={isSelected}
aria-disabled={itemDisabled}
onClick={() => {
if (!itemDisabled) {
if (item[IS_CREATE]) {
createItem(item);
}
changeFocusItem(item);
changeSelectByClick(itemValue);
}
}}
>
{focusVisible && focusItem?.value === itemValue && <div className={`${dPrefix}focus-outline`}></div>}
{item[IS_CREATE] ? (
<PlusOutlined dTheme="primary" />
) : dMultiple ? (
<DCheckbox dDisabled={itemDisabled} dModel={isSelected}></DCheckbox>
) : null}
<div className={`${dPrefix}select__option-content`}>{node}</div>
</li>
);
}}
dFocusItem={focusItem}
dSize={dVirtual ? 264 : Infinity}
dPadding={4}
dEmptyRender={() => (
<li className={`${dPrefix}select__empty`} style={{ paddingLeft: 12 + 8 }}>
<div className={`${dPrefix}select__option-content`}>{t('No Data')}</div>
</li>
)}
onScrollEnd={onScrollBottom}
>
{({ render, vsList }) =>
render(
<ul
id={listId}
className={`${dPrefix}select__list`}
style={{ pointerEvents: dLoading ? 'none' : undefined }}
tabIndex={-1}
role="listbox"
aria-multiselectable={dMultiple}
aria-activedescendant={isUndefined(focusItem) ? undefined : getItemId(focusItem.value)}
>
{list.length === 0 ? (
<li className={`${dPrefix}select__empty`}>
<div className={`${dPrefix}select__option-content`}>{t('No Data')}</div>
</li>
) : (
vsList
)}
</ul>
)
}
</DVirtualScroll>
</div>
);
}}
</DTransition>
)}
</DSelectbox>
);
}
export const DSelect: <V extends DId, T extends DSelectItem<V>>(
props: DSelectProps<V, T> & React.RefAttributes<DSelectRef>
) => ReturnType<typeof Select> = React.forwardRef(Select) as any;