feat(web): multi-select + bulk actions on track lists (Tier A3)
test-web / test (push) Successful in 31s
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user