feat(web/m7-364): QueueTrackRow with neodrag reorder + remove
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
<script lang="ts">
|
||||
import { GripVertical, X } from 'lucide-svelte';
|
||||
import { draggable } from '@neodrag/svelte';
|
||||
import type { DragEventData } from '@neodrag/svelte';
|
||||
import type { TrackRef } from '$lib/api/types';
|
||||
import { playFromQueueIndex, removeFromQueue, moveQueueItem } from '$lib/player/store.svelte';
|
||||
|
||||
let { track, index, isCurrent } = $props<{
|
||||
track: TrackRef;
|
||||
index: number;
|
||||
isCurrent: boolean;
|
||||
}>();
|
||||
|
||||
// Approximate row height in px — used to translate the drop offset
|
||||
// into a destination index. The row's Tailwind sets h-16 (64px).
|
||||
const ROW_HEIGHT = 64;
|
||||
|
||||
function handleBodyClick() {
|
||||
if (isCurrent) return;
|
||||
playFromQueueIndex(index);
|
||||
}
|
||||
|
||||
function handleRemove(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
removeFromQueue(index);
|
||||
}
|
||||
|
||||
function handleDragEnd(data: DragEventData) {
|
||||
const delta = Math.round(data.offsetY / ROW_HEIGHT);
|
||||
if (delta === 0) return;
|
||||
moveQueueItem(index, index + delta);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
use:draggable={{ axis: 'y', bounds: 'parent', onDragEnd: handleDragEnd }}
|
||||
class="flex items-center gap-2 border-b border-border px-3 py-2 h-16
|
||||
{isCurrent ? 'border-l-2 border-l-accent bg-surface-hover' : ''}"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Reorder track"
|
||||
class="cursor-grab text-text-secondary hover:text-text-primary flex-shrink-0"
|
||||
>
|
||||
<GripVertical size={16} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleBodyClick}
|
||||
class="flex-1 text-left min-w-0 {isCurrent ? 'cursor-default' : 'cursor-pointer'}"
|
||||
aria-label={isCurrent ? `Now playing — ${track.title}` : `Play ${track.title}`}
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
{#if isCurrent}
|
||||
<span class="text-xs text-accent uppercase tracking-wide flex-shrink-0">Now playing</span>
|
||||
{/if}
|
||||
<span class="font-medium truncate text-text-primary">{track.title}</span>
|
||||
</div>
|
||||
<div class="text-xs text-text-secondary truncate">{track.artist_name}</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleRemove}
|
||||
aria-label="Remove from queue"
|
||||
class="text-text-secondary hover:text-text-primary flex-shrink-0"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||
import QueueTrackRow from './QueueTrackRow.svelte';
|
||||
import type { TrackRef } from '$lib/api/types';
|
||||
|
||||
const playFromQueueIndex = vi.fn();
|
||||
const removeFromQueue = vi.fn();
|
||||
const moveQueueItem = vi.fn();
|
||||
|
||||
vi.mock('$lib/player/store.svelte', () => ({
|
||||
playFromQueueIndex: (...args: unknown[]) => playFromQueueIndex(...args),
|
||||
removeFromQueue: (...args: unknown[]) => removeFromQueue(...args),
|
||||
moveQueueItem: (...args: unknown[]) => moveQueueItem(...args)
|
||||
}));
|
||||
|
||||
const sampleTrack: TrackRef = {
|
||||
id: 't1',
|
||||
title: 'Song Title',
|
||||
artist_name: 'Artist Name',
|
||||
album_name: 'Album',
|
||||
duration_ms: 200000,
|
||||
stream_url: '/stream/t1'
|
||||
} as TrackRef;
|
||||
|
||||
describe('QueueTrackRow', () => {
|
||||
beforeEach(() => {
|
||||
playFromQueueIndex.mockClear();
|
||||
removeFromQueue.mockClear();
|
||||
moveQueueItem.mockClear();
|
||||
});
|
||||
|
||||
it('renders track title and artist', () => {
|
||||
render(QueueTrackRow, { props: { track: sampleTrack, index: 3, isCurrent: false } });
|
||||
expect(screen.getByText('Song Title')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Artist Name/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking the body calls playFromQueueIndex with the row index', async () => {
|
||||
render(QueueTrackRow, { props: { track: sampleTrack, index: 3, isCurrent: false } });
|
||||
const body = screen.getByRole('button', { name: /play song title/i });
|
||||
await fireEvent.click(body);
|
||||
expect(playFromQueueIndex).toHaveBeenCalledWith(3);
|
||||
});
|
||||
|
||||
it('clicking the remove button calls removeFromQueue with the row index', async () => {
|
||||
render(QueueTrackRow, { props: { track: sampleTrack, index: 3, isCurrent: false } });
|
||||
const remove = screen.getByLabelText(/remove from queue/i);
|
||||
await fireEvent.click(remove);
|
||||
expect(removeFromQueue).toHaveBeenCalledWith(3);
|
||||
});
|
||||
|
||||
it('current row shows the "Now playing" chip', () => {
|
||||
render(QueueTrackRow, { props: { track: sampleTrack, index: 3, isCurrent: true } });
|
||||
expect(screen.getByText(/now playing/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('current row body click does NOT call playFromQueueIndex', async () => {
|
||||
render(QueueTrackRow, { props: { track: sampleTrack, index: 3, isCurrent: true } });
|
||||
// The body button has a different aria-label when isCurrent
|
||||
const body = screen.getByLabelText(/now playing/i);
|
||||
await fireEvent.click(body);
|
||||
expect(playFromQueueIndex).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user