mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
c119a32277
* feat(tooltip): 2s open delay and shared tooltipAttrs helper Add a 2s hover open delay in TooltipPortal (single behaviour source) so tooltips no longer flash on quick pointer passes; hiding stays immediate. Add tooltipAttrs() to pair data-tooltip with a matching aria-label for buttons touched in the unification work. Covered by Vitest. * feat(tooltip): lower open delay to 1s 2s felt too long in testing; 1s gives the same anti-flash behaviour without making intentional hovers wait. * feat(tooltip): action tooltips on the artist overview Add tooltips describing the action to Last.fm, Wikipedia, Play All, Shuffle and Radio. Shuffle/Radio now show a tooltip on desktop too, not just mobile. Strings added to all 9 locales. * feat(tooltip): action tooltips on the album overview Add tooltips describing the action to the desktop Play, Artist Bio and Download (ZIP) buttons, matching the mobile layout. Strings added to all 9 locales. * feat(tooltip): action tooltips on the All Albums toolbar Add tooltips describing the action to the sort, year and genre filter buttons. SortDropdown gains an optional tooltip prop; the year and genre filter components carry their own, so the tooltips also appear on the other browse pages that reuse them. Strings added to all 9 locales. * feat(tooltip): action tooltips on song-list rows Add Play and Add-to-queue tooltips to the per-row icons in SongRow, used by the Tracks browse list, Search and Advanced Search. Also localizes the aria-labels, which were hardcoded English. New common.addToQueue in all 9 locales. * fix(tooltip): uniform tooltip placement on the Artists toolbar The favourite and multi-select buttons forced tooltips below while the view-mode buttons auto-flipped above, so the row looked inconsistent. Pin the view-mode buttons below too, matching the rest of the row and the Albums toolbar. * feat(tooltip): clarify and align the Advanced Search scope row Add a leading "Search in:" label and per-chip tooltips so the All/Artists/Albums/Songs row reads as a scope limiter. Drop the forced below-placement on the small star filter (used only here) so the favourites chip flips with the others instead of sitting alone below. Strings added to all 9 locales. * docs(changelog): tooltip unification (#972)
213 lines
6.9 KiB
TypeScript
213 lines
6.9 KiB
TypeScript
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||
import { createPortal } from 'react-dom';
|
||
import { CalendarRange, X } from 'lucide-react';
|
||
import { useTranslation } from 'react-i18next';
|
||
import FilterQuickClear from './FilterQuickClear';
|
||
import { tooltipAttrs } from './tooltipAttrs';
|
||
import {
|
||
ALBUM_YEAR_MAX,
|
||
ALBUM_YEAR_MIN,
|
||
clampAlbumYearFieldInput,
|
||
formatAlbumYearFilterLabel,
|
||
normalizeAlbumYearToFieldChange,
|
||
resolveAlbumYearBounds,
|
||
stepAlbumYearField,
|
||
} from '../utils/library/albumYearFilter';
|
||
|
||
interface Props {
|
||
from: string;
|
||
to: string;
|
||
onChange: (from: string, to: string) => void;
|
||
/** When set, spinners are limited to the indexed catalog (from `library_get_catalog_year_bounds`). */
|
||
catalogMinYear?: number;
|
||
catalogMaxYear?: number;
|
||
}
|
||
|
||
export default function YearFilterButton({
|
||
from,
|
||
to,
|
||
onChange,
|
||
catalogMinYear,
|
||
catalogMaxYear,
|
||
}: 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 yMin = catalogMinYear ?? ALBUM_YEAR_MIN;
|
||
const yMax = catalogMaxYear ?? ALBUM_YEAR_MAX;
|
||
|
||
const { active, bounds } = resolveAlbumYearBounds(from, to);
|
||
const activeLabel = formatAlbumYearFilterLabel(bounds, { min: yMin, max: yMax });
|
||
|
||
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('', '');
|
||
};
|
||
|
||
const handleFromChange = (raw: string) => {
|
||
onChange(clampAlbumYearFieldInput(raw, yMin, yMax), to);
|
||
};
|
||
|
||
const handleToChange = (raw: string) => {
|
||
onChange(from, normalizeAlbumYearToFieldChange(to, raw, yMin, yMax));
|
||
};
|
||
|
||
const onYearWheel = (
|
||
e: React.WheelEvent<HTMLInputElement>,
|
||
field: 'from' | 'to',
|
||
) => {
|
||
e.preventDefault();
|
||
const delta = e.deltaY < 0 ? 1 : -1;
|
||
if (field === 'from') {
|
||
onChange(stepAlbumYearField(from, delta, yMin, yMax, 'min'), to);
|
||
} else {
|
||
onChange(from, stepAlbumYearField(to, delta, yMin, yMax, 'max'));
|
||
}
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<button
|
||
ref={triggerRef}
|
||
type="button"
|
||
className={`btn btn-surface${active ? ' btn-sort-active' : ''}`}
|
||
onClick={() => setOpen(v => !v)}
|
||
aria-haspopup="dialog"
|
||
aria-expanded={open}
|
||
{...tooltipAttrs(t('albums.yearFilterTooltip'), { pos: 'bottom' })}
|
||
style={{
|
||
display: 'flex', alignItems: 'center', gap: '0.4rem',
|
||
...(active ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}),
|
||
}}
|
||
>
|
||
<CalendarRange size={14} />
|
||
{active && activeLabel ? activeLabel : t('albums.yearFilterLabel')}
|
||
{active && <FilterQuickClear onActiveChip onClear={clear} />}
|
||
</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={yMin}
|
||
max={yMax}
|
||
placeholder={String(yMin)}
|
||
value={from}
|
||
onChange={e => handleFromChange(e.target.value)}
|
||
onWheel={e => onYearWheel(e, 'from')}
|
||
/>
|
||
</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={yMin}
|
||
max={yMax}
|
||
placeholder={String(yMax)}
|
||
value={to}
|
||
onChange={e => handleToChange(e.target.value)}
|
||
onWheel={e => onYearWheel(e, 'to')}
|
||
/>
|
||
</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,
|
||
)}
|
||
</>
|
||
);
|
||
}
|