import type { DId } from '../../utils/types';
import { isNull, isNumber, isUndefined, nth } from 'lodash';
import React, { useImperativeHandle, useRef, useState } from 'react';
import ReactDOM from 'react-dom';
import { useEvent, useEventCallback, useId, useImmer, useRefExtra } from '@react-devui/hooks';
import { getClassName, scrollToView } from '@react-devui/utils';
import { useMaxIndex, useDValue } from '../../hooks';
import { getVerticalSidePosition, registerComponentMate, TTANSITION_DURING_POPUP, WINDOW_SPACE } from '../../utils';
import { ESC_CLOSABLE_DATA } from '../../utils/checkNoExpandedEl';
import { DFocusVisible } from '../_focus-visible';
import { DPopup, useNestedPopup } from '../_popup';
import { DTransition } from '../_transition';
import { useComponentConfig, usePrefixConfig, useTranslation } from '../root';
import { DSeparator } from '../separator';
import { DGroup } from './Group';
import { DItem } from './Item';
import { DSub } from './Sub';
import { checkEnableItem, getSameLevelEnableItems } from './utils';
export interface DDropdownRef {
updatePosition: () => void;
}
export interface DDropdownItem<ID extends DId> {
id: ID;
label: React.ReactNode;
type: 'item' | 'group' | 'sub';
icon?: React.ReactNode;
disabled?: boolean;
separator?: boolean;
children?: DDropdownItem<ID>[];
}
export interface DDropdownProps<ID extends DId, T extends DDropdownItem<ID>>
extends Omit<React.HTMLAttributes<HTMLDivElement>, 'children'> {
children: React.ReactElement;
dList: T[];
dVisible?: boolean;
dInitialVisible?: boolean;
dPlacement?: 'top' | 'top-left' | 'top-right' | 'bottom' | 'bottom-left' | 'bottom-right';
dTrigger?: 'hover' | 'click';
dArrow?: boolean;
dZIndex?: number | string;
onVisibleChange?: (visible: boolean) => void;
onItemClick?: (id: T['id'], item: T) => void | boolean | Promise<void>;
afterVisibleChange?: (visible: boolean) => void;
}
const { COMPONENT_NAME } = registerComponentMate({ COMPONENT_NAME: 'DDropdown' as const });
function Dropdown<ID extends DId, T extends DDropdownItem<ID>>(
props: DDropdownProps<ID, T>,
ref: React.ForwardedRef<DDropdownRef>
): JSX.Element | null {
const {
children,
dList,
dVisible,
dInitialVisible = false,
dPlacement = 'bottom-right',
dTrigger = 'hover',
dArrow = false,
dZIndex,
onVisibleChange,
onItemClick,
afterVisibleChange,
...restProps
} = useComponentConfig(COMPONENT_NAME, props);
const dPrefix = usePrefixConfig();
const windowRef = useRefExtra(() => window);
const dropdownRef = useRef<HTMLDivElement>(null);
const childRef = useRefExtra(() => document.getElementById(triggerId));
const ulRef = useRef<HTMLUListElement>(null);
const containerRef = useRefExtra(() => {
let el = document.getElementById(`${dPrefix}dropdown-root`);
if (!el) {
el = document.createElement('div');
el.id = `${dPrefix}dropdown-root`;
document.body.appendChild(el);
}
return el;
}, true);
const dataRef = useRef<{
updatePosition: Map<ID, () => void>;
}>({
updatePosition: new Map(),
});
const [t] = useTranslation();
const uniqueId = useId();
const id = restProps.id ?? `${dPrefix}dropdown-${uniqueId}`;
const triggerId = children.props.id ?? `${dPrefix}dropdown-trigger-${uniqueId}`;
const getItemId = (id: ID) => `${dPrefix}dropdown-item-${id}-${uniqueId}`;
const [loadingIds, setLoadingIds] = useImmer(new Set<ID>());
const { popupIds, setPopupIds, addPopupId, removePopupId } = useNestedPopup<ID>();
const [focusIds, setFocusIds] = useState<ID[]>([]);
const [isFocus, setIsFocus] = useState(false);
const [focusVisible, setFocusVisible] = useState(false);
const focusId = (() => {
if (isFocus) {
let id: ID | undefined;
for (const [index, focusId] of focusIds.entries()) {
id = focusId;
if (nth(popupIds, index)?.id !== focusId) {
break;
}
}
return id;
}
})();
const focusFirst = () => {
const ids: ID[] = [];
const reduceArr = (arr: T[]) => {
for (const item of arr) {
if (ids.length === 1) {
break;
}
if (item.type === 'group' && item.children) {
reduceArr(item.children as T[]);
} else if (checkEnableItem(item)) {
ids.push(item.id);
}
}
};
reduceArr(dList);
setFocusIds(ids);
};
const focusLast = () => {
const ids: ID[] = [];
const reduceArr = (arr: T[]) => {
for (let index = arr.length - 1; index >= 0; index--) {
if (ids.length === 1) {
break;
}
const item = arr[index];
if (item.type === 'group' && item.children) {
reduceArr(item.children as T[]);
} else if (checkEnableItem(item)) {
ids.push(item.id);
}
}
};
reduceArr(dList);
setFocusIds(ids);
};
const [visible, _changeVisible] = useDValue<boolean>(dInitialVisible, dVisible, onVisibleChange);
const changeVisible = (visible: boolean) => {
if (!visible) {
setPopupIds([]);
}
_changeVisible(visible);
};
const maxZIndex = useMaxIndex(visible);
const zIndex = (() => {
if (isUndefined(dZIndex)) {
return maxZIndex;
} else {
return dZIndex;
}
})();
const [popupPositionStyle, setPopupPositionStyle] = useState<React.CSSProperties>({
top: '-200vh',
left: '-200vw',
});
const [transformOrigin, setTransformOrigin] = useState<string>();
const [arrowPosition, setArrowPosition] = useState<React.CSSProperties>();
const updatePosition = useEventCallback(() => {
if (visible && childRef.current && dropdownRef.current) {
const [width, height] = [dropdownRef.current.offsetWidth, dropdownRef.current.offsetHeight];
const { top, left, transformOrigin, arrowPosition } = getVerticalSidePosition(
childRef.current,
{ width, height },
{
placement: dPlacement,
inWindow: WINDOW_SPACE,
}
);
setPopupPositionStyle({ top, left });
setTransformOrigin(transformOrigin);
setArrowPosition(arrowPosition);
}
});
const preventBlur: React.MouseEventHandler<HTMLElement> = (e) => {
if (e.button === 0) {
e.preventDefault();
}
};
useEvent<KeyboardEvent>(
windowRef,
'keydown',
(e) => {
if (e.code === 'Escape') {
changeVisible(false);
}
},
{},
!visible
);
let handleKeyDown: React.KeyboardEventHandler<HTMLElement> | undefined;
const nodes = (() => {
const getNodes = (arr: T[], level: number, subParents: T[]): JSX.Element[] =>
arr.map((item) => {
const {
id: itemId,
label: itemLabel,
type: itemType,
icon: itemIcon,
disabled: itemDisabled = false,
separator: itemSeparator,
children,
} = item;
const newSubParents = itemType === 'sub' ? subParents.concat([item]) : subParents;
const id = getItemId(itemId);
const isFocus = itemId === focusId;
const isEmpty = !(children && children.length > 0);
const popupState = popupIds.find((v) => v.id === itemId);
const handleItemClick = () => {
const shouldClose = onItemClick?.(itemId, item);
setFocusIds(subParents.map((parentItem) => parentItem.id).concat([itemId]));
if (shouldClose instanceof Promise) {
setLoadingIds((draft) => {
draft.add(itemId);
});
shouldClose.then(() => {
setLoadingIds((draft) => {
draft.delete(itemId);
});
});
} else if (shouldClose !== false) {
changeVisible(false);
}
};
if (isFocus) {
handleKeyDown = (e) => {
const sameLevelItems = getSameLevelEnableItems((nth(subParents, -1)?.children as T[]) ?? dList);
const focusItem = (val?: T) => {
if (val) {
setFocusIds(subParents.map((parentItem) => parentItem.id).concat([val.id]));
}
};
const scrollToItem = (val?: T) => {
if (val) {
const el = document.getElementById(getItemId(val.id));
if (el && ulRef.current) {
scrollToView(el, ulRef.current, 4);
}
}
};
switch (e.code) {
case 'ArrowUp': {
e.preventDefault();
const index = sameLevelItems.findIndex((sameLevelItem) => sameLevelItem.id === itemId);
const item = nth(sameLevelItems, index - 1);
focusItem(item);
scrollToItem(item);
if (item && nth(popupIds, -1)?.id === itemId) {
setPopupIds(popupIds.slice(0, -1));
}
break;
}
case 'ArrowDown': {
e.preventDefault();
const index = sameLevelItems.findIndex((sameLevelItem) => sameLevelItem.id === itemId);
const item = nth(sameLevelItems, (index + 1) % sameLevelItems.length);
focusItem(item);
scrollToItem(item);
if (item && nth(popupIds, -1)?.id === itemId) {
setPopupIds(popupIds.slice(0, -1));
}
break;
}
case 'ArrowLeft': {
e.preventDefault();
setPopupIds(popupIds.slice(0, -1));
const ids = subParents.map((item) => item.id);
if (ids.length > 0) {
setFocusIds(ids);
}
break;
}
case 'ArrowRight':
e.preventDefault();
if (itemType === 'sub') {
addPopupId(itemId);
if (children) {
const newFocusItem = nth(getSameLevelEnableItems(children), 0);
if (newFocusItem) {
setFocusIds(newSubParents.map((parentItem) => parentItem.id).concat([newFocusItem.id]));
}
}
}
break;
case 'Home':
e.preventDefault();
focusItem(nth(sameLevelItems, 0));
if (ulRef.current) {
ulRef.current.scrollTop = 0;
}
break;
case 'End':
e.preventDefault();
focusItem(nth(sameLevelItems, -1));
if (ulRef.current) {
ulRef.current.scrollTop = ulRef.current.scrollHeight;
}
break;
case 'Enter':
case 'Space':
e.preventDefault();
if (itemType === 'item') {
handleItemClick();
} else if (itemType === 'sub') {
addPopupId(itemId);
}
break;
default:
break;
}
};
}
return (
<React.Fragment key={itemId}>
{itemType === 'item' ? (
<DItem
dId={id}
dLevel={level}
dIcon={itemIcon}
dFocusVisible={focusVisible && isFocus}
dLoading={loadingIds.has(itemId)}
dDisabled={itemDisabled}
onItemClick={handleItemClick}
>
{itemLabel}
</DItem>
) : itemType === 'group' ? (
<DGroup dId={id} dLevel={level} dList={children && getNodes(children as T[], level + 1, newSubParents)} dEmpty={isEmpty}>
{itemLabel}
</DGroup>
) : (
<DSub
ref={(fn) => {
if (isNull(fn)) {
dataRef.current.updatePosition.delete(itemId);
} else {
dataRef.current.updatePosition.set(itemId, fn);
}
}}
dId={id}
dLevel={level}
dList={children && getNodes(children as T[], 0, newSubParents)}
dPopupState={popupState?.visible}
dTrigger={dTrigger}
dZIndex={
isUndefined(zIndex)
? zIndex
: isNumber(zIndex)
? zIndex + 1 + subParents.length
: `calc(${zIndex} + ${1 + subParents.length})`
}
dIcon={itemIcon}
dEmpty={isEmpty}
dFocusVisible={focusVisible && isFocus}
dDisabled={itemDisabled}
onVisibleChange={(visible) => {
if (visible) {
if (subParents.length === 0) {
setPopupIds([{ id: itemId, visible: true }]);
} else {
addPopupId(itemId);
}
} else {
removePopupId(itemId);
}
}}
>
{itemLabel}
</DSub>
)}
{itemSeparator && <DSeparator style={{ margin: '2px 0' }} />}
</React.Fragment>
);
});
return getNodes(dList, 0, []);
})();
useImperativeHandle(
ref,
() => ({
updatePosition: () => {
updatePosition();
for (const fn of dataRef.current.updatePosition.values()) {
fn();
}
},
}),
[updatePosition]
);
return (
<DPopup
dVisible={visible}
dTrigger={dTrigger}
dUpdatePosition={{
fn: updatePosition,
triggerRef: childRef,
popupRef: ulRef,
extraScrollRefs: [],
}}
onVisibleChange={changeVisible}
>
{({ renderTrigger, renderPopup }) => (
<>
<DFocusVisible onFocusVisibleChange={setFocusVisible}>
{({ render: renderFocusVisible }) =>
renderFocusVisible(
renderTrigger(
React.cloneElement<React.HTMLAttributes<HTMLElement>>(children, {
id: triggerId,
tabIndex: children.props.tabIndex ?? 0,
role: 'button',
'aria-haspopup': 'menu',
'aria-expanded': visible,
'aria-controls': id,
[ESC_CLOSABLE_DATA as string]: visible,
onFocus: (e) => {
children.props.onFocus?.(e);
setIsFocus(true);
focusFirst();
},
onBlur: (e) => {
children.props.onBlur?.(e);
setIsFocus(false);
changeVisible(false);
},
onKeyDown: (e) => {
children.props.onKeyDown?.(e);
if (visible) {
handleKeyDown?.(e);
} else {
switch (e.code) {
case 'Enter':
case 'Space':
case 'ArrowDown':
e.preventDefault();
focusFirst();
changeVisible(true);
break;
case 'ArrowUp':
e.preventDefault();
focusLast();
changeVisible(true);
break;
default:
break;
}
}
},
})
)
)
}
</DFocusVisible>
{containerRef.current &&
ReactDOM.createPortal(
<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={dropdownRef}
{...restProps}
className={getClassName(restProps.className, `${dPrefix}dropdown`)}
style={{
...restProps.style,
...popupPositionStyle,
zIndex,
...transitionStyle,
}}
onMouseDown={(e) => {
restProps.onMouseDown?.(e);
preventBlur(e);
}}
onMouseUp={(e) => {
restProps.onMouseUp?.(e);
preventBlur(e);
}}
>
<ul
ref={ulRef}
id={id}
className={`${dPrefix}dropdown__list`}
tabIndex={-1}
role="menu"
aria-labelledby={triggerId}
aria-activedescendant={isUndefined(focusId) ? undefined : getItemId(focusId)}
>
{dList.length === 0 ? <div className={`${dPrefix}dropdown__empty`}>{t('No Data')}</div> : nodes}
</ul>
{dArrow && <div className={`${dPrefix}dropdown__arrow`} style={arrowPosition}></div>}
</div>
);
}}
</DTransition>,
containerRef.current
)}
</>
)}
</DPopup>
);
}
export const DDropdown: <ID extends DId, T extends DDropdownItem<ID>>(
props: DDropdownProps<ID, T> & React.RefAttributes<DDropdownRef>
) => ReturnType<typeof Dropdown> = React.forwardRef(Dropdown) as any;