diff --git a/packages/devui-vue/devui/overlay/src/flexible-overlay/flexible-overlay-types.ts b/packages/devui-vue/devui/overlay/src/flexible-overlay/flexible-overlay-types.ts index 50001073ba..1b7488a055 100644 --- a/packages/devui-vue/devui/overlay/src/flexible-overlay/flexible-overlay-types.ts +++ b/packages/devui-vue/devui/overlay/src/flexible-overlay/flexible-overlay-types.ts @@ -1,4 +1,4 @@ -import type { ExtractPropTypes, PropType, Ref } from 'vue'; +import type { ExtractPropTypes, PropType, Ref,ComponentInternalInstance } from 'vue'; export type Placement = | 'top' @@ -15,15 +15,12 @@ export type Placement = | 'left-end'; export type Alignment = 'start' | 'end'; +export type AppendToBodyScrollStrategy = 'close' | 'repostion' +export type PlaceStrategy = 'most-space' | 'no-space' export type OffsetOptions = { mainAxis?: number; crossAxis?: number }; export type Point = { x?: number; y?: number }; -export type UseOverlayFn = { - arrowRef: Ref; - overlayRef: Ref; - updatePosition: () => void; -}; export type EmitEventFn = (event: 'positionChange' | 'update:modelValue', result?: unknown) => void; @@ -40,7 +37,7 @@ export const flexibleOverlayProps = { default: false, }, origin: { - type: Object as PropType, + type: Object as PropType | ComponentInternalInstance, require: true, }, position: { @@ -70,6 +67,28 @@ export const flexibleOverlayProps = { type: Boolean, default: false, }, + appendToBodyScrollStrategy:{ + type:String as PropType, + default:'reposition' + }, + // 保持和宿主元素的宽度一致 + fitOriginWidth:{ + type: Boolean, + default: false, + }, + // 宽高变化时,是否自动调整位置 + autoUpdatePosition:{ + type: Boolean, + default: false, + }, + // 弹出层位置的放置策略 + placeStrategy:{ + type:String as PropType, + default:'most-space' + }, + scrollElement:{ + type:[Object,String] as PropType + } }; export type FlexibleOverlayProps = ExtractPropTypes; diff --git a/packages/devui-vue/devui/overlay/src/flexible-overlay/flexible-overlay.scss b/packages/devui-vue/devui/overlay/src/flexible-overlay/flexible-overlay.scss index 7322e8260e..ed08008d83 100644 --- a/packages/devui-vue/devui/overlay/src/flexible-overlay/flexible-overlay.scss +++ b/packages/devui-vue/devui/overlay/src/flexible-overlay/flexible-overlay.scss @@ -1,4 +1,4 @@ -@import '../../../styles-var/devui-var.scss'; +@import '@devui/theme/styles-var/devui-var.scss'; .#{$devui-prefix}-flexible-overlay { position: fixed; diff --git a/packages/devui-vue/devui/overlay/src/flexible-overlay/index.tsx b/packages/devui-vue/devui/overlay/src/flexible-overlay/index.tsx index 347b0be591..e5e779c0a8 100644 --- a/packages/devui-vue/devui/overlay/src/flexible-overlay/index.tsx +++ b/packages/devui-vue/devui/overlay/src/flexible-overlay/index.tsx @@ -1,7 +1,7 @@ import { defineComponent, toRefs, withModifiers } from 'vue'; import { flexibleOverlayProps, FlexibleOverlayProps } from './flexible-overlay-types'; import { useOverlay } from './use-flexible-overlay'; -import { useNamespace } from '../../../shared/hooks/use-namespace'; +import { useNamespace } from '@devui/shared/utils'; import './flexible-overlay.scss'; export const FlexibleOverlay = defineComponent({ @@ -12,14 +12,15 @@ export const FlexibleOverlay = defineComponent({ setup(props: FlexibleOverlayProps, { slots, attrs, emit, expose }) { const ns = useNamespace('flexible-overlay'); const { clickEventBubble } = toRefs(props); - const { arrowRef, overlayRef, updatePosition } = useOverlay(props, emit); + const { arrowRef, overlayRef, styles,showOverlay, updatePosition } = useOverlay(props, emit); expose({ updatePosition }); return () => - props.modelValue && ( + showOverlay.value && (
({}), [clickEventBubble.value ? '' : 'stop'])} onPointerup={withModifiers(() => ({}), ['stop'])}> diff --git a/packages/devui-vue/devui/overlay/src/flexible-overlay/use-flexible-overlay.ts b/packages/devui-vue/devui/overlay/src/flexible-overlay/use-flexible-overlay.ts index 594b7aa7dc..cc0c79e086 100644 --- a/packages/devui-vue/devui/overlay/src/flexible-overlay/use-flexible-overlay.ts +++ b/packages/devui-vue/devui/overlay/src/flexible-overlay/use-flexible-overlay.ts @@ -1,6 +1,6 @@ -import { ref, unref, watch, nextTick, onUnmounted } from 'vue'; -import { arrow, autoPlacement, computePosition, offset, shift } from '@floating-ui/dom'; -import { FlexibleOverlayProps, Placement, Point, UseOverlayFn, EmitEventFn, Rect } from './flexible-overlay-types'; +import { ref, unref, watch, nextTick, onUnmounted, computed, toRefs } from 'vue'; +import { arrow, autoPlacement, computePosition, offset, shift, flip } from '@floating-ui/dom'; +import { FlexibleOverlayProps, Placement, Point, EmitEventFn, Rect } from './flexible-overlay-types'; import { getScrollParent } from './utils'; function adjustArrowPosition(isArrowCenter: boolean, point: Point, placement: Placement, originRect: Rect): Point { @@ -24,10 +24,58 @@ function adjustArrowPosition(isArrowCenter: boolean, point: Point, placement: Pl return { x, y }; } -export function useOverlay(props: FlexibleOverlayProps, emit: EmitEventFn): UseOverlayFn { +export function useOverlay(props: FlexibleOverlayProps, emit: EmitEventFn) { + const { fitOriginWidth, autoUpdatePosition, align, position, showArrow, shiftOffset, placeStrategy } = toRefs(props); const overlayRef = ref(); const arrowRef = ref(); - let originParent = null; + const showOverlay = ref(false); + const overlayWidth = ref(0) + const baseOption = { strategy: 'fixed' } + const baseMiddleware = [offset(props.offset)] + let originParent: HTMLElement; + let rect: DOMRect + let originObserver: ResizeObserver + let overlayObserver: ResizeObserver + const styles = computed(() => { + if (fitOriginWidth.value) { + return { width: overlayWidth.value + 'px' } + } else { + return {} + } + }) + + const generateMostSpaceOptions = () => { + const middleware = [ + ...baseMiddleware, + autoPlacement({ + alignment: align.value, + allowedPlacements: position.value, + }) + ]; + if (showArrow.value) { + middleware.push(arrow({ element: arrowRef.value! })) + } + if (shiftOffset?.value !== undefined) { + middleware.push(shift()) + } + return { ...baseOption, middleware } + }; + + const generateNoSpaceOptions = () => { + const [mainPostion, ...fallbackPostion] = position.value + const middleware = [...baseMiddleware]; + if (showArrow.value) { + middleware.push(arrow({ element: arrowRef.value! })) + } + middleware.push(fallbackPostion.length ? flip({ fallbackPlacements: fallbackPostion }) : flip()); + return { ...baseOption, placement: mainPostion, middleware } + } + + const optionMap = { + 'most-space': generateMostSpaceOptions, + 'no-space': generateNoSpaceOptions, + } + const updateArrowPosition = (arrowEl: HTMLElement, placement: Placement, point: Point, overlayEl: HTMLElement) => { const { x, y } = adjustArrowPosition(props.isArrowCenter, point, placement, overlayEl.getBoundingClientRect()); const staticSide = { @@ -45,25 +93,16 @@ export function useOverlay(props: FlexibleOverlayProps, emit: EmitEventFn): UseO }); }; const updatePosition = async () => { - const hostEl = props.origin; + const hostEl = (props.origin?.$el ?? props.origin); const overlayEl = unref(overlayRef.value); const arrowEl = unref(arrowRef.value); - const middleware = [ - offset(props.offset), - autoPlacement({ - alignment: props.align, - allowedPlacements: props.position, - }), - ]; - props.showArrow && middleware.push(arrow({ element: arrowEl })); - props.shiftOffset !== undefined && middleware.push(shift()); - const { x, y, placement, middlewareData } = await computePosition(hostEl, overlayEl, { - strategy: 'fixed', - middleware, - }); + if (!hostEl || !overlayEl) { + return; + } + const { x, y, placement, middlewareData } = await computePosition(hostEl, overlayEl, optionMap[placeStrategy.value]); let applyX = x; let applyY = y; - if (props.shiftOffset !== undefined) { + if (placeStrategy.value === 'most-space' && props.shiftOffset !== undefined) { const { x: shiftX, y: shiftY } = middlewareData.shift; shiftX < 0 && (applyX -= props.shiftOffset); shiftX > 0 && (applyX += props.shiftOffset); @@ -74,27 +113,107 @@ export function useOverlay(props: FlexibleOverlayProps, emit: EmitEventFn): UseO Object.assign(overlayEl.style, { top: `${applyY}px`, left: `${applyX}px` }); props.showArrow && updateArrowPosition(arrowEl, placement, middlewareData.arrow, overlayEl); }; - watch( - () => props.modelValue, - () => { - if (props.modelValue && props.origin) { - originParent = getScrollParent(props.origin); - nextTick(updatePosition); - originParent?.addEventListener('scroll', updatePosition); - originParent !== window && window.addEventListener('scroll', updatePosition); - window.addEventListener('resize', updatePosition); - } else { - originParent?.removeEventListener('scroll', updatePosition); - originParent !== window && window.removeEventListener('scroll', updatePosition); - window.removeEventListener('resize', updatePosition); + + const scrollCallBack = (e: Event) => { + const scrollElement = e.target as HTMLElement; + if (scrollElement?.contains(props.origin?.$el ?? props.origin)) { + if (props.appendToBodyScrollStrategy === 'repostion') { + updatePosition() + } + if (props.appendToBodyScrollStrategy === 'close') { + showOverlay.value = false; + emit('update:modelValue', false) + } + } + } + + const updateWidth = (originEl: Element) => { + overlayWidth.value = originEl.getBoundingClientRect().width; + updatePosition() + }; + + const observeOrigin = () => { + if (fitOriginWidth.value && typeof window !== 'undefined') { + const originEl = props.origin?.$el ?? props.origin + if (originEl) { + originObserver = new window.ResizeObserver(() => updateWidth(originEl)) + originObserver.observe(originEl) + } + } + } + + const unobserveOrigin = () => { + const originEl = props.origin?.$el ?? props.origin + originEl && originObserver?.unobserve(originEl) + } + + const observeOverlay = () => { + if (autoUpdatePosition.value && typeof window !== 'undefined') { + overlayObserver = new window.ResizeObserver(updatePosition) + originObserver.observe(overlayRef.value) + } + } + const unobserveOverlay = () => { + overlayRef.value && overlayObserver?.unobserve(overlayRef.value) + } + + + const checkBounds = (rect: DOMRect, scrollElement: HTMLElement) => { + if (!scrollElement.getBoundingClientRect) { + return false; + } + if (props.scrollElement) { + const containerRect = scrollElement.getBoundingClientRect() + const positionFixArr = [rect.height, 0, 0, 0]; + const bounds = [ + Math.round(rect.top + positionFixArr[0]) >= Math.round(containerRect.top), + Math.round(rect.right + positionFixArr[1]) <= Math.round(containerRect.right), + Math.round(rect.bottom + positionFixArr[2]) <= Math.round(containerRect.bottom), + Math.round(rect.left + positionFixArr[3]) >= Math.round(containerRect.left), + ] + if (bounds.includes(false)) { + return true; + } + } + return false; + }; + watch([() => props.modelValue, () => props.origin], () => { + if (props.modelValue && props.origin) { + originParent = + !props.scrollElement || props.scrollElement === 'auto' + ? (getScrollParent((props.origin.$el ?? props.origin) as HTMLElement) as HTMLElement) + : props.scrollElement; + rect = ((props.origin.$el ?? props.origin) as HTMLElement).getBoundingClientRect() + if (checkBounds(rect, originParent)) { + showOverlay.value = false; + nextTick(() => { + emit('update:modelValue', false) + }) + return } + showOverlay.value = true + nextTick(updatePosition); + window.addEventListener('scroll', scrollCallBack, true); + window.addEventListener('resize', updatePosition); + observeOrigin() + nextTick(observeOverlay) + } else { + showOverlay.value = false; + originParent?.removeEventListener('scroll', scrollCallBack, true); + originParent?.removeEventListener('resize', updatePosition); + unobserveOrigin() + unobserveOverlay() } + } ); onUnmounted(() => { - originParent?.removeEventListener('scroll', updatePosition); - originParent !== window && window.removeEventListener('scroll', updatePosition); - window.removeEventListener('resize', updatePosition); + showOverlay.value = false; + originParent?.removeEventListener('scroll', scrollCallBack, true); + originParent?.removeEventListener('resize', updatePosition); + unobserveOrigin() + unobserveOverlay() + }); - return { arrowRef, overlayRef, updatePosition }; + return { arrowRef, overlayRef, styles, showOverlay, updatePosition }; } diff --git a/packages/devui-vue/devui/shared/components/popper-trigger/src/popper-trigger.tsx b/packages/devui-vue/devui/shared/components/popper-trigger/src/popper-trigger.tsx index d4bb2feb65..daa84b137c 100644 --- a/packages/devui-vue/devui/shared/components/popper-trigger/src/popper-trigger.tsx +++ b/packages/devui-vue/devui/shared/components/popper-trigger/src/popper-trigger.tsx @@ -1,41 +1,20 @@ -import { cloneVNode, defineComponent, withDirectives, inject } from 'vue'; -import type { SetupContext, Ref } from 'vue'; -import { POPPER_TRIGGER_TOKEN } from './popper-trigger-types'; -import { getFirstValidChild } from './use-popper-trigger'; +import { defineComponent } from 'vue'; +import type { SetupContext } from 'vue'; +import { usePopperTrigger } from './use-popper-trigger'; export default defineComponent({ name: 'DPopperTrigger', setup(_, ctx: SetupContext) { - const { slots, attrs } = ctx; + const { slots } = ctx; return () => { - const defaultSlot = slots.default?.(attrs); - const triggerRef = inject(POPPER_TRIGGER_TOKEN) as Ref; - + const defaultSlot = slots.default?.(); if (!defaultSlot) { return null; } + const {addDirectiveToFirstValidChild} = usePopperTrigger(); + addDirectiveToFirstValidChild(defaultSlot) - const firstValidChild = getFirstValidChild(defaultSlot); - - if (!firstValidChild) { - return null; - } - - return withDirectives(cloneVNode(firstValidChild, attrs), [ - [ - { - mounted(el) { - triggerRef.value = el; - }, - updated(el) { - triggerRef.value = el; - }, - unmounted() { - triggerRef.value = null; - }, - }, - ], - ]); + return defaultSlot; }; }, }); diff --git a/packages/devui-vue/devui/shared/components/popper-trigger/src/use-popper-trigger.ts b/packages/devui-vue/devui/shared/components/popper-trigger/src/use-popper-trigger.ts index ec292c2b97..0c8fe362c1 100644 --- a/packages/devui-vue/devui/shared/components/popper-trigger/src/use-popper-trigger.ts +++ b/packages/devui-vue/devui/shared/components/popper-trigger/src/use-popper-trigger.ts @@ -1,30 +1,58 @@ -import { h, Comment, Text, Fragment } from 'vue'; +import { h, Comment, Text, Fragment, cloneVNode, withDirectives, inject } from 'vue'; import { isObject } from '@vue/shared'; -import type { VNode } from 'vue'; -import { useNamespace } from '../../../hooks/use-namespace'; +import type { VNode, Ref } from 'vue'; +import { useNamespace } from '../../../utils/use-namespace'; +import { POPPER_TRIGGER_TOKEN } from './popper-trigger-types'; -const ns = useNamespace('popper-trigger'); - -function wrapContent(content: string | VNode) { - return h('span', { class: ns.b() }, content); -} +export function usePopperTrigger() { + const ns = useNamespace('popper-trigger'); + const triggerRef = inject(POPPER_TRIGGER_TOKEN) as Ref; + function addDirective(node: VNode): VNode { + return withDirectives(cloneVNode(node, {}, true), [ + [ + { + mounted(el) { + triggerRef.value = el; + }, + updated(el) { + triggerRef.value = el; + }, + unmounted(el) { + triggerRef.value = null; + }, + }, + ], + ]); + } -export function getFirstValidChild(nodes: VNode[]): VNode | null { - for (const child of nodes) { - if (isObject(child)) { - if (child.type === Comment) { - continue; - } - if (child.type === 'svg' || child.type === Text) { - return wrapContent(child); - } - if (child.type === Fragment) { - return getFirstValidChild(child.children as VNode[]); + function wrapContent(content: string | VNode) { + const node = h('span', { class: ns.b() }, content); + return addDirective(node) + } + function addDirectiveToFirstValidChild(nodes: VNode[]): VNode | null | undefined { + for (let i = 0; i < nodes.length; i++) { + let child = nodes[i]; + if (child) { + if (isObject(child)) { + if (child.type === Comment) { + continue; + } + if (child.type === 'svg' || child.type === Text) { + nodes[i] = wrapContent(child) + return; + } + if (child.type === Fragment) { + return addDirectiveToFirstValidChild(child.children as VNode[]); + } + nodes[i] = addDirective(child); + return; + } + nodes[i] = wrapContent(child) } - return child; + return; } - return wrapContent(child); + return null; } - - return null; + return { addDirectiveToFirstValidChild } } + diff --git a/packages/devui-vue/devui/tooltip/src/tooltip-types.ts b/packages/devui-vue/devui/tooltip/src/tooltip-types.ts index 02d218c8cc..411471c901 100644 --- a/packages/devui-vue/devui/tooltip/src/tooltip-types.ts +++ b/packages/devui-vue/devui/tooltip/src/tooltip-types.ts @@ -1,6 +1,7 @@ import type { ComputedRef, ExtractPropTypes, PropType, Ref } from 'vue'; export type BasePlacement = 'top' | 'right' | 'bottom' | 'left'; +export type PlaceStrategy = 'most-space' | 'no-space' ; export const tooltipProps = { content: { @@ -9,7 +10,7 @@ export const tooltipProps = { }, position: { type: [String, Array] as PropType>, - default: 'top', + default: ['top','right','bottom','left'], }, showAnimation: { type: Boolean, @@ -34,6 +35,13 @@ export const tooltipProps = { hideAfter: { type: Number, default: 0, + }, + placeStrategy:{ + type: String as PropType, + default:'no-space' + }, + scrollElement:{ + type:[Object,String] as PropType } }; diff --git a/packages/devui-vue/devui/tooltip/src/tooltip.tsx b/packages/devui-vue/devui/tooltip/src/tooltip.tsx index bda4868e80..873774b31a 100644 --- a/packages/devui-vue/devui/tooltip/src/tooltip.tsx +++ b/packages/devui-vue/devui/tooltip/src/tooltip.tsx @@ -1,17 +1,17 @@ -import { computed, defineComponent, provide, ref, Teleport, toRefs, Transition } from 'vue'; +import { defineComponent, provide, ref, Teleport, toRefs, Transition } from 'vue'; import { FlexibleOverlay } from '../../overlay'; import { PopperTrigger } from '../../shared/components/popper-trigger'; import { TooltipProps, tooltipProps } from './tooltip-types'; import { POPPER_TRIGGER_TOKEN } from '../../shared/components/popper-trigger/src/popper-trigger-types'; import { useTooltip } from './use-tooltip'; -import { useNamespace } from '../../shared/hooks/use-namespace'; +import { useNamespace } from '@devui/shared/utils'; import './tooltip.scss'; export default defineComponent({ name: 'DTooltip', props: tooltipProps, setup(props: TooltipProps, { slots }) { - const { showAnimation, content } = toRefs(props); + const { showAnimation, content, placeStrategy, scrollElement } = toRefs(props); const origin = ref(); const tooltipRef = ref(); const { visible, placement, positionArr, overlayStyles, onPositionChange, onMouseleave, onMouseenterOverlay } = useTooltip( @@ -19,9 +19,7 @@ export default defineComponent({ props ); const ns = useNamespace('tooltip'); - const className = computed(() => { - return [ns.b(), ns.m(placement.value)].join(' '); - }); + provide(POPPER_TRIGGER_TOKEN, origin); return () => ( @@ -32,16 +30,18 @@ export default defineComponent({ - + {content.value} diff --git a/packages/devui-vue/devui/tooltip/src/use-tooltip.ts b/packages/devui-vue/devui/tooltip/src/use-tooltip.ts index 1b97c5d66f..09b12ec394 100644 --- a/packages/devui-vue/devui/tooltip/src/use-tooltip.ts +++ b/packages/devui-vue/devui/tooltip/src/use-tooltip.ts @@ -3,7 +3,7 @@ import type { Ref } from 'vue'; import { debounce } from 'lodash'; import { TooltipProps, BasePlacement, UseTooltipFn } from './tooltip-types'; -export const transformOriginMap: Record = { +export const TransformOriginMap: Record = { top: '50% calc(100% + 8px)', bottom: '50% -8px', left: 'calc(100% + 8px)', @@ -11,14 +11,15 @@ export const transformOriginMap: Record = { }; export function useTooltip(origin: Ref, props: TooltipProps): UseTooltipFn { - const { position, mouseEnterDelay, mouseLeaveDelay, enterable, disabled, hideAfter } = toRefs(props); + const { position,content, mouseEnterDelay, mouseLeaveDelay, enterable, disabled, hideAfter } = toRefs(props); const visible = ref(false); const isEnter = ref(false); const positionArr = computed(() => (typeof position.value === 'string' ? [position.value] : position.value)); const placement = ref(positionArr.value[0]); const overlayStyles = computed(() => ({ - transformOrigin: transformOriginMap[placement.value], + transformOrigin: TransformOriginMap[placement.value], })); + const isDisabled = computed(()=> disabled.value || !Boolean(content.value)) const enter = debounce(() => { isEnter.value && (visible.value = true); }, mouseEnterDelay.value); @@ -27,7 +28,7 @@ export function useTooltip(origin: Ref, props: TooltipProps): UseTooltipFn { }, mouseLeaveDelay.value); const onMouseenter = () => { - if (disabled.value) { + if (isDisabled.value) { return; } isEnter.value = true; diff --git a/packages/devui-vue/docs/components/tooltip/index.md b/packages/devui-vue/docs/components/tooltip/index.md index c44085d2c1..8387634b65 100644 --- a/packages/devui-vue/docs/components/tooltip/index.md +++ b/packages/devui-vue/docs/components/tooltip/index.md @@ -93,6 +93,8 @@ export default defineComponent({ | disabled | `boolean` | false | 可选,Tooltip 是否可用 | [基本用法](#基本用法) | | enterable | `boolean` | true | 可选,鼠标是否可以进入到 tooltip 中 | [基本用法](#基本用法) | | hide-after | `number` | 0 | 可选,tooltip 出现后自动隐藏延时,单位为 ms | [基本用法](#基本用法) | +| place-strategy | `'most-space' \| 'no-space'` |'most-space'| 可选,弹出层渲染策略,选用`no-space`策略,则会在放不下时,按照`position`参数配置的位置自动选择 | | +| scroll-element | `HTMLElement \| 'auto'` | auto | 可选,主动传入滚动元素,用于判断是否超出可视区,默认会自动向上查找样式设置了 overflow:auto \| overlay \| hidden 的元素,作为滚动元素 | | ### Tooltip 插槽