feat(web/m7-364): drawer focus management + grip Enter/Space handling

This commit is contained in:
2026-05-03 22:23:20 -04:00
parent d83b3eab25
commit 22e3f67411
3 changed files with 36 additions and 0 deletions
+16
View File
@@ -3,12 +3,27 @@
import { player, closeQueueDrawer } from '$lib/player/store.svelte';
import QueueTrackRow from './QueueTrackRow.svelte';
let previouslyFocused: HTMLElement | null = null;
let closeButton: HTMLButtonElement | undefined = $state();
function totalDurationLabel(tracks: { duration_sec: number }[]): string {
const totalSec = tracks.reduce((s, tr) => s + (tr.duration_sec ?? 0), 0);
const m = Math.floor(totalSec / 60);
const h = Math.floor(m / 60);
return h > 0 ? `${h}h ${m % 60}m` : `${m} min`;
}
$effect(() => {
if (player.queueDrawerOpen) {
previouslyFocused = document.activeElement as HTMLElement | null;
// Defer to next microtask so the close button is rendered + visible
// before we focus (the drawer may still be transitioning in).
Promise.resolve().then(() => closeButton?.focus());
} else if (previouslyFocused) {
previouslyFocused.focus();
previouslyFocused = null;
}
});
</script>
{#if player.queueDrawerOpen}
@@ -38,6 +53,7 @@
</div>
<button
type="button"
bind:this={closeButton}
aria-label="Close queue"
onclick={() => closeQueueDrawer()}
class="text-text-secondary hover:text-text-primary"
@@ -41,6 +41,11 @@
} else if (e.key === 'ArrowDown') {
e.preventDefault();
moveQueueItem(index, index + 1);
} else if (e.key === ' ' || e.key === 'Enter') {
// Reorder activates via arrow keys, not Space/Enter. Prevent
// default so Space doesn't scroll the queue list and Enter doesn't
// fire the button's no-op click.
e.preventDefault();
}
}
</script>
@@ -53,6 +58,7 @@
<button
type="button"
aria-label="Reorder track (use arrow keys)"
aria-keyshortcuts="ArrowUp ArrowDown"
onkeydown={handleHandleKeydown}
class="cursor-grab text-text-secondary hover:text-text-primary flex-shrink-0"
>
@@ -77,4 +77,18 @@ describe('QueueTrackRow', () => {
await fireEvent.keyDown(handle, { key: 'ArrowUp' });
expect(moveQueueItem).toHaveBeenCalledWith(3, 2);
});
it('Enter on the drag handle prevents default but does not call moveQueueItem', async () => {
render(QueueTrackRow, { props: { track: sampleTrack, index: 3, isCurrent: false } });
const handle = screen.getByLabelText(/reorder track/i);
await fireEvent.keyDown(handle, { key: 'Enter' });
expect(moveQueueItem).not.toHaveBeenCalled();
});
it('Space on the drag handle prevents default but does not call moveQueueItem', async () => {
render(QueueTrackRow, { props: { track: sampleTrack, index: 3, isCurrent: false } });
const handle = screen.getByLabelText(/reorder track/i);
await fireEvent.keyDown(handle, { key: ' ' });
expect(moveQueueItem).not.toHaveBeenCalled();
});
});