diff --git a/devui/overlay/index.ts b/devui/overlay/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..0e363ac8cdff8c34abd987d93223208b80c695bf --- /dev/null +++ b/devui/overlay/index.ts @@ -0,0 +1,31 @@ +import type { App } from 'vue' +import {FixedOverlay} from './src/fixed-overlay'; +import {FlexibleOverlay } from './src/flexible-overlay'; + +FlexibleOverlay.install = function(app: App) { + app.component(FlexibleOverlay.name, FlexibleOverlay); +} + +FixedOverlay.install = function(app: App) { + app.component(FixedOverlay.name, FixedOverlay); +} + +export { FlexibleOverlay, FixedOverlay } + +export default { + title: 'Overlay 浮层', + category: '通用', + install(app: App): void { + app.use(FixedOverlay as any); + app.use(FlexibleOverlay as any); + if (!document.getElementById('d-overlay-anchor')) { + const overlayAnchor = document.createElement('div'); + overlayAnchor.setAttribute('id', 'd-overlay-anchor'); + overlayAnchor.style.position = 'fixed'; + overlayAnchor.style.left = '0'; + overlayAnchor.style.top = '0'; + overlayAnchor.style.zIndex = '1000'; + document.body.appendChild(overlayAnchor); + } + } +} diff --git a/devui/overlay/src/common-overlay.tsx b/devui/overlay/src/common-overlay.tsx new file mode 100644 index 0000000000000000000000000000000000000000..783f16923231e903ff623c35c20cf086260d220e --- /dev/null +++ b/devui/overlay/src/common-overlay.tsx @@ -0,0 +1,14 @@ +import { defineComponent, renderSlot, Teleport, Transition } from 'vue'; +import './overlay.scss'; + +export const CommonOverlay = defineComponent({ + setup(props, ctx) { + return () => ( + + + {renderSlot(ctx.slots, 'default')} + + + ); + }, +}); diff --git a/devui/overlay/src/fixed-overlay.tsx b/devui/overlay/src/fixed-overlay.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5b6e903c0d5b11327d00a9061505460f87bbc421 --- /dev/null +++ b/devui/overlay/src/fixed-overlay.tsx @@ -0,0 +1,52 @@ +import { defineComponent, ref, renderSlot, CSSProperties, PropType } from 'vue'; +import { CommonOverlay } from './common-overlay'; +import { overlayProps } from './overlay-types'; +import { useOverlayLogic } from './utils'; +import './overlay.scss'; + +export const FixedOverlay = defineComponent({ + name: 'DFixedOverlay', + props: { + ...overlayProps, + overlayStyle: { + type: Object as PropType, + default: undefined, + }, + }, + setup(props, ctx) { + const { containerClass, panelClass, handleBackdropClick } = + useOverlayLogic(props); + + const overlayRef = ref(null); + const handleBubbleCancel = (event: Event) => (event.cancelBubble = true); + + const panelStyle: CSSProperties = { + position: 'fixed', + top: '0', + left: '0', + width: '100vw', + height: '100vh', + display: 'flex', + }; + return () => ( + + + + + {renderSlot(ctx.slots, 'default')} + + + + + ); + }, +}); diff --git a/devui/overlay/src/flexible-overlay.tsx b/devui/overlay/src/flexible-overlay.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6129b18756fe8f5e8dbad77bbe9bba3791e7cd8f --- /dev/null +++ b/devui/overlay/src/flexible-overlay.tsx @@ -0,0 +1,349 @@ +import { + ComponentPublicInstance, + CSSProperties, + defineComponent, + getCurrentInstance, + isRef, + nextTick, + onBeforeUnmount, + onMounted, + PropType, + reactive, + ref, + Ref, + renderSlot, + toRef, + watch, +} from 'vue'; +import { CommonOverlay } from './common-overlay'; +import { overlayProps } from './overlay-types'; +import { useOverlayLogic } from './utils'; + +/** + * 弹性的 Overlay,用于连接固定的和相对点 + */ +export const FlexibleOverlay = defineComponent({ + name: 'DFlexibleOverlay', + props: { + origin: { + type: Object as PropType, + require: true, + }, + position: { + type: Object as PropType, + default: () => ({ + originX: 'left', + originY: 'top', + overlayX: 'left', + overlayY: 'top', + }), + }, + ...overlayProps, + }, + emits: ['onUpdate:visible'], + setup(props, ctx) { + // lift cycle + const overlayRef = ref(null); + const positionedStyle = reactive({ position: 'absolute' }); + const instance = getCurrentInstance(); + onMounted(async () => { + await nextTick(); + + // 获取背景 + const overlay = overlayRef.value; + if (!overlay) { + return; + } + + // 获取原点 + const origin = getOrigin(props.origin); + if (!origin) { + return; + } + + const handleRectChange = (rect: DOMRect) => { + // TODO: add optimize for throttle + const point = calculatePosition(props.position, rect, origin); + + // set the current position style's value. + // the current position style is a 'ref'. + positionedStyle.left = `${point.x}px`; + positionedStyle.top = `${point.y}px`; + }; + const handleChange = () => + handleRectChange(overlay.getBoundingClientRect()); + + watch(toRef(props, 'visible'), (visible, ov, onInvalidate) => { + if (visible) { + subscribeLayoutEvent(handleChange); + } else { + unsbscribeLayoutEvent(handleChange); + } + onInvalidate(() => { + unsbscribeLayoutEvent(handleChange); + }); + }); + + const resizeObserver = new ResizeObserver((entries) => { + handleRectChange(entries[0].contentRect); + }); + resizeObserver.observe(overlay as unknown as Element); + onBeforeUnmount(() => { + resizeObserver.disconnect(); + }, instance); + + if (origin instanceof Element) { + // Only when the style changing, you can change + // the position. + const observer = new MutationObserver(handleChange); + observer.observe(origin, { + attributeFilter: ['style'], + }); + onBeforeUnmount(() => { + observer.disconnect(); + }, instance); + } + }, instance); + + const { containerClass, panelClass, handleBackdropClick } = + useOverlayLogic(props); + + return () => ( + + + + (event.cancelBubble = true)} + > + {renderSlot(ctx.slots, 'default')} + + + + + ); + }, +}); + +/** + * 提取 Vue Intance 中的元素,如果本身就是元素,直接返回。 + * @param {any} element + * @returns + */ +function getElement( + element: Element | { $el: Element; } | null +): Element | null { + if (element instanceof Element) { + return element; + } + if ( + element && + typeof element === 'object' && + element.$el instanceof Element + ) { + return element.$el; + } + return null; +} + +interface ClientRect { + bottom: number + readonly height: number + left: number + right: number + top: number + readonly width: number +} + +interface Point { + x: number + y: number +} + +interface Rect { + x: number + y: number + width?: number + height?: number +} + +type OriginOrDomRef = + | Element + | Ref + | Rect; + +type Origin = Element | Rect; + +type HorizontalConnectionPos = 'left' | 'center' | 'right'; +type VerticalConnectionPos = 'top' | 'center' | 'bottom'; + +interface ConnectionPosition { + originX: HorizontalConnectionPos + originY: VerticalConnectionPos + overlayX: HorizontalConnectionPos + overlayY: VerticalConnectionPos +} + +/** + * 获取原点,可能是 Element 或者 Rect + * @param {OriginOrDomRef} origin + * @returns {Origin} + */ +function getOrigin(origin: OriginOrDomRef): Origin { + // Check for Element so SVG elements are also supported. + if (origin instanceof Element) { + return origin; + } + + if (isRef(origin)) { + return getElement(origin.value); + } + + // is point { x: number, y: number, width: number, height: number } + return origin; +} + +/** + * 计算坐标系 + * @param {ConnectionPosition} position + * @param {HTMLElement | DOMRect} panelOrRect + * @param {Origin} origin + * @returns + */ +function calculatePosition( + position: ConnectionPosition, + panelOrRect: HTMLElement | DOMRect, + origin: Origin +): Point { + // get overlay rect + const originRect = getOriginRect(origin); + + // calculate the origin point + const originPoint = getOriginRelativePoint(originRect, position); + + let rect: DOMRect; + if (panelOrRect instanceof HTMLElement) { + rect = panelOrRect.getBoundingClientRect(); + } else { + rect = panelOrRect; + } + + // calculate the overlay anchor point + return getOverlayPoint(originPoint, rect, position); +} + +/** + * 返回原点元素的 ClientRect + * @param origin + * @returns {ClientRect} + */ +function getOriginRect(origin: Origin): ClientRect { + if (origin instanceof Element) { + return origin.getBoundingClientRect(); + } + // Origin is point + const width = origin.width || 0; + const height = origin.height || 0; + + // If the origin is a point, return a client rect as if it was a 0x0 element at the point. + return { + top: origin.y, + bottom: origin.y + height, + left: origin.x, + right: origin.x + width, + height, + width, + }; +} + +/** + * 获取浮层的左上角坐标 + * @param {Point} originPoint + * @param {DOMRect} rect + * @param {ConnectionPosition} position + * @returns + */ +function getOverlayPoint( + originPoint: Point, + rect: DOMRect, + position: ConnectionPosition +): Point { + let x: number; + const { width, height } = rect; + if (position.overlayX == 'center') { + x = originPoint.x - width / 2; + } else { + x = position.overlayX == 'left' ? originPoint.x : originPoint.x - width; + } + + let y: number; + if (position.overlayY == 'center') { + y = originPoint.y - height / 2; + } else { + y = position.overlayY == 'top' ? originPoint.y : originPoint.y - height; + } + + return { x, y }; +} + +/** + * 获取原点相对于 position 的坐标 (x, y) + * @param originRect + * @param position + * @returns + */ +function getOriginRelativePoint( + originRect: ClientRect, + position: ConnectionPosition +): Point { + let x: number; + if (position.originX == 'center') { + x = originRect.left + originRect.width / 2; + } else { + const startX = originRect.left; + const endX = originRect.right; + x = position.originX == 'left' ? startX : endX; + } + + let y: number; + if (position.originY == 'center') { + y = originRect.top + originRect.height / 2; + } else { + y = position.originY == 'top' ? originRect.top : originRect.bottom; + } + + return { x, y }; +} + +/** + * 订阅 layout 变化事件 + * @param event + */ +function subscribeLayoutEvent(event: (e?: Event) => void) { + window.addEventListener('scroll', event, true); + window.addEventListener('resize', event); + window.addEventListener('orientationchange', event); +} + +/** + * 取消 layout 变化事件 + * @param event + */ +function unsbscribeLayoutEvent(event: (e?: Event) => void) { + window.removeEventListener('scroll', event, true); + window.removeEventListener('resize', event); + window.removeEventListener('orientationchange', event); +} diff --git a/devui/overlay/src/overlay-types.ts b/devui/overlay/src/overlay-types.ts new file mode 100644 index 0000000000000000000000000000000000000000..80b20826335ffd0f82160380a62d8cbd823b1568 --- /dev/null +++ b/devui/overlay/src/overlay-types.ts @@ -0,0 +1,32 @@ +import { ExtractPropTypes, PropType } from 'vue'; + +export const overlayProps = { + visible: { + type: Boolean, + }, + 'onUpdate:visible': { + type: Function as PropType<(v: boolean) => void> + }, + backgroundBlock: { + type: Boolean, + default: false + }, + backgroundClass: { + type: String, + default: '' + }, + hasBackdrop: { + type: Boolean, + default: true + }, + backdropClick: { + type: Function, + }, + backdropClose: { + type: Boolean, + default: true + } +} as const; + + +export type OverlayProps = ExtractPropTypes; \ No newline at end of file diff --git a/devui/overlay/src/overlay.scss b/devui/overlay/src/overlay.scss new file mode 100644 index 0000000000000000000000000000000000000000..f07525797b7e1f4c50f2809b9d05e88a978cede7 --- /dev/null +++ b/devui/overlay/src/overlay.scss @@ -0,0 +1,58 @@ +.d-overlay-container { + position: fixed; + top: 0; + left: 0; + height: 100%; + width: 100%; + + &__disabled { + pointer-events: none; + } + + &__background { + background: rgba(0, 0, 0, 0.4); + } + + .d-overlay-panel { + position: relative; + z-index: 1000; + } + + .d-overlay { + pointer-events: auto; + } +} + +.d-overlay-fade { + @mixin d-overlay-fade-animation { + animation-name: d-overlay-fade; + animation-duration: 0.3s; + } + @keyframes d-overlay-fade { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } + } + + &-enter { + opacity: 0; + } + + &-enter-active { + @include d-overlay-fade-animation; + } + + &-leave { + opacity: 1; + } + + &-leave-active { + @include d-overlay-fade-animation; + + animation-direction: reverse; + } +} diff --git a/devui/overlay/src/utils.ts b/devui/overlay/src/utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..40d7b9476084f080045d76377ed6bcb4ffe64e27 --- /dev/null +++ b/devui/overlay/src/utils.ts @@ -0,0 +1,52 @@ +import { onUnmounted, watch, computed, ComputedRef } from 'vue'; +import { OverlayProps } from './overlay-types'; + +interface CommonInfo { + containerClass: ComputedRef + panelClass: ComputedRef + handleBackdropClick: (e: Event) => void +} + +export function useOverlayLogic(props: OverlayProps): CommonInfo { + const containerClass = computed(() => { + if (props.hasBackdrop) { + return ['d-overlay-container', props.backgroundClass]; + } else { + return ['d-overlay-container', 'd-overlay-container__disabled']; + } + }); + const panelClass = computed(() => { + if (props.hasBackdrop) { + return ['d-overlay-panel']; + } else { + return ['d-overlay-panel', 'd-overlay-container__disabled']; + } + }); + + const handleBackdropClick = (event: Event) => { + event.preventDefault(); + + props.backdropClick?.(); + if (props.backdropClose) { + props['onUpdate:visible']?.(false); + } + }; + + const body = document.body; + const originOverflow = body.style.overflow; + watch([() => props.visible, () => props.backgroundBlock], ([visible, backgroundBlock]) => { + if (backgroundBlock) { + body.style.overflow = visible ? 'hidden' : originOverflow; + } + }); + + onUnmounted(() => { + body.style.overflow = originOverflow; + }); + + return { + containerClass, + panelClass, + handleBackdropClick + } +} diff --git a/sites/.vitepress/config/sidebar.ts b/sites/.vitepress/config/sidebar.ts index fbafed02c0021e878725a73807dfdd889144c59b..7c9f0f769a5d71664eea48998892d7ab61e698e1 100644 --- a/sites/.vitepress/config/sidebar.ts +++ b/sites/.vitepress/config/sidebar.ts @@ -12,6 +12,7 @@ const sidebar = { { text: 'Search 搜索框', link: '/components/search/', status: '已完成' }, { text: 'Status 状态', link: '/components/status/', status: '已完成' }, { text: 'Sticky 便贴', link: '/components/sticky/' }, + { text: 'Overlay 浮层', link: '/components/overlay/'} ] }, { diff --git a/sites/components/overlay/index.md b/sites/components/overlay/index.md new file mode 100644 index 0000000000000000000000000000000000000000..6ea87353260edb3336b4df4153b0ebb08c5bc98b --- /dev/null +++ b/sites/components/overlay/index.md @@ -0,0 +1,76 @@ +## 浮层 + +### 固定浮层 + fixedVisible = !fixedVisible">{{fixedVisible ? '隐藏' : '显示固定浮层' }} + + hello world + + + +### 弹性浮层 + + orgin + visible = !visible">{{visible ? '隐藏' : '显示' }} + + + + hello world + + + + + \ No newline at end of file