feat(ux): redesign genre filter as portal popover

Trigger button with count badge, portal-rendered popover with search
input and full scrollable checkbox list (no 60-item cap). Selected
genres sort to the top. Replaces the inline tagbox that ate header
space and cut off the alphabetical list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-18 22:24:32 +02:00
parent 4f2c313bb7
commit da38b411b0
2 changed files with 242 additions and 172 deletions
+160 -103
View File
@@ -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<string[]>([]);
const [search, setSearch] = useState('');
const [dropdownOpen, setDropdownOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const [popStyle, setPopStyle] = useState<React.CSSProperties>({});
const triggerRef = useRef<HTMLButtonElement>(null);
const popRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(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 (
<button className="btn btn-surface" onClick={openFilter} style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
<Filter size={14} />
{t('common.filterGenre')}
</button>
);
}
const handleBlur = (e: React.FocusEvent<HTMLDivElement>) => {
// 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 (
<div ref={containerRef} onBlur={handleBlur} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
<Filter size={14} style={{ color: 'var(--accent)', flexShrink: 0 }} />
<>
<button
ref={triggerRef}
type="button"
className={`btn btn-surface${count > 0 ? ' btn-sort-active' : ''}`}
onClick={() => setOpen(v => !v)}
aria-haspopup="dialog"
aria-expanded={open}
style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}
>
<Filter size={14} />
{t('common.filterGenre')}
{count > 0 && <span className="genre-filter-count">{count}</span>}
</button>
<div className="genre-filter-tagbox">
{selected.map(g => (
<span key={g} className="genre-filter-chip">
{g}
<button onClick={() => remove(g)} aria-label={`Remove ${g}`}>
<X size={11} />
</button>
</span>
))}
{open && createPortal(
<div
ref={popRef}
className="genre-filter-popover"
style={popStyle}
role="dialog"
>
<div className="genre-filter-popover__search">
<input
ref={inputRef}
type="text"
placeholder={t('common.filterSearchGenres')}
value={search}
onChange={e => setSearch(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && filteredGenres.length > 0) {
toggle(filteredGenres[0]);
}
}}
/>
</div>
<input
ref={inputRef}
className="genre-filter-input"
placeholder={selected.length === 0 ? t('common.filterSearchGenres') : ''}
value={search}
onChange={e => { 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 && (
<div className="genre-filter-dropdown" onWheel={e => e.stopPropagation()}>
{filteredOptions.slice(0, 60).map(g => (
<div key={g} className="genre-filter-option" onMouseDown={() => add(g)}>
{g}
<div className="genre-filter-popover__list">
{filteredGenres.length === 0 ? (
<div className="genre-filter-popover__empty">
{t('common.filterNoGenres')}
</div>
))}
) : (
filteredGenres.map(g => {
const isSel = selectedSet.has(g);
return (
<div
key={g}
className={`genre-filter-popover__option${isSel ? ' genre-filter-popover__option--selected' : ''}`}
onClick={() => toggle(g)}
role="option"
aria-selected={isSel}
>
<span className="genre-filter-popover__check">
{isSel && <Check size={12} strokeWidth={3} />}
</span>
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{g}
</span>
</div>
);
})
)}
</div>
)}
{dropdownOpen && filteredOptions.length === 0 && search.length > 0 && (
<div className="genre-filter-dropdown" onWheel={e => e.stopPropagation()}>
<div className="genre-filter-empty">{t('common.filterNoGenres')}</div>
</div>
)}
</div>
{selected.length > 0 && (
<button className="btn btn-ghost" onClick={clear} style={{ padding: '0.35rem 0.6rem', display: 'flex', alignItems: 'center', gap: '0.3rem', fontSize: '0.8rem' }}>
<X size={13} />
{t('common.filterClear')}
</button>
{count > 0 && (
<div className="genre-filter-popover__footer">
<button
className="btn btn-ghost"
onClick={clear}
style={{ padding: '0.3rem 0.55rem', display: 'flex', alignItems: 'center', gap: '0.3rem', fontSize: '0.8rem' }}
>
<X size={13} />
{t('common.filterClear')}
</button>
</div>
)}
</div>,
document.body,
)}
</div>
</>
);
}
+82 -69
View File
@@ -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 ─ */