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);
}
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 {
const m = Math.floor(sec / 60);
const s = sec % 60;
@@ -74,12 +89,18 @@
}}
>
{#if isOwner}
<span
class="flex-shrink-0 cursor-grab text-text-muted active:cursor-grabbing"
aria-label="Drag handle"
<button
type="button"
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} />
</span>
</button>
{/if}
<button
@@ -67,26 +67,46 @@ describe('PlaylistTrackRow', () => {
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, {
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(
screen.getByLabelText(/Remove Roygbiv from playlist/i)
).toBeInTheDocument();
});
test('hides drag handle and remove button for non-owner', () => {
test('hides reorder handle and remove button for non-owner', () => {
render(PlaylistTrackRow, {
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(
screen.queryByLabelText(/Remove Roygbiv from playlist/i)
).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', () => {
render(PlaylistTrackRow, {
props: { row: removed, isOwner: true, onRemove: vi.fn(), onPlay: vi.fn() }