diff --git a/CHANGELOG.md b/CHANGELOG.md index b29b8238..592407db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -132,6 +132,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Sidebar and shell gates react when hot-cache rows appear; browse pages reload after hot-cache growth and library sync without leaving the page. * Album vs track artist credit mode, starred artists, genre filters, and Tracks discovery rails respect the on-disk scope; album artist grouping follows indexed `album_artist` parity. +### All Albums — year filter keyboard entry + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#1244](https://github.com/Psychotoxical/psysonic/pull/1244)** + +* The year filter on All Albums no longer clamps on every keystroke while typing a four-digit year — drafts commit on blur, Enter, or outside click; incomplete input reverts to the last applied value. Wheel and spinner controls are unchanged. + ## [1.49.0] - 2026-06-29 diff --git a/src/lib/library/albumYearFilter.test.ts b/src/lib/library/albumYearFilter.test.ts index 80031dd5..9e6042ca 100644 --- a/src/lib/library/albumYearFilter.test.ts +++ b/src/lib/library/albumYearFilter.test.ts @@ -2,10 +2,12 @@ import { describe, expect, it } from 'vitest'; import { albumYearFilterClauses, albumYearSubsonicParams, + commitAlbumYearDraftField, clampAlbumYearFieldInput, formatAlbumYearFilterLabel, normalizeAlbumYearToFieldChange, resolveAlbumYearBounds, + sanitizeAlbumYearTypingInput, stepAlbumYearField, } from './albumYearFilter'; @@ -96,3 +98,28 @@ describe('album year spinner helpers', () => { expect(normalizeAlbumYearToFieldChange('2010', '1975', min, max)).toBe('1975'); }); }); + +describe('album year typing helpers', () => { + const min = 1975; + const max = 2020; + + it('sanitizeAlbumYearTypingInput keeps digits only', () => { + expect(sanitizeAlbumYearTypingInput('19a9b0')).toBe('1990'); + expect(sanitizeAlbumYearTypingInput('19999')).toBe('1999'); + }); + + it('commitAlbumYearDraftField keeps partial drafts on blur', () => { + expect(commitAlbumYearDraftField('199', '1980', min, max)).toBe('1980'); + expect(commitAlbumYearDraftField('1', '', min, max)).toBe(''); + }); + + it('commitAlbumYearDraftField clamps complete years', () => { + expect(commitAlbumYearDraftField('1990', '', min, max)).toBe('1990'); + expect(commitAlbumYearDraftField('1960', '', min, max)).toBe('1975'); + expect(commitAlbumYearDraftField('2030', '2010', min, max)).toBe('2020'); + }); + + it('commitAlbumYearDraftField clears empty draft', () => { + expect(commitAlbumYearDraftField('', '1990', min, max)).toBe(''); + }); +}); diff --git a/src/lib/library/albumYearFilter.ts b/src/lib/library/albumYearFilter.ts index e8a8c9e3..2347777f 100644 --- a/src/lib/library/albumYearFilter.ts +++ b/src/lib/library/albumYearFilter.ts @@ -56,6 +56,26 @@ export function parseAlbumYearField(raw: string): number | null { return n; } +/** Digits-only year typing buffer (max four digits). */ +export function sanitizeAlbumYearTypingInput(raw: string): string { + return raw.replace(/\D/g, '').slice(0, 4); +} + +/** + * Commit a typed year field on blur — incomplete drafts revert to the last applied value. + */ +export function commitAlbumYearDraftField( + draft: string, + previousCommitted: string, + min: number, + max: number, +): string { + const trimmed = sanitizeAlbumYearTypingInput(draft); + if (!trimmed) return ''; + if (trimmed.length < 4) return previousCommitted; + return clampAlbumYearFieldInput(trimmed, min, max); +} + export function resolveAlbumYearBounds(from: string, to: string): { active: boolean; bounds: AlbumYearBounds; diff --git a/src/ui/YearFilterButton.tsx b/src/ui/YearFilterButton.tsx index 6f2c13a2..a5dfc562 100644 --- a/src/ui/YearFilterButton.tsx +++ b/src/ui/YearFilterButton.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { CalendarRange, X } from 'lucide-react'; import { useTranslation } from 'react-i18next'; @@ -7,10 +7,11 @@ import { tooltipAttrs } from '@/ui/tooltipAttrs'; import { ALBUM_YEAR_MAX, ALBUM_YEAR_MIN, - clampAlbumYearFieldInput, + commitAlbumYearDraftField, formatAlbumYearFilterLabel, normalizeAlbumYearToFieldChange, resolveAlbumYearBounds, + sanitizeAlbumYearTypingInput, stepAlbumYearField, } from '@/lib/library/albumYearFilter'; @@ -33,10 +34,14 @@ export default function YearFilterButton({ const { t } = useTranslation(); const [open, setOpen] = useState(false); const [popStyle, setPopStyle] = useState({}); + const [draftFrom, setDraftFrom] = useState(from); + const [draftTo, setDraftTo] = useState(to); const triggerRef = useRef(null); const popRef = useRef(null); const fromRef = useRef(null); + const fromFocusedRef = useRef(false); + const toFocusedRef = useRef(false); const yMin = catalogMinYear ?? ALBUM_YEAR_MIN; const yMax = catalogMaxYear ?? ALBUM_YEAR_MAX; @@ -75,6 +80,18 @@ export default function YearFilterButton({ setTimeout(() => fromRef.current?.focus(), 0); }, [open]); + useEffect(() => { + if (!fromFocusedRef.current) { + setDraftFrom(from); + } + }, [from]); + + useEffect(() => { + if (!toFocusedRef.current) { + setDraftTo(to); + } + }, [to]); + useEffect(() => { if (!open) return; const onResize = () => updatePopStyle(); @@ -86,33 +103,104 @@ export default function YearFilterButton({ }; }, [open]); + const resolveDraftTo = useCallback((): string => { + if (!draftTo.trim()) return ''; + if (sanitizeAlbumYearTypingInput(draftTo).length < 4) return to; + return normalizeAlbumYearToFieldChange(to, draftTo, yMin, yMax); + }, [draftTo, to, yMin, yMax]); + + const applyDraftsAndClose = useCallback(() => { + fromFocusedRef.current = false; + toFocusedRef.current = false; + const nextFrom = commitAlbumYearDraftField(draftFrom, from, yMin, yMax); + const nextTo = resolveDraftTo(); + setDraftFrom(nextFrom); + setDraftTo(nextTo); + onChange(nextFrom, nextTo); + setOpen(false); + }, [draftFrom, from, onChange, resolveDraftTo, yMin, yMax]); + + const commitFromField = useCallback(() => { + if (!fromFocusedRef.current) return; + fromFocusedRef.current = false; + const next = commitAlbumYearDraftField(draftFrom, from, yMin, yMax); + setDraftFrom(next); + onChange(next, to); + }, [draftFrom, from, onChange, to, yMin, yMax]); + + const commitToField = useCallback(() => { + if (!toFocusedRef.current) return; + toFocusedRef.current = false; + if (!draftTo.trim()) { + setDraftTo(''); + onChange(from, ''); + return; + } + if (sanitizeAlbumYearTypingInput(draftTo).length < 4) { + setDraftTo(to); + return; + } + const next = normalizeAlbumYearToFieldChange(to, draftTo, yMin, yMax); + setDraftTo(next); + onChange(from, next); + }, [draftTo, from, onChange, to, yMin, yMax]); + 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); + ) { + applyDraftsAndClose(); + } + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + applyDraftsAndClose(); + } }; - 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]); + }, [open, applyDraftsAndClose]); const clear = () => { + fromFocusedRef.current = false; + toFocusedRef.current = false; + setDraftFrom(''); + setDraftTo(''); onChange('', ''); }; const handleFromChange = (raw: string) => { - onChange(clampAlbumYearFieldInput(raw, yMin, yMax), to); + setDraftFrom(sanitizeAlbumYearTypingInput(raw)); }; const handleToChange = (raw: string) => { - onChange(from, normalizeAlbumYearToFieldChange(to, raw, yMin, yMax)); + const sanitized = sanitizeAlbumYearTypingInput(raw); + const spinnerTick = !draftTo.trim() + && sanitized === String(yMin) + && yMin !== yMax + && sanitized.length === 4; + if (spinnerTick) { + const next = normalizeAlbumYearToFieldChange(to, sanitized, yMin, yMax); + toFocusedRef.current = false; + setDraftTo(next); + onChange(from, next); + return; + } + setDraftTo(sanitized); + }; + + const onYearFieldKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + applyDraftsAndClose(); + } }; const onYearWheel = ( @@ -122,9 +210,15 @@ export default function YearFilterButton({ e.preventDefault(); const delta = e.deltaY < 0 ? 1 : -1; if (field === 'from') { - onChange(stepAlbumYearField(from, delta, yMin, yMax, 'min'), to); + fromFocusedRef.current = false; + const nextFrom = stepAlbumYearField(from, delta, yMin, yMax, 'min'); + setDraftFrom(nextFrom); + onChange(nextFrom, to); } else { - onChange(from, stepAlbumYearField(to, delta, yMin, yMax, 'max')); + toFocusedRef.current = false; + const nextTo = stepAlbumYearField(to, delta, yMin, yMax, 'max'); + setDraftTo(nextTo); + onChange(from, nextTo); } }; @@ -134,7 +228,16 @@ export default function YearFilterButton({ ref={triggerRef} type="button" className={`btn btn-surface${active ? ' btn-sort-active' : ''}`} - onClick={() => setOpen(v => !v)} + onClick={() => { + setOpen(prev => { + const next = !prev; + if (next) { + setDraftFrom(from); + setDraftTo(to); + } + return next; + }); + }} aria-haspopup="dialog" aria-expanded={open} {...tooltipAttrs(t('albums.yearFilterTooltip'), { pos: 'bottom' })} @@ -168,8 +271,11 @@ export default function YearFilterButton({ min={yMin} max={yMax} placeholder={String(yMin)} - value={from} + value={draftFrom} + onFocus={() => { fromFocusedRef.current = true; }} onChange={e => handleFromChange(e.target.value)} + onBlur={commitFromField} + onKeyDown={onYearFieldKeyDown} onWheel={e => onYearWheel(e, 'from')} /> @@ -184,8 +290,11 @@ export default function YearFilterButton({ min={yMin} max={yMax} placeholder={String(yMax)} - value={to} + value={draftTo} + onFocus={() => { toFocusedRef.current = true; }} onChange={e => handleToChange(e.target.value)} + onBlur={commitToField} + onKeyDown={onYearFieldKeyDown} onWheel={e => onYearWheel(e, 'to')} />