feat(ux): year filter as portal popover

Replaces the inline From/To number inputs in the Albums header with a
single button that opens a popover — same pattern as the genre filter.
Button shows the active range (e.g. 2020–2024) with accent styling.
Header is now a homogeneous row of buttons.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-18 22:43:56 +02:00
parent 89e8f43add
commit 8b7bce5b85
2 changed files with 174 additions and 39 deletions
+167
View File
@@ -0,0 +1,167 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { CalendarRange, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface Props {
from: string;
to: string;
onChange: (from: string, to: string) => void;
}
const CURRENT_YEAR = new Date().getFullYear();
export default function YearFilterButton({ from, to, onChange }: Props) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [popStyle, setPopStyle] = useState<React.CSSProperties>({});
const triggerRef = useRef<HTMLButtonElement>(null);
const popRef = useRef<HTMLDivElement>(null);
const fromRef = useRef<HTMLInputElement>(null);
const fromNum = parseInt(from, 10);
const toNum = parseInt(to, 10);
const active = !isNaN(fromNum) && !isNaN(toNum) && fromNum >= 1 && toNum >= 1;
const updatePopStyle = () => {
if (!triggerRef.current) return;
const rect = triggerRef.current.getBoundingClientRect();
const MARGIN = 6;
const WIDTH = 260;
const MAX_H = 200;
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();
setTimeout(() => fromRef.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 clear = () => {
onChange('', '');
};
return (
<>
<button
ref={triggerRef}
type="button"
className={`btn btn-surface${active ? ' btn-sort-active' : ''}`}
onClick={() => setOpen(v => !v)}
aria-haspopup="dialog"
aria-expanded={open}
style={{
display: 'flex', alignItems: 'center', gap: '0.4rem',
...(active ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}),
}}
>
<CalendarRange size={14} />
{active ? `${fromNum}${toNum}` : t('albums.yearFilterLabel')}
</button>
{open && createPortal(
<div
ref={popRef}
className="genre-filter-popover"
style={popStyle}
role="dialog"
>
<div style={{ padding: '0.75rem 0.75rem 0.5rem', display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, gap: '0.2rem' }}>
<label style={{ fontSize: '0.72rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
{t('albums.yearFrom')}
</label>
<input
ref={fromRef}
className="input"
type="number"
min={1900}
max={CURRENT_YEAR}
placeholder="1970"
value={from}
onChange={e => onChange(e.target.value, to)}
/>
</div>
<span style={{ alignSelf: 'flex-end', paddingBottom: '0.4rem', color: 'var(--text-muted)' }}></span>
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, gap: '0.2rem' }}>
<label style={{ fontSize: '0.72rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
{t('albums.yearTo')}
</label>
<input
className="input"
type="number"
min={1900}
max={CURRENT_YEAR}
placeholder={String(CURRENT_YEAR)}
value={to}
onChange={e => onChange(from, e.target.value)}
/>
</div>
</div>
</div>
{active && (
<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('albums.yearFilterClear')}
</button>
</div>
)}
</div>,
document.body,
)}
</>
);
}