Skip to content

Dialog

dialog() is a behavior for a native <dialog>. It opens the dialog with showModal(), so the browser does the hard parts: it traps the focus inside the dialog, makes the rest of the page inert, closes it on Escape and returns the focus when it closes. The behavior keeps open and returnValue in state, whichever way the dialog closed.

import { dialog } from 'sygnal/ui'
function Profile({ state, uid }) {
return (
<div>
<button className="edit-profile">Edit profile</button>
<dialog className="profile" aria-labelledby={uid('title')}>
<h2 id={uid('title')}>Edit profile</h2>
<label>Name <input className="name" value={state.name} /></label>
<button className="save">Save</button>
<button className="cancel">Cancel</button>
</dialog>
<p>{state.profile.returnValue === 'saved' ? 'Profile saved' : ''}</p>
</div>
)
}
Profile.initialState = { name: '' }
Profile.uses = { profile: dialog({ dialog: '.profile', trigger: '.edit-profile', close: '.cancel' }) }
Profile.intent = ({ DOM }) => ({
NAME: DOM.input('.name').value(),
SAVE: DOM.click('.save'),
})
Profile.model = {
NAME: (state, name) => ({ ...state, name }),
SAVE: { ELEMENT: { close: '.profile', returnValue: 'saved' } },
}

A click on .edit-profile opens the dialog and a click on .cancel closes it. Save closes it from the model with a return value, through the built-in ELEMENT sink.

OptionDefault
dialog(required)The <dialog> element: a selector or a control. It must match only this behavior’s dialog: a selector that also matches a dialog nested in it (dialog, a shared class) gets that dialog’s events too
triggerIts clicks open the dialog
closeIts clicks close the dialog, with the return value ''
modaltruefalse opens it with show(): not modal, the page stays usable
cancelabletruefalse keeps Escape from closing it: the cancel event is prevented, and opening it sets closedby="none" (Chromium closes a dialog on a second Escape otherwise; the attribute goes again when it closes). CANCEL still runs on each Escape. In a browser without closedby, a second Escape can still close it; the CLOSED action tells you
returnFocustrueWhen the dialog closes and the focus was lost, focus the element that opened it (the clicked trigger, when several match trigger), else the trigger. A selector names another element; false leaves the focus to the browser

The browser returns the focus to the element that had it before the dialog opened. Safari doesn’t focus a button when it is clicked, so after a mouse click the focus would go back to the page itself; returnFocus focuses the trigger instead. It does nothing when the browser already moved the focus somewhere. It works for a dialog the host renders only while it is open, too.

state.profile is { open, returnValue }.

ActionData
profile.OPENOpens the dialog (no change when it is open)
profile.CLOSEthe return valueCloses it with that return value
profile.CLOSEDthe return valueThe dialog closed, whichever way: sets open: false and returnValue
profile.TOGGLEDtrue / falseThe dialog’s toggle event: a dialog opened without the model (for example with commandfor) sets open: true
profile.CANCELthe eventEscape was pressed (the dialog closes next, unless cancelable: false). The cancel event; with cancelable: false, the Escape keydown in the dialog (closedby="none" stops the cancel event)
profile.SYNCfalseThe dialog left the page while open (a page change, a conditional render), or OPEN’s showModal() threw (a disconnected dialog, a popover open on it): sets open: false, as no close event comes (your close listeners don’t run for a dialog that never opened)

OPEN and CLOSE check the dialog itself before they act: showModal() only on a closed dialog, close() only on an open one. A dialog removed while open opens again when it is back.

React to a close in your own model with an entry for the namespaced action. It runs after the behavior’s, on the full state:

Profile.model = {
'profile.CLOSED': (state, returnValue) => (returnValue === 'saved' ? { ...state, savedName: state.name } : state),
}

Open the dialog from another place with an intent action of the same name, 'profile.OPEN': DOM.click('.avatar'), or from a model entry that sends { showModal: '.profile' } to ELEMENT.

  • Name the dialog: aria-labelledby pointing at its heading, as above, or aria-label.
  • Put a close button inside. Escape closes a modal dialog, but not everyone has a keyboard.
  • The dialog element has role="dialog"; a modal one is also aria-modal to assistive technology.
  • A form inside the dialog with method="dialog" closes it on submit, with the submit button’s value as the return value; CLOSED sees it.
.profile { border: none; border-radius: 8px; padding: 1.5rem; }
.profile::backdrop { background: rgb(0 0 0 / 0.4); }
.profile[open] { animation: fade-in 150ms ease-out; }

The mock DOM records the showModal and close commands without running them, so simulate the dialog’s close event as the browser sends it:

import { it, expect } from 'vitest'
import { renderComponent } from 'sygnal'
import Profile from './Profile.jsx'
it('opens the dialog and keeps the return value', async () => {
const t = renderComponent(Profile)
await t.ready()
t.simulateEvent('.edit-profile', 'click')
await t.next((s) => s.profile.open)
expect(t.commands('ELEMENT')).toEqual([{ showModal: '.profile' }])
t.simulateEvent('.save', 'click')
await t.settle()
expect(t.commands('ELEMENT').at(-1)).toEqual({ close: '.profile', returnValue: 'saved' })
t.simulateEvent('.profile', 'close', { target: { returnValue: 'saved' } })
await t.next((s) => !s.profile.open)
expect(t.state.profile.returnValue).toBe('saved')
expect(t.actions.map((a) => [a.type, a.cause])).toEqual([
['INITIALIZE', 'built-in'],
['profile.OPEN', 'behavior'],
['SAVE', 'intent'],
['profile.CLOSED', 'behavior'],
])
t.dispose()
})

With dom: 'real', the commands run on the jsdom elements; focus trapping and the backdrop need a real browser.