fix(web/m7-364): keyboard reorder + measured row height + offsetToDelta helper
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { GripVertical, X } from 'lucide-svelte';
|
||||
import { draggable } from '@neodrag/svelte';
|
||||
import type { DragEventData } from '@neodrag/svelte';
|
||||
import { draggable, type DragEventData } from '@neodrag/svelte';
|
||||
import type { TrackRef } from '$lib/api/types';
|
||||
import { playFromQueueIndex, removeFromQueue, moveQueueItem } from '$lib/player/store.svelte';
|
||||
import { offsetToDelta } from './queue-row-math';
|
||||
|
||||
let { track, index, isCurrent } = $props<{
|
||||
track: TrackRef;
|
||||
@@ -11,35 +11,49 @@
|
||||
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;
|
||||
let measuredRowHeight = 64; // sensible default; replaced on dragStart
|
||||
|
||||
function handleDragStart(data: DragEventData) {
|
||||
// The action's `data.rootNode` is the element use:draggable was applied to.
|
||||
const rect = data.rootNode.getBoundingClientRect();
|
||||
if (rect.height > 0) measuredRowHeight = rect.height;
|
||||
}
|
||||
|
||||
function handleBodyClick() {
|
||||
if (isCurrent) return;
|
||||
playFromQueueIndex(index);
|
||||
}
|
||||
|
||||
function handleRemove(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
function handleRemove() {
|
||||
removeFromQueue(index);
|
||||
}
|
||||
|
||||
function handleDragEnd(data: DragEventData) {
|
||||
const delta = Math.round(data.offsetY / ROW_HEIGHT);
|
||||
const delta = offsetToDelta(data.offsetY, measuredRowHeight);
|
||||
if (delta === 0) return;
|
||||
moveQueueItem(index, index + delta);
|
||||
}
|
||||
|
||||
function handleHandleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
moveQueueItem(index, index - 1);
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
moveQueueItem(index, index + 1);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
use:draggable={{ axis: 'y', bounds: 'parent', onDragEnd: handleDragEnd }}
|
||||
use:draggable={{ axis: 'y', bounds: 'parent', onDragStart: handleDragStart, 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"
|
||||
aria-label="Reorder track (use arrow keys)"
|
||||
onkeydown={handleHandleKeydown}
|
||||
class="cursor-grab text-text-secondary hover:text-text-primary flex-shrink-0"
|
||||
>
|
||||
<GripVertical size={16} />
|
||||
|
||||
@@ -61,4 +61,18 @@ describe('QueueTrackRow', () => {
|
||||
await fireEvent.click(body);
|
||||
expect(playFromQueueIndex).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ArrowDown on the drag handle calls moveQueueItem with index+1', async () => {
|
||||
render(QueueTrackRow, { props: { track: sampleTrack, index: 3, isCurrent: false } });
|
||||
const handle = screen.getByLabelText(/reorder track/i);
|
||||
await fireEvent.keyDown(handle, { key: 'ArrowDown' });
|
||||
expect(moveQueueItem).toHaveBeenCalledWith(3, 4);
|
||||
});
|
||||
|
||||
it('ArrowUp on the drag handle calls moveQueueItem with index-1', async () => {
|
||||
render(QueueTrackRow, { props: { track: sampleTrack, index: 3, isCurrent: false } });
|
||||
const handle = screen.getByLabelText(/reorder track/i);
|
||||
await fireEvent.keyDown(handle, { key: 'ArrowUp' });
|
||||
expect(moveQueueItem).toHaveBeenCalledWith(3, 2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { offsetToDelta } from './queue-row-math';
|
||||
|
||||
describe('offsetToDelta', () => {
|
||||
it('returns 0 for zero offset', () => {
|
||||
expect(offsetToDelta(0, 64)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 1 for a full row down', () => {
|
||||
expect(offsetToDelta(64, 64)).toBe(1);
|
||||
});
|
||||
|
||||
it('rounds half-rows down (just under midpoint)', () => {
|
||||
expect(offsetToDelta(31, 64)).toBe(0);
|
||||
});
|
||||
|
||||
it('rounds half-rows up (at or past midpoint)', () => {
|
||||
expect(offsetToDelta(32, 64)).toBe(1);
|
||||
});
|
||||
|
||||
it('handles negative offsets symmetrically (drag up)', () => {
|
||||
expect(offsetToDelta(-64, 64)).toBe(-1);
|
||||
expect(offsetToDelta(-32, 64)).toBe(0); // Math.round(-0.5) === 0 in JS
|
||||
});
|
||||
|
||||
it('handles multi-row offsets', () => {
|
||||
expect(offsetToDelta(192, 64)).toBe(3);
|
||||
expect(offsetToDelta(-128, 64)).toBe(-2);
|
||||
});
|
||||
|
||||
it('returns 0 when rowHeight is non-positive (defensive)', () => {
|
||||
expect(offsetToDelta(100, 0)).toBe(0);
|
||||
expect(offsetToDelta(100, -1)).toBe(0);
|
||||
});
|
||||
|
||||
it('respects the measured row height (e.g., h-14 = 56px)', () => {
|
||||
expect(offsetToDelta(56, 56)).toBe(1);
|
||||
expect(offsetToDelta(28, 56)).toBe(1); // 0.5 → 1 (half-up)
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
// Translate a vertical drag offset (px) into an integer row-delta,
|
||||
// given the measured row height (px). Used by QueueTrackRow when a
|
||||
// neodrag drop completes — we round to the nearest row boundary so
|
||||
// the user's drop intent maps to a clean queue index.
|
||||
export function offsetToDelta(offsetY: number, rowHeight: number): number {
|
||||
if (rowHeight <= 0) return 0;
|
||||
return Math.round(offsetY / rowHeight);
|
||||
}
|
||||
Reference in New Issue
Block a user