Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,5 @@ packages
.wxt
.output
dev
local_output
local_output
diff.txt
3 changes: 2 additions & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@
},
"suspicious": {
"noExplicitAny": "off",
"noArrayIndexKey": "off"
"noArrayIndexKey": "off",
"useIterableCallbackReturn": "off"
},
"correctness": {
"useExhaustiveDependencies": "off",
Expand Down
126 changes: 72 additions & 54 deletions src/components/dropdown/dropdown.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { ReactNode } from 'react'
import { Portal } from '../portal/Portal'
import { useDropdown } from './useDropdown'
import { useEffect, useState } from 'react'
import { useState, useLayoutEffect, useRef } from 'react'

export interface DropdownOption {
id: string
Expand Down Expand Up @@ -43,100 +43,116 @@ export function Dropdown({
onClose,
}: DropdownProps) {
const { isOpen, toggle, close, dropdownRef, dropdownContentRef } = useDropdown()
const [isReady, setIsReady] = useState(false)
const [dropdownPosition, setDropdownPosition] = useState({ top: '0px', left: '0px' })
const positionCalculatedRef = useRef(false)

const handleOptionClick = (option: DropdownOption) => {
if (option.disabled) return

option.onClick?.()
onOptionSelect?.(option)

if (!option.value || option.value !== 'keep-open') {
close()
}
}

useEffect(() => {
useLayoutEffect(() => {
if (!isOpen) {
setIsReady(false)
positionCalculatedRef.current = false
onClose?.()
}
}, [isOpen])
}, [isOpen, onClose])

useLayoutEffect(() => {
if (!isOpen || !dropdownRef.current || !dropdownContentRef.current) {
return
}

useEffect(() => {
if (!isOpen || !dropdownRef.current) return
if (positionCalculatedRef.current) return

const calculatePosition = () => {
if (!dropdownRef.current) return
const triggerEl = dropdownRef.current
const contentEl = dropdownContentRef.current
if (!triggerEl || !contentEl) return

const rect = dropdownRef.current.getBoundingClientRect()
const viewportHeight = window.innerHeight
const triggerRect = triggerEl.getBoundingClientRect()
const contentRect = contentEl.getBoundingClientRect()
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight

const dropdownWidth =
let dropdownWidth =
width === 'auto'
? 250
? Math.min(contentRect.width || 250, viewportWidth - 32)
: width === 'full'
? Math.min(400, viewportWidth - 32)
: parseInt(width as string, 10) || 250

const dropdownHeight = dropdownContentRef.current
? dropdownContentRef.current.offsetHeight
: parseInt(maxHeight, 10) || 300
const dropdownHeight = contentRect.height || parseInt(maxHeight, 10) || 300

let top: number
let left: number
let top: number, left: number

switch (position) {
case 'bottom-left':
case 'top-left':
left = rect.left
left = triggerRect.left
break
case 'bottom-right':
case 'top-right':
left = rect.right - dropdownWidth
left = triggerRect.right - dropdownWidth
break
case 'bottom-center':
left = rect.left + rect.width / 2 - dropdownWidth / 2
left = triggerRect.left + triggerRect.width / 2 - dropdownWidth / 2
break
default:
left = rect.left
left = triggerRect.left
}

if (position.startsWith('bottom')) {
top = rect.bottom + 4
top = triggerRect.bottom + 4
if (top + dropdownHeight > viewportHeight - 16) {
top = rect.top - dropdownHeight - 4
top = triggerRect.top - dropdownHeight - 4
}
} else {
top = rect.top - dropdownHeight - 4
top = triggerRect.top - dropdownHeight - 4
if (top < 16) {
top = rect.bottom + 4
top = triggerRect.bottom + 4
}
}

const padding = 16
if (left < padding) left = padding
if (left + dropdownWidth > viewportWidth - padding) {
left = viewportWidth - dropdownWidth - padding
}

if (top < padding) top = padding
if (top + dropdownHeight > viewportHeight - padding) {
top = viewportHeight - dropdownHeight - padding
}
left = Math.max(
padding,
Math.min(left, viewportWidth - dropdownWidth - padding)
)
top = Math.max(
padding,
Math.min(top, viewportHeight - dropdownHeight - padding)
)

setDropdownPosition({
top: `${Math.max(0, top)}px`,
left: `${Math.max(0, left)}px`,
})

positionCalculatedRef.current = true
setIsReady(true)
}

const initialTimeout = setTimeout(calculatePosition, 10)
const rafId = requestAnimationFrame(() => {
calculatePosition()
})

window.addEventListener('resize', calculatePosition)
window.addEventListener('scroll', calculatePosition, true)
const handleUpdate = () => {
positionCalculatedRef.current = false
requestAnimationFrame(calculatePosition)
}

window.addEventListener('resize', handleUpdate)
window.addEventListener('scroll', handleUpdate, true)

const resizeObserver = new ResizeObserver(() => {
positionCalculatedRef.current = false
requestAnimationFrame(calculatePosition)
})

Expand All @@ -145,15 +161,14 @@ export function Dropdown({
resizeObserver.observe(element)
element = element.parentElement
}

if (dropdownContentRef.current) {
resizeObserver.observe(dropdownContentRef.current)
}

return () => {
clearTimeout(initialTimeout)
window.removeEventListener('resize', calculatePosition)
window.removeEventListener('scroll', calculatePosition, true)
cancelAnimationFrame(rafId)
window.removeEventListener('resize', handleUpdate)
window.removeEventListener('scroll', handleUpdate, true)
resizeObserver.disconnect()
}
}, [isOpen, position, width, maxHeight])
Expand All @@ -169,12 +184,12 @@ export function Dropdown({
onClick={() => handleOptionClick(option)}
disabled={option.disabled}
className={`
w-full text-left px-3 py-2 text-sm transition-colors
hover:bg-primary/10 hover:text-primary
disabled:opacity-50 disabled:cursor-not-allowed
disabled:hover:bg-transparent disabled:hover:text-muted
focus:outline-none focus:bg-primary/10 focus:text-primary
`}
w-full text-left px-3 py-2 text-sm transition-colors
hover:bg-primary/10 hover:text-primary
disabled:opacity-50 disabled:cursor-not-allowed
disabled:hover:bg-transparent disabled:hover:text-muted
focus:outline-none focus:bg-primary/10 focus:text-primary
`}
>
{option.label}
</button>
Expand All @@ -197,12 +212,13 @@ export function Dropdown({
<div
ref={dropdownContentRef}
className={`
fixed z-[9999] border border-content rounded-xl
bg-content shadow-xl
overflow-hidden pointer-events-auto
animate-in fade-in-0 zoom-in-95 duration-100 bg-glass
${dropdownClassName}
`}
fixed z-[9999] border border-content rounded-xl
bg-content shadow-xl overflow-hidden
transition-opacity duration-100
${isReady ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'}
bg-glass
${dropdownClassName}
`}
style={{
maxHeight,
width:
Expand All @@ -212,7 +228,9 @@ export function Dropdown({
? '100%'
: width,
minWidth: width === 'auto' ? '150px' : undefined,
...dropdownPosition,
top: dropdownPosition.top,
left: dropdownPosition.left,
visibility: isReady ? 'visible' : 'hidden',
}}
>
<div className="max-h-full overflow-y-auto">
Expand Down
Loading