Release: web UX overhaul + Android native port + server polish #59

Merged
bvandeusen merged 298 commits from dev into main 2026-06-01 16:11:21 -04:00
2 changed files with 49 additions and 8 deletions
Showing only changes of commit 9fc21d217a - Show all commits
+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() }