Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion src/components/AddToPlaylistSheet.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -25,7 +29,15 @@ export default function AddToPlaylistSheet() {
return (
<>
<div className="sheet-overlay" onClick={close}>
<div className="sheet" onClick={(e) => e.stopPropagation()}>
<div
className="sheet"
ref={dialog.ref}
onKeyDown={dialog.onKeyDown}
role="dialog"
aria-modal="true"
tabIndex={-1}
onClick={(e) => e.stopPropagation()}
>
<div className="sheet__grip" />
<p className="sheet__title">{addTarget.title}</p>

Expand Down
18 changes: 11 additions & 7 deletions src/components/ConfirmModal.jsx
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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 (
<div className="modal-overlay" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<div
className="modal"
ref={dialog.ref}
onKeyDown={dialog.onKeyDown}
role="dialog"
aria-modal="true"
tabIndex={-1}
onClick={(e) => e.stopPropagation()}
>
<h3 className="modal__title">{title}</h3>
{message && <p className="modal__msg">{message}</p>}
<div className="modal__actions">
Expand Down
13 changes: 12 additions & 1 deletion src/components/PlaylistActionsSheet.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="sheet-overlay" onClick={onClose}>
<div className="sheet" onClick={(e) => e.stopPropagation()}>
<div
className="sheet"
ref={dialog.ref}
onKeyDown={dialog.onKeyDown}
role="dialog"
aria-modal="true"
tabIndex={-1}
onClick={(e) => e.stopPropagation()}
>
<div className="sheet__grip" />
<p className="sheet__title">{playlist.name}</p>

Expand Down
18 changes: 13 additions & 5 deletions src/components/PromptModal.jsx
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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()
Expand All @@ -30,7 +30,15 @@ export default function PromptModal({

return (
<div className="modal-overlay" onClick={onClose}>
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={submit}>
<form
className="modal"
ref={dialog.ref}
onKeyDown={dialog.onKeyDown}
role="dialog"
aria-modal="true"
onClick={(e) => e.stopPropagation()}
onSubmit={submit}
>
<h3 className="modal__title">{title}</h3>
<input
ref={inputRef}
Expand Down
22 changes: 22 additions & 0 deletions src/lib/useDialog.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { useEffect, useRef } from 'react'

// Shared modal / bottom-sheet accessibility. Returns { ref, onKeyDown } to put
// on the dialog element (the caller also adds role="dialog" aria-modal="true").
// It moves focus into the dialog on open and closes on Escape. Escape is handled
// at the element (not window), so when a modal is stacked over a sheet only the
// focused top-most dialog reacts — the one underneath doesn't also close.
// Background scrolling is already locked globally by
// body { position: fixed; overflow: hidden }, so this doesn't re-lock it.
// `active` distinguishes conditionally-mounted dialogs (default true — focus on
// mount) from an always-mounted host that only *renders* the dialog when open
// (pass active=open, so focus moves in each time it opens, not once at startup).
export function useDialog(onClose, { autoFocus = true, active = true } = {}) {
const ref = useRef(null)
useEffect(() => {
if (autoFocus && active) ref.current?.focus()
}, [autoFocus, active])
const onKeyDown = (e) => {
if (e.key === 'Escape') onClose?.()
}
return { ref, onKeyDown }
}
Loading