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:
@@ -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() }
|
||||
|
||||
Reference in New Issue
Block a user