From c255c91a0bf48eecfe10e7e026d3c744588c3169 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Mon, 25 May 2026 11:32:27 +0200 Subject: [PATCH] fix: keep sort dropdown within viewport --- src/components/NoteList.sorting.test.tsx | 24 +++- src/components/SortDropdown.tsx | 155 +++++++++++++++++++---- 2 files changed, 154 insertions(+), 25 deletions(-) diff --git a/src/components/NoteList.sorting.test.tsx b/src/components/NoteList.sorting.test.tsx index 48686fab..e4d07698 100644 --- a/src/components/NoteList.sorting.test.tsx +++ b/src/components/NoteList.sorting.test.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { beforeEach, describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { fireEvent, render, screen } from '@testing-library/react' import { NoteList } from './NoteList' import { APP_STORAGE_KEYS, LEGACY_APP_STORAGE_KEYS } from '../constants/appStorage' @@ -194,6 +194,28 @@ describe('NoteList sort controls', () => { expect(screen.getByTestId('sort-option-status')).toBeInTheDocument() }) + it('keeps the sort menu in a viewport-clamped fixed layer', () => { + renderNoteList() + const trigger = screen.getByTestId('sort-button-__list__') + vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 20, + top: 20, + right: 44, + bottom: 40, + left: 0, + width: 44, + height: 20, + toJSON: () => ({}), + }) + + fireEvent.click(trigger) + + const menu = screen.getByTestId('sort-menu-__list__') + expect(menu).toHaveClass('fixed') + expect(menu).toHaveStyle({ left: '8px', top: '44px' }) + }) + it('changes list order when a different sort option is selected', () => { openListSortMenu(zamEntries) diff --git a/src/components/SortDropdown.tsx b/src/components/SortDropdown.tsx index fbf89ded..0dbe4a73 100644 --- a/src/components/SortDropdown.tsx +++ b/src/components/SortDropdown.tsx @@ -1,4 +1,5 @@ -import { useState, useEffect, useMemo, useRef, useCallback } from 'react' +import { useState, useEffect, useMemo, useRef, useCallback, useLayoutEffect } from 'react' +import { createPortal } from 'react-dom' import { cn } from '@/lib/utils' import { ArrowUp, ArrowDown } from '@phosphor-icons/react' import { translate, type AppLocale, type TranslationKey } from '../lib/i18n' @@ -16,11 +17,22 @@ const SORT_LABEL_KEYS = { status: 'noteList.sort.status', } satisfies Record const SORT_LABEL_KEYS_BY_OPTION = new Map(Object.entries(SORT_LABEL_KEYS)) +const SORT_MENU_WIDTH = 170 +const SORT_MENU_MAX_HEIGHT = 280 +const SORT_MENU_MIN_HEIGHT = 160 +const SORT_MENU_OFFSET = 4 +const SORT_MENU_VIEWPORT_PADDING = 8 type SortMenuAction = | { type: 'close' } | { type: 'focus'; index: number } +interface SortMenuPosition { + left: number + top: number + maxHeight: number +} + function getLocalizedSortOptionLabel(option: SortOption, locale: AppLocale): string { if (option.startsWith('property:')) return option.slice('property:'.length) return translate(locale, SORT_LABEL_KEYS_BY_OPTION.get(option) ?? 'noteList.sort.modified') @@ -51,6 +63,30 @@ function focusSortItem(sortButtonRefs: React.MutableRefObject availableBelow + const availableHeight = openAbove ? availableAbove : availableBelow + const viewportBoundedMinHeight = Math.min(SORT_MENU_MIN_HEIGHT, Math.max(0, viewportHeight - (SORT_MENU_VIEWPORT_PADDING * 2))) + const maxHeight = Math.max(viewportBoundedMinHeight, Math.min(SORT_MENU_MAX_HEIGHT, availableHeight)) + const top = openAbove + ? Math.max(SORT_MENU_VIEWPORT_PADDING, rect.top - SORT_MENU_OFFSET - maxHeight) + : Math.min(belowTop, viewportHeight - SORT_MENU_VIEWPORT_PADDING - maxHeight) + + return { left, top, maxHeight } +} + function resolveSortMenuAction(key: string, focusIndex: number, itemCount: number): SortMenuAction | null { const lastIndex = itemCount - 1 @@ -88,6 +124,58 @@ function getDirectionButtonClass(isActive: boolean, activeDirection: SortDirecti ) } +function useSortMenuDismissal({ + open, + containerRef, + menuRef, + onDismiss, +}: { + open: boolean + containerRef: React.RefObject + menuRef: React.RefObject + onDismiss: () => void +}) { + useEffect(() => { + if (!open) return + + function handlePointerDown(event: MouseEvent) { + if (containerRef.current?.contains(event.target as Node)) return + if (menuRef.current?.contains(event.target as Node)) return + onDismiss() + } + + document.addEventListener('mousedown', handlePointerDown) + return () => document.removeEventListener('mousedown', handlePointerDown) + }, [containerRef, menuRef, onDismiss, open]) +} + +function useSortMenuPosition({ + open, + triggerRef, +}: { + open: boolean + triggerRef: React.RefObject +}) { + const [menuPosition, setMenuPosition] = useState(null) + const updateMenuPosition = useCallback(() => { + if (!triggerRef.current) return + setMenuPosition(resolveSortMenuPosition(triggerRef.current)) + }, [triggerRef]) + + useLayoutEffect(() => { + if (!open) return + + window.addEventListener('resize', updateMenuPosition) + window.addEventListener('scroll', updateMenuPosition, true) + return () => { + window.removeEventListener('resize', updateMenuPosition) + window.removeEventListener('scroll', updateMenuPosition, true) + } + }, [open, updateMenuPosition]) + + return { menuPosition, updateMenuPosition } +} + function useSortDropdownState({ groupLabel, current, @@ -101,31 +189,23 @@ function useSortDropdownState({ }) { const [open, setOpen] = useState(false) const containerRef = useRef(null) + const menuRef = useRef(null) const triggerRef = useRef(null) const sortButtonRefs = useRef>([]) + const closeMenu = useCallback(() => { + setOpen(false) + triggerRef.current?.focus() + }, []) + const dismissMenu = useCallback(() => setOpen(false), []) + const { menuPosition, updateMenuPosition } = useSortMenuPosition({ open, triggerRef }) - useEffect(() => { - if (!open) return - - function handlePointerDown(event: MouseEvent) { - if (containerRef.current?.contains(event.target as Node)) return - setOpen(false) - } - - document.addEventListener('mousedown', handlePointerDown) - return () => document.removeEventListener('mousedown', handlePointerDown) - }, [open]) + useSortMenuDismissal({ open, containerRef, menuRef, onDismiss: dismissMenu }) useEffect(() => { if (!open) return focusSortItem(sortButtonRefs, resolveFocusedIndex(groupLabel, current, sortItems)) }, [current, groupLabel, open, sortItems]) - const closeMenu = useCallback(() => { - setOpen(false) - triggerRef.current?.focus() - }, []) - const handleSelect = useCallback((option: SortOption, nextDirection: SortDirection) => { onChange(groupLabel, option, nextDirection) closeMenu() @@ -147,13 +227,24 @@ function useSortDropdownState({ focusSortItem(sortButtonRefs, action.index) }, [closeMenu, current, groupLabel, sortItems]) + const toggleMenu = useCallback(() => { + if (open) { + setOpen(false) + return + } + + updateMenuPosition() + setOpen(true) + }, [open, updateMenuPosition]) return { open, - setOpen, + toggleMenu, containerRef, + menuRef, triggerRef, sortButtonRefs, + menuPosition, handleSelect, handleMenuKeyDown, } @@ -206,6 +297,8 @@ function SortDropdownMenu({ direction, sortItems, sortButtonRefs, + menuRef, + menuPosition, locale, onKeyDown, onSelect, @@ -216,6 +309,8 @@ function SortDropdownMenu({ direction: SortDirection sortItems: SortItem[] sortButtonRefs: React.MutableRefObject> + menuRef: React.RefObject + menuPosition: SortMenuPosition | null locale: AppLocale onKeyDown: (event: React.KeyboardEvent) => void onSelect: (option: SortOption, nextDirection: SortDirection) => void @@ -225,12 +320,20 @@ function SortDropdownMenu({ const hasCustom = sortItems.length > SORT_OPTIONS.length const builtInOptionCount = SORT_OPTIONS.length - return ( + return createPortal((
@@ -252,7 +355,7 @@ function SortDropdownMenu({ /> ))}
- ) + ), document.body) } export function SortDropdown({ groupLabel, current, direction, customProperties, locale = 'en', onChange }: { @@ -266,10 +369,12 @@ export function SortDropdown({ groupLabel, current, direction, customProperties, const sortItems = useMemo(() => buildSortItems(locale, customProperties), [customProperties, locale]) const { open, - setOpen, + toggleMenu, containerRef, + menuRef, triggerRef, sortButtonRefs, + menuPosition, handleSelect, handleMenuKeyDown, } = useSortDropdownState({ @@ -288,7 +393,7 @@ export function SortDropdown({ groupLabel, current, direction, customProperties, groupLabel={groupLabel} direction={direction} locale={locale} - onToggle={() => setOpen((value) => !value)} + onToggle={toggleMenu} />