From 78e6fb6617ed692d39cf8b1d440196b9b13db694 Mon Sep 17 00:00:00 2001 From: xiejay97 Date: Mon, 22 Nov 2021 11:19:01 +0800 Subject: [PATCH] feat(ui): add custom trigger node --- packages/ui/src/components/_popup/Popup.tsx | 232 +++++++++++------- .../ui/src/components/_trigger/Trigger.tsx | 164 +++++++++---- packages/ui/src/components/menu/Menu.tsx | 36 +-- packages/ui/src/components/menu/MenuItem.tsx | 82 +++---- packages/ui/src/components/menu/MenuSub.tsx | 141 +++++------ packages/ui/src/components/tooltip/README.md | 3 +- .../src/components/tooltip/README.zh-Hant.md | 5 +- .../ui/src/components/tooltip/Tooltip.tsx | 33 ++- packages/ui/src/hooks/manual-or-auto-state.ts | 44 ++-- 9 files changed, 427 insertions(+), 313 deletions(-) diff --git a/packages/ui/src/components/_popup/Popup.tsx b/packages/ui/src/components/_popup/Popup.tsx index 69b0bfc0..9f318095 100644 --- a/packages/ui/src/components/_popup/Popup.tsx +++ b/packages/ui/src/components/_popup/Popup.tsx @@ -13,7 +13,9 @@ import { DTransition } from '../_transition'; export interface DPopupProps extends React.HTMLAttributes { dVisible?: boolean; + dPopupContent: React.ReactNode; dContainer?: DElementSelector | false; + dTriggerNode?: DElementSelector; dPlacement?: DPlacement; dAutoPlace?: boolean; dTrigger?: 'hover' | 'focus' | 'click' | null; @@ -23,22 +25,23 @@ export interface DPopupProps extends React.HTMLAttributes { dDestroy?: boolean; dMouseEnterDelay?: number; dMouseLeaveDelay?: number; - dCustomPopup?: (popupEl: HTMLElement, targetEl: HTMLElement) => { top: number; left: number; stateList: DTransitionStateList }; - dTriggerNode: React.ReactNode; + dCustomPopup?: (popupEl: HTMLElement, triggerEl: HTMLElement) => { top: number; left: number; stateList: DTransitionStateList }; onTrigger?: (visible: boolean) => void; afterVisibleChange?: (visible: boolean) => void; } export interface DPopupRef { el: HTMLDivElement | null; - targetEl: { current: HTMLElement | null }; + triggerEl: { current: HTMLElement | null }; updatePosition: () => void; } export const DPopup = React.forwardRef((props, ref) => { const { dVisible, + dPopupContent, dContainer, + dTriggerNode, dPlacement = 'top', dAutoPlace = true, dTrigger = 'hover', @@ -49,7 +52,6 @@ export const DPopup = React.forwardRef((props, ref) => { dMouseEnterDelay = 150, dMouseLeaveDelay = 200, dCustomPopup, - dTriggerNode, onTrigger, afterVisibleChange, className, @@ -81,11 +83,11 @@ export const DPopup = React.forwardRef((props, ref) => { const [zIndex, setZIndex] = useImmer(1000); const id = useId(); - const [visible, setVisible] = useManualOrAutoState(false, dVisible, onTrigger); + const [visible, dispatchVisible] = useManualOrAutoState(false, dVisible, onTrigger); const [autoPlacement, setAutoPlacement] = useImmer(dPlacement); - const targetEl = useElement(`[data-${dPrefix}popup-target="${id}"]`); + const triggerEl = useElement(isUndefined(dTriggerNode) ? `[data-${dPrefix}popup-trigger="${id}"]` : dTriggerNode); const handleContainer = useCallback(() => { if (isUndefined(dContainer)) { @@ -97,16 +99,16 @@ export const DPopup = React.forwardRef((props, ref) => { } return el; } else if (dContainer === false) { - return targetEl.current?.parentElement ?? null; + return triggerEl.current?.parentElement ?? null; } return null; - }, [dContainer, dPrefix, targetEl]); + }, [dContainer, dPrefix, triggerEl]); const containerEl = useElement(dContainer, handleContainer); const placement = dAutoPlace ? autoPlacement : dPlacement; const updatePosition = useCallback(() => { - if (transitionRefContent && popupEl && targetEl.current && containerEl.current) { + if (transitionRefContent && popupEl && triggerEl.current && containerEl.current) { const fixed = isUndefined(dContainer); if (isUndefined(dCustomPopup)) { @@ -123,7 +125,7 @@ export const DPopup = React.forwardRef((props, ref) => { containerRect.left, ]; } - const position = getPopupPlacementStyle(popupEl, targetEl.current, dPlacement, dDistance, fixed, space); + const position = getPopupPlacementStyle(popupEl, triggerEl.current, dPlacement, dDistance, fixed, space); if (position) { currentPlacement = position.placement; setAutoPlacement(position.placement); @@ -133,7 +135,7 @@ export const DPopup = React.forwardRef((props, ref) => { left: position.left, }); } else { - const position = getPopupPlacementStyle(popupEl, targetEl.current, autoPlacement, dDistance, fixed); + const position = getPopupPlacementStyle(popupEl, triggerEl.current, autoPlacement, dDistance, fixed); setPopupPositionStyle({ position: fixed ? 'fixed' : 'absolute', top: position.top, @@ -141,7 +143,7 @@ export const DPopup = React.forwardRef((props, ref) => { }); } } else { - const position = getPopupPlacementStyle(popupEl, targetEl.current, dPlacement, dDistance, fixed); + const position = getPopupPlacementStyle(popupEl, triggerEl.current, dPlacement, dDistance, fixed); setPopupPositionStyle({ position: fixed ? 'fixed' : 'absolute', top: position.top, @@ -208,7 +210,7 @@ export const DPopup = React.forwardRef((props, ref) => { 'leave-to': { transform: 'scale(0)', opacity: '0', transition: 'transform 0.1s ease-in, opacity 0.1s ease-in', transformOrigin }, }; } else { - const { top, left, stateList } = dCustomPopup(popupEl, targetEl.current); + const { top, left, stateList } = dCustomPopup(popupEl, triggerEl.current); setPopupPositionStyle({ position: fixed ? 'fixed' : 'absolute', top, @@ -220,7 +222,7 @@ export const DPopup = React.forwardRef((props, ref) => { }, [ transitionRefContent, popupEl, - targetEl, + triggerEl, containerEl, dContainer, dCustomPopup, @@ -240,11 +242,11 @@ export const DPopup = React.forwardRef((props, ref) => { currentData.tid && asyncCapture.clearTimeout(currentData.tid); currentData.tid = asyncCapture.setTimeout(() => { currentData.tid = null; - setVisible(true); + dispatchVisible({ value: true }); }, dMouseEnterDelay); } }, - [asyncCapture, currentData, dMouseEnterDelay, dTrigger, onMouseEnter, setVisible] + [asyncCapture, currentData, dMouseEnterDelay, dTrigger, onMouseEnter, dispatchVisible] ); const handleMouseLeave = useCallback( @@ -255,11 +257,11 @@ export const DPopup = React.forwardRef((props, ref) => { currentData.tid && asyncCapture.clearTimeout(currentData.tid); currentData.tid = asyncCapture.setTimeout(() => { currentData.tid = null; - setVisible(false); + dispatchVisible({ value: false }); }, dMouseLeaveDelay); } }, - [asyncCapture, currentData, dMouseLeaveDelay, dTrigger, onMouseLeave, setVisible] + [asyncCapture, currentData, dMouseLeaveDelay, dTrigger, onMouseLeave, dispatchVisible] ); const handleFocus = useCallback( @@ -268,10 +270,10 @@ export const DPopup = React.forwardRef((props, ref) => { if (dTrigger === 'focus') { currentData.tid && asyncCapture.clearTimeout(currentData.tid); - setVisible(true); + dispatchVisible({ value: true }); } }, - [asyncCapture, currentData, dTrigger, onFocus, setVisible] + [asyncCapture, currentData, dTrigger, onFocus, dispatchVisible] ); const handleBlur = useCallback( @@ -279,10 +281,10 @@ export const DPopup = React.forwardRef((props, ref) => { onBlur?.(e); if (dTrigger === 'focus') { - currentData.tid = asyncCapture.setTimeout(() => setVisible(false), 20); + currentData.tid = asyncCapture.setTimeout(() => dispatchVisible({ value: false }), 20); } }, - [asyncCapture, currentData, dTrigger, onBlur, setVisible] + [asyncCapture, currentData, dTrigger, onBlur, dispatchVisible] ); const handleClick = useCallback( @@ -291,10 +293,10 @@ export const DPopup = React.forwardRef((props, ref) => { if (dTrigger === 'click') { currentData.tid && asyncCapture.clearTimeout(currentData.tid); - setVisible(true); + dispatchVisible({ value: true }); } }, - [asyncCapture, currentData, dTrigger, onClick, setVisible] + [asyncCapture, currentData, dTrigger, onClick, dispatchVisible] ); //#region DidUpdate @@ -316,6 +318,61 @@ export const DPopup = React.forwardRef((props, ref) => { } }, [dVisible, dContainer, dZIndex, setZIndex]); + useEffect(() => { + if (!isUndefined(dTriggerNode)) { + const [asyncGroup, asyncId] = asyncCapture.createGroup(); + if (triggerEl.current) { + if (dTrigger === 'hover') { + asyncGroup.fromEvent(triggerEl.current, 'mouseenter').subscribe({ + next: () => { + currentData.tid && asyncGroup.clearTimeout(currentData.tid); + currentData.tid = asyncGroup.setTimeout(() => { + currentData.tid = null; + dispatchVisible({ value: true }); + }, dMouseEnterDelay); + }, + }); + asyncGroup.fromEvent(triggerEl.current, 'mouseleave').subscribe({ + next: () => { + currentData.tid && asyncGroup.clearTimeout(currentData.tid); + currentData.tid = asyncGroup.setTimeout(() => { + currentData.tid = null; + dispatchVisible({ value: false }); + }, dMouseLeaveDelay); + }, + }); + } + + if (dTrigger === 'focus') { + asyncGroup.fromEvent(triggerEl.current, 'focus').subscribe({ + next: () => { + currentData.tid && asyncGroup.clearTimeout(currentData.tid); + dispatchVisible({ value: true }); + }, + }); + asyncGroup.fromEvent(triggerEl.current, 'blur').subscribe({ + next: () => { + currentData.tid = asyncCapture.setTimeout(() => dispatchVisible({ value: false }), 20); + }, + }); + } + + if (dTrigger === 'click') { + asyncGroup.fromEvent(triggerEl.current, 'click').subscribe({ + next: () => { + currentData.tid && asyncGroup.clearTimeout(currentData.tid); + dispatchVisible({ reverse: true }); + }, + }); + } + } + + return () => { + asyncCapture.deleteGroup(asyncId); + }; + } + }, [asyncCapture, currentData, dMouseEnterDelay, dMouseLeaveDelay, dTrigger, dTriggerNode, dispatchVisible, triggerEl]); + useEffect(() => { const [asyncGroup, asyncId] = asyncCapture.createGroup(); @@ -323,7 +380,7 @@ export const DPopup = React.forwardRef((props, ref) => { asyncGroup.fromEvent(document, 'click', { capture: true }).subscribe({ next: () => { currentData.tid = asyncGroup.setTimeout(() => { - setVisible(false); + dispatchVisible({ value: false }); }, 20); }, }); @@ -332,7 +389,7 @@ export const DPopup = React.forwardRef((props, ref) => { return () => { asyncCapture.deleteGroup(asyncId); }; - }, [asyncCapture, currentData, dTrigger, setVisible]); + }, [asyncCapture, currentData, dTrigger, dispatchVisible]); useEffect(() => { const [asyncGroup, asyncId] = asyncCapture.createGroup(); @@ -346,13 +403,13 @@ export const DPopup = React.forwardRef((props, ref) => { useEffect(() => { const [asyncGroup, asyncId] = asyncCapture.createGroup(); - if (visible && targetEl.current && transitionRefContent) { - asyncGroup.onResize(targetEl.current, () => transitionRefContent.transitionThrottle.run(updatePosition)); + if (visible && triggerEl.current && transitionRefContent) { + asyncGroup.onResize(triggerEl.current, () => transitionRefContent.transitionThrottle.run(updatePosition)); } return () => { asyncCapture.deleteGroup(asyncId); }; - }, [asyncCapture, transitionRefContent, targetEl, visible, updatePosition]); + }, [asyncCapture, transitionRefContent, triggerEl, visible, updatePosition]); useEffect(() => { if (visible && transitionRefContent) { @@ -374,69 +431,78 @@ export const DPopup = React.forwardRef((props, ref) => { ref, () => ({ el: popupEl, - targetEl, + triggerEl, updatePosition, }), - [popupEl, targetEl, updatePosition] + [popupEl, triggerEl, updatePosition] ); - const triggerNode = useMemo(() => { - const _triggerNode = React.Children.only(dTriggerNode) as React.ReactElement>; - return React.cloneElement>(_triggerNode, { - ..._triggerNode.props, - [`data-${dPrefix}popup-target`]: id, - onMouseEnter: (e) => { - _triggerNode.props.onMouseEnter?.(e); - - if (dTrigger === 'hover') { - currentData.tid && asyncCapture.clearTimeout(currentData.tid); - currentData.tid = asyncCapture.setTimeout(() => { - currentData.tid = null; - setVisible(true); - }, dMouseEnterDelay); - } - }, - onMouseLeave: (e) => { - _triggerNode.props.onMouseLeave?.(e); - - if (dTrigger === 'hover') { - currentData.tid && asyncCapture.clearTimeout(currentData.tid); - currentData.tid = asyncCapture.setTimeout(() => { - currentData.tid = null; - setVisible(false); - }, dMouseLeaveDelay); - } - }, - onFocus: (e) => { - _triggerNode.props.onFocus?.(e); + const child = useMemo(() => { + if (isUndefined(dTriggerNode)) { + const _child = React.Children.only(children) as React.ReactElement>; + let triggerProps: React.HTMLAttributes = {}; + if (dTrigger === 'hover') { + triggerProps = { + onMouseEnter: (e) => { + _child.props.onMouseEnter?.(e); + + currentData.tid && asyncCapture.clearTimeout(currentData.tid); + currentData.tid = asyncCapture.setTimeout(() => { + currentData.tid = null; + dispatchVisible({ value: true }); + }, dMouseEnterDelay); + }, + onMouseLeave: (e) => { + _child.props.onMouseLeave?.(e); + + currentData.tid && asyncCapture.clearTimeout(currentData.tid); + currentData.tid = asyncCapture.setTimeout(() => { + currentData.tid = null; + dispatchVisible({ value: false }); + }, dMouseLeaveDelay); + }, + }; + } + if (dTrigger === 'focus') { + triggerProps = { + onFocus: (e) => { + _child.props.onFocus?.(e); + + currentData.tid && asyncCapture.clearTimeout(currentData.tid); + dispatchVisible({ value: true }); + }, + onBlur: (e) => { + _child.props.onBlur?.(e); + + currentData.tid = asyncCapture.setTimeout(() => dispatchVisible({ value: false }), 20); + }, + }; + } + if (dTrigger === 'click') { + triggerProps = { + onClick: (e) => { + _child.props.onClick?.(e); - if (dTrigger === 'focus') { - currentData.tid && asyncCapture.clearTimeout(currentData.tid); - setVisible(true); - } - }, - onBlur: (e) => { - _triggerNode.props.onBlur?.(e); + currentData.tid && asyncCapture.clearTimeout(currentData.tid); + dispatchVisible({ reverse: true }); + }, + }; + } - if (dTrigger === 'focus') { - currentData.tid = asyncCapture.setTimeout(() => setVisible(false), 20); - } - }, - onClick: (e) => { - _triggerNode.props.onClick?.(e); + return React.cloneElement>(_child, { + ..._child.props, + ...triggerProps, + [`data-${dPrefix}popup-trigger`]: id, + }); + } - if (dTrigger === 'click') { - currentData.tid && asyncCapture.clearTimeout(currentData.tid); - setVisible(undefined, true); - } - }, - }); - }, [asyncCapture, currentData, dMouseEnterDelay, dMouseLeaveDelay, dPrefix, dTrigger, dTriggerNode, id, setVisible]); + return null; + }, [dTriggerNode, children, dTrigger, dPrefix, id, currentData, asyncCapture, dMouseEnterDelay, dispatchVisible, dMouseLeaveDelay]); return ( <> - {triggerNode} - {children && + {child} + {dPopupContent && containerEl.current && ReactDOM.createPortal( ((props, ref) => { onClick={handleClick} > {dArrow &&
} - {children} + {dPopupContent}
, containerEl.current diff --git a/packages/ui/src/components/_trigger/Trigger.tsx b/packages/ui/src/components/_trigger/Trigger.tsx index 3c0fc22e..d77679ce 100644 --- a/packages/ui/src/components/_trigger/Trigger.tsx +++ b/packages/ui/src/components/_trigger/Trigger.tsx @@ -1,6 +1,10 @@ -import React, { useMemo, useState } from 'react'; +import type { DElementSelector } from '../../hooks/element'; + +import { isUndefined } from 'lodash'; +import React, { useEffect, useMemo, useState } from 'react'; import { useAsync } from '../../hooks'; +import { useElement } from '../../hooks/element'; export type DTriggerType = 'hover' | 'focus' | 'click'; @@ -8,12 +12,13 @@ export interface DTriggerProps { dTrigger?: DTriggerType | DTriggerType[]; dMouseEnterDelay?: number; dMouseLeaveDelay?: number; - children: React.ReactNode; + dTriggerNode?: DElementSelector; + children?: React.ReactNode; onTrigger?: (state?: boolean) => void; } export function DTrigger(props: DTriggerProps) { - const { dTrigger, dMouseEnterDelay = 150, dMouseLeaveDelay = 200, children, onTrigger } = props; + const { dTrigger, dMouseEnterDelay = 150, dMouseLeaveDelay = 200, dTriggerNode, children, onTrigger } = props; const [currentData] = useState<{ tid: number | null }>({ tid: null, @@ -21,57 +26,126 @@ export function DTrigger(props: DTriggerProps) { const asyncCapture = useAsync(); - const child = useMemo(() => { - const _child = React.Children.only(children) as React.ReactElement>; - return React.cloneElement>(_child, { - ..._child.props, - onMouseEnter: (e) => { - _child.props.onMouseEnter?.(e); - - if (dTrigger === 'hover') { - currentData.tid && asyncCapture.clearTimeout(currentData.tid); - currentData.tid = asyncCapture.setTimeout(() => { - currentData.tid = null; - onTrigger?.(true); - }, dMouseEnterDelay); - } - }, - onMouseLeave: (e) => { - _child.props.onMouseLeave?.(e); + const triggerEl = useElement(dTriggerNode ?? null); + //#region DidUpdate + useEffect(() => { + if (!isUndefined(dTriggerNode)) { + const [asyncGroup, asyncId] = asyncCapture.createGroup(); + if (triggerEl.current) { if (dTrigger === 'hover') { - currentData.tid && asyncCapture.clearTimeout(currentData.tid); - currentData.tid = asyncCapture.setTimeout(() => { - currentData.tid = null; - onTrigger?.(false); - }, dMouseLeaveDelay); + asyncGroup.fromEvent(triggerEl.current, 'mouseenter').subscribe({ + next: () => { + currentData.tid && asyncCapture.clearTimeout(currentData.tid); + currentData.tid = asyncCapture.setTimeout(() => { + currentData.tid = null; + onTrigger?.(true); + }, dMouseEnterDelay); + }, + }); + asyncGroup.fromEvent(triggerEl.current, 'mouseleave').subscribe({ + next: () => { + currentData.tid && asyncCapture.clearTimeout(currentData.tid); + currentData.tid = asyncCapture.setTimeout(() => { + currentData.tid = null; + onTrigger?.(false); + }, dMouseLeaveDelay); + }, + }); } - }, - onFocus: (e) => { - _child.props.onFocus?.(e); if (dTrigger === 'focus') { - currentData.tid && asyncCapture.clearTimeout(currentData.tid); - onTrigger?.(true); + asyncGroup.fromEvent(triggerEl.current, 'focus').subscribe({ + next: () => { + currentData.tid && asyncCapture.clearTimeout(currentData.tid); + onTrigger?.(true); + }, + }); + asyncGroup.fromEvent(triggerEl.current, 'blur').subscribe({ + next: () => { + currentData.tid = asyncCapture.setTimeout(() => onTrigger?.(false), 20); + }, + }); } - }, - onBlur: (e) => { - _child.props.onBlur?.(e); - - if (dTrigger === 'focus') { - currentData.tid = asyncCapture.setTimeout(() => onTrigger?.(false), 20); - } - }, - onClick: (e) => { - _child.props.onClick?.(e); if (dTrigger === 'click') { - currentData.tid && asyncCapture.clearTimeout(currentData.tid); - onTrigger?.(); + asyncGroup.fromEvent(triggerEl.current, 'click').subscribe({ + next: () => { + currentData.tid && asyncCapture.clearTimeout(currentData.tid); + onTrigger?.(); + }, + }); } - }, - }); - }, [asyncCapture, children, currentData, dMouseEnterDelay, dMouseLeaveDelay, dTrigger, onTrigger]); + } + + return () => { + asyncCapture.deleteGroup(asyncId); + }; + } + }, [asyncCapture, currentData, dMouseEnterDelay, dMouseLeaveDelay, dTrigger, dTriggerNode, onTrigger, triggerEl]); + //#endregion + + const child = useMemo(() => { + if (isUndefined(dTriggerNode)) { + const _child = React.Children.only(children) as React.ReactElement>; + let childProps: React.HTMLAttributes = {}; + + if (dTrigger === 'hover') { + childProps = { + onMouseEnter: (e) => { + _child.props.onMouseEnter?.(e); + + currentData.tid && asyncCapture.clearTimeout(currentData.tid); + currentData.tid = asyncCapture.setTimeout(() => { + currentData.tid = null; + onTrigger?.(true); + }, dMouseEnterDelay); + }, + onMouseLeave: (e) => { + _child.props.onMouseLeave?.(e); + + currentData.tid && asyncCapture.clearTimeout(currentData.tid); + currentData.tid = asyncCapture.setTimeout(() => { + currentData.tid = null; + onTrigger?.(false); + }, dMouseLeaveDelay); + }, + }; + } + if (dTrigger === 'focus') { + childProps = { + onFocus: (e) => { + _child.props.onFocus?.(e); + + currentData.tid && asyncCapture.clearTimeout(currentData.tid); + onTrigger?.(true); + }, + onBlur: (e) => { + _child.props.onBlur?.(e); + + currentData.tid = asyncCapture.setTimeout(() => onTrigger?.(false), 20); + }, + }; + } + if (dTrigger === 'click') { + childProps = { + onClick: (e) => { + _child.props.onClick?.(e); + + currentData.tid && asyncCapture.clearTimeout(currentData.tid); + onTrigger?.(); + }, + }; + } + + return React.cloneElement>(_child, { + ..._child.props, + ...childProps, + }); + } + + return null; + }, [asyncCapture, children, currentData, dMouseEnterDelay, dMouseLeaveDelay, dTrigger, dTriggerNode, onTrigger]); return child; } diff --git a/packages/ui/src/components/menu/Menu.tsx b/packages/ui/src/components/menu/Menu.tsx index 01417ef4..79cb79b6 100644 --- a/packages/ui/src/components/menu/Menu.tsx +++ b/packages/ui/src/components/menu/Menu.tsx @@ -5,7 +5,7 @@ import { isUndefined } from 'lodash'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useImmer } from 'use-immer'; -import { useDPrefixConfig, useDComponentConfig, useManualOrAutoState, useCustomRef, useAsync } from '../../hooks'; +import { useDPrefixConfig, useDComponentConfig, useManualOrAutoState, useCustomRef } from '../../hooks'; import { getClassName } from '../../utils'; import { DCollapseTransition } from '../_transition'; import { DTrigger } from '../_trigger'; @@ -23,9 +23,7 @@ export interface DMenuContextData { menuCurrentData: { navIds: Set; ids: Map>; - mode: [DMenuMode, DMenuMode]; }; - menuPopup: boolean; onActiveChange: (id: string) => void; onExpandChange: (id: string, expand: boolean) => void; onFocus: (dId: string, id: string) => void; @@ -70,20 +68,13 @@ export function DMenu(props: DMenuProps) { const [currentData] = useState({ navIds: new Set(), ids: new Map(), - mode: [dMode, dMode], }); - if (currentData.mode[1] !== dMode) { - currentData.mode[0] = currentData.mode[1]; - currentData.mode[1] = dMode; - } - const asyncCapture = useAsync(); const [focusId, setFocusId] = useImmer(null); const [activedescendant, setActiveDescendant] = useImmer(undefined); const [expandIds, setExpandIds] = useImmer(() => new Set(dDefaultExpands)); - const [popup, setPopup] = useImmer(dMode !== 'vertical'); - const [activeId, setActiveId] = useManualOrAutoState(dDefaultActive ?? null, dActive, onActiveChange); + const [activeId, dispatchActiveId] = useManualOrAutoState(dDefaultActive ?? null, dActive, onActiveChange); const expandTrigger = isUndefined(dExpandTrigger) ? (dMode === 'vertical' ? 'click' : 'hover') : dExpandTrigger; const handleTrigger = useCallback( @@ -113,24 +104,6 @@ export function DMenu(props: DMenuProps) { useEffect(() => { onExpandsChange?.(Array.from(expandIds)); }, [expandIds, onExpandsChange]); - - useEffect(() => { - const [asyncGroup, asyncId] = asyncCapture.createGroup(); - - if (dMode !== 'vertical') { - asyncGroup.setTimeout(() => { - setPopup(true); - }, 200 + 10); - } else { - asyncGroup.setTimeout(() => { - setPopup(false); - }, 200 + 10); - } - - return () => { - asyncCapture.deleteGroup(asyncId); - }; - }, [asyncCapture, dMode, setPopup]); //#endregion const contextValue = useMemo( @@ -140,10 +113,9 @@ export function DMenu(props: DMenuProps) { menuActiveId: activeId, menuExpandIds: expandIds, menuFocusId: focusId, - menuPopup: popup, menuCurrentData: currentData, onActiveChange: (id) => { - setActiveId(id); + dispatchActiveId({ value: id }); }, onExpandChange: (id, expand) => { setExpandIds((draft) => { @@ -171,7 +143,7 @@ export function DMenu(props: DMenuProps) { setFocusId(null); }, }), - [activeId, currentData, dExpandOne, dMode, expandIds, expandTrigger, focusId, popup, setActiveId, setExpandIds, setFocusId] + [activeId, currentData, dExpandOne, dMode, dispatchActiveId, expandIds, expandTrigger, focusId, setExpandIds, setFocusId] ); const childs = useMemo(() => { diff --git a/packages/ui/src/components/menu/MenuItem.tsx b/packages/ui/src/components/menu/MenuItem.tsx index 27c17f58..f670a51b 100644 --- a/packages/ui/src/components/menu/MenuItem.tsx +++ b/packages/ui/src/components/menu/MenuItem.tsx @@ -2,7 +2,7 @@ import { isUndefined } from 'lodash'; import React from 'react'; import { useCallback } from 'react'; -import { useDPrefixConfig, useDComponentConfig, useCustomContext } from '../../hooks'; +import { useDPrefixConfig, useDComponentConfig, useCustomContext, useCustomRef } from '../../hooks'; import { getClassName, toId } from '../../utils'; import { DTooltip } from '../tooltip'; import { DMenuContext } from './Menu'; @@ -33,15 +33,11 @@ export function DMenuItem(props: DMenuItemProps) { //#region Context const dPrefix = useDPrefixConfig(); - const { - menuMode, - menuActiveId, - menuCurrentData, - menuPopup, - onActiveChange, - onFocus: _onFocus, - onBlur: _onBlur, - } = useCustomContext(DMenuContext); + const { menuMode, menuActiveId, menuCurrentData, onActiveChange, onFocus: _onFocus, onBlur: _onBlur } = useCustomContext(DMenuContext); + //#endregion + + //#region Ref + const [liEl, liRef] = useCustomRef(); //#endregion const inNav = menuCurrentData?.navIds.has(dId) ?? false; @@ -73,40 +69,36 @@ export function DMenuItem(props: DMenuItemProps) { [_onBlur, onBlur] ); - const node = ( - - ); - - return inNav && (menuMode === 'icon' || menuCurrentData?.mode[0] === 'icon') && menuPopup ? ( - - {node} - - ) : ( - node + return ( + <> + + {inNav && menuMode === 'icon' && } + ); } diff --git a/packages/ui/src/components/menu/MenuSub.tsx b/packages/ui/src/components/menu/MenuSub.tsx index 857f0234..ffceded6 100644 --- a/packages/ui/src/components/menu/MenuSub.tsx +++ b/packages/ui/src/components/menu/MenuSub.tsx @@ -54,7 +54,6 @@ export function DMenuSub(props: DMenuSubProps) { menuActiveId, menuExpandIds, menuFocusId, - menuPopup, menuCurrentData, onExpandChange, onFocus: _onFocus, @@ -66,6 +65,7 @@ export function DMenuSub(props: DMenuSubProps) { //#region Ref const [menuCollapseEl, menuCollapseRef] = useCustomRef(); const [menuPopupEl, menuPopupRef] = useCustomRef(); + const [liEl, liRef] = useCustomRef(); //#endregion const [menuWidth, setMenuWidth] = useImmer(undefined); @@ -73,6 +73,7 @@ export function DMenuSub(props: DMenuSubProps) { const [activedescendant, setActiveDescendant] = useImmer(undefined); const expand = menuExpandIds?.has(dId) ?? false; + const popupMode = menuMode !== 'vertical'; const popupVisible = useMemo(() => { let visible = false; for (const childVisible of childrenPopup.values()) { @@ -87,7 +88,7 @@ export function DMenuSub(props: DMenuSubProps) { const horizontal = menuMode === 'horizontal' && inNav; const _id = id ?? `${dPrefix}menu-sub-${toId(dId)}`; const isActive = useMemo(() => { - if (menuPopup ? !popupVisible : !expand) { + if (popupMode ? !popupVisible : !expand) { const ids: string[] = []; const getAllIds = (id: string) => { ids.push(id); @@ -102,7 +103,7 @@ export function DMenuSub(props: DMenuSubProps) { return menuActiveId ? ids.includes(menuActiveId) : false; } return false; - }, [dId, expand, menuActiveId, menuCurrentData?.ids, menuPopup, popupVisible]); + }, [dId, expand, menuActiveId, menuCurrentData?.ids, popupMode, popupVisible]); const iconRotate = useMemo(() => { if (horizontal && popupVisible) { return 180; @@ -110,11 +111,11 @@ export function DMenuSub(props: DMenuSubProps) { if (menuMode === 'vertical' && expand) { return 180; } - if (menuMode !== 'vertical' && !horizontal) { + if (popupMode && !horizontal) { return -90; } return undefined; - }, [expand, horizontal, menuMode, popupVisible]); + }, [expand, horizontal, menuMode, popupMode, popupVisible]); const customTransition = useCallback( (popupEl, targetEl) => { @@ -185,22 +186,22 @@ export function DMenuSub(props: DMenuSubProps) { useEffect(() => { let isFocus = false; if (menuFocusId) { - (menuPopup ? menuPopupEl : menuCollapseEl)?.childNodes.forEach((child) => { + (popupMode ? menuPopupEl : menuCollapseEl)?.childNodes.forEach((child) => { if (menuFocusId[1] === (child as HTMLElement)?.id) { isFocus = true; } }); } setActiveDescendant(isFocus ? menuFocusId?.[1] : undefined); - }, [menuCollapseEl, menuFocusId, menuPopup, menuPopupEl, setActiveDescendant]); + }, [menuCollapseEl, menuFocusId, popupMode, menuPopupEl, setActiveDescendant]); useEffect(() => { - if (menuMode === 'vertical') { + if (!popupMode) { setChildrenPopup((draft) => { draft.clear(); }); } - }, [menuMode, setChildrenPopup]); + }, [popupMode, setChildrenPopup]); //#endregion const childs = useMemo(() => { @@ -213,44 +214,12 @@ export function DMenuSub(props: DMenuSubProps) { 'is-first': length > 1 && index === 0, 'is-last': length > 1 && index === length - 1, }), - __level: menuMode !== 'vertical' && menuPopup ? 0 : __level + 1, + __level: popupMode ? 0 : __level + 1, }) ); - }, [children, menuMode, menuPopup, __level]); + }, [children, popupMode, __level]); - const liNode = ( - - ); + const triggerNode =
; const menuNode = (ref: (instance: HTMLUListElement | null) => void) => (
    - {dDisabled ? ( - liNode + + {popupMode ? ( + ) : ( - <> - {menuPopup ? ( - - {menuNode(menuPopupRef)} - - ) : ( - - {liNode} - - )} - - {menuNode(menuCollapseRef)} - - + )} + + {menuNode(menuCollapseRef)} + ); } diff --git a/packages/ui/src/components/tooltip/README.md b/packages/ui/src/components/tooltip/README.md index 4e59932f..73e1bc1d 100644 --- a/packages/ui/src/components/tooltip/README.md +++ b/packages/ui/src/components/tooltip/README.md @@ -29,7 +29,9 @@ Extend `React.HTMLAttributes`. | Property | Description | Type | Default | | --- | --- | --- | --- | | dVisible | Manually control the display of popup | boolean | - | +| dPopupContent | The contents of the popup | React.ReactNode | - | | dContainer | Mount node of popup, `false` represents the parent node mounted to the target node | string \| HTMLElement \| `(() => HTMLElement \| null)` \| null \| false | - | +| dTriggerNode | Custom popup target node | string \| HTMLElement \| `(() => HTMLElement \| null)` \| null | - | | dPlacement | popup direction | 'top' \| 'top-left' \| 'top-right' \| 'right' \| 'right-top' \| 'right-bottom' \| 'bottom' \| 'bottom-left' \| 'bottom-right' \| 'left' \| 'left-top' \| 'left-bottom' | 'top' | | dAutoPlace | When the popup is occluded, the position is automatically adjusted. If the `dContainer` attribute is not specified, the `window` view will be compared by default | boolean | true | | dTrigger | Trigger behavior | 'hover' \| 'focus' \| 'click' \| null | 'hover' | @@ -40,7 +42,6 @@ Extend `React.HTMLAttributes`. | dMouseEnterDelay | How many milliseconds after the mouse is moved to display | number | 150 | | dMouseLeaveDelay | How many milliseconds after the mouse is moved out will it be displayed | number | 150 | | dCustomPopup | Custom popup | `(popupEl: HTMLElement, targetEl: HTMLElement) => { top: number; left: number; stateList: DTransitionStateList }` | - | -| dTriggerNode | Target node | React.ReactNode | - | | onTrigger | Trigger popup display/hide callback | `(visible: boolean) => void` | - | | afterVisibleChange | Callback for the end of the popup show/hide animation | `(visible: boolean) => void` | - | diff --git a/packages/ui/src/components/tooltip/README.zh-Hant.md b/packages/ui/src/components/tooltip/README.zh-Hant.md index 97fae6b4..fb0bbfef 100644 --- a/packages/ui/src/components/tooltip/README.zh-Hant.md +++ b/packages/ui/src/components/tooltip/README.zh-Hant.md @@ -12,7 +12,7 @@ title: 文字提示 ### DTooltipProps -继承 `Omit`。 +继承 `Omit`。 | 参数 | 说明 | 类型 | 默认值 | @@ -28,7 +28,9 @@ title: 文字提示 | 参数 | 说明 | 类型 | 默认值 | | --- | --- | --- | --- | | dVisible | 手动控制 popup 的显示 | boolean | - | +| dPopupContent | popup 的内容 | React.ReactNode | - | | dContainer | popup 的挂载节点,`false` 代表挂载到目标节点的父节点 | string \| HTMLElement \| `(() => HTMLElement \| null)` \| null \| false | - | +| dTriggerNode | 自定义 popup 的目标节点 | string \| HTMLElement \| `(() => HTMLElement \| null)` \| null | - | | dPlacement | popup 弹出方向 | 'top' \| 'top-left' \| 'top-right' \| 'right' \| 'right-top' \| 'right-bottom' \| 'bottom' \| 'bottom-left' \| 'bottom-right' \| 'left' \| 'left-top' \| 'left-bottom' | 'top' | | dAutoPlace | 当 popup 被遮挡时,自动调整位置,如果未指定 `dContainer` 属性,那么默认比较 `window` 视图 | boolean | true | | dTrigger | 触发行为 | 'hover' \| 'focus' \| 'click' \| null | 'hover' | @@ -39,7 +41,6 @@ title: 文字提示 | dMouseEnterDelay | 鼠标移入后多少毫秒后显示 | number | 150 | | dMouseLeaveDelay | 鼠标移出后多少毫秒后显示 | number | 150 | | dCustomPopup | 自定义 popup | `(popupEl: HTMLElement, targetEl: HTMLElement) => { top: number; left: number; stateList: DTransitionStateList }` | - | -| dTriggerNode | 目标节点 | React.ReactNode | - | | onTrigger | 触发 popup 显示/隐藏的回调 | `(visible: boolean) => void` | - | | afterVisibleChange | popup 显示/隐藏动画结束的回调 | `(visible: boolean) => void` | - | diff --git a/packages/ui/src/components/tooltip/Tooltip.tsx b/packages/ui/src/components/tooltip/Tooltip.tsx index e232d23c..0fcd74c3 100644 --- a/packages/ui/src/components/tooltip/Tooltip.tsx +++ b/packages/ui/src/components/tooltip/Tooltip.tsx @@ -1,17 +1,18 @@ import type { DPopupProps, DPopupRef } from '../_popup'; +import { isUndefined } from 'lodash'; import React, { useMemo } from 'react'; import { useDPrefixConfig, useDComponentConfig, useId } from '../../hooks'; import { getClassName } from '../../utils'; import { DPopup } from '../_popup'; -export interface DTooltipProps extends Omit { +export interface DTooltipProps extends Omit { dTitle: React.ReactNode; } export const DTooltip = React.forwardRef((props, ref) => { - const { dTitle, id, className, children, ...restProps } = useDComponentConfig('tooltip', props); + const { dTitle, dTriggerNode, id, className, children, ...restProps } = useDComponentConfig('tooltip', props); //#region Context const dPrefix = useDPrefixConfig(); @@ -21,16 +22,28 @@ export const DTooltip = React.forwardRef((props, ref) const __id = id ?? `${dPrefix}tooltip-${_id}`; const child = useMemo(() => { - const _child = React.Children.only(children) as React.ReactElement>; - return React.cloneElement(_child, { - ..._child.props, - 'aria-describedby': __id, - }); - }, [__id, children]); + if (isUndefined(dTriggerNode)) { + const _child = React.Children.only(children) as React.ReactElement>; + return React.cloneElement(_child, { + ..._child.props, + 'aria-describedby': __id, + }); + } + + return null; + }, [__id, children, dTriggerNode]); return ( - - {dTitle} + + {child} ); }); diff --git a/packages/ui/src/hooks/manual-or-auto-state.ts b/packages/ui/src/hooks/manual-or-auto-state.ts index 768ed46a..e3cdc36e 100644 --- a/packages/ui/src/hooks/manual-or-auto-state.ts +++ b/packages/ui/src/hooks/manual-or-auto-state.ts @@ -1,34 +1,36 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { isEqual, isUndefined } from 'lodash'; -import { useCallback, useMemo } from 'react'; -import { useImmer } from 'use-immer'; +import { isUndefined } from 'lodash'; +import { useEffect, useMemo } from 'react'; +import { useImmerReducer } from 'use-immer'; export function useManualOrAutoState( defaultState: boolean, manualState?: boolean, onStateChange?: (state: boolean) => void -): [boolean, (value?: boolean, reverse?: true) => void]; -export function useManualOrAutoState(defaultState: T, manualState?: T, onStateChange?: (state: T) => void): [T, (value: T) => void]; +): [boolean, React.Dispatch<{ reverse?: true; value?: boolean }>]; +export function useManualOrAutoState( + defaultState: T, + manualState?: T, + onStateChange?: (state: T) => void +): [T, React.Dispatch<{ value: T }>]; export function useManualOrAutoState(defaultState: any, manualState: any, onStateChange?: (state: any) => void) { - const [autoState, setAutoState] = useImmer(() => (isUndefined(manualState) ? defaultState : manualState)); - - const state = useMemo(() => (isUndefined(manualState) ? autoState : manualState), [manualState, autoState]); - - const setState = useCallback( - (value, reverse) => { - let newState: any; - if (reverse === true) { - newState = !state; + const [autoState, dispatchAutoState] = useImmerReducer( + (draft, action) => { + if (action.reverse) { + return isUndefined(manualState) ? !draft : !manualState; } else { - newState = value; - } - if (!isEqual(newState, autoState)) { - setAutoState(newState); - onStateChange?.(newState); + return action.value; } }, - [autoState, onStateChange, setAutoState, state] + isUndefined(manualState) ? defaultState : manualState ); - return [state, setState] as const; + const state = useMemo(() => (isUndefined(manualState) ? autoState : manualState), [manualState, autoState]); + + useEffect(() => { + onStateChange?.(autoState); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [autoState]); + + return [state, dispatchAutoState] as const; }