Dialogs that behave like dialogs
Handle cancel, close, and focus
Separate a request to dismiss from a completed close, then restore focus when the opener no longer exists.
Two dialog events answer different questions. cancel fires when the user asks to dismiss through a platform action like Escape. close fires after the dialog has actually closed, however that happened.
Use cancel when the request might need confirmation. Call preventDefault() only for a real reason, like unsaved changes. Use close for cleanup and state updates. Blocking Escape with no reason traps people.
const addTask = document.querySelector('#add-task')
const taskDialog = document.querySelector('#task-dialog')
let opener
const formIsDirty = () => taskDialog.querySelector('input[name="title"]').value !== ''
addTask.addEventListener('click', event => {
opener = event.currentTarget
taskDialog.showModal()
})
taskDialog.addEventListener('cancel', event => {
if (formIsDirty()) event.preventDefault()
})
taskDialog.addEventListener('close', () => {
if (opener?.isConnected) opener.focus()
else document.querySelector('h1')?.focus()
})
Browsers usually restore focus to the control that opened a modal dialog. Explicit restoration helps when your close handler removes or replaces that opener. Store the actual element, not only an ID that might now point at a different card.
A heading is not normally focusable. If you use it as a fallback target, give it tabindex="-1" so you can focus it programmatically without adding it to the Tab order. A nearby stable button is often a better fallback.
Do not install your own global Escape listener to compete with the dialog. The native cancel path knows which top-layer element is active. If you prevent cancel for dirty data, show a second clear choice and make sure that confirmation has its own working Cancel path. I store the opener on showModal(), not on every intermediate click inside the dialog. That way focus restoration still points at the control the user actually used.
Try this on your board: open the editor from a task-specific Edit button, delete that task while the dialog is open in a test handler, then close the dialog. Focus should land on a stable fallback, not vanish to body. Type a change, press Escape, and confirm your dirty-state policy. Press Escape with no changes and confirm normal dismissal.
Lesson completed