From 9bef2157c2bc1df83b34d0a50120831ec3ae74f8 Mon Sep 17 00:00:00 2001 From: yangxiaolu1993 <32215990+yangxiaolu1993@users.noreply.github.com> Date: Tue, 23 Aug 2022 17:19:32 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9APopover=E5=8A=9F=E8=83=BD=E8=A1=A5?= =?UTF-8?q?=E9=BD=90=E4=B8=8E=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95=20(#219)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../popover/__tests__/popover.spec.tsx | 90 +++++ src/packages/popover/demo.tsx | 322 ++++++++++-------- src/packages/popover/doc.md | 42 ++- src/packages/popover/popover.scss | 288 +++++++++++----- src/packages/popover/popover.tsx | 227 ++++++------ 5 files changed, 624 insertions(+), 345 deletions(-) create mode 100644 src/packages/popover/__tests__/popover.spec.tsx diff --git a/src/packages/popover/__tests__/popover.spec.tsx b/src/packages/popover/__tests__/popover.spec.tsx new file mode 100644 index 0000000..5500cc3 --- /dev/null +++ b/src/packages/popover/__tests__/popover.spec.tsx @@ -0,0 +1,90 @@ +import React from 'react' +import { render, waitFor, fireEvent } from '@testing-library/react' +import '@testing-library/jest-dom' +import Popover from '../index' +import Button from '@/packages/button' + +const itemList = [ + { + name: '选项一', + }, + { + name: '选项二', + }, + { + name: '选项三', + }, +] + +const itemListDisabled = [ + { + name: '选项一', + disabled: true, + }, + { + name: '选项二', + disabled: true, + }, + { + name: '选项三', + }, +] + +const iconItemList = [ + { + name: '选项一', + icon: 'my2', + }, + { + name: '选项二', + icon: 'cart2', + }, + { + name: '选项三', + icon: 'location2', + }, +] + +test('render popover content', async () => { + const { container } = render( + + + + ) + const content = container.querySelectorAll('.popover-content')[0] + expect(content.className).toContain( + 'popover-content-show popover-content popover-content--bottom' + ) +}) + +test('should emit onchoose event when clicking the action', async () => { + const choose = jest.fn() + const { container } = render( + + + + ) + const contentItem = container.querySelectorAll('.popover-menu-item')[0] + fireEvent.click(contentItem) + await waitFor(() => expect(choose.mock.calls[0][0].name).toEqual('选项一')) + await waitFor(() => expect(choose.mock.calls[0][1]).toBe(0)) +}) + +test('should not emit select event when the action is disabled', async () => { + const choose = jest.fn() + const { container } = render( + + + + ) + const contentItem = container.querySelectorAll('.popover-menu-item')[0] + fireEvent.click(contentItem) + + await waitFor(() => expect(choose).not.toBeCalled()) +}) diff --git a/src/packages/popover/demo.tsx b/src/packages/popover/demo.tsx index 2701e62..fa6af85 100644 --- a/src/packages/popover/demo.tsx +++ b/src/packages/popover/demo.tsx @@ -1,8 +1,14 @@ -import React, { useState } from 'react' +import React, { useRef, useState } from 'react' import { Popover } from './popover' import Button from '@/packages/button' import Icon from '@/packages/icon' +interface List { + name: string + icon?: string + disabled?: boolean +} + const BadgeDemo = () => { const selfContentStyle = { width: '195px', @@ -23,14 +29,7 @@ const BadgeDemo = () => { fontSize: '10px', textAlign: 'center', } as any - const hTwo = { - marginTop: '30px', - marginBottom: '10px', - fontSize: '14px', - color: '#909ca4', - padding: '0 10px', - fontWeight: 400, - } as any + const itemList = [ { name: '选项一', @@ -99,144 +98,183 @@ const BadgeDemo = () => { const [darkTheme, setDarkTheme] = useState(false) const [showIcon, setShowIcon] = useState(false) const [disableAction, setDisableAction] = useState(false) - const [topLocation, setTopLocation] = useState(false) - const [rightLocation, setRightLocation] = useState(false) - const [leftLocation, setLeftLocation] = useState(false) const [customized, setCustomized] = useState(false) + const customLocation = useRef([ + { bottom: false }, + { top: false }, + { left: false }, + { right: false }, + { 'top-start': false }, + { 'top-end': false }, + { 'bottom-start': false }, + { 'bottom-end': false }, + { 'left-start': false }, + { 'left-end': false }, + { 'right-start': false }, + { 'right-end': false }, + ]) + + const [customLocationName, setCustomLocationName] = useState('top') + const [customLocationShow, setCustomLocationShow] = useState(false) + + const chooseHandle = (item: List, index: number) => { + console.log('选择') + } + + const styles = ` + .customButtonBox { + display: flex; + flex-wrap: wrap; + justify-content: space-around; + } + + .brickBox { + display: flex; + justify-content: center; + margin: 80px 0; + } + + .brick { + width: 60px; + height: 60px; + background: #1989fa; + border-radius: 10px; + } + + .popover-content { + width: 100px; + } + + .customContent .popover-content{ + width: 200px; + } + ` + return ( <> +
-
-
基础用法
- { - lightTheme ? setLightTheme(false) : setLightTheme(true) - }} - list={itemList} - > - - - { - darkTheme ? setDarkTheme(false) : setDarkTheme(true) - }} - list={itemList} - > - - -
-
-
选项配置
- { - showIcon ? setShowIcon(false) : setShowIcon(true) - }} - list={iconItemList} - > - - - { - disableAction ? setDisableAction(false) : setDisableAction(true) - }} - list={itemListDisabled} - > - - -
-
-
自定义内容
- { - customized ? setCustomized(false) : setCustomized(true) - }} - > - - {customized ? ( -
- {selfContent.map((item: any) => { - return ( -
- -
- {item.desc} -
+

基础用法

+ { + lightTheme ? setLightTheme(false) : setLightTheme(true) + }} + list={itemList} + style={{ marginRight: '30px' }} + > + + + { + darkTheme ? setDarkTheme(false) : setDarkTheme(true) + }} + list={itemList} + > + + + +

选项配置

+ { + showIcon ? setShowIcon(false) : setShowIcon(true) + }} + list={iconItemList} + style={{ marginRight: '30px' }} + > + + + { + disableAction ? setDisableAction(false) : setDisableAction(true) + }} + list={itemListDisabled} + onChoose={chooseHandle} + > + + + +

自定义内容

+ { + customized ? setCustomized(false) : setCustomized(true) + }} + location="bottom-start" + className="customContent" + > + + {customized ? ( +
+ {selfContent.map((item: any) => { + return ( +
+ +
+ {item.desc}
- ) - })} -
- ) : ( - '' - )} - -
-
-
位置自定义
- { - topLocation ? setTopLocation(false) : setTopLocation(true) - }} - list={iconItemList} - > - - - {/* eslint-disable-next-line jsx-a11y/heading-has-content */} -

