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);
}
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() }