import React, { useMemo, useCallback, useRef, useState, useEffect, } from "react"; import classNames from "classnames"; import { StyledProps } from "../_type"; import { Icon } from "../icon"; import { Popover, PopoverProps, Trigger, TriggerProps, TriggerWithProps, } from "../popover"; import { createRocket } from "../_util/create-rocket"; import { useOutsideClick } from "../_util/use-outside-click"; import { forwardRefWithStatics } from "../_util/forward-ref-with-statics"; import { useDefault } from "../_util/use-default"; import { useConfig } from "../_util/config-context"; import { KeyMap } from "../_util/key-map"; import { mergeRefs } from "../_util/merge-refs"; import { noop } from "../_util/noop"; import { SizeType } from "../_type/Size"; import { getARIAProps } from "../_util/get-aria-props"; import { useScheduleUpdate } from "../_util/use-schedule-update"; import { mergeEventProps } from "../_util/merge-event-props"; import { isMobile } from "../_util/is-mobile"; /** * 下拉类组件通用 Props */ export interface CommonDropdownProps extends Pick< PopoverProps, | "escapeWithReference" | "flipped" | "popupContainer" | "openDelay" | "closeDelay" | "destroyOnClose" | "overlayClassName" | "overlayStyle" | "positionFixed" > { /** * 是否默认展开 */ defaultOpen?: boolean; /** * 是否展开(受控) */ open?: boolean; /** * 展开变化回调(受控) */ onOpenChange?: (open: boolean) => void; /** * 下拉出现的位置 * @default "bottom-start"(从底部弹出,左侧对齐) */ placement?: PopoverProps["placement"]; /** * 弹出位置偏离参考位置的位移 * * 如: `10`、`"50%"`、`"10 + 10%"`、`[10, 10]` * @default 5 */ placementOffset?: PopoverProps["placementOffset"]; /** * 是否在父容器滚动时关闭 * @default true */ closeOnScroll?: PopoverProps["closeOnScroll"]; } export interface DropdownKeyDownContext { /** * 当前是否展开 */ open: boolean; } export interface DropdownProps extends Omit, StyledProps { /** * 下拉按钮文本 */ button: React.ReactNode; /** * 触发方式 * * - `2.7.0` 支持自定义 Trigger * * @default "click" */ trigger?: "click" | "hover" | Trigger | TriggerWithProps; /** * 下拉内容 * @docType React.ReactNode | ((close: () => void) => React.ReactNode) */ children: React.ReactNode | ((close: () => void) => React.ReactNode); /** * 弹出区域底部内容 * @since 2.6.3 */ footer?: React.ReactNode; /** * 下拉按钮的外观: * * - `default` 无边框,适用于页面标题和表格内 * - `button` 为按钮风格,有边框,多用于操作栏中 * - `link` 为超链接风格 * - `filter` 为过滤组件风格,多用于表头筛选 * - `pure` 无额外样式 * * 原有 `raw` 类型建议使用 `pure` 进行改造 * * @default "default" * @since 2.0.9 */ appearance?: "default" | "button" | "link" | "filter" | "pure"; /** * 下拉框尺寸,使用 `"full"` 撑满容器宽度 */ size?: SizeType | "auto"; /** * 是否在下拉内容点击时关闭 * @default true */ clickClose?: boolean; /** * 是否禁用 * @default false */ disabled?: boolean; /** * 弹出区域是否与下拉按钮同宽 * @default false */ matchButtonWidth?: boolean; /** * 弹出区域自定义类名 */ boxClassName?: string; /** * 弹出区域自定义样式 */ boxStyle?: React.CSSProperties; /** * 弹出区域是否自适应可视区域大小 * * 内容超出时可视区域大小出现内部滚动 * * @default false * @since 2.2.4 */ boxAdaptable?: boolean; /** * 是否支持清空 * @default false * @since 2.3.2 */ clearable?: boolean; /** * 点击清空回调 * @since 2.3.2 */ onClear?: (event: React.MouseEvent) => void; /** * 打开时回调 */ onOpen?: () => void; /** * 关闭时回调 */ onClose?: () => void; /** * 是否在 `children` 变化时更新下拉浮层位置 * * **更新位置可防止浮层溢出屏幕或产生偏移** * * @default false */ updateOnChildrenChange?: boolean; /** * 触发下拉浮层位置更新的变量依赖列表 * * **更新位置可防止浮层溢出屏幕或产生偏移** * * @since 2.6.14 * @default [] */ updateDeps?: any[]; /** * 当焦点在 Dropdown 按钮时的键盘事件,返回 `false` 禁用默认行为 */ onKeyDown?: ( event: React.KeyboardEvent, context: DropdownKeyDownContext ) => void | boolean; /** * 阻止下拉面板中 MouseDown 事件 * * **防止点击下拉面板触发 Dropdown 按钮 onBlur** * * @since 2.7.0 * @default false */ preventMouseDown?: boolean; /** * 元素聚焦事件 */ onFocus?: React.DOMAttributes["onFocus"]; /** * 元素失焦事件 * @since 2.7.0 */ onBlur?: React.DOMAttributes["onBlur"]; /** * **\[Deprecated\]** 请使用 `appearance` 属性 * @deprecated */ appearence?: "default" | "button" | "link" | "filter" | "pure"; /** * **\[Deprecated\]** 请使用 `matchButtonWidth` 属性 * @default false * @deprecated */ boxSizeSync?: boolean; } const getAppearanceConfig = (classPrefix: string) => ({ // appearance headerClassName icon default: [`${classPrefix}-dropdown-default`, "arrowdown"], button: [`${classPrefix}-dropdown-btn`, "arrowdown"], link: [`${classPrefix}-dropdown-link`, "arrowdown"], filter: [`${classPrefix}-dropdown-filter`, "filter"], // 兼容原有 raw 类型 raw: [null, null], }); export const DropdownBox = React.forwardRef(function DropdownBox( { className, style = {}, children, adaptable, footer, preventMouseDown, ...props }: { onClick?: (event: React.MouseEvent) => void; children?: React.ReactNode; footer?: React.ReactNode; adaptable?: boolean; preventMouseDown?: boolean; } & StyledProps, ref: React.Ref ) { const { classPrefix } = useConfig(); if (!children) { return null; } return (
{ if (preventMouseDown) { event.preventDefault(); } }} {...props} > {children} {footer && {footer}}
); }); DropdownBox.displayName = "DropdownBox"; const ClickTrigger = ({ childrenElementRef, overlayElementRef, visible, setVisible, openDelay = 0, closeDelay = 0, render, onClose, onOpen, }: TriggerProps & DropdownProps) => { const { listen, ignoreProps } = useOutsideClick([ childrenElementRef, overlayElementRef, ]); listen(() => { if (visible) { onClose(); setVisible(false, closeDelay); } }); return render({ overlayProps: ignoreProps, childrenProps: {}, }); }; ClickTrigger.displayName = "DropdownClickTrigger"; const HoverTrigger = (props: TriggerProps & DropdownProps) => { if (isMobile) { return ; } const { visible, setVisible, openDelay = 50, closeDelay = 100, render, onClose, onOpen, } = props; const commonProps = { onMouseEnter: () => { setVisible(true, openDelay).then(done => done && !visible && onOpen()); }, onMouseLeave: () => { setVisible(false, closeDelay).then(done => done && onClose()); }, }; return render({ overlayProps: commonProps, childrenProps: commonProps, }); }; HoverTrigger.displayName = "DropdownHoverTrigger"; export const Dropdown = forwardRefWithStatics( (props: DropdownProps, ref: React.Ref) => { const { classPrefix } = useConfig(); const { defaultOpen = false, open, onOpenChange, appearence, appearance = appearence, button, size, placement = "bottom-start", placementOffset = 5, trigger = "click", children, footer, disabled, onOpen = noop, onClose = noop, onKeyDown = noop, onFocus = noop, onBlur = noop, preventMouseDown, clickClose = true, closeOnScroll = true, escapeWithReference, popupContainer, destroyOnClose, style, className, boxClassName, boxSizeSync, matchButtonWidth = boxSizeSync, boxAdaptable, updateOnChildrenChange, updateDeps = [], openDelay, closeDelay, overlayClassName, overlayStyle, clearable, onClear = noop, flipped, positionFixed, ...restProps } = props; let { boxStyle = {} } = props; const [hover, setHover] = useState(false); const [isOpened, setIsOpened] = useDefault(open, defaultOpen, onOpenChange); // 是否打开一段时间,防止连续 focus/click 连续两次触发开启导致关闭 const [stayedOpen, setStayedOpen] = useState(false); useEffect(() => { if (isOpened) { const timer = setTimeout(() => setStayedOpen(true), 150); return () => { clearTimeout(timer); }; } setStayedOpen(false); return noop; }, [isOpened]); const dropdownRef = useRef(null); const [dropdownWidth, setDropdownWidth] = useState(null); const close = useCallback(() => { setIsOpened(false); onClose(); }, [onClose, setIsOpened]); const appearanceConfig = getAppearanceConfig(classPrefix); const [headerClassName, icon] = appearanceConfig[appearance] || appearanceConfig.default; const boxChildren = useMemo(() => { return typeof children === "function" ? children.call(null, close) : children; }, [children, close]); useEffect(() => { if (dropdownRef.current) { setDropdownWidth(dropdownRef.current.clientWidth); } }, [button, size]); if (matchButtonWidth && dropdownWidth) { boxStyle = { ...boxStyle, minWidth: dropdownWidth, maxWidth: dropdownWidth, }; } const scheduleUpdateRef = useScheduleUpdate( updateOnChildrenChange ? [boxChildren, ...updateDeps] : updateDeps ); const dropdownTrigger = useMemo(() => { if (disabled) { return "empty"; } const innerTrigger = trigger === "hover" ? HoverTrigger : ClickTrigger; // 自定义 Trigger if (trigger && typeof trigger !== "string") { return trigger; } return [ innerTrigger, { onClose, onOpen, }, ] as TriggerWithProps; }, [disabled, onClose, onOpen, trigger]); return ( { scheduleUpdateRef.current = scheduleUpdate; return disabled ? null : ( {boxChildren} ); }} >
{ if (onKeyDown(event, { open: isOpened }) === false) { return; } switch (event.key) { case KeyMap.Space: case KeyMap.Enter: if (!isOpened) { setIsOpened(true); onOpen(); } if (isOpened) { close(); } break; case KeyMap.Up: case KeyMap.Down: event.preventDefault(); if (!isOpened) { setIsOpened(true); onOpen(); } break; case KeyMap.Esc: if (isOpened) { close(); } break; } }} {...getARIAProps(restProps)} {...mergeEventProps(restProps, { onClick: event => { event.preventDefault(); event.stopPropagation(); if (disabled) { return; } if (!isOpened) { onOpen(); setIsOpened(true); } else if (stayedOpen) { onClose(); setIsOpened(false); } }, onMouseEnter: () => { setHover(true); }, onMouseLeave: () => { setHover(false); }, onFocus, onBlur, })} > {appearance === "pure" ? ( button ) : (
{button}
{clearable && !disabled && hover ? ( { e.stopPropagation(); onClear(e); }} /> ) : ( icon && )}
)}
); }, { Footer: createRocket( "DropdownFooter", "div.@{prefix}-dropdown-box__footer" ), } ); Dropdown.displayName = "Dropdown";