- { - rightLocation ? setRightLocation(false) : setRightLocation(true) - }} - list={iconItemList} - > - - - { - leftLocation ? setLeftLocation(false) : setLeftLocation(true) - }} - list={iconItemList} - > - - +

+ ) + })} +
+ ) : ( + '' + )} + + +

位置自定义

+ + { + setCustomLocationShow(false) + }} + list={iconItemList} + onChoose={chooseHandle} + className="brickBox" + > +
+ + +
+ {customLocation.current.map((location, i) => { + const k = Object.keys(location)[0] as any + const v = Object.values(location)[0] + return ( + + ) + })}
diff --git a/src/packages/popover/doc.md b/src/packages/popover/doc.md index 8b54e3e..25ecbe5 100644 --- a/src/packages/popover/doc.md +++ b/src/packages/popover/doc.md @@ -161,6 +161,26 @@ export default App; ::: ### 位置自定义 + +通过 location 属性来控制气泡的弹出位置。可选值 +``` +top # 顶部中间位置 +left # 左侧中间位置 +right # 右侧中间位置 +bottom # 底部中间位置 +``` +自 `v1.2.3` 起新增 +``` +top-start # 顶部左侧位置 +top-end # 顶部右侧位置 +left-start # 左侧上方位置 +left-end # 左侧下方位置 +right-start # 右侧上方位置 +right-end # 右侧下方位置 +bottom-start # 底部左侧位置 +bottom-end # 底部右侧位置 +``` + :::demo ```tsx import React, { useState, useRef } from "react"; @@ -186,22 +206,6 @@ const App = () => { list={iconItemList}>
- {rightLocation ? setRightLocation(false) : setRightLocation(true)}} - list={iconItemList}> - - - {leftLocation ? setLeftLocation(false) : setLeftLocation(true)}} - list={iconItemList}> - - ) } @@ -219,7 +223,8 @@ export default App; | list | 选项列表 | List[] | [] | | visible | 是否展示气泡弹出层 | boolean | false | | theme | 主题风格,可选值为 dark | string | `light` | -| location | 弹出位置,可选值为 top,left,right | string | `bottom` | +| location | 弹出位置 | string | `bottom` | +| offset `v1.2.3` | 出现位置的偏移量 | number | 20 | ### List 数据结构 @@ -235,7 +240,8 @@ List 属性是一个由对象构成的数组,数组中的每个对象配置一 | 名称 | 说明 | |---------|--------------| -| onClick | 打开(关闭)菜单时触发 | +| onClick | 点击菜单时触发 | +| onChoose | 点击选项时触发 | diff --git a/src/packages/popover/popover.scss b/src/packages/popover/popover.scss index 3337c0e..6a08606 100644 --- a/src/packages/popover/popover.scss +++ b/src/packages/popover/popover.scss @@ -1,17 +1,13 @@ @import '../icon/icon.scss'; -.popBox { - // background: skyblue; - // // background: #fff; - // padding: 20px; - .titBox { - // margin-bottom: 20px; - } -} -.nut-popover--dark, + .nut-popover { - position: relative; display: inline-block; - margin-right: 20px; + + > div { + position: relative; + // z-index: 9999; + } + .more-background { background: $popover-white-background-color; opacity: 0; @@ -21,10 +17,163 @@ z-index: 10; left: 0; } - .popoverContent--left, - .popoverContent--right, - .popoverContent--top, - .popoverContent { + + .popover-arrow { + position: absolute; + width: 0; + height: 0; + border: 8px solid transparent; + } + + // top + + .popover-arrow-top { + bottom: 0; + border-top-color: $popover-white-background-color; + border-bottom-width: 0; + margin-bottom: -8px; + } + + .popover-content--top { + left: 50%; + transform: translateX(-50%); + + .popover-arrow--top { + left: 50%; + transform: translateX(-50%); + } + } + + .popover-content--top-end { + .popover-arrow--top-end { + right: 16px; + transform: translateX(0%); + } + + right: 0; + } + + .popover-content--top-start { + left: 0; + + .popover-arrow--top-start { + left: 16px; + transform: translateX(0%); + } + } + + // bottom + .popover-content--bottom { + left: 50%; + transform: translateX(-50%); + } + + .popover-content--bottom-end { + right: 0; + } + + .popover-content--bottom-start { + left: 0; + } + + // left + .popover-content--left { + top: 50%; + transform: translateY(-50%); + } + + .popover-content--left-end { + bottom: 0; + } + + .popover-content--left-start { + top: 0; + } + + // right + .popover-content--right { + top: 50%; + transform: translateY(-50%); + } + + .popover-content--right-end { + bottom: 0; + } + + .popover-content--right-start { + top: 0; + } + + // arrow bottom + .popover-arrow-bottom { + top: 0px; + border-bottom-color: $popover-white-background-color; + border-top-width: 0; + margin-top: -8px; + } + + .popover-arrow--bottom { + left: 50%; + transform: translateX(-50%); + } + + .popover-arrow--bottom-start { + left: 16px; + transform: translateX(0%); + } + + .popover-arrow--bottom-end { + right: 16px; + transform: translateX(0%); + } + + // arrow left + .popover-arrow-left { + right: 0px; + border-left-color: $popover-white-background-color; + border-right-width: 0; + margin-right: -8px; + } + + .popover-arrow--left { + top: 50%; + transform: translateY(-50%); + } + + .popover-arrow--left-start { + top: 16px; + transform: translateY(0%); + } + + .popover-arrow--left-end { + bottom: 16px; + transform: translateY(0%); + } + + // arrow right + .popover-arrow-right { + left: 0px; + border-right-color: $popover-white-background-color; + border-left-width: 0; + margin-left: -8px; + } + + .popover-arrow--right { + top: 50%; + transform: translateY(-50%); + } + + .popover-arrow--right-start { + top: 16px; + transform: translateY(0%); + } + + .popover-arrow--right-end { + bottom: 16px; + transform: translateY(0%); + } + + .popover-content { z-index: 12; background: $popover-white-background-color; border-radius: 5px; @@ -34,63 +183,37 @@ font-weight: normal; color: $popover-primary-text-color; position: absolute; - .popoverArrow { - position: absolute; - width: 0; - height: 0; - border-left: 8px solid transparent; - border-right: 8px solid transparent; - border-top: 10px solid transparent; - border-bottom: 10px solid $popover-white-background-color; + box-shadow: 0 2px 12px #3232331f; + opacity: 0; + transition: opacity 0.1s; + + &.popover-content-show { + opacity: 1; } - .title-item { + + .popover-menu-item { display: flex; align-items: center; padding-bottom: 8px; margin: 8px; border-bottom: 1px solid $popover-border-bottom-color; + &:first-child { margin-top: 15px; } + &:last-child { margin-bottom: 2px; border-bottom: none; } - .title-name { + + .popover-menu-item-name { margin-right: 12px; margin-left: 8px; width: 100%; } } } - .popoverContent--top { - .popoverArrow--top { - position: absolute; - top: auto; - border-left: 8px solid transparent; - border-right: 8px solid transparent; - border-top: 10px solid $popover-white-background-color; - border-bottom: 10px solid transparent; - } - } - .popoverContent--left { - .popoverArrow--left { - position: absolute; - border-left: 10px solid $popover-white-background-color; - border-right: 10px solid transparent; - border-top: 8px solid transparent; - border-bottom: 8px solid transparent; - } - } - .popoverContent--right { - .popoverArrow--right { - position: absolute; - border-left: 10px solid transparent; - border-right: 10px solid $popover-white-background-color; - border-top: 8px solid transparent; - border-bottom: 8px solid transparent; - } - } .disabled { color: $popover-disable-color; @@ -101,42 +224,41 @@ .nut-popover--dark { background: $popover-dark-background-color; color: $popover-white-background-color; - .popoverContent--left, - .popoverContent--right, - .popoverContent--top, - .popoverContent { - background: $popover-dark-background-color; - color: $popover-white-background-color; - .popoverArrow { - border-bottom: 10px solid $popover-dark-background-color; + + .popover-content { + background: $popover-dark-background-color !important; + color: $popover-white-background-color !important; + } + + .popover-content--bottom, + .popover-content--bottom-start, + .popover-content--bottom-end { + .popover-arrow { + border-bottom-color: $popover-dark-background-color; } } - .popoverContent--top { - .popoverArrow--top { - position: absolute; - top: auto; - border-left: 8px solid transparent; - border-right: 8px solid transparent; - border-top: 10px solid $popover-dark-background-color; - border-bottom: 10px solid transparent; + + .popover-content--top, + .popover-content--top-start, + .popover-content--top-end { + .popover-arrow { + border-top-color: $popover-dark-background-color; } } - .popoverContent--left { - .popoverArrow--left { - position: absolute; - border-left: 10px solid $popover-dark-background-color; - border-right: 10px solid transparent; - border-top: 8px solid transparent; - border-bottom: 8px solid transparent; + + .popover-content--left, + .popover-content--left-start, + .popover-content--left-end { + .popover-arrow { + border-left-color: $popover-dark-background-color; } } - .popoverContent--right { - .popoverArrow--right { - position: absolute; - border-left: 10px solid transparent; - border-right: 10px solid $popover-dark-background-color; - border-top: 8px solid transparent; - border-bottom: 8px solid transparent; + + .popover-content--right, + .popover-content--right-start, + .popover-content--right-end { + .popover-arrow { + border-right-color: $popover-dark-background-color; } } } diff --git a/src/packages/popover/popover.tsx b/src/packages/popover/popover.tsx index d669465..4e50ead 100644 --- a/src/packages/popover/popover.tsx +++ b/src/packages/popover/popover.tsx @@ -5,73 +5,90 @@ import React, { useRef, useState, } from 'react' -import ReactDOM from 'react-dom' import Trigger from './Trigger' import Icon from '@/packages/icon' +import Overlay from '@/packages/overlay' + +export type PopoverTheme = 'light' | 'dark' + +export type PopoverLocation = + | 'bottom' + | 'top' + | 'left' + | 'right' + | 'top-start' + | 'top-end' + | 'bottom-start' + | 'bottom-end' + | 'left-start' + | 'left-end' + | 'right-start' + | 'right-end' + +export interface List { + name: string + icon?: string + disabled?: boolean +} export interface PopoverProps { - list: Array - theme: string - location: string + list: List[] + theme: PopoverTheme + location: PopoverLocation | string visible: boolean + offset: string | number className: string style?: CSSProperties - onClick: (e: MouseEvent) => void children?: React.ReactNode + onClick: (e: React.MouseEvent) => void + onChoose: (item: List, index: number) => void } -export function findDOMNode( - node: React.ReactInstance | HTMLElement -): T { - if (node instanceof HTMLElement) { - return node as unknown as T - } - // eslint-disable-next-line react/no-find-dom-node - return ReactDOM.findDOMNode(node) as unknown as T -} + const getEleAttr = (ele: HTMLElement | Element) => { if (ele && ele.getBoundingClientRect) { return ele.getBoundingClientRect() } return null } -export type PopoverType = - | 'default' - | 'primary' - | 'success' - | 'warning' - | 'danger' + const defaultProps = { list: [], theme: 'light', location: 'bottom', visible: false, + offset: 20, className: '', - onClick: (e: MouseEvent) => {}, + onClick: (e: React.MouseEvent) => {}, + onChoose: (item, index) => {}, } as PopoverProps -export const Popover: FunctionComponent> = (props) => { +export const Popover: FunctionComponent< + Partial & React.HTMLAttributes +> = (props) => { const { children, list, theme, location, visible, + offset, className, style, onClick, + onChoose, ...reset } = { ...defaultProps, ...props, } + const goodItem = useRef(null) - // eslint-disable-next-line react/no-find-dom-node - const aa = goodItem.current && findDOMNode(goodItem.current) setTimeout(() => { - if (aa) { - setElWidth((getEleAttr(aa) as any).width) - setElHeight((getEleAttr(aa) as any).height) + if (goodItem.current && getEleAttr(goodItem.current)) { + setElWidth((getEleAttr(goodItem.current) as any).width) + setElHeight((getEleAttr(goodItem.current) as any).height) } }) + const [classes, setClasses] = useState('') const [elWidth, setElWidth] = useState(0) const [elHeight, setElHeight] = useState(0) @@ -83,101 +100,107 @@ export const Popover: FunctionComponent> = (props) => { setPopoverArrow(popoverArrowSelf()) }, [list, theme]) const getStyle = () => { + const offNumer = Number(offset) ? Number(offset) : 0 const style: CSSProperties = {} - if (location === 'top') { - style.bottom = elHeight + 20 - style.left = 0 - } else if (location === 'right') { - style.top = 0 - style.right = -elWidth - 20 - } else if (location === 'left') { - style.top = 0 - style.left = -elWidth - 20 - } else { - style.top = elHeight + 20 - style.left = 0 - } - style.top += 'px' - style.left += 'px' - style.bottom += 'px' - style.right += 'px' - return style - } - const getArrowStyle = () => { - const style: CSSProperties = {} - if (location === 'top') { - style.bottom = -20 - style.left = elWidth / 2 - } else if (location === 'right') { - style.top = 20 - style.left = -20 - } else if (location === 'left') { - style.top = 20 - style.right = -20 + if (location.includes('top')) { + style.bottom = `${elHeight + offNumer}px` + } else if (location.includes('right')) { + style.left = `${elWidth + offNumer}px` + } else if (location.includes('left')) { + style.right = `${elWidth + offNumer}px` } else { - style.left = elWidth / 2 - style.top = -20 + style.top = `${elHeight + offNumer}px` } - style.top += 'px' - style.left += 'px' - style.bottom += 'px' - style.right += 'px' return style } const classesSelf = () => { const prefixCls = 'nut-popover' - return `${prefixCls} - ${theme ? `${prefixCls}--${theme}` : ''}` + return `${prefixCls} ${theme ? `${prefixCls}--${theme}` : ''}` } const popoverContentSelf = () => { - const prefixCls = 'popoverContent' - return `${prefixCls} - ${location ? `${prefixCls}--${location}` : ''}` + const prefixCls = 'popover-content' + return `${prefixCls}-show ${prefixCls} ${ + location ? `${prefixCls}--${location}` : '' + }` + } + + const filter = () => { + const ms = ['top', 'bottom', 'left', 'right'] + return ms.filter((m) => location.includes(m))[0] } const popoverArrowSelf = () => { - const prefixCls = 'popoverArrow' - return `${prefixCls} - ${location ? `${prefixCls}--${location}` : ''}` + const prefixCls = 'popover-arrow' + return `${prefixCls} ${prefixCls}-${filter()} ${ + location ? `${prefixCls}--${location}` : '' + }` } - // const showPopup = props.visible - const handleClick = (e: any) => { + const handleClick = (e: React.MouseEvent) => { if (props.onClick) { props.onClick(e) } } + + const handleChoose = (item: List, index: number) => { + if (!item.disabled) { + onChoose(item, index) + } + } return ( -
- -
handleClick(e)}> - {Array.isArray(children) ? children[0] : children} - {visible ? ( -
-
- {' '} + <> +
+ +
handleClick(e)}> + {Array.isArray(children) ? children[0] : children} + + {visible ? ( +
+
+ {Array.isArray(children) ? children[1] : ''} + {list.map((item: List, i: number) => { + return ( +
{ + handleChoose(item, i) + }} + > + {item.icon ? ( + + ) : ( + '' + )} +
{item.name}
+
+ ) + })}
- {Array.isArray(children) ? children[1] : ''} - {list.map((item) => { - return ( -
- {item.icon ? ( - - ) : ( - '' - )} -
{item.name}
-
- ) - })} -
- ) : null} -
-
-
+ ) : null} +
+ +
+ + {visible ? ( + handleClick(e)} + style={{ background: 'transparent' }} + /> + ) : ( + '' + )} + ) } -- GitLab