fix(albums): keyboard year filter entry without per-keystroke clamp (#1244)

This commit is contained in:
cucadmuh
2026-07-06 12:09:16 +03:00
committed by GitHub
parent cf982a6ac2
commit c8d2a55e86
4 changed files with 174 additions and 12 deletions
+6
View File
@@ -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
+27
View File
@@ -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('');
});
});
+20
View File
@@ -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;
+121 -12
View File
@@ -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<React.CSSProperties>({});
const [draftFrom, setDraftFrom] = useState(from);
const [draftTo, setDraftTo] = useState(to);
const triggerRef = useRef<HTMLButtonElement>(null);
const popRef = useRef<HTMLDivElement>(null);
const fromRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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')}
/>
</div>
@@ -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')}
/>
</div>