From a923f24b0e0fb281d37e793a73d032525b761875 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 00:59:43 +0000 Subject: [PATCH] fix(a11y): expose modals/sheets as dialogs with focus + Escape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two bottom sheets had no role/aria-modal, no focus management, and no Escape-to-close, and the two modals duplicated a window-level Escape listener (which would double-close a modal stacked over a sheet). Add a shared useDialog hook: marks the element as a dialog (caller adds role="dialog" aria-modal="true"), moves focus into it on open, and closes on Escape via an element-level onKeyDown — so a modal opened over the sheet closes only itself, not both. `active` keys the focus for the always-mounted AddToPlaylistSheet host (focus each time it opens, not once at startup). All four overlays now use it; the modals' hand-rolled Escape effects are removed. (Background scroll bleed is already prevented by the global body lock from the bottom-nav fix, so this pass is the a11y/focus/Escape half of the issue.) Verified in-browser: each overlay is role=dialog + aria-modal, receives focus on open, closes on Escape, and a confirm modal over the sheet closes only the modal. Fixes #15. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01FkDKh1Uo1a4D7n5wCKdcKF --- src/components/AddToPlaylistSheet.jsx | 14 +++++++++++++- src/components/ConfirmModal.jsx | 18 +++++++++++------- src/components/PlaylistActionsSheet.jsx | 13 ++++++++++++- src/components/PromptModal.jsx | 18 +++++++++++++----- src/lib/useDialog.js | 22 ++++++++++++++++++++++ 5 files changed, 71 insertions(+), 14 deletions(-) create mode 100644 src/lib/useDialog.js diff --git a/src/components/AddToPlaylistSheet.jsx b/src/components/AddToPlaylistSheet.jsx index 5059de2..97867df 100644 --- a/src/components/AddToPlaylistSheet.jsx +++ b/src/components/AddToPlaylistSheet.jsx @@ -5,6 +5,7 @@ import { usePlayer } from '../state/PlayerProvider' import { addToPlaylist, createPlaylist, toggleStar, deleteTrack } from '../lib/db' import PromptModal from './PromptModal' import ConfirmModal from './ConfirmModal' +import { useDialog } from '../lib/useDialog' // Track actions sheet (opened from a TrackRow's ⋯). Quick playback actions on // top (Play next / Add to queue / Favorite), then the "add to playlist" picker. @@ -14,6 +15,9 @@ export default function AddToPlaylistSheet() { const { playNext, addToQueue } = usePlayer() const [creating, setCreating] = useState(false) const [confirmDelete, setConfirmDelete] = useState(false) + // This host is always mounted (returns null when closed), so key the focus on + // whether a track is open, not on mount. + const dialog = useDialog(closeAddToPlaylist, { active: !!addTarget }) if (!addTarget) return null const close = closeAddToPlaylist @@ -25,7 +29,15 @@ export default function AddToPlaylistSheet() { return ( <>
-
e.stopPropagation()}> +
e.stopPropagation()} + >

{addTarget.title}

diff --git a/src/components/ConfirmModal.jsx b/src/components/ConfirmModal.jsx index e411d73..cb0e67d 100644 --- a/src/components/ConfirmModal.jsx +++ b/src/components/ConfirmModal.jsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react' +import { useDialog } from '../lib/useDialog' // Destructive confirm dialog (e.g. delete a playlist) — replaces window.confirm. export default function ConfirmModal({ @@ -8,15 +8,19 @@ export default function ConfirmModal({ onConfirm, onClose, }) { - useEffect(() => { - const onKey = (e) => e.key === 'Escape' && onClose() - window.addEventListener('keydown', onKey) - return () => window.removeEventListener('keydown', onKey) - }, [onClose]) + const dialog = useDialog(onClose) return (
-
e.stopPropagation()}> +
e.stopPropagation()} + >

{title}

{message &&

{message}

}
diff --git a/src/components/PlaylistActionsSheet.jsx b/src/components/PlaylistActionsSheet.jsx index 1bfcd34..08d8e36 100644 --- a/src/components/PlaylistActionsSheet.jsx +++ b/src/components/PlaylistActionsSheet.jsx @@ -1,9 +1,20 @@ +import { useDialog } from '../lib/useDialog' + // Bottom sheet for a single playlist — opened by long-pressing a playlist card // or tapping ⋯ inside a playlist. Offers Rename and Delete. export default function PlaylistActionsSheet({ playlist, onRename, onDelete, onClose }) { + const dialog = useDialog(onClose) return (
-
e.stopPropagation()}> +
e.stopPropagation()} + >

{playlist.name}

diff --git a/src/components/PromptModal.jsx b/src/components/PromptModal.jsx index ff9d878..2dacf85 100644 --- a/src/components/PromptModal.jsx +++ b/src/components/PromptModal.jsx @@ -1,4 +1,5 @@ import { useState, useEffect, useRef } from 'react' +import { useDialog } from '../lib/useDialog' // A small centered text-entry modal — replaces window.prompt() so naming a // playlist feels native and on-brand instead of a browser system dialog. @@ -13,14 +14,13 @@ export default function PromptModal({ }) { const [value, setValue] = useState(initialValue) const inputRef = useRef(null) + // autoFocus:false — we focus the text input, not the form container. + const dialog = useDialog(onClose, { autoFocus: false }) useEffect(() => { inputRef.current?.focus() inputRef.current?.select() - const onKey = (e) => e.key === 'Escape' && onClose() - window.addEventListener('keydown', onKey) - return () => window.removeEventListener('keydown', onKey) - }, [onClose]) + }, []) const submit = (e) => { e.preventDefault() @@ -30,7 +30,15 @@ export default function PromptModal({ return (
-
e.stopPropagation()} onSubmit={submit}> + e.stopPropagation()} + onSubmit={submit} + >

{title}

{ + if (autoFocus && active) ref.current?.focus() + }, [autoFocus, active]) + const onKeyDown = (e) => { + if (e.key === 'Escape') onClose?.() + } + return { ref, onKeyDown } +}