diff --git a/packages/ui/src/drag-panel.tsx b/packages/ui/src/drag-panel.tsx new file mode 100644 index 0000000..74213d8 --- /dev/null +++ b/packages/ui/src/drag-panel.tsx @@ -0,0 +1,122 @@ +import React, { useState } from 'react'; +import { Box, Text, styled } from 'coral-system'; +import { Popover, PopoverProps, IconFont } from '../lib/esm'; +import Draggable from 'react-draggable'; +import { CloseOutlined } from '@ant-design/icons'; +import { noop } from '@music163/tango-helpers'; + +const CloseIcon = styled(CloseOutlined)` + cursor: pointer; + margin-left: 10px; + padding: 2px; + font-size: 13px; + &:hover { + color: var(--tango-colors-text1); + background-color: var(--tango-colors-line1); + border-radius: 4px; + } +`; + +interface DragPanelProps extends Omit { + // 标题 + title?: React.ReactNode | string; + // 内容 + body?: React.ReactNode | string; + // 底部 + footer?: ((close: () => void) => React.ReactNode) | React.ReactNode | string; + // 宽度 + width?: number | string; + // 右上角区域 + extra?: React.ReactNode | string; + children?: React.ReactNode; +} + +export function DragPanel({ + title, + footer, + body, + children, + width = 330, + extra, + onOpenChange = noop, + ...props +}: DragPanelProps) { + const [open, setOpen] = useState(false); + + const footerNode = + typeof footer === 'function' + ? footer(() => { + setOpen(false); + onOpenChange(false); + }) + : footer; + + return ( + { + setOpen(innerOpen); + onOpenChange(innerOpen); + }} + overlay={ + + + {/* 头部区域 */} + + + + {title} + + + {extra} + { + setOpen(false); + onOpenChange(false); + }} + /> + + + {/* 主体区域 */} + {body} + {/* 底部 */} + {footer && ( + + {footerNode} + + )} + + + } + {...props} + > + {children} + + ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 8f10ba6..eaa4efb 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -22,3 +22,5 @@ export * from './tabs'; export * from './select-action'; export * from './copy-clipboard'; export * from './tag-select'; +export * from './popover'; +export * from './drag-panel'; diff --git a/packages/ui/src/popover.tsx b/packages/ui/src/popover.tsx new file mode 100644 index 0000000..e3670c4 --- /dev/null +++ b/packages/ui/src/popover.tsx @@ -0,0 +1,166 @@ +import React, { useState, useRef, useEffect, useMemo, useCallback, useLayoutEffect } from 'react'; +import ReactDOM from 'react-dom'; +import { noop } from '@music163/tango-helpers'; +import { Box } from 'coral-system'; + +export interface PopoverProps { + open?: boolean; + /** + * 浮层内容 + */ + overlay: React.ReactNode; + /** + * 浮层打开或关闭时的回调 + */ + onOpenChange?: (open: boolean) => void; + /** + * 浮层被遮挡时自动调整位置 + */ + autoAdjustOverflow?: boolean; + /** + * 点击蒙层是否允许关闭 + */ + maskClosable?: boolean; + /** + * 手动唤起时的位置 + */ + left?: number; + /** + * 手动唤起时的位置 + */ + top?: number; + /** + * z-index + */ + zIndex?: number; + /** + * popoverStyle + */ + popoverStyle?: React.CSSProperties; + children?: React.ReactNode; +} + +export const Popover: React.FC = ({ + open, + overlay, + maskClosable = false, + autoAdjustOverflow = true, + left: controlledLeft, + top: controlledTop, + children, + popoverStyle, + onOpenChange = noop, + zIndex = 9999, +}) => { + const [visible, setVisible] = useState(false); + const [left, setLeft] = useState(0); + const [top, setTop] = useState(0); + const popoverRef = useRef(null); + + // 唤起位置受控 + const isControlledPostion = useMemo( + () => controlledLeft !== undefined || controlledTop !== undefined, + [controlledLeft, controlledTop], + ); + + useEffect(() => { + if (typeof controlledTop === 'number') { + setTop(controlledTop); + } + if (typeof controlledLeft === 'number') { + setLeft(controlledLeft); + } + }, [controlledTop, controlledLeft]); + + useLayoutEffect(() => { + const handleDocumentClick = (e: MouseEvent) => { + if ( + maskClosable && + visible && + popoverRef.current && + !popoverRef.current.contains(e.target as Node) + ) { + setVisible(false); + onOpenChange(false); + } + }; + + if (maskClosable && visible) { + document.addEventListener('click', handleDocumentClick, true); + } + + return () => { + document.removeEventListener('click', handleDocumentClick); + }; + }, [maskClosable, onOpenChange, visible]); + + const handleClick = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + const x = e.clientX; + const y = e.clientY; + setLeft(x); + setTop(y + 10); + setVisible(true); + onOpenChange(true); + }, + [onOpenChange], + ); + + useEffect(() => { + setVisible(open); + }, [open]); + + const getAdjustedPosition = () => { + const popoverElement = popoverRef.current; + if (popoverElement) { + const popoverRect = popoverElement.getBoundingClientRect(); + if (popoverRect.right > window.innerWidth) { + setLeft(window.innerWidth - popoverRect.width); + } + if (popoverRect.bottom > window.innerHeight) { + setTop(window.innerHeight - popoverRect.height); + } + } + }; + + useEffect(() => { + if (visible && autoAdjustOverflow) { + getAdjustedPosition(); + } + }, [visible, autoAdjustOverflow]); + + const overlayStyle: React.CSSProperties = useMemo( + () => ({ + display: visible ? 'block' : 'none', + position: 'fixed', + left, + top, + zIndex, + ...popoverStyle, + }), + [left, popoverStyle, top, visible, zIndex], + ); + + const overlayDom = ( + + + {overlay} + + + ); + + return ( + <> + {!isControlledPostion && + React.cloneElement(children as any, { + onClick: (e: React.MouseEvent) => { + e.stopPropagation(); + handleClick(e); + (children as any).props?.onClick?.(e); + }, + })} + {visible ? ReactDOM.createPortal(overlayDom, document.body) : null} + + ); +};