Skip to content

Commit

Permalink
fix(HoverCard): missing gracearea like tooltip (unovue#777)
Browse files Browse the repository at this point in the history
* feat: useGraceArea composable

* chore: implement on both tooltip and hovercard
  • Loading branch information
zernonia authored Mar 20, 2024
1 parent f7d611c commit 1172a68
Show file tree
Hide file tree
Showing 8 changed files with 241 additions and 215 deletions.
8 changes: 7 additions & 1 deletion packages/radix-vue/src/HoverCard/HoverCardContentImpl.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script lang="ts">
import type { PopperContentProps } from '@/Popper'
import type { DismissableLayerEmits } from '@/DismissableLayer'
import { useForwardExpose } from '@/shared'
import { useForwardExpose, useGraceArea } from '@/shared'
export type HoverCardContentImplEmits = DismissableLayerEmits
export interface HoverCardContentImplProps extends PopperContentProps {}
Expand All @@ -21,6 +21,12 @@ const forwarded = useForwardProps(props)
const { forwardRef, currentElement: contentElement } = useForwardExpose()
const rootContext = injectHoverCardRootContext()
const { isPointerInTransit, onPointerExit } = useGraceArea(rootContext.triggerElement, contentElement)
rootContext.isPointerInTransit = isPointerInTransit
onPointerExit(() => {
rootContext.onClose()
})
const containSelection = ref(false)
let originalBodyUserSelect: string
Expand Down
6 changes: 6 additions & 0 deletions packages/radix-vue/src/HoverCard/HoverCardRoot.vue
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export interface HoverCardRootContext {
onDismiss(): void
hasSelectionRef: Ref<boolean>
isPointerDownOnContentRef: Ref<boolean>
isPointerInTransit: Ref<boolean>
triggerElement: Ref<HTMLElement | undefined>
}
export const [injectHoverCardRootContext, provideHoverCardRootContext]
Expand Down Expand Up @@ -56,6 +58,8 @@ const openTimerRef = ref(0)
const closeTimerRef = ref(0)
const hasSelectionRef = ref(false)
const isPointerDownOnContentRef = ref(false)
const isPointerInTransit = ref(false)
const triggerElement = ref<HTMLElement>()
function handleOpen() {
clearTimeout(closeTimerRef.value)
Expand All @@ -82,6 +86,8 @@ provideHoverCardRootContext({
onDismiss: handleDismiss,
hasSelectionRef,
isPointerDownOnContentRef,
isPointerInTransit,
triggerElement,
})
</script>

Expand Down
4 changes: 2 additions & 2 deletions packages/radix-vue/src/HoverCard/HoverCardTrigger.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ withDefaults(defineProps<HoverCardTriggerProps>(), {
as: 'a',
})
const { forwardRef } = useForwardExpose()
const { forwardRef, currentElement } = useForwardExpose()
const rootContext = injectHoverCardRootContext()
rootContext.triggerElement = currentElement
</script>

<template>
Expand All @@ -27,7 +28,6 @@ const rootContext = injectHoverCardRootContext()
:as="as"
:data-state="rootContext.open.value ? 'open' : 'closed'"
@pointerenter="excludeTouch(rootContext.onOpen)($event)"
@pointerleave="excludeTouch(rootContext.onClose)($event)"
@focus="rootContext.onOpen()"
@blur="rootContext.onClose"
@touchstart.prevent="() => {
Expand Down
62 changes: 5 additions & 57 deletions packages/radix-vue/src/Tooltip/TooltipContentHoverable.vue
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
<script setup lang="ts">
import { ref, watchEffect } from 'vue'
import TooltipContentImpl, { type TooltipContentImplProps } from './TooltipContentImpl.vue'
import { injectTooltipRootContext } from './TooltipRoot.vue'
import { injectTooltipProviderContext } from './TooltipProvider.vue'
import { type Polygon, getExitSideFromRect, getHull, getPaddedExitPoints, getPointsFromRect, isPointInPolygon } from './utils'
import { useForwardExpose, useForwardProps } from '@/shared'
import { useForwardExpose, useForwardProps, useGraceArea } from '@/shared'
const props = defineProps<TooltipContentImplProps>()
const forwardedProps = useForwardProps(props)
Expand All @@ -13,61 +11,11 @@ const { forwardRef, currentElement } = useForwardExpose()
const { trigger, onClose } = injectTooltipRootContext()
const providerContext = injectTooltipProviderContext()
const pointerGraceArea = ref<Polygon | null>(null)
const { isPointerInTransit, onPointerExit } = useGraceArea(trigger, currentElement)
function handleRemoveGraceArea() {
pointerGraceArea.value = null
providerContext.onPointerInTransitChange(false)
}
function handleCreateGraceArea(event: PointerEvent, hoverTarget: HTMLElement) {
const currentTarget = event.currentTarget as HTMLElement
const exitPoint = { x: event.clientX, y: event.clientY }
const exitSide = getExitSideFromRect(exitPoint, currentTarget.getBoundingClientRect())
const paddedExitPoints = getPaddedExitPoints(exitPoint, exitSide)
const hoverTargetPoints = getPointsFromRect(hoverTarget.getBoundingClientRect())
const graceArea = getHull([...paddedExitPoints, ...hoverTargetPoints])
pointerGraceArea.value = graceArea
providerContext.onPointerInTransitChange(true)
}
watchEffect((cleanupFn) => {
if (trigger.value && currentElement.value) {
const handleTriggerLeave = (event: PointerEvent) => handleCreateGraceArea(event, currentElement.value!)
const handleContentLeave = (event: PointerEvent) => handleCreateGraceArea(event, trigger.value!)
trigger.value.addEventListener('pointerleave', handleTriggerLeave)
currentElement.value.addEventListener('pointerleave', handleContentLeave)
cleanupFn(() => {
trigger.value?.removeEventListener('pointerleave', handleTriggerLeave)
currentElement.value?.removeEventListener('pointerleave', handleContentLeave)
})
}
})
watchEffect((cleanupFn) => {
if (pointerGraceArea.value) {
const handleTrackPointerGrace = (event: PointerEvent) => {
if (!pointerGraceArea.value)
return
const target = event.target as HTMLElement
const pointerPosition = { x: event.clientX, y: event.clientY }
const hasEnteredTarget = trigger.value?.contains(target) || currentElement.value?.contains(target)
const isPointerOutsideGraceArea = !isPointInPolygon(pointerPosition, pointerGraceArea.value)
if (hasEnteredTarget) {
handleRemoveGraceArea()
}
else if (isPointerOutsideGraceArea) {
handleRemoveGraceArea()
onClose()
}
}
document.addEventListener('pointermove', handleTrackPointerGrace)
cleanupFn(() => document.removeEventListener('pointermove', handleTrackPointerGrace))
}
providerContext.isPointerInTransitRef = isPointerInTransit
onPointerExit(() => {
onClose()
})
</script>

Expand Down
8 changes: 2 additions & 6 deletions packages/radix-vue/src/Tooltip/TooltipProvider.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ interface TooltipProviderContext {
delayDuration: Ref<number>
onOpen(): void
onClose(): void
onPointerInTransitChange(inTransit: boolean): void
isPointerInTransitRef: Ref<boolean>
disableHoverableContent: Ref<boolean>
disableClosingTrigger: Ref<boolean>
Expand Down Expand Up @@ -41,7 +40,7 @@ export interface TooltipProviderProps {
</script>

<script setup lang="ts">
import { refAutoReset, useTimeoutFn } from '@vueuse/shared'
import { useTimeoutFn } from '@vueuse/shared'
import { ref, toRefs } from 'vue'
const props = withDefaults(defineProps<TooltipProviderProps>(), {
Expand All @@ -54,7 +53,7 @@ useForwardExpose()
const isOpenDelayed = ref(true)
// Reset the inTransit state if idle/scrolled.
const isPointerInTransitRef = refAutoReset(false, 300)
const isPointerInTransitRef = ref(false)
const { start: startTimer, stop: clearTimer } = useTimeoutFn(() => {
isOpenDelayed.value = true
Expand All @@ -71,9 +70,6 @@ provideTooltipProviderContext({
startTimer()
},
isPointerInTransitRef,
onPointerInTransitChange(inTransit) {
isPointerInTransitRef.value = inTransit
},
disableHoverableContent,
disableClosingTrigger,
})
Expand Down
149 changes: 0 additions & 149 deletions packages/radix-vue/src/Tooltip/utils.ts
Original file line number Diff line number Diff line change
@@ -1,150 +1 @@
import type { Side } from '@/Popper/utils'

export const TOOLTIP_OPEN = 'tooltip.open'

export interface Point { x: number; y: number }
export type Polygon = Point[]

export function getExitSideFromRect(point: Point, rect: DOMRect): Side {
const top = Math.abs(rect.top - point.y)
const bottom = Math.abs(rect.bottom - point.y)
const right = Math.abs(rect.right - point.x)
const left = Math.abs(rect.left - point.x)

switch (Math.min(top, bottom, right, left)) {
case left:
return 'left'
case right:
return 'right'
case top:
return 'top'
case bottom:
return 'bottom'
default:
throw new Error('unreachable')
}
}

export function getPaddedExitPoints(exitPoint: Point, exitSide: Side, padding = 5) {
const paddedExitPoints: Point[] = []
switch (exitSide) {
case 'top':
paddedExitPoints.push(
{ x: exitPoint.x - padding, y: exitPoint.y + padding },
{ x: exitPoint.x + padding, y: exitPoint.y + padding },
)
break
case 'bottom':
paddedExitPoints.push(
{ x: exitPoint.x - padding, y: exitPoint.y - padding },
{ x: exitPoint.x + padding, y: exitPoint.y - padding },
)
break
case 'left':
paddedExitPoints.push(
{ x: exitPoint.x + padding, y: exitPoint.y - padding },
{ x: exitPoint.x + padding, y: exitPoint.y + padding },
)
break
case 'right':
paddedExitPoints.push(
{ x: exitPoint.x - padding, y: exitPoint.y - padding },
{ x: exitPoint.x - padding, y: exitPoint.y + padding },
)
break
}
return paddedExitPoints
}

export function getPointsFromRect(rect: DOMRect) {
const { top, right, bottom, left } = rect
return [
{ x: left, y: top },
{ x: right, y: top },
{ x: right, y: bottom },
{ x: left, y: bottom },
]
}

// Determine if a point is inside of a polygon.
// Based on https://github.com/substack/point-in-polygon
export function isPointInPolygon(point: Point, polygon: Polygon) {
const { x, y } = point
let inside = false
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const xi = polygon[i].x
const yi = polygon[i].y
const xj = polygon[j].x
const yj = polygon[j].y

// prettier-ignore
const intersect = ((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi)
if (intersect)
inside = !inside
}

return inside
}

// Returns a new array of points representing the convex hull of the given set of points.
// https://www.nayuki.io/page/convex-hull-algorithm
export function getHull<P extends Point>(points: Readonly<Array<P>>): Array<P> {
const newPoints: Array<P> = points.slice()
newPoints.sort((a: Point, b: Point) => {
if (a.x < b.x)
return -1
else if (a.x > b.x)
return +1
else if (a.y < b.y)
return -1
else if (a.y > b.y)
return +1
else return 0
})
return getHullPresorted(newPoints)
}

// Returns the convex hull, assuming that each points[i] <= points[i + 1]. Runs in O(n) time.
export function getHullPresorted<P extends Point>(points: Readonly<Array<P>>): Array<P> {
if (points.length <= 1)
return points.slice()

const upperHull: Array<P> = []
for (let i = 0; i < points.length; i++) {
const p = points[i]
while (upperHull.length >= 2) {
const q = upperHull[upperHull.length - 1]
const r = upperHull[upperHull.length - 2]
if ((q.x - r.x) * (p.y - r.y) >= (q.y - r.y) * (p.x - r.x))
upperHull.pop()
else break
}
upperHull.push(p)
}
upperHull.pop()

const lowerHull: Array<P> = []
for (let i = points.length - 1; i >= 0; i--) {
const p = points[i]
while (lowerHull.length >= 2) {
const q = lowerHull[lowerHull.length - 1]
const r = lowerHull[lowerHull.length - 2]
if ((q.x - r.x) * (p.y - r.y) >= (q.y - r.y) * (p.x - r.x))
lowerHull.pop()
else break
}
lowerHull.push(p)
}
lowerHull.pop()

if (
upperHull.length === 1
&& lowerHull.length === 1
&& upperHull[0].x === lowerHull[0].x
&& upperHull[0].y === lowerHull[0].y
)
return upperHull

else
return upperHull.concat(lowerHull)
}
1 change: 1 addition & 0 deletions packages/radix-vue/src/shared/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export { useForwardProps } from './useForwardProps'
export { useForwardPropsEmits } from './useForwardPropsEmits'
export { useForwardExpose } from './useForwardExpose'
export { useForwardRef } from './useForwardRef'
export { useGraceArea } from './useGraceArea'
export { useHideOthers } from './useHideOthers'
export { useId } from './useId'
export { useSize } from './useSize'
Expand Down
Loading

0 comments on commit 1172a68

Please sign in to comment.