diff --git a/packages/devui-vue/devui/popover/index.ts b/packages/devui-vue/devui/popover/index.ts index 671476ac44..db62a83bed 100644 --- a/packages/devui-vue/devui/popover/index.ts +++ b/packages/devui-vue/devui/popover/index.ts @@ -1,17 +1,14 @@ -import type { App } from 'vue' -import Popover from './src/popover' +import type { App } from 'vue'; +import Popover from './src/popover'; +export * from './src/popover-types'; -Popover.install = function (app: App): void { - app.component(Popover.name, Popover) -} - -export { Popover } +export { Popover }; export default { title: 'Popover 悬浮提示', category: '反馈', status: '100%', install(app: App): void { - app.use(Popover as any); - } -} + app.component(Popover.name, Popover); + }, +}; diff --git a/packages/devui-vue/devui/popover/src/debounce.ts b/packages/devui-vue/devui/popover/src/debounce.ts deleted file mode 100644 index a9e0115be2..0000000000 --- a/packages/devui-vue/devui/popover/src/debounce.ts +++ /dev/null @@ -1,9 +0,0 @@ -export default ( callBack,wait: number) => { - let time = null - return () => { - time && clearTimeout(time); - time = setTimeout(() => { - callBack?.() - }, wait) - } -} \ No newline at end of file diff --git a/packages/devui-vue/devui/popover/src/popover-icon.scss b/packages/devui-vue/devui/popover/src/popover-icon.scss new file mode 100644 index 0000000000..cfa994cee1 --- /dev/null +++ b/packages/devui-vue/devui/popover/src/popover-icon.scss @@ -0,0 +1,48 @@ +@import '../../styles-var/devui-var.scss'; + +.devui-popover-icon { + width: 16px; + height: 16px; + margin-right: 8px; + + .devui-icon.devui-icon-success > g { + & > path { + fill: $devui-success; + } + + & > circle, + & > polygon { + fill: $devui-light-text; + } + } + + .devui-icon.devui-icon-warning > g { + & > path { + fill: $devui-warning; + } + + & > polygon { + fill: $devui-light-text; + } + } + + .devui-icon.devui-icon-info > g { + & > g { + fill: $devui-info; + } + + & > circle { + fill: $devui-light-text; + } + } + + .devui-icon.devui-icon-error > g { + & > path { + fill: $devui-danger; + } + + & > circle { + fill: $devui-light-text; + } + } +} diff --git a/packages/devui-vue/devui/popover/src/popover-icon.tsx b/packages/devui-vue/devui/popover/src/popover-icon.tsx new file mode 100644 index 0000000000..d7802fb6eb --- /dev/null +++ b/packages/devui-vue/devui/popover/src/popover-icon.tsx @@ -0,0 +1,26 @@ +import { defineComponent } from 'vue'; +import type { PropType } from 'vue'; +import { PopType } from './popover-types'; +import { SuccessIcon, WarningIcon, InfoIcon, ErrorIcon } from './popover-icons'; +import './popover-icon.scss'; + +export default defineComponent({ + props: { + type: { + type: String as PropType, + default: 'default', + }, + }, + setup(props) { + return () => + props.type && + props.type !== 'default' && ( + + {props.type === 'success' && } + {props.type === 'warning' && } + {props.type === 'info' && } + {props.type === 'error' && } + + ); + }, +}); diff --git a/packages/devui-vue/devui/popover/src/popover-icons.tsx b/packages/devui-vue/devui/popover/src/popover-icons.tsx new file mode 100644 index 0000000000..db9b74f09e --- /dev/null +++ b/packages/devui-vue/devui/popover/src/popover-icons.tsx @@ -0,0 +1,57 @@ +export function SuccessIcon(): JSX.Element { + return ( + + + + + + + + ); +} + +export function WarningIcon(): JSX.Element { + return ( + + + + + + + ); +} + +export function InfoIcon(): JSX.Element { + return ( + + + + + + + + + ); +} + +export function ErrorIcon(): JSX.Element { + return ( + + + + + + + ); +} diff --git a/packages/devui-vue/devui/popover/src/popover-types.ts b/packages/devui-vue/devui/popover/src/popover-types.ts new file mode 100644 index 0000000000..7d4cd68d73 --- /dev/null +++ b/packages/devui-vue/devui/popover/src/popover-types.ts @@ -0,0 +1,64 @@ +import type { PropType, ExtractPropTypes } from 'vue'; + +export type TriggerType = 'click' | 'hover' | 'manually'; +export type PopType = 'success' | 'error' | 'warning' | 'info' | 'default'; +export type Placement = + | 'top' + | 'right' + | 'bottom' + | 'left' + | 'top-start' + | 'top-end' + | 'right-start' + | 'right-end' + | 'bottom-start' + | 'bottom-end' + | 'left-start' + | 'left-end'; +export type Alignment = 'start' | 'end'; +export type OffsetOptions = { mainAxis?: number; crossAxis?: number }; + +export const popoverProps = { + isOpen: { + type: Boolean, + default: false, + }, + position: { + type: Array as PropType>, + default: ['bottom'], + }, + align: { + type: String as PropType | null, + default: null, + }, + offset: { + type: [Number, Object] as PropType, + default: 8, + }, + content: { + type: String, + default: '', + }, + trigger: { + type: String as PropType, + default: 'click', + }, + popType: { + type: String as PropType, + default: 'default', + }, + showAnimation: { + type: Boolean, + default: true, + }, + mouseEnterDelay: { + type: Number, + default: 150, + }, + mouseLeaveDelay: { + type: Number, + default: 100, + }, +}; + +export type PopoverProps = ExtractPropTypes; diff --git a/packages/devui-vue/devui/popover/src/popover.scss b/packages/devui-vue/devui/popover/src/popover.scss index 4923d1df12..42ca55d683 100644 --- a/packages/devui-vue/devui/popover/src/popover.scss +++ b/packages/devui-vue/devui/popover/src/popover.scss @@ -3,307 +3,71 @@ @import '../../style//theme/font'; @import '../../style//theme//animation'; -$devui-popover-margin: -8px; -$devui-popover-width: -6px; -$devui-popover-offset: 8px; - -@mixin some-display { - display: inline-flex; -} - -.devui-popover { - position: relative; - @include some-display; - - &.devui-popover-isVisible { - .devui-popover-content { - @include some-display; - } - } - - .devui-popover-content { - position: absolute; - display: none; - padding: 5px 14px; - align-items: center; - flex-wrap: wrap; - white-space: nowrap; - border-radius: $devui-border-radius-feedback; - color: $devui-feedback-overlay-text; - background-color: $devui-feedback-overlay-bg; - font-size: $devui-font-size-sm; - - .after { - content: ''; - width: 12px; - height: 12px; - transform: rotate(45deg); - position: absolute; - background-color: $devui-feedback-overlay-bg; - } - - &.is-icon { - flex-wrap: nowrap; - } - - .devui-popover-icon { - margin-right: 5px; - } - } -} - -@keyframes some-animation { - 0% { - opacity: 0; - @include some-display; - } - - 100% { - opacity: 1; - } -} - -//动画效果 -.devui-popover { - &.devui-popover-animation { - @mixin some-animation { - animation: some-animation 0.5s $devui-animation-linear 1 forwards; - } - - .devui-popover-content { - @include some-animation; - } - } -} - -//left等方向 -.devui-popover { - @mixin left-postion--content { - left: $devui-popover-margin; - top: 0; - transform: translate(-100%, 0); - } - @mixin left-postion--after { - right: $devui-popover-width; - top: 50%; - margin-top: $devui-popover-width; - margin-left: $devui-popover-margin; - } - - &.left { - .devui-popover-content { - @include left-postion--content; - - top: 50%; - transform: translate(-100%, -50%); - } - - .after { - @include left-postion--after; - } - } - - &.left-top { - .devui-popover-content { - @include left-postion--content; - } - - .after { - @include left-postion--after; - - top: $devui-popover-offset; - margin-top: auto; - } - } - - &.left-bottom { - .devui-popover-content { - @include left-postion--content; - - top: auto; - bottom: 0; - } - - .after { - @include left-postion--after; - - bottom: $devui-popover-offset; - margin-top: auto; - } - } +.devui-popover-reference { + display: inline-block; } -// //top等方向 -.devui-popover { - @mixin top-postion--content { - top: $devui-popover-margin; - left: 0; - transform: translate(0, -100%); - } - @mixin top-postion--after { - bottom: $devui-popover-width; - margin-left: $devui-popover-width; - } - - &.top { - .devui-popover-content { - @include top-postion--content; - - left: 50%; - transform: translate(-50%, -100%); - } - - .after { - left: 50%; - @include top-postion--after; - } - } - - &.top-left { - .devui-popover-content { - @include top-postion--content; - } - - .after { - @include top-postion--after; - - left: $devui-popover-offset; - margin-left: auto; - margin-right: $devui-popover-width; - } - } - - &.top-right { - .devui-popover-content { - @include top-postion--content; - - left: auto; - right: 0; - } - - .after { - @include top-postion--after; - - right: $devui-popover-offset; - } +.devui-popover-content { + display: flex; + flex-wrap: wrap; + align-items: center; + white-space: nowrap; + padding: 4px 12px; + line-height: 1.5; + border-radius: $devui-border-radius-feedback; + color: $devui-feedback-overlay-text; + background-color: $devui-feedback-overlay-bg; + font-size: $devui-font-size-sm; + + &.is-icon { + flex-wrap: nowrap; } } -//right等方向 -.devui-popover { - @mixin right-postion--content { - right: $devui-popover-margin; - top: 50%; - transform: translate(100%, 0); - } - @mixin right-postion--after { - left: $devui-popover-width; - margin-right: $devui-popover-width; - top: $devui-popover-offset; - } - - &.right { - .devui-popover-content { - @include right-postion--content; - - transform: translate(100%, -50%); +.devui-popover-fade { + &-bottom, + &-top { + &-enter-from, + &-leave-to { + opacity: 0.8; + transform: scaleY(0.8); } - .after { - @include right-postion--after; - - top: 50%; - margin-top: $devui-popover-width; - } - } - - &.right-top { - .devui-popover-content { - @include right-postion--content; - - top: 0; - } - - .after { - @include right-postion--after; - } - } - - &.right-bottom { - .devui-popover-content { - @include right-postion--content; - - bottom: 0; - top: auto; - } - - .after { - @include right-postion--after; - - top: auto; - bottom: $devui-popover-offset; + &-enter-to, + &-leave-from { + opacity: 1; + transform: scaleY(1); } - } -} - -//bottom等方向 -.devui-popover { - @mixin bottom-postion--content { - left: 0; - bottom: 0; - margin-bottom: $devui-popover-width; - transform: translate(0, 100%); - } - @mixin bottom-postion--after { - left: $devui-popover-offset; - top: $devui-popover-width; - margin-bottom: $devui-popover-width; - margin-right: $devui-popover-width; - } - - &.bottom { - .devui-popover-content { - @include bottom-postion--content; - left: 50%; - bottom: 0; - transform: translate(-50%, 100%); + &-enter-active { + transition: transform 0.1s cubic-bezier(0.16, 0.75, 0.5, 1), opacity 0.1s cubic-bezier(0.16, 0.75, 0.5, 1); } - .after { - @include bottom-postion--after; - - left: 50%; - margin-right: auto; - margin-left: $devui-popover-width; + &-leave-active { + transition: transform 0.1s cubic-bezier(0.5, 0, 0.84, 0.25), opacity 0.1s cubic-bezier(0.5, 0, 0.84, 0.25); } } - &.bottom-left { - .devui-popover-content { - @include bottom-postion--content; + &-left, + &-right { + &-enter-from, + &-leave-to { + opacity: 0.8; + transform: scaleX(0.8); } - .after { - @include bottom-postion--after; + &-enter-to, + &-leave-from { + opacity: 1; + transform: scaleX(1); } - } - - &.bottom-right { - .devui-popover-content { - @include bottom-postion--content; - left: auto; - right: 0; + &-enter-active { + transition: transform 0.1s cubic-bezier(0.16, 0.75, 0.5, 1), opacity 0.1s cubic-bezier(0.16, 0.75, 0.5, 1); } - .after { - @include bottom-postion--after; - - left: auto; - margin-right: auto; - right: $devui-popover-offset; - margin-left: $devui-popover-width; + &-leave-active { + transition: transform 0.1s cubic-bezier(0.5, 0, 0.84, 0.25), opacity 0.1s cubic-bezier(0.5, 0, 0.84, 0.25); } } } diff --git a/packages/devui-vue/devui/popover/src/popover.tsx b/packages/devui-vue/devui/popover/src/popover.tsx index 4298261c9c..e1c8750973 100644 --- a/packages/devui-vue/devui/popover/src/popover.tsx +++ b/packages/devui-vue/devui/popover/src/popover.tsx @@ -1,147 +1,48 @@ -import { defineComponent, toRefs, ref, CSSProperties, reactive, watch } from 'vue' -import debounce from './debounce'; -import clickoutsideDirective from '../../shared/devui-directive/clickoutside' -import './popover.scss' - -type positionType = 'top' | 'right' | 'bottom' | 'left' | 'left-top' | 'left-bottom' | 'top-left' | 'top-right' | 'right-top' | 'right-bottom' | 'bottom-left' | 'bottom-right' -type triggerType = 'click' | 'hover' -type popType = 'success' | 'error' | 'warning' | 'info' | 'default' -const popTypeClass = { - success: { name: 'right-o', color: 'rgb(61, 204, 166)' }, - error: { name: 'error-o', color: 'rgb(249, 95, 91)' }, - info: { name: 'info-o', color: 'rgb(81, 112, 255)' }, - warning: { name: 'warning-o', color: 'rgb(254, 204, 85)' }, - default: {} -} +import { defineComponent, toRefs, ref, Teleport, Transition } from 'vue'; +import { FlexibleOverlay } from '../../overlay'; +import { popoverProps, PopoverProps } from './popover-types'; +import { usePopover, usePopoverEvent } from './use-popover'; +import PopoverIcon from './popover-icon'; +import './popover.scss'; export default defineComponent({ name: 'DPopover', - - directives: { - clickoutside: clickoutsideDirective - }, - - props: { - visible: { - type: Boolean, - default: false - }, - - position: { - type: String as () => positionType, - default: 'bottom' - }, - - content: { - type: String, - default: 'default' - }, - - trigger: { - type: String as () => triggerType, - default: 'click', - }, - - zIndex: { - type: Number as () => CSSProperties, - default: 1060 - }, - - controlled: { - type: Boolean, - default: false - }, - - popType: { - type: String as () => popType, - default: 'default' - }, - - showAnimation: { - type: Boolean, - default: true - }, - - mouseEnterDelay: { - type: Number, - default: 150 - }, - - mouseLeaveDelay: { - type: Number, - default: 100 - }, - - popMaxWidth: { - type: Number, - default: undefined - }, - - popoverStyle: { - type: Object, - default: () => ({}) - } - }, - - setup(props, ctx) { - const { slots } = ctx; - const visible = ref(props.visible); - const { - position, content, zIndex, trigger, popType, - popoverStyle, mouseEnterDelay, mouseLeaveDelay, - showAnimation, popMaxWidth, controlled - } = toRefs(props); - const style: CSSProperties = { zIndex: zIndex.value, ...popoverStyle.value } - const isClick = trigger.value === 'click' - const iconType = reactive(popTypeClass[popType.value]) - const event = function () { - if (visible.value) { - visible.value = false; - return - } - visible.value = true - } - const onClick = isClick && controlled.value ? event : null; - const enter = debounce(() => { visible.value = true }, mouseEnterDelay.value) - const leave = debounce(() => { visible.value = false }, mouseLeaveDelay.value) - const onMouseenter = !isClick && controlled.value ? enter : null - const onMouseleave = !isClick && controlled.value ? leave : null - const hiddenContext = () => { - if (!controlled.value) return; - visible.value = false - } - popMaxWidth.value && (style.maxWidth = `${popMaxWidth.value}px`) - - watch(() => props.visible, (newVal) => { - if (controlled.value) return; - visible.value = newVal; - }) - - return () => { - return ( -
-
- {slots.reference?.()} -
-
- {iconType.name && } - {slots.content?.() || {content.value}} - -
+ inheritAttrs: false, + props: popoverProps, + setup(props: PopoverProps, { slots, attrs }) { + const { content, popType, position, align, offset, showAnimation } = toRefs(props); + const origin = ref(); + const popoverRef = ref(); + const visible = ref(false); + const { placement, handlePositionChange } = usePopoverEvent(props, visible, origin); + const { overlayStyles } = usePopover(props, visible, placement, origin, popoverRef); + + return () => ( + <> +
+ {slots.reference?.()}
- ) - } + + + + + {slots.content?.() || {content.value}} + + + + + ); }, -}) +}); diff --git a/packages/devui-vue/devui/popover/src/use-popover.ts b/packages/devui-vue/devui/popover/src/use-popover.ts new file mode 100644 index 0000000000..a090f8ca08 --- /dev/null +++ b/packages/devui-vue/devui/popover/src/use-popover.ts @@ -0,0 +1,92 @@ +import { toRefs, ref, computed, watch, onUnmounted, onMounted } from 'vue'; +import type { Ref, ComputedRef } from 'vue'; +import { debounce } from 'lodash'; +import { PopoverProps } from './popover-types'; + +const TransformOriginMap: Record = { + top: '50% calc(100% + 8px)', + bottom: '50% -8px', + left: 'calc(100% + 8px)', + right: '-8px 50%', +}; + +export function usePopover( + props: PopoverProps, + visible: Ref, + placement: Ref, + origin: Ref, + popoverRef: Ref +): { overlayStyles: ComputedRef> } { + const { trigger, isOpen } = toRefs(props); + const overlayStyles = computed(() => ({ + zIndex: 1060, + transformOrigin: TransformOriginMap[placement.value], + })); + + const onDocumentClick: (e: Event) => void = (e: Event) => { + if (!origin.value.contains(e.target) && !popoverRef.value.$el?.contains(e.target)) { + visible.value = false; + } + }; + + watch(isOpen, (isOpenVal) => { + visible.value = isOpenVal; + }); + + watch(visible, () => { + if (visible.value && trigger.value !== 'manually') { + document.addEventListener('click', onDocumentClick); + } else { + document.removeEventListener('click', onDocumentClick); + } + }); + onUnmounted(() => { + document.removeEventListener('click', onDocumentClick); + }); + + return { overlayStyles }; +} + +export function usePopoverEvent( + props: PopoverProps, + visible: Ref, + origin: Ref +): { placement: Ref; handlePositionChange: (pos: string) => void } { + const { trigger, position, mouseEnterDelay, mouseLeaveDelay } = toRefs(props); + const isClick: ComputedRef = computed(() => trigger.value === 'click'); + const placement: Ref = ref(position.value[0].split('-')[0]); + const isEnter: Ref = ref(false); + + const onClick = () => isClick.value && (visible.value = !visible.value); + const enter = debounce(() => { + isEnter.value && (visible.value = true); + }, mouseEnterDelay.value); + const leave = debounce(() => { + !isEnter.value && (visible.value = false); + }, mouseLeaveDelay.value); + const onMouseenter = () => { + if (!isClick.value) { + isEnter.value = true; + enter(); + } + }; + const onMouseleave = () => { + if (!isClick.value) { + isEnter.value = false; + leave(); + } + }; + const handlePositionChange: (pos: string) => void = (pos: string) => { + placement.value = pos.split('-')[0]; + }; + onMounted(() => { + if (trigger.value === 'click') { + origin.value.addEventListener('click', onClick); + } else if (trigger.value === 'hover') { + origin.value.addEventListener('mouseenter', onMouseenter); + origin.value.addEventListener('mouseleave', onMouseleave); + } + }); + + return { placement, handlePositionChange }; +} diff --git a/packages/devui-vue/docs/components/popover/index.md b/packages/devui-vue/docs/components/popover/index.md index 625524f3bd..ef78c886aa 100644 --- a/packages/devui-vue/docs/components/popover/index.md +++ b/packages/devui-vue/docs/components/popover/index.md @@ -1,309 +1,366 @@ -# Popover 悬浮提示 +# Popover 悬浮提示 + 简单的文字提示气泡框。 -### 何时使用 +#### 何时使用 + 用来通知用户非关键性问题或提示某控件处于某特殊情况。 ### 基本用法 -当Popover弹出时,会基于`reference`插槽的内容进行定位。 -:::demo + +:::demo 当 Popover 弹出时,会基于`reference`插槽的内容进行定位。 + ```vue ``` + ::: -### 自定义内容 -自定义`reference`插槽的内容与弹出提示内容。 +### 自定义内容 -:::demo +:::demo 自定义`reference`插槽的内容与弹出提示内容。 ```vue - - ``` -::: +::: ### 弹出位置 -总共支持12个弹出位置。 -:::demo +:::demo 总共支持 12 个弹出位置。 ```vue