From 7fe65bd3ed1934d73648c3542a5ffe63a2eacf36 Mon Sep 17 00:00:00 2001 From: Matthew L McPeak Date: Mon, 13 Jul 2026 18:11:28 -0400 Subject: [PATCH] feat: add usePopoverPosition composable Replaces reka-ui's floating-position logic for the Select listbox with a simple flip-above/below fixed-position calculation. --- .../library/src/lib/use-popover-position.ts | 70 +++++++++++++++++++ .../tests/use-popover-position.spec.ts | 57 +++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 packages/library/src/lib/use-popover-position.ts create mode 100644 packages/library/tests/use-popover-position.spec.ts diff --git a/packages/library/src/lib/use-popover-position.ts b/packages/library/src/lib/use-popover-position.ts new file mode 100644 index 0000000..fa7fbdb --- /dev/null +++ b/packages/library/src/lib/use-popover-position.ts @@ -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, + contentRef: Ref, + open: Ref, +) { + const position = reactive({ 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 +} diff --git a/packages/library/tests/use-popover-position.spec.ts b/packages/library/tests/use-popover-position.spec.ts new file mode 100644 index 0000000..558a826 --- /dev/null +++ b/packages/library/tests/use-popover-position.spec.ts @@ -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) { + 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) + }) +})