From 2e5a34178bfea1fa82cabecffe787574ee77ed8c Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Sat, 18 Apr 2026 22:52:38 +0200 Subject: [PATCH] feat(ux): collapse Albums sort buttons into a dropdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two sort buttons (A–Z Album / A–Z Artist) become one SortDropdown — same portal popover pattern as the year and genre filters. Button shows the current choice with an up/down arrow icon. Generic component, reusable for other pages. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/SortDropdown.tsx | 133 ++++++++++++++++++++++++++++++++ src/pages/Albums.tsx | 18 ++--- 2 files changed, 141 insertions(+), 10 deletions(-) create mode 100644 src/components/SortDropdown.tsx diff --git a/src/components/SortDropdown.tsx b/src/components/SortDropdown.tsx new file mode 100644 index 00000000..53f12708 --- /dev/null +++ b/src/components/SortDropdown.tsx @@ -0,0 +1,133 @@ +import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { ArrowDownUp, Check } from 'lucide-react'; + +export interface SortOption { + value: V; + label: string; +} + +interface Props { + value: V; + options: SortOption[]; + onChange: (value: V) => void; + ariaLabel?: string; +} + +export default function SortDropdown({ value, options, onChange, ariaLabel }: Props) { + const [open, setOpen] = useState(false); + const [popStyle, setPopStyle] = useState({}); + + const triggerRef = useRef(null); + const popRef = useRef(null); + + const current = options.find(o => o.value === value); + + const updatePopStyle = () => { + if (!triggerRef.current) return; + const rect = triggerRef.current.getBoundingClientRect(); + const MARGIN = 6; + const WIDTH = Math.max(rect.width, 220); + const MAX_H = 300; + const spaceBelow = window.innerHeight - rect.bottom - MARGIN; + const spaceAbove = rect.top - MARGIN; + const useAbove = spaceBelow < 160 && spaceAbove > spaceBelow; + const left = Math.min( + Math.max(rect.left, 8), + window.innerWidth - WIDTH - 8, + ); + setPopStyle({ + position: 'fixed', + left, + width: WIDTH, + ...(useAbove + ? { bottom: window.innerHeight - rect.top + MARGIN } + : { top: rect.bottom + MARGIN }), + maxHeight: Math.min(MAX_H, useAbove ? spaceAbove : spaceBelow), + zIndex: 99998, + }); + }; + + useLayoutEffect(() => { + if (!open) return; + updatePopStyle(); + }, [open]); + + useEffect(() => { + if (!open) return; + const onResize = () => updatePopStyle(); + window.addEventListener('resize', onResize); + window.addEventListener('scroll', onResize, true); + return () => { + window.removeEventListener('resize', onResize); + window.removeEventListener('scroll', onResize, true); + }; + }, [open]); + + useEffect(() => { + if (!open) return; + const onDown = (e: MouseEvent) => { + if ( + !triggerRef.current?.contains(e.target as Node) && + !popRef.current?.contains(e.target as Node) + ) setOpen(false); + }; + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); }; + document.addEventListener('mousedown', onDown); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('mousedown', onDown); + document.removeEventListener('keydown', onKey); + }; + }, [open]); + + return ( + <> + + + {open && createPortal( +
+
+ {options.map(opt => { + const isSel = opt.value === value; + return ( +
{ onChange(opt.value); setOpen(false); }} + role="option" + aria-selected={isSel} + > + + {isSel && } + + + {opt.label} + +
+ ); + })} +
+
, + document.body, + )} + + ); +} diff --git a/src/pages/Albums.tsx b/src/pages/Albums.tsx index 8495ed67..0b0684e2 100644 --- a/src/pages/Albums.tsx +++ b/src/pages/Albums.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react' import AlbumCard from '../components/AlbumCard'; import GenreFilterBar from '../components/GenreFilterBar'; import YearFilterButton from '../components/YearFilterButton'; +import SortDropdown from '../components/SortDropdown'; import { getAlbumList, getAlbumsByGenre, getAlbum, SubsonicAlbum, buildDownloadUrl } from '../api/subsonic'; import { useTranslation } from 'react-i18next'; import { useAuthStore } from '../store/authStore'; @@ -218,16 +219,13 @@ export default function Albums() { ) : ( <> - {!yearActive && sortOptions.map(o => ( - - ))} + {!yearActive && ( + + )}