import type { DCloneHTMLElement, DId, DSize } from '../../utils/types';
import type { ComboboxKeyDownRef } from '../_keyboard';
import type { DDropdownItem } from '../dropdown';
import type { DFormControl } from '../form';
import type { DSelectItem } from '../select';
import type { AbstractTreeNode, TreeOrigin } from '../tree/abstract-node';
import { isNull, isUndefined } from 'lodash';
import React, { useCallback, useState, useMemo, useRef, useImperativeHandle } from 'react';
import { useEventCallback, useId } from '@react-devui/hooks';
import { CloseOutlined, LoadingOutlined } 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 { DDropdown } from '../dropdown';
import { useFormControl } from '../form';
import { ROOT_DATA, useComponentConfig, usePrefixConfig, useTranslation } from '../root';
import { DTag } from '../tag';
import { DSearchPanel as DTreeSearchPanel } from '../tree/SearchPanel';
import { MultipleTreeNode } from '../tree/multiple-node';
import { SingleTreeNode } from '../tree/single-node';
import { getText, TREE_NODE_KEY } from '../tree/utils';
import { DList } from './List';
export interface DCascaderRef {
updatePosition: () => void;
}
export type DSearchItem<V extends DId, T extends TreeOrigin> = DSelectItem<V> & { [TREE_NODE_KEY]: AbstractTreeNode<V, T> };
export interface DCascaderItem<V extends DId> {
label: string;
value: V;
loading?: boolean;
disabled?: boolean;
children?: DCascaderItem<V>[];
}
export interface DCascaderProps<V extends DId, T extends DCascaderItem<V>> extends React.HTMLAttributes<HTMLDivElement> {
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;
dOnlyLeafSelectable?: 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;
};
dPopupClassName?: string;
dInputRender?: DCloneHTMLElement<React.InputHTMLAttributes<HTMLInputElement>>;
onModelChange?: (value: any, item: any) => void;
onVisibleChange?: (visible: boolean) => void;
onSearchValueChange?: (value: string) => void;
onClear?: () => void;
onFirstFocus?: (value: T['value'], item: T) => void;
afterVisibleChange?: (visible: boolean) => void;
}
const { COMPONENT_NAME } = registerComponentMate({ COMPONENT_NAME: 'DCascader' as const });
function Cascader<V extends DId, T extends DCascaderItem<V>>(
props: DCascaderProps<V, T>,
ref: React.ForwardedRef<DCascaderRef>
): JSX.Element | null {
const {
dRef,
dFormControl,
dList,
dModel,
dVisible,
dInitialVisible = false,
dPlaceholder,
dSize,
dLoading = false,
dSearchable = false,
dSearchValue,
dClearable = false,
dDisabled = false,
dMultiple = false,
dOnlyLeafSelectable = true,
dVirtual = false,
dCustomItem,
dCustomSelected,
dCustomSearch,
dPopupClassName,
dInputRender,
onModelChange,
onVisibleChange,
onSearchValueChange,
onClear,
onFirstFocus,
afterVisibleChange,
...restProps
} = useComponentConfig(COMPONENT_NAME, props);
const dPrefix = usePrefixConfig();
const { gSize, gDisabled } = useGeneralContext();
const boxRef = useRef<HTMLDivElement>(null);
const popupRef = useRef<HTMLDivElement>(null);
const comboboxKeyDownRef = useRef<ComboboxKeyDownRef>(null);
const dataRef = useRef<{
focusList: Set<V>;
}>({
focusList: new Set(),
});
const [t] = useTranslation();
const uniqueId = useId();
const listId = `${dPrefix}cascader-list-${uniqueId}`;
const getItemId = (val: V) => `${dPrefix}cascader-item-${val}-${uniqueId}`;
const renderNodes = useMemo(
() =>
dList.map((item) =>
dMultiple
? new MultipleTreeNode(item, (origin) => origin.value, {
disabled: item.disabled,
})
: new SingleTreeNode(item, (origin) => origin.value, {
disabled: item.disabled,
})
),
[dMultiple, dList]
);
const nodesMap = useMemo(() => {
const nodes = new Map<V, AbstractTreeNode<V, T>>();
const reduceArr = (arr: AbstractTreeNode<V, T>[]) => {
for (const item of arr) {
nodes.set(item.id, item);
if (item.children) {
reduceArr(item.children);
}
}
};
reduceArr(renderNodes);
return nodes;
}, [renderNodes]);
const [searchValue, changeSearchValue] = useDValue<string>('', dSearchValue, onSearchValueChange);
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) => nodesMap.get(v)?.origin)
);
} else {
onModelChange(value, isNull(value) ? null : nodesMap.get(value as V)?.origin);
}
}
},
undefined,
formControlInject
);
const select = useMemo(() => (dMultiple ? new Set(_select as V[]) : (_select as V | null)), [_select, dMultiple]);
renderNodes.forEach((node) => {
node.updateStatus(select);
});
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 [focusVisible, setFocusVisible] = useState(false);
const filterFn = useCallback(
(item: T, searchStr = searchValue) => {
const defaultFilterFn = (item: T) => {
return item.label.includes(searchStr);
};
return dCustomSearch && dCustomSearch.filter ? dCustomSearch.filter(searchStr, item) : defaultFilterFn(item);
},
[dCustomSearch, searchValue]
);
const sortFn = dCustomSearch?.sort;
const searchList = useMemo(() => {
if (!hasSearch) {
return [];
}
const searchList: DSearchItem<V, T>[] = [];
const reduceNodes = (nodes: AbstractTreeNode<V, T>[]) => {
nodes.forEach((node) => {
if ((!dMultiple && !dOnlyLeafSelectable) || node.isLeaf) {
if (filterFn(node.origin)) {
searchList.push({
label: getText(node),
value: node.id,
disabled: node.disabled,
[TREE_NODE_KEY]: node,
});
}
}
if (node.children) {
reduceNodes(node.children);
}
});
};
reduceNodes(renderNodes);
if (sortFn) {
searchList.sort((a, b) => sortFn(a[TREE_NODE_KEY].origin, b[TREE_NODE_KEY].origin));
}
return searchList;
}, [dMultiple, dOnlyLeafSelectable, filterFn, hasSearch, renderNodes, sortFn]);
const [_noSearchFocusItem, setNoSearchFocusItem] = useState<AbstractTreeNode<V, T> | undefined>();
const noSearchFocusItem = (() => {
if (_noSearchFocusItem) {
const node = nodesMap.get(_noSearchFocusItem.id);
if (node && node.enabled) {
return node;
}
}
if (hasSelected) {
return findNested(renderNodes, (node) => node.enabled && node.checked);
}
})();
const [_searchFocusItem, setSearchFocusItem] = useState<DSearchItem<V, T> | undefined>();
const searchFocusItem = (() => {
if (_searchFocusItem && findNested(searchList, (item) => item[TREE_NODE_KEY].enabled && item.value === _searchFocusItem.value)) {
return _searchFocusItem;
}
if (hasSearch) {
return findNested(searchList, (item) => item[TREE_NODE_KEY].enabled);
}
})();
const handleClear = () => {
onClear?.();
if (dMultiple) {
changeSelect([]);
} else {
changeSelect(null);
}
};
const [popupPositionStyle, setPopupPositionStyle] = useState<React.CSSProperties>({
top: '-200vh',
left: '-200vw',
});
const [transformOrigin, setTransformOrigin] = useState<string>();
const updatePosition = useEventCallback(() => {
if (visible && boxRef.current && popupRef.current) {
const height = popupRef.current.offsetHeight;
const maxWidth = ROOT_DATA.pageSize.width - WINDOW_SPACE * 2;
const width = Math.min(popupRef.current.scrollWidth, maxWidth);
const { top, left, transformOrigin } = getVerticalSidePosition(
boxRef.current,
{ width, height },
{
placement: 'bottom-left',
inWindow: WINDOW_SPACE,
}
);
setPopupPositionStyle({
top,
left,
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 selectedNodes: MultipleTreeNode<V, T>[] = [];
for (const v of _select as V[]) {
const node = nodesMap.get(v);
if (node) {
selectedNodes.push(node as MultipleTreeNode<V, T>);
} else {
console.warn(`Can't find item that value field is ${v}!`);
}
}
suffixNode = (
<DDropdown
dList={selectedNodes.map<DDropdownItem<V> & { node: MultipleTreeNode<V, T> }>((node) => {
const { value: itemValue, disabled: itemDisabled } = node.origin;
const text = dCustomSelected ? dCustomSelected(node.origin) : getText(node);
return {
id: itemValue,
label: text,
type: 'item',
disabled: itemDisabled,
node,
};
})}
onItemClick={(id, item) => {
const checkeds = (item.node as MultipleTreeNode<V, T>).changeStatus('UNCHECKED', select as Set<V>);
changeSelect(Array.from(checkeds.keys()));
return false;
}}
>
<DTag className={`${dPrefix}cascader__multiple-count`} tabIndex={-1} dSize={size}>
{(select as Set<V>).size}
</DTag>
</DDropdown>
);
selectedNode = selectedNodes.map((node) => (
<DTag key={node.id} className={`${dPrefix}cascader__multiple-tag`} dSize={size}>
{dCustomSelected ? dCustomSelected(node.origin) : node.origin.label}
{!(node.disabled || disabled) && (
<div
className={`${dPrefix}cascader__close`}
role="button"
aria-label={t('Close')}
onClick={(e) => {
e.stopPropagation();
const checkeds = (node as MultipleTreeNode<V, T>).changeStatus('UNCHECKED', select as Set<V>);
changeSelect(Array.from(checkeds.keys()));
}}
>
<CloseOutlined />
</div>
)}
</DTag>
));
} else {
if (!isNull(select)) {
const node = nodesMap.get(select as V);
if (node) {
selectedLabel = getText(node);
selectedNode = dCustomSelected ? dCustomSelected(node.origin) : selectedLabel;
} else {
console.warn(`Can't find item that value field is ${select}!`);
}
}
}
return [selectedNode, suffixNode, selectedLabel];
})();
return (
<DSelectbox
{...restProps}
onClick={(e) => {
restProps.onClick?.(e);
changeVisible((draft) => (dSearchable ? true : !draft));
}}
dRef={{
box: boxRef,
input: dRef?.input,
}}
dClassNamePrefix="cascader"
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={!hasSearch}
onVisibleChange={changeVisible}
onFocusChange={(key) => {
comboboxKeyDownRef.current?.(key);
}}
>
{({ 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) {
comboboxKeyDownRef.current?.('click');
}
},
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={handleClear}
>
{({ 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}cascader__popup`)}
style={{
...popupPositionStyle,
...transitionStyle,
}}
>
{dLoading && (
<div
className={getClassName(`${dPrefix}cascader__loading`, {
[`${dPrefix}cascader__loading--empty`]: (hasSearch ? searchList : renderNodes).length === 0,
})}
>
<LoadingOutlined dSize={(hasSearch ? searchList : renderNodes).length === 0 ? 18 : 24} dSpin />
</div>
)}
{hasSearch ? (
<DTreeSearchPanel
ref={comboboxKeyDownRef}
id={listId}
style={{ pointerEvents: dLoading ? 'none' : undefined }}
dGetItemId={getItemId}
dList={searchList}
dFocusItem={searchFocusItem}
dCustomItem={dCustomItem}
dMultiple={dMultiple}
dOnlyLeafSelectable={dOnlyLeafSelectable}
dFocusVisible={focusVisible}
dVirtual={dVirtual}
onFocusChange={(item) => {
if (!dataRef.current.focusList.has(item.value)) {
dataRef.current.focusList.add(item.value);
onFirstFocus?.(item.value, item[TREE_NODE_KEY].origin);
}
setSearchFocusItem(item);
}}
onClickItem={(item) => {
if (dMultiple) {
const checkeds = (item[TREE_NODE_KEY] as MultipleTreeNode<V, T>).changeStatus(
item[TREE_NODE_KEY].checked ? 'UNCHECKED' : 'CHECKED',
select as Set<V>
);
changeSelect(Array.from(checkeds.keys()));
} else {
changeSelect(item[TREE_NODE_KEY].id);
changeVisible(false);
}
}}
></DTreeSearchPanel>
) : (
<DList
ref={comboboxKeyDownRef}
id={listId}
style={{ pointerEvents: dLoading ? 'none' : undefined }}
dGetItemId={getItemId}
dList={renderNodes}
dFocusItem={noSearchFocusItem}
dCustomItem={dCustomItem}
dMultiple={dMultiple}
dVirtual={dVirtual}
dFocusVisible={focusVisible}
dRoot
onFocusChange={(node) => {
if (!dataRef.current.focusList.has(node.id)) {
dataRef.current.focusList.add(node.id);
onFirstFocus?.(node.id, node.origin);
}
setNoSearchFocusItem(node);
}}
onClickItem={(node) => {
if (dMultiple) {
const checkeds = (node as MultipleTreeNode<V, T>).changeStatus(
node.checked ? 'UNCHECKED' : 'CHECKED',
select as Set<V>
);
changeSelect(Array.from(checkeds.keys()));
} else {
if (!dOnlyLeafSelectable || node.isLeaf) {
changeSelect(node.id);
}
if (node.isLeaf) {
changeVisible(false);
}
}
}}
></DList>
)}
</div>
);
}}
</DTransition>
)}
</DSelectbox>
);
}
export const DCascader: <V extends DId, T extends DCascaderItem<V>>(
props: DCascaderProps<V, T> & React.RefAttributes<DCascaderRef>
) => ReturnType<typeof Cascader> = React.forwardRef(Cascader) as any;