feat(web): keyboard reorder on playlist tracks (Tier C10)
test-web / test (push) Successful in 31s

Drag was already wired via @neodrag/svelte; this adds the missing
keyboard path so reorder is accessible without a pointer device.

- The drag handle becomes a real <button> (focusable). Disabled
  when canDrag is false so non-owners + unavailable tracks can't
  attempt a move.
- ArrowUp / ArrowDown on the focused handle call
  onMove(row.position, row.position +/- 1). Bounds clamping
  already lives in the page-level handler (playlists/[id]/+page.svelte:39),
  so out-of-range targets resolve as no-ops.
- Matches QueueTrackRow's pattern: aria-label "Reorder track …",
  aria-keyshortcuts, swallow Space/Enter to avoid scrolling.

Test updates: drag-handle assertions retargeted to /reorder track/
+ two new ArrowUp/ArrowDown handler tests.
This commit is contained in:
2026-06-01 14:16:27 -04:00
parent cc81e0f183
commit 9fc21d217a
2 changed files with 49 additions and 8 deletions
+25 -4
View File
@@ -41,6 +41,21 @@
onMove?.(row.position, row.position + delta); onMove?.(row.position, row.position + delta);
} }
function handleHandleKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowUp') {
e.preventDefault();
onMove?.(row.position, row.position - 1);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
onMove?.(row.position, row.position + 1);
} else if (e.key === ' ' || e.key === 'Enter') {
// Reorder activates via arrow keys, not Space/Enter. Prevent
// default so Space doesn't scroll the list and Enter doesn't
// fire the button's no-op click.
e.preventDefault();
}
}
function format(sec: number): string { function format(sec: number): string {
const m = Math.floor(sec / 60); const m = Math.floor(sec / 60);
const s = sec % 60; const s = sec % 60;
@@ -74,12 +89,18 @@
}} }}
> >
{#if isOwner} {#if isOwner}
<span <button
class="flex-shrink-0 cursor-grab text-text-muted active:cursor-grabbing" type="button"
aria-label="Drag handle" disabled={!canDrag}
aria-label="Reorder track (drag or use arrow keys)"
aria-keyshortcuts="ArrowUp ArrowDown"
onkeydown={handleHandleKeydown}
class="flex-shrink-0 cursor-grab text-text-muted hover:text-text-primary
active:cursor-grabbing focus-visible:outline focus-visible:outline-2
focus-visible:outline-accent disabled:cursor-not-allowed disabled:opacity-50"
> >
<GripVertical size={14} strokeWidth={1} /> <GripVertical size={14} strokeWidth={1} />
</span> </button>
{/if} {/if}
<button <button
@@ -67,26 +67,46 @@ describe('PlaylistTrackRow', () => {
expect(screen.getByText('2:17')).toBeInTheDocument(); expect(screen.getByText('2:17')).toBeInTheDocument();
}); });
test('shows drag handle and remove button for owner', () => { test('shows reorder handle and remove button for owner', () => {
render(PlaylistTrackRow, { render(PlaylistTrackRow, {
props: { row: live, isOwner: true, onRemove: vi.fn(), onPlay: vi.fn() } props: { row: live, isOwner: true, onRemove: vi.fn(), onPlay: vi.fn() }
}); });
expect(screen.getByLabelText('Drag handle')).toBeInTheDocument(); expect(screen.getByLabelText(/reorder track/i)).toBeInTheDocument();
expect( expect(
screen.getByLabelText(/Remove Roygbiv from playlist/i) screen.getByLabelText(/Remove Roygbiv from playlist/i)
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
test('hides drag handle and remove button for non-owner', () => { test('hides reorder handle and remove button for non-owner', () => {
render(PlaylistTrackRow, { render(PlaylistTrackRow, {
props: { row: live, isOwner: false, onRemove: vi.fn(), onPlay: vi.fn() } props: { row: live, isOwner: false, onRemove: vi.fn(), onPlay: vi.fn() }
}); });
expect(screen.queryByLabelText('Drag handle')).not.toBeInTheDocument(); expect(screen.queryByLabelText(/reorder track/i)).not.toBeInTheDocument();
expect( expect(
screen.queryByLabelText(/Remove Roygbiv from playlist/i) screen.queryByLabelText(/Remove Roygbiv from playlist/i)
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
}); });
test('ArrowDown on the reorder handle calls onMove(pos, pos+1)', async () => {
const onMove = vi.fn();
render(PlaylistTrackRow, {
props: { row: live, isOwner: true, onRemove: vi.fn(), onPlay: vi.fn(), onMove }
});
const handle = screen.getByLabelText(/reorder track/i);
await fireEvent.keyDown(handle, { key: 'ArrowDown' });
expect(onMove).toHaveBeenCalledWith(2, 3);
});
test('ArrowUp on the reorder handle calls onMove(pos, pos-1)', async () => {
const onMove = vi.fn();
render(PlaylistTrackRow, {
props: { row: live, isOwner: true, onRemove: vi.fn(), onPlay: vi.fn(), onMove }
});
const handle = screen.getByLabelText(/reorder track/i);
await fireEvent.keyDown(handle, { key: 'ArrowUp' });
expect(onMove).toHaveBeenCalledWith(2, 1);
});
test('greyed-out + strikethrough when track_id is null', () => { test('greyed-out + strikethrough when track_id is null', () => {
render(PlaylistTrackRow, { render(PlaylistTrackRow, {
props: { row: removed, isOwner: true, onRemove: vi.fn(), onPlay: vi.fn() } props: { row: removed, isOwner: true, onRemove: vi.fn(), onPlay: vi.fn() }