diff --git a/src/components/GenreFilterBar.tsx b/src/components/GenreFilterBar.tsx index ac92f178..2a942726 100644 --- a/src/components/GenreFilterBar.tsx +++ b/src/components/GenreFilterBar.tsx @@ -1,5 +1,6 @@ -import React, { useEffect, useRef, useState } from 'react'; -import { Filter, X } from 'lucide-react'; +import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { Check, Filter, X } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { getGenres } from '../api/subsonic'; @@ -13,8 +14,10 @@ export default function GenreFilterBar({ selected, onSelectionChange }: GenreFil const [open, setOpen] = useState(false); const [genres, setGenres] = useState([]); const [search, setSearch] = useState(''); - const [dropdownOpen, setDropdownOpen] = useState(false); - const containerRef = useRef(null); + const [popStyle, setPopStyle] = useState({}); + + const triggerRef = useRef(null); + const popRef = useRef(null); const inputRef = useRef(null); useEffect(() => { @@ -23,124 +26,178 @@ export default function GenreFilterBar({ selected, onSelectionChange }: GenreFil ); }, []); - // close dropdown on outside click - useEffect(() => { - const handler = (e: MouseEvent) => { - if (containerRef.current && !containerRef.current.contains(e.target as Node)) { - setDropdownOpen(false); - } - }; - document.addEventListener('mousedown', handler); - return () => document.removeEventListener('mousedown', handler); - }, []); + const selectedSet = useMemo(() => new Set(selected), [selected]); - // sync open state with selection - useEffect(() => { - if (selected.length > 0) setOpen(true); - }, [selected]); + // Selected on top, then alphabetical (stable for comfortable scanning). + const sortedGenres = useMemo(() => { + const arr = [...genres]; + arr.sort((a, b) => { + const sa = selectedSet.has(a) ? 0 : 1; + const sb = selectedSet.has(b) ? 0 : 1; + if (sa !== sb) return sa - sb; + return a.localeCompare(b); + }); + return arr; + }, [genres, selectedSet]); - const filteredOptions = genres.filter( - g => !selected.includes(g) && g.toLowerCase().includes(search.toLowerCase()) - ); + const filteredGenres = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return sortedGenres; + return sortedGenres.filter(g => g.toLowerCase().includes(q)); + }, [sortedGenres, search]); - const add = (genre: string) => { - onSelectionChange([...selected, genre]); - setSearch(''); - inputRef.current?.focus(); + const updatePopStyle = () => { + if (!triggerRef.current) return; + const rect = triggerRef.current.getBoundingClientRect(); + const MARGIN = 6; + const WIDTH = 280; + const MAX_H = 360; + 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, + }); }; - const remove = (genre: string) => { - onSelectionChange(selected.filter(s => s !== genre)); + useLayoutEffect(() => { + if (!open) return; + updatePopStyle(); + setTimeout(() => inputRef.current?.focus(), 0); + }, [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]); + + const toggle = (genre: string) => { + if (selectedSet.has(genre)) onSelectionChange(selected.filter(s => s !== genre)); + else onSelectionChange([...selected, genre]); }; const clear = () => { onSelectionChange([]); setSearch(''); - setOpen(false); - setDropdownOpen(false); }; - const openFilter = () => { - setOpen(true); - setTimeout(() => { inputRef.current?.focus(); setDropdownOpen(true); }, 30); - }; - - if (!open) { - return ( - - ); - } - - const handleBlur = (e: React.FocusEvent) => { - // relatedTarget is the next focused element; if it's outside our container, handle close - const next = e.relatedTarget as Node | null; - if (containerRef.current && next && containerRef.current.contains(next)) return; - setTimeout(() => { - if (selected.length === 0) { - setOpen(false); - setSearch(''); - setDropdownOpen(false); - } else { - setDropdownOpen(false); - } - }, 150); - }; + const count = selected.length; return ( -
- + <> + -
- {selected.map(g => ( - - {g} - - - ))} + {open && createPortal( +
+
+ setSearch(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter' && filteredGenres.length > 0) { + toggle(filteredGenres[0]); + } + }} + /> +
- { setSearch(e.target.value); setDropdownOpen(true); }} - onFocus={() => setDropdownOpen(true)} - onKeyDown={e => { - if (e.key === 'Escape') { setDropdownOpen(false); e.currentTarget.blur(); } - if (e.key === 'Backspace' && search === '' && selected.length > 0) { - remove(selected[selected.length - 1]); - } - }} - /> - - {dropdownOpen && filteredOptions.length > 0 && ( -
e.stopPropagation()}> - {filteredOptions.slice(0, 60).map(g => ( -
add(g)}> - {g} +
+ {filteredGenres.length === 0 ? ( +
+ {t('common.filterNoGenres')}
- ))} + ) : ( + filteredGenres.map(g => { + const isSel = selectedSet.has(g); + return ( +
toggle(g)} + role="option" + aria-selected={isSel} + > + + {isSel && } + + + {g} + +
+ ); + }) + )}
- )} - {dropdownOpen && filteredOptions.length === 0 && search.length > 0 && ( -
e.stopPropagation()}> -
{t('common.filterNoGenres')}
-
- )} -
- - {selected.length > 0 && ( - + {count > 0 && ( +
+ +
+ )} +
, + document.body, )} -
+ ); } diff --git a/src/styles/components.css b/src/styles/components.css index d49b8545..9fd6b4a1 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -6048,97 +6048,110 @@ html.no-compositing .fsr-lyric-line.fsrl-active .fsr-lyric-word.active { } /* ─ Genre Filter Bar ─ */ -.genre-filter-tagbox { - position: relative; - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.3rem; - padding: 0.3rem 0.6rem; - border: 1px solid var(--border-subtle); - border-radius: 8px; - background: var(--bg-card); - min-width: 220px; - cursor: text; - transition: border-color 0.15s; -} - -.genre-filter-tagbox:focus-within { - border-color: var(--accent); -} - -.genre-filter-chip { +.genre-filter-count { display: inline-flex; align-items: center; - gap: 0.2rem; - padding: 0.15rem 0.3rem 0.15rem 0.5rem; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 5px; + margin-left: 0.35rem; + border-radius: 9px; background: var(--accent); color: var(--ctp-crust); - border-radius: 4px; - font-size: 0.75rem; - font-weight: 600; - white-space: nowrap; -} - -.genre-filter-chip button { - background: none; - border: none; - color: inherit; - cursor: pointer; - padding: 0; - display: flex; - align-items: center; - opacity: 0.75; + font-size: 0.7rem; + font-weight: 700; line-height: 1; } -.genre-filter-chip button:hover { opacity: 1; } - -.genre-filter-input { - border: none; - background: none; - outline: none; - color: var(--text-primary); - font-size: 0.85rem; - min-width: 100px; - flex: 1; - padding: 0.1rem 0; -} - -.genre-filter-input::placeholder { color: var(--text-muted); } - -.genre-filter-dropdown { - position: absolute; - top: calc(100% + 4px); - left: 0; - right: 0; +.genre-filter-popover { background: var(--bg-card); border: 1px solid var(--border-subtle); - border-radius: 8px; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25); - max-height: 220px; - overflow-y: auto; + border-radius: 10px; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35); + display: flex; + flex-direction: column; + overflow: hidden; overscroll-behavior: contain; - z-index: 500; } -.genre-filter-option { - padding: 0.45rem 0.75rem; +.genre-filter-popover__search { + padding: 0.55rem 0.6rem 0.4rem; + border-bottom: 1px solid var(--border-subtle); +} + +.genre-filter-popover__search input { + width: 100%; + border: 1px solid var(--border-subtle); + background: var(--bg-app); + color: var(--text-primary); + border-radius: 6px; + padding: 0.35rem 0.55rem; + font-size: 0.85rem; + outline: none; +} + +.genre-filter-popover__search input:focus { + border-color: var(--accent); +} + +.genre-filter-popover__list { + flex: 1; + overflow-y: auto; + overscroll-behavior: contain; + padding: 0.25rem 0; +} + +.genre-filter-popover__option { + display: flex; + align-items: center; + gap: 0.55rem; + padding: 0.4rem 0.7rem; font-size: 0.85rem; color: var(--text-primary); cursor: pointer; - transition: background 0.1s, color 0.1s; + user-select: none; } -.genre-filter-option:hover { +.genre-filter-popover__option:hover { background: var(--bg-hover); - color: var(--accent); } -.genre-filter-empty { - padding: 0.6rem 0.75rem; +.genre-filter-popover__option--selected { + color: var(--accent); + font-weight: 600; +} + +.genre-filter-popover__check { + width: 16px; + height: 16px; + border: 1.5px solid var(--border-subtle); + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--ctp-crust); + background: transparent; +} + +.genre-filter-popover__option--selected .genre-filter-popover__check { + background: var(--accent); + border-color: var(--accent); +} + +.genre-filter-popover__empty { + padding: 0.75rem; font-size: 0.82rem; color: var(--text-muted); + text-align: center; +} + +.genre-filter-popover__footer { + padding: 0.4rem 0.5rem; + border-top: 1px solid var(--border-subtle); + display: flex; + justify-content: flex-end; } /* ─ Genre Cards ─ */