feat(web): multi-select + bulk actions on track lists (Tier A3)
test-web / test (push) Successful in 31s

Add a global selection store backing a checkbox per track row and a
floating SelectionBar above PlayerBar with Play next / Add to queue /
Add to playlist / Like all / Clear.

- selection/store.svelte.ts: id Set + TrackRef Map + anchor index;
  toggleOne, selectRange (shift+click), clearSelection. Singleton —
  layout effect clears on pathname change.
- TrackRow: checkbox slot replaces the track number on hover; row
  click toggles selection once any row is picked; shift+click extends
  the range. Esc clears (takes priority over closing the queue
  drawer).
- SelectionBar: floating pill above PlayerBar, mounted inside the
  QueryClientProvider so the Like-all action can resolve.
- player.playNextMany: bulk variant of playNext for "Play next" on a
  multi-track selection.

Covered by selection-store unit tests and three new TrackRow tests
(checkbox toggle, sticky select-mode row click, shift+click range).
This commit is contained in:
2026-06-01 11:41:29 -04:00
parent bbc1036f77
commit fd12a145c8
7 changed files with 409 additions and 15 deletions
@@ -0,0 +1,88 @@
import { describe, expect, test, beforeEach } from 'vitest';
import type { TrackRef } from '$lib/api/types';
import { makeTrack } from '$test-utils/fixtures/track';
import {
selection, toggleOne, selectRange, clearSelection
} from './store.svelte';
function tracks(): TrackRef[] {
return [
makeTrack({ id: 'a', title: 'A' }),
makeTrack({ id: 'b', title: 'B' }),
makeTrack({ id: 'c', title: 'C' }),
makeTrack({ id: 'd', title: 'D' })
];
}
beforeEach(() => clearSelection());
describe('selection store', () => {
test('starts empty + inactive', () => {
expect(selection.count).toBe(0);
expect(selection.active).toBe(false);
expect(selection.tracks).toEqual([]);
});
test('toggleOne selects and deselects', () => {
const ts = tracks();
toggleOne(ts[1], 1);
expect(selection.isSelected('b')).toBe(true);
expect(selection.count).toBe(1);
expect(selection.active).toBe(true);
toggleOne(ts[1], 1);
expect(selection.isSelected('b')).toBe(false);
expect(selection.count).toBe(0);
expect(selection.active).toBe(false);
});
test('selectRange extends from the last anchor inclusive', () => {
const ts = tracks();
toggleOne(ts[0], 0);
selectRange(2, ts);
expect(selection.isSelected('a')).toBe(true);
expect(selection.isSelected('b')).toBe(true);
expect(selection.isSelected('c')).toBe(true);
expect(selection.isSelected('d')).toBe(false);
expect(selection.count).toBe(3);
});
test('selectRange handles reverse direction', () => {
const ts = tracks();
toggleOne(ts[3], 3);
selectRange(1, ts);
expect(selection.isSelected('b')).toBe(true);
expect(selection.isSelected('c')).toBe(true);
expect(selection.isSelected('d')).toBe(true);
expect(selection.isSelected('a')).toBe(false);
});
test('selectRange with no anchor collapses to a single toggle', () => {
const ts = tracks();
selectRange(2, ts);
expect(selection.isSelected('c')).toBe(true);
expect(selection.count).toBe(1);
});
test('selection.tracks returns the full TrackRef payloads', () => {
const ts = tracks();
toggleOne(ts[0], 0);
toggleOne(ts[2], 2);
const picked = selection.tracks;
const titles = picked.map((t) => t.title).sort();
expect(titles).toEqual(['A', 'C']);
});
test('clearSelection resets state', () => {
const ts = tracks();
toggleOne(ts[0], 0);
toggleOne(ts[1], 1);
clearSelection();
expect(selection.count).toBe(0);
expect(selection.active).toBe(false);
// Anchor is reset too — a follow-up range collapses to one.
selectRange(2, ts);
expect(selection.count).toBe(1);
});
});
+68
View File
@@ -0,0 +1,68 @@
import type { TrackRef } from '$lib/api/types';
// Global multi-select state for track lists (album, library/liked,
// search results, etc.). Reset on route change by the layout effect.
//
// The store keeps both a Set of ids (for O(1) isSelected) and a Map of
// id->TrackRef (so the bulk-action bar can act on the full TrackRef
// payload without re-querying the source list). Shift-range uses the
// most recent toggle as the anchor.
let _selectedIds = $state(new Set<string>());
let _selectedTrackMap = $state(new Map<string, TrackRef>());
let _anchorIndex = $state<number | null>(null);
export const selection = {
get count(): number { return _selectedIds.size; },
get active(): boolean { return _selectedIds.size > 0; },
get tracks(): TrackRef[] {
return Array.from(_selectedTrackMap.values());
},
get ids(): string[] { return Array.from(_selectedIds); },
isSelected(id: string): boolean { return _selectedIds.has(id); }
};
export function toggleOne(track: TrackRef, index: number): void {
const ids = new Set(_selectedIds);
const map = new Map(_selectedTrackMap);
if (ids.has(track.id)) {
ids.delete(track.id);
map.delete(track.id);
} else {
ids.add(track.id);
map.set(track.id, track);
}
_selectedIds = ids;
_selectedTrackMap = map;
_anchorIndex = index;
}
// Shift-click range: select every track from the anchor to `toIndex`
// inclusive. If no anchor is set (first click ever in this list), the
// range collapses to a single toggle.
export function selectRange(toIndex: number, tracks: TrackRef[]): void {
if (_anchorIndex === null) {
toggleOne(tracks[toIndex], toIndex);
return;
}
const from = _anchorIndex;
const [a, b] = from <= toIndex ? [from, toIndex] : [toIndex, from];
const ids = new Set(_selectedIds);
const map = new Map(_selectedTrackMap);
for (let i = a; i <= b; i++) {
const t = tracks[i];
if (!t) continue;
ids.add(t.id);
map.set(t.id, t);
}
_selectedIds = ids;
_selectedTrackMap = map;
_anchorIndex = toIndex;
}
export function clearSelection(): void {
if (_selectedIds.size === 0 && _anchorIndex === null) return;
_selectedIds = new Set();
_selectedTrackMap = new Map();
_anchorIndex = null;
}