mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 23:35:44 +00:00
fix(albums): keyboard year filter entry without per-keystroke clamp (#1244)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user