diff --git a/.gitignore b/.gitignore index 8372388c..9febc396 100644 --- a/.gitignore +++ b/.gitignore @@ -28,4 +28,5 @@ packages .wxt .output dev -local_output \ No newline at end of file +local_output +diff.txt \ No newline at end of file diff --git a/biome.json b/biome.json index 03d8ccdb..58956562 100644 --- a/biome.json +++ b/biome.json @@ -38,7 +38,8 @@ }, "suspicious": { "noExplicitAny": "off", - "noArrayIndexKey": "off" + "noArrayIndexKey": "off", + "useIterableCallbackReturn": "off" }, "correctness": { "useExhaustiveDependencies": "off", diff --git a/src/components/dropdown/dropdown.tsx b/src/components/dropdown/dropdown.tsx index b8426749..2ecd4717 100644 --- a/src/components/dropdown/dropdown.tsx +++ b/src/components/dropdown/dropdown.tsx @@ -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 @@ -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) }) @@ -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]) @@ -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} @@ -197,12 +212,13 @@ export function Dropdown({