mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 11:19:24 +00:00
feat(v3 library): A–Z fast-scroll jump rail on the Songs grid (#634)
* feat(v3 library): A–Z fast-scroll jump rail on the Songs grid Adds a vertical letter rail (Plex/Radarr/iOS-contacts pattern) pinned to the right edge next to the scrollbar so you can jump the library to a starting letter — tap, drag-to-scrub with a live letter bubble, or arrow-key between letters. The classic (v2) tree already had letter selection; this brings the new v3 grid to parity (it was the gap behind the "alphabetical scroll selection next to the scrollbar" idea). It shows ONLY for the grid view + alphabetical (artist/title) sorts, and only offers letters present in the current sort AND filter set, so a tap always lands on a real card (absent letters are dimmed + non-interactive). The grid is forward-only, server-paged infinite scroll with no virtualization, so a jump pages through to the target card then scrolls to it; a token guards overlapping jumps (drag) so the newest wins. A keyset-seek + virtualized window is the scaling follow-up for very large libraries. Backend: /api/library/stats gains an optional `sort` param and an additive `sort_letters` map — songs-per-first-letter of the ACTIVE sort column (artist or title), filter-synced — so the rail's present-letters match the grid's real order. The legacy `letters` (distinct-artist) field is unchanged, so the dashboard + classic tree are unaffected. `sort` is dropped for providers whose query_stats predates it (existing kwarg-filter), so third-party library providers keep working (rail simply falls back / hides). Frontend: static/v3/songs.js (refreshRail / jumpToLetter / pointer-drag + keyboard, cards tagged data-letter), static/v3/v3.css (.v3-azrail + bubble). Tests: tests/test_library_filters.py (sort_letters artist/title, song-vs- distinct-artist counting), tests/test_library_providers.py (sort forwarded), tests/js/v3_az_rail.test.js (gating, data-letter, load-through, drag/keys). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF * fix(v3 library): harden A–Z jump rail (review P2/P3) Addresses the PR #634 review findings (manual + Codex): P2 correctness - refreshRail prefers the active-sort `sort_letters`; falls back to the artist-based `letters` only on an artist sort, and hides the rail on a title sort when a legacy provider returns none (was mislabeling letters). - reload() bumps `_jumpToken` so an in-flight letter jump can't scroll a grid that's being rebuilt from page 0. - songBucket no longer trims, matching the server SQL + grid ORDER BY raw first-char bucketing (a leading-space title now buckets under '#' on both sides). P3 polish - Paging guard is total-derived (ceil(total/PAGE_SIZE)+2) instead of a magic 4000, keeping large libraries reachable while still bounded. - Roving tabindex: only the first present letter is tabbable; arrow keys move it. Removes up to 27 page tab stops. - `sort_letters` is computed only when the caller opts in (want_sort_letters / route `sort_letters=1`); the dashboard + v2 tree skip the extra GROUP BY. Added sort + want_sort_letters to the optional provider-kwargs so non-introspectable legacy providers drop them. - _railToken supersedes stale refreshRail responses; hide the rail when no letters are present instead of rendering disabled buttons. Tests updated accordingly (v3_az_rail.test.js, test_library_filters.py). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: byrongamatos <xasiklas@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
byrongamatos
parent
b29bab1884
commit
6a71577e05
@@ -0,0 +1,85 @@
|
||||
// Pins the v3 Songs A–Z jump rail wiring in static/v3/songs.js.
|
||||
//
|
||||
// The rail lets a user jump the library grid to artists/titles starting with a
|
||||
// letter (Plex/Radarr/iOS-contacts pattern). Because the grid is forward-only,
|
||||
// server-paged infinite scroll, the jump pages through to the target card then
|
||||
// scrolls — and the rail only offers letters the server reports present for the
|
||||
// active sort+filter (so a tap always terminates at a real card). It is shown
|
||||
// only for the grid view + alphabetical (artist/title) sorts.
|
||||
//
|
||||
// Source-level only — same strategy as tests/js/highway_3d_camera_framing.test.js.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const SONGS_JS = path.join(__dirname, '..', '..', 'static', 'v3', 'songs.js');
|
||||
const src = fs.readFileSync(SONGS_JS, 'utf8');
|
||||
|
||||
test('the rail is context-gated to grid view + alphabetical sorts', () => {
|
||||
// railSortColumn returns the active alpha column or null (recent/year/tuning).
|
||||
assert.match(src, /function\s+railSortColumn\s*\(\)/);
|
||||
assert.match(src, /state\.sort === 'artist'[\s\S]*?return 'artist'/);
|
||||
assert.match(src, /state\.sort === 'title'[\s\S]*?return 'title'/);
|
||||
assert.match(
|
||||
src,
|
||||
/function\s+railVisible\s*\(\)\s*\{\s*return\s+state\.view === 'grid'\s*&&\s*!!railSortColumn\(\)/,
|
||||
'the rail must be visible only for the grid view + an alphabetical sort',
|
||||
);
|
||||
});
|
||||
|
||||
test('cards carry a data-letter bucket and non-A–Z buckets under #', () => {
|
||||
assert.match(src, /data-letter="'\s*\+\s*esc\(songBucket\(song\)\)/,
|
||||
'each card must tag its sort-letter bucket via songBucket(song)');
|
||||
assert.match(
|
||||
src,
|
||||
/function\s+songBucket[\s\S]*?\(ch >= 'A' && ch <= 'Z'\)\s*\?\s*ch\s*:\s*'#'/,
|
||||
'songBucket must bucket non-A–Z first chars under "#"',
|
||||
);
|
||||
});
|
||||
|
||||
test('refreshRail reads present letters from the stats endpoint (sort-aware)', () => {
|
||||
assert.match(src, /\/api\/library\/stats\?'\s*\+\s*queryParams/,
|
||||
'refreshRail must query /api/library/stats with the active filter params');
|
||||
// Opts into the active-sort breakdown so non-rail callers skip the scan.
|
||||
assert.match(src, /queryParams\(\{\s*sort_letters:\s*1\s*\}\)/,
|
||||
'refreshRail must request the sort_letters breakdown');
|
||||
assert.match(src, /letters\s*=\s*stats\s*&&\s*stats\.sort_letters/,
|
||||
'refreshRail must prefer the active-sort breakdown (sort_letters)');
|
||||
// The legacy artist `letters` is only a valid fallback for an artist sort;
|
||||
// a title sort with no sort_letters hides the rail rather than mislabel it.
|
||||
assert.match(src, /col === 'artist'[\s\S]*?stats\.letters/,
|
||||
'refreshRail must only fall back to letters for an artist sort');
|
||||
// Absent letters are disabled (non-interactive), not just dimmed.
|
||||
assert.match(src, /present\s*\?\s*''\s*:\s*' disabled'/);
|
||||
});
|
||||
|
||||
test('reload() refreshes the rail', () => {
|
||||
assert.match(src, /function reload\s*\([\s\S]*?refreshRail\(\)/,
|
||||
'reload() must call refreshRail() so the rail tracks filter/sort/view changes');
|
||||
});
|
||||
|
||||
test('the rail + drag bubble are rendered in the Songs markup', () => {
|
||||
assert.match(src, /id="v3-songs-azrail"[\s\S]*?aria-label="Jump to letter"/);
|
||||
assert.match(src, /id="v3-songs-azbubble"/);
|
||||
});
|
||||
|
||||
test('jumpToLetter pages through to the target then scrolls (load-through)', () => {
|
||||
// Forward-paging helper used to load rows up to the target letter.
|
||||
assert.match(src, /async function\s+_loadNextAwait\s*\(\)/);
|
||||
assert.match(
|
||||
src,
|
||||
/async function\s+jumpToLetter[\s\S]*?_loadNextAwait\(\)[\s\S]*?(scrollTo|scrollIntoView)/,
|
||||
'jumpToLetter must page forward (_loadNextAwait) then scroll to the target card',
|
||||
);
|
||||
// A token guards against overlapping jumps (drag scrubbing) — newest wins.
|
||||
assert.match(src, /_jumpToken\s*===\s*myToken/);
|
||||
});
|
||||
|
||||
test('the rail supports pointer drag-scrub + keyboard arrows', () => {
|
||||
assert.match(src, /addEventListener\('pointerdown'/);
|
||||
assert.match(src, /addEventListener\('pointermove'/);
|
||||
assert.match(src, /ArrowUp'[\s\S]*?ArrowDown'|ArrowDown'[\s\S]*?ArrowUp'/,
|
||||
'arrow keys must move between present letters');
|
||||
});
|
||||
Reference in New Issue
Block a user