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:
parent
0abc7c124d
commit
7fe65bd3ed
2 changed files with 127 additions and 0 deletions
70
packages/library/src/lib/use-popover-position.ts
Normal file
70
packages/library/src/lib/use-popover-position.ts
Normal 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
|
||||||
|
}
|
||||||
57
packages/library/tests/use-popover-position.spec.ts
Normal file
57
packages/library/tests/use-popover-position.spec.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue