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