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
+129
View File
@@ -0,0 +1,129 @@
<script lang="ts">
import { X, ListPlus, Plus, ListMusic, Heart } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query';
import { selection, clearSelection } from '$lib/selection/store.svelte';
import { playNextMany, enqueueTracks } from '$lib/player/store.svelte';
import { likeEntity, createLikedIdsQuery } from '$lib/api/likes';
import AddToPlaylistMenu from './AddToPlaylistMenu.svelte';
const queryClient = useQueryClient();
const likedQ = $derived(createLikedIdsQuery());
const likedIds = $derived($likedQ?.data?.track_ids ?? []);
let addOpen = $state(false);
let busy = $state(false);
function onPlayNext() {
playNextMany(selection.tracks);
clearSelection();
}
function onAddQueue() {
enqueueTracks(selection.tracks);
clearSelection();
}
async function onLikeAll() {
if (busy) return;
busy = true;
try {
const likedSet = new Set(likedIds);
const toLike = selection.tracks.filter((t) => !likedSet.has(t.id));
for (const t of toLike) {
await likeEntity(queryClient, 'track', t.id);
}
clearSelection();
} finally {
busy = false;
}
}
function onClose() {
addOpen = false;
}
</script>
{#if selection.active}
<!-- Sits above PlayerBar (z-30 < 40). Anchors to the viewport bottom
with bottom-24 so PlayerBar's ~80px shell stays visible. The bar
is dismissed by the × button or by Escape (handled in layout). -->
<div
role="region"
aria-label="Selection actions"
class="pointer-events-none fixed inset-x-0 bottom-24 z-30 flex justify-center px-4"
>
<div
class="pointer-events-auto flex items-center gap-2 rounded-full border border-border
bg-surface px-3 py-2 shadow-lg"
>
<span class="px-2 text-sm text-text-primary">
{selection.count} selected
</span>
<span class="h-5 w-px bg-border"></span>
<button
type="button"
onclick={onPlayNext}
aria-label="Play selected next"
title="Play next"
class="flex items-center gap-1.5 rounded px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary focus-visible:ring-2 focus-visible:ring-accent"
>
<ListPlus size={16} strokeWidth={1.5} />
<span class="hidden sm:inline">Play next</span>
</button>
<button
type="button"
onclick={onAddQueue}
aria-label="Add selected to queue"
title="Add to queue"
class="flex items-center gap-1.5 rounded px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary focus-visible:ring-2 focus-visible:ring-accent"
>
<Plus size={16} strokeWidth={1.5} />
<span class="hidden sm:inline">Add to queue</span>
</button>
<button
type="button"
onclick={() => (addOpen = !addOpen)}
aria-haspopup="menu"
aria-expanded={addOpen}
aria-label="Add selected to playlist"
title="Add to playlist"
class="flex items-center gap-1.5 rounded px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary focus-visible:ring-2 focus-visible:ring-accent"
>
<ListMusic size={16} strokeWidth={1.5} />
<span class="hidden sm:inline">Add to playlist</span>
</button>
<button
type="button"
onclick={onLikeAll}
disabled={busy}
aria-label="Like all selected"
title="Like all"
class="flex items-center gap-1.5 rounded px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50"
>
<Heart size={16} strokeWidth={1.5} />
<span class="hidden sm:inline">Like all</span>
</button>
<span class="h-5 w-px bg-border"></span>
<button
type="button"
onclick={clearSelection}
aria-label="Clear selection"
title="Clear selection"
class="rounded p-1.5 text-text-secondary hover:text-text-primary focus-visible:ring-2 focus-visible:ring-accent"
>
<X size={16} strokeWidth={1.5} />
</button>
</div>
{#if addOpen}
<div class="pointer-events-auto absolute bottom-full mb-2">
<AddToPlaylistMenu tracks={selection.tracks} {onClose} />
</div>
{/if}
</div>
{/if}
+53 -12
View File
@@ -2,9 +2,12 @@
import type { TrackRef } from '$lib/api/types';
import { formatDuration } from '$lib/media/duration';
import { player, playQueue, enqueueTrack, playRadio } from '$lib/player/store.svelte';
import { Plus } from 'lucide-svelte';
import { Plus, Check } from 'lucide-svelte';
import LikeButton from './LikeButton.svelte';
import TrackMenu from './TrackMenu.svelte';
import {
selection, toggleOne, selectRange
} from '$lib/selection/store.svelte';
type PlayHandler = (tracks: TrackRef[], index: number) => void;
@@ -16,29 +19,45 @@
const track = $derived(tracks[index]);
// Mirrors QueueTrackRow's "now playing" treatment so the user sees
// which row in the album / liked / history / search view is currently
// playing without having to read the player bar. Only the accent
// border + bg lift — no "Now playing" pill, since the player bar
// already names the track.
const isCurrent = $derived(player.current?.id === track.id);
const isCurrent = $derived(player.current?.id === track.id);
const isSelected = $derived(selection.isSelected(track.id));
const selectMode = $derived(selection.active);
function activate() {
if (onPlay) onPlay(tracks, index);
else playQueue(tracks, index);
}
function onRowClick() {
function onRowClick(e: MouseEvent) {
// In selection mode any click toggles; outside selection mode click
// plays as before. Shift+click extends the range from the anchor
// regardless of mode (enters selection mode if nothing was picked).
if (e.shiftKey) {
e.preventDefault();
selectRange(index, tracks);
return;
}
if (selectMode) {
toggleOne(track, index);
return;
}
activate();
}
function onRowKey(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
activate();
if (selectMode) toggleOne(track, index);
else activate();
}
}
function onCheckboxClick(e: MouseEvent) {
e.stopPropagation();
if (e.shiftKey) selectRange(index, tracks);
else toggleOne(track, index);
}
function onAddClick(e: MouseEvent) {
e.stopPropagation();
enqueueTrack(track);
@@ -54,12 +73,34 @@
role="button"
tabindex="0"
aria-label={track.title}
data-selected={isSelected}
onclick={onRowClick}
onkeydown={onRowKey}
class="grid w-full cursor-pointer grid-cols-[32px_1fr_auto_auto_auto_auto_auto] items-center gap-4 px-3 py-2 text-left text-sm odd:bg-surface/50 hover:bg-surface focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent {isCurrent ? 'border-l-2 border-l-accent bg-surface-hover' : ''}"
class="group grid w-full cursor-pointer grid-cols-[32px_1fr_auto_auto_auto_auto_auto] items-center gap-4 px-3 py-2 text-left text-sm odd:bg-surface/50 hover:bg-surface focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent {isCurrent ? 'border-l-2 border-l-accent bg-surface-hover' : ''} {isSelected ? 'bg-surface-hover' : ''}"
>
<span class="text-right tabular-nums text-text-secondary">
{track.track_number ?? '—'}
<span class="relative flex h-6 w-8 items-center justify-end">
<!-- Track number: visible by default; hidden when the row is hovered
(so the checkbox replaces it) or when this row is selected. -->
<span
class="tabular-nums text-text-secondary
{selectMode || isSelected ? 'hidden' : 'group-hover:hidden'}"
>{track.track_number ?? '—'}</span>
<!-- Checkbox: hidden by default; revealed on row hover OR whenever
the selection bar is active OR this row is the selected one. -->
<button
type="button"
role="checkbox"
aria-checked={isSelected}
aria-label={isSelected ? `Deselect ${track.title}` : `Select ${track.title}`}
onclick={onCheckboxClick}
class="flex h-5 w-5 items-center justify-center rounded border
{isSelected
? 'border-accent bg-accent text-text-primary'
: 'border-border bg-surface text-transparent'}
{selectMode || isSelected ? 'flex' : 'hidden group-hover:flex'}"
>
<Check size={14} strokeWidth={2} />
</button>
</span>
<span class="truncate">{track.title}</span>
<LikeButton entityType="track" entityId={track.id} />
+39 -1
View File
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import type { TrackRef } from '$lib/api/types';
import { emptyLikesMock } from '../../test-utils/mocks/likes';
@@ -18,12 +18,14 @@ vi.mock('$lib/api/quarantine', () => emptyQuarantineMock());
import TrackRow from './TrackRow.svelte';
import { playQueue, enqueueTrack, playRadio } from '$lib/player/store.svelte';
import { selection, clearSelection } from '$lib/selection/store.svelte';
const tracks: TrackRef[] = [
makeTrack({ title: 'So What', duration_sec: 545 }),
makeTrack({ id: 't2', title: 'Freddie Freeloader', track_number: 2, duration_sec: 565, stream_url: '/api/tracks/t2/stream' })
];
beforeEach(() => clearSelection());
afterEach(() => vi.clearAllMocks());
describe('TrackRow', () => {
@@ -86,4 +88,40 @@ describe('TrackRow', () => {
screen.getByRole('button', { name: /track actions for/i })
).toBeInTheDocument();
});
describe('selection mode', () => {
test('clicking the checkbox toggles selection without playing', async () => {
render(TrackRow, { props: { tracks, index: 0 } });
const box = screen.getByRole('checkbox', { name: /select so what/i });
await fireEvent.click(box);
expect(selection.isSelected('t1')).toBe(true);
expect(playQueue).not.toHaveBeenCalled();
});
test('row click toggles selection (not play) once any row is selected', async () => {
render(TrackRow, { props: { tracks, index: 0 } });
const box = screen.getByRole('checkbox', { name: /select so what/i });
await fireEvent.click(box);
// Re-render at index 1 — selection mode is now active globally.
const { container } = render(TrackRow, { props: { tracks, index: 1 } });
const row = container.querySelector('[role="button"][aria-label="Freddie Freeloader"]')!;
await fireEvent.click(row);
expect(selection.isSelected('t2')).toBe(true);
expect(playQueue).not.toHaveBeenCalled();
});
test('shift+click on the row extends the range from the anchor', async () => {
render(TrackRow, { props: { tracks, index: 0 } });
const box = screen.getByRole('checkbox', { name: /select so what/i });
await fireEvent.click(box);
const { container } = render(TrackRow, { props: { tracks, index: 1 } });
const row = container.querySelector('[role="button"][aria-label="Freddie Freeloader"]')!;
await fireEvent.click(row, { shiftKey: true });
expect(selection.isSelected('t1')).toBe(true);
expect(selection.isSelected('t2')).toBe(true);
expect(selection.count).toBe(2);
});
});
});