Compare commits

...

1 Commits

Author SHA1 Message Date
Glary-Bot
b7a6141c50 fix: add vertical viewport collision detection to job details popover
The job details popover was clipped at the bottom of the viewport when
hovering over jobs near the bottom of the job list. The existing
getHoverPopoverPosition utility only handled horizontal positioning
and top-edge clamping, with no bottom-edge awareness.

Add viewportHeight parameter and return a maxHeight value that
constrains the popover to the available vertical space. Apply
overflow-y: auto on the popover wrapper so content scrolls when it
exceeds the constrained height.
2026-04-19 10:47:34 +00:00
4 changed files with 88 additions and 27 deletions

View File

@@ -93,10 +93,11 @@
<Teleport to="body">
<div
v-if="activeDetails && popoverPosition"
class="job-details-popover fixed z-50"
class="job-details-popover fixed z-50 overflow-y-auto"
:style="{
top: `${popoverPosition.top}px`,
left: `${popoverPosition.left}px`
left: `${popoverPosition.left}px`,
maxHeight: `${popoverPosition.maxHeight}px`
}"
@mouseenter="onPopoverEnter"
@mouseleave="onPopoverLeave"
@@ -150,7 +151,11 @@ const { t } = useI18n()
const scrollContainer = ref<HTMLElement | null>(null)
const hoveredJobId = ref<string | null>(null)
const activeRowElement = ref<HTMLElement | null>(null)
const popoverPosition = ref<{ top: number; left: number } | null>(null)
const popoverPosition = ref<{
top: number
left: number
maxHeight: number
} | null>(null)
const flatRows = computed(() => buildVirtualJobRows(displayedJobGroups))
const virtualizer = useVirtualizer({
get count(): number {
@@ -237,7 +242,11 @@ function updatePopoverPosition() {
if (!rowElement) return
const rect = rowElement.getBoundingClientRect()
popoverPosition.value = getHoverPopoverPosition(rect, window.innerWidth)
popoverPosition.value = getHoverPopoverPosition(
rect,
window.innerWidth,
window.innerHeight
)
}
function onJobLeave(jobId: string) {

View File

@@ -9,10 +9,11 @@
<Teleport to="body">
<div
v-if="!isPreviewVisible && showDetails && popoverPosition"
class="fixed z-50"
class="fixed z-50 overflow-y-auto"
:style="{
top: `${popoverPosition.top}px`,
left: `${popoverPosition.left}px`
left: `${popoverPosition.left}px`,
maxHeight: `${popoverPosition.maxHeight}px`
}"
@mouseenter="onPopoverEnter"
@mouseleave="onPopoverLeave"
@@ -299,13 +300,21 @@ const onIconLeave = () => scheduleHidePreview()
const onPreviewEnter = () => scheduleShowPreview()
const onPreviewLeave = () => scheduleHidePreview()
const popoverPosition = ref<{ top: number; left: number } | null>(null)
const popoverPosition = ref<{
top: number
left: number
maxHeight: number
} | null>(null)
const updatePopoverPosition = () => {
const el = rowRef.value
if (!el) return
const rect = el.getBoundingClientRect()
popoverPosition.value = getHoverPopoverPosition(rect, window.innerWidth)
popoverPosition.value = getHoverPopoverPosition(
rect,
window.innerWidth,
window.innerHeight
)
}
const isAnyPopoverVisible = computed(

View File

@@ -3,59 +3,95 @@ import { describe, expect, it } from 'vitest'
import { getHoverPopoverPosition } from './getHoverPopoverPosition'
describe('getHoverPopoverPosition', () => {
const viewportHeight = 800
it('places the popover to the right when space is available', () => {
const position = getHoverPopoverPosition(
{ top: 100, left: 40, right: 240 },
1280
1280,
viewportHeight
)
expect(position).toEqual({ top: 100, left: 248 })
expect(position).toEqual({ top: 100, left: 248, maxHeight: 692 })
})
it('places the popover to the left when right space is insufficient', () => {
const position = getHoverPopoverPosition(
{ top: 100, left: 980, right: 1180 },
1280
1280,
viewportHeight
)
expect(position).toEqual({ top: 100, left: 672 })
expect(position).toEqual({ top: 100, left: 672, maxHeight: 692 })
})
it('clamps the top to viewport padding when rect.top is near the top edge', () => {
const position = getHoverPopoverPosition(
{ top: 2, left: 40, right: 240 },
1280
1280,
viewportHeight
)
expect(position).toEqual({ top: 8, left: 248 })
expect(position).toEqual({ top: 8, left: 248, maxHeight: 784 })
})
it('clamps left to viewport padding when fallback would go off-screen', () => {
const position = getHoverPopoverPosition(
{ top: 100, left: 100, right: 300 },
320
320,
viewportHeight
)
expect(position).toEqual({ top: 100, left: 8 })
expect(position).toEqual({ top: 100, left: 8, maxHeight: 692 })
})
it('prefers right when both sides have equal space', () => {
const position = getHoverPopoverPosition(
{ top: 200, left: 340, right: 640 },
1280
1280,
viewportHeight
)
expect(position).toEqual({ top: 200, left: 648 })
expect(position).toEqual({ top: 200, left: 648, maxHeight: 592 })
})
it('falls back to left when right space is less than popover width', () => {
const position = getHoverPopoverPosition(
{ top: 100, left: 600, right: 1000 },
1280
1280,
viewportHeight
)
expect(position).toEqual({ top: 100, left: 292 })
expect(position).toEqual({ top: 100, left: 292, maxHeight: 692 })
})
it('handles narrow viewport where popover barely fits', () => {
const position = getHoverPopoverPosition(
{ top: 50, left: 8, right: 100 },
316
316,
viewportHeight
)
expect(position).toEqual({ top: 50, left: 8 })
expect(position).toEqual({ top: 50, left: 8, maxHeight: 742 })
})
it('constrains maxHeight when anchor is near the bottom of the viewport', () => {
const position = getHoverPopoverPosition(
{ top: 700, left: 40, right: 240 },
1280,
viewportHeight
)
expect(position).toEqual({ top: 700, left: 248, maxHeight: 92 })
})
it('provides minimal maxHeight when anchor is at the very bottom', () => {
const position = getHoverPopoverPosition(
{ top: 790, left: 40, right: 240 },
1280,
viewportHeight
)
expect(position).toEqual({ top: 790, left: 248, maxHeight: 2 })
})
it('provides full maxHeight when anchor is at the top of the viewport', () => {
const position = getHoverPopoverPosition(
{ top: 8, left: 40, right: 240 },
1280,
viewportHeight
)
expect(position).toEqual({ top: 8, left: 248, maxHeight: 784 })
})
})

View File

@@ -7,11 +7,13 @@ type AnchorRect = Pick<DOMRect, 'top' | 'left' | 'right'>
type HoverPopoverPosition = {
top: number
left: number
maxHeight: number
}
export function getHoverPopoverPosition(
rect: AnchorRect,
viewportWidth: number
viewportWidth: number,
viewportHeight: number
): HoverPopoverPosition {
const availableLeft = rect.left - POPOVER_GAP
const availableRight = viewportWidth - rect.right - POPOVER_GAP
@@ -22,18 +24,23 @@ export function getHoverPopoverPosition(
viewportWidth - POPOVER_WIDTH - VIEWPORT_PADDING
)
const top = Math.max(VIEWPORT_PADDING, rect.top)
const maxHeight = viewportHeight - top - VIEWPORT_PADDING
if (
availableRight >= POPOVER_WIDTH &&
(availableRight >= availableLeft || availableLeft < POPOVER_WIDTH)
) {
return {
top: Math.max(VIEWPORT_PADDING, rect.top),
left: Math.min(maxLeft, preferredLeft)
top,
left: Math.min(maxLeft, preferredLeft),
maxHeight
}
}
return {
top: Math.max(VIEWPORT_PADDING, rect.top),
left: Math.max(VIEWPORT_PADDING, Math.min(maxLeft, fallbackLeft))
top,
left: Math.max(VIEWPORT_PADDING, Math.min(maxLeft, fallbackLeft)),
maxHeight
}
}