feat: add usePopoverPosition composable

Replaces reka-ui's floating-position logic for the Select listbox with
a simple flip-above/below fixed-position calculation.
This commit is contained in:
Matthew McPeak 2026-07-13 18:11:28 -04:00
parent 0abc7c124d
commit 7fe65bd3ed
2 changed files with 127 additions and 0 deletions

View file

@ -0,0 +1,70 @@
import type { Ref } from 'vue'
import { nextTick, onUnmounted, reactive, watch } from 'vue'
export interface PopoverPosition {
top: number
left: number
minWidth: number
maxHeight: number
side: 'top' | 'bottom'
}
const GAP = 4
const VIEWPORT_MARGIN = 8
export function usePopoverPosition(
triggerRef: Ref<HTMLElement | null>,
contentRef: Ref<HTMLElement | null>,
open: Ref<boolean>,
) {
const position = reactive<PopoverPosition>({ top: 0, left: 0, minWidth: 0, maxHeight: 0, side: 'bottom' })
function update() {
const trigger = triggerRef.value
const content = contentRef.value
if (!trigger || !content) return
const triggerRect = trigger.getBoundingClientRect()
const contentHeight = content.offsetHeight
const spaceBelow = window.innerHeight - triggerRect.bottom - VIEWPORT_MARGIN
const spaceAbove = triggerRect.top - VIEWPORT_MARGIN
const placeAbove = spaceBelow < contentHeight && spaceAbove > spaceBelow
position.side = placeAbove ? 'top' : 'bottom'
position.left = Math.min(
Math.max(triggerRect.left, VIEWPORT_MARGIN),
window.innerWidth - triggerRect.width - VIEWPORT_MARGIN,
)
position.minWidth = triggerRect.width
position.maxHeight = Math.max(placeAbove ? spaceAbove : spaceBelow, 0)
position.top = placeAbove
? triggerRect.top - GAP - Math.min(contentHeight, position.maxHeight)
: triggerRect.bottom + GAP
}
function handleReposition() {
if (open.value) update()
}
watch(
open,
(isOpen) => {
if (isOpen) {
nextTick(update)
window.addEventListener('scroll', handleReposition, true)
window.addEventListener('resize', handleReposition)
} else {
window.removeEventListener('scroll', handleReposition, true)
window.removeEventListener('resize', handleReposition)
}
},
{ immediate: true },
)
onUnmounted(() => {
window.removeEventListener('scroll', handleReposition, true)
window.removeEventListener('resize', handleReposition)
})
return position
}

View file

@ -0,0 +1,57 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { nextTick, ref } from 'vue'
import { usePopoverPosition } from '../src/lib/use-popover-position'
function mockRect(el: HTMLElement, rect: Partial<DOMRect>) {
vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({
top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0, x: 0, y: 0,
toJSON: () => {},
...rect,
} as DOMRect)
}
describe('usePopoverPosition', () => {
afterEach(() => {
document.body.replaceChildren()
vi.restoreAllMocks()
})
it('places the content below the trigger when there is enough room', async () => {
const trigger = document.createElement('button')
const content = document.createElement('div')
document.body.append(trigger, content)
mockRect(trigger, { top: 100, bottom: 130, left: 20, width: 80 })
Object.defineProperty(content, 'offsetHeight', { value: 40, configurable: true })
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800)
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1000)
const open = ref(false)
const position = usePopoverPosition(ref(trigger), ref(content), open)
open.value = true
await nextTick()
await nextTick()
expect(position.side).toBe('bottom')
expect(position.top).toBe(134)
expect(position.minWidth).toBe(80)
})
it('flips above the trigger when there is not enough room below', async () => {
const trigger = document.createElement('button')
const content = document.createElement('div')
document.body.append(trigger, content)
mockRect(trigger, { top: 700, bottom: 730, left: 20, width: 80 })
Object.defineProperty(content, 'offsetHeight', { value: 200, configurable: true })
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800)
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1000)
const open = ref(false)
const position = usePopoverPosition(ref(trigger), ref(content), open)
open.value = true
await nextTick()
await nextTick()
expect(position.side).toBe('top')
expect(position.top).toBeLessThan(700)
})
})