Element Commands
Some things the browser does are not state you render: which element has the focus, where the page is scrolled to, whether a <dialog> is open as a modal, whether a popover is showing. You change them by calling a method on the element (input.focus(), dialog.showModal()). In Sygnal, a model entry asks for that call with an element command on the built-in ELEMENT sink:
Signup.model = { SUBMIT: { STATE: (state) => ({ ...state, errors: validate(state) }), ELEMENT: (state) => (validate(state).email ? { focus: '.email' } : ABORT), }, OPEN_HELP: { ELEMENT: { showModal: '.help' } },}ELEMENT is built in: there is no driver to register, and it works in every component. The target is a CSS selector ({ focus: '.email' }), looked up in the view of the component that sent the command (see Which element). A control works as a target too.
The command object
Section titled “The command object”A command is an object whose first key is the method and whose value is the target. The other keys are the method’s options:
Form.model = { EDIT_EMAIL: { ELEMENT: { focus: '.email' } }, // email.focus({}) QUIET_FOCUS: { ELEMENT: { focus: '.email', preventScroll: true } }, // email.focus({ preventScroll: true }) SHOW_LAST: { ELEMENT: { scrollIntoView: '.row', block: 'nearest' } }, // row.scrollIntoView({ block: 'nearest' }) CLOSE_HELP: { ELEMENT: { close: '.help', returnValue: 'done' } }, // dialog.close('done')}The documented methods:
| Method | Options | Element |
|---|---|---|
focus | preventScroll, focusVisible | any focusable element |
blur | any element | |
select | <input>, <textarea>: selects the text | |
click | any element (a programmatic click, e.g. to open a file input’s picker) | |
scrollIntoView | block, inline, behavior | any element |
showModal | <dialog> | |
show | <dialog> (non-modal) | |
close | returnValue (passed as the argument) | <dialog> |
showPopover | an element with a popover attribute | |
hidePopover | an element with a popover attribute | |
togglePopover | force | an element with a popover attribute |
The options are passed to the method as one object, except for close, which gets returnValue as its argument. A popover is an element rendered with attrs: { popover: 'auto' } (or 'manual'); its toggle event reaches intent as DOM.toggle('.tip'), with newState 'open' or 'closed'.
The browser’s declarative forms need no model: <button popovertarget="tip"> toggles a popover and <button commandfor="dlg" command="show-modal"> opens a dialog. JSX writes these as attributes, as it does popovertargetaction, closedby, interestfor and anchor, and an aria-* boolean as "true" / "false".
The value of the ELEMENT entry is a command, or a reducer that returns one: (state, data) => command. Return ABORT to send nothing. An array sends several commands, in order:
Search.model = { // put the cursor in the search box and select what is in it EDIT_QUERY: { ELEMENT: [{ focus: '.query' }, { select: '.query' }] },}Sygnal runs any method the element has, so other methods work too ({ play: '.video' }, { showPicker: '.date' }, { requestSubmit: '.checkout' }). The eleven above are the ones the types know; for TypeScript, add others to the ElementCommandRegistry interface:
import 'sygnal'
declare module 'sygnal' { interface ElementCommandRegistry { play: {}; showPicker: {} }}Methods that change the DOM Sygnal renders (remove, append, setAttribute and the like) are reported as SYG641: the next render would undo them or trip over them. Change the state instead.
Which element
Section titled “Which element”The target is looked up in the view of the component instance that sent the command, the same scope its intent’s DOM has. An element inside a child component is isolated from its parent, and each item of a Collection reaches only its own elements. To focus or scroll something a child renders, send the command from the child’s own model. If more than one element matches, the first one gets the command.
Focusing inside children: focusWithin
Section titled “Focusing inside children: focusWithin”To focus an element a child renders (a field of a child form, the input of a Collection item the same action adds), use focusWithin(selector) as the target. It looks for selector anywhere under the sender’s root element, children included, and focuses the first match with the command’s options:
import { Collection, focusWithin } from 'sygnal'
function Row({ state }) { return ( <li data-id={state.id}> <input className="title" aria-label="Title" value={state.title} /> </li> )}Row.intent = ({ DOM }) => ({ TITLE: DOM.input('.title').value() })Row.model = { TITLE: (state, title) => ({ ...state, title }) }
export function Checklist({ state }) { return ( <div> <button className="add">Add item</button> <ul><Collection of={Row} from="rows" /></ul> </div> )}Checklist.initialState = { rows: [], next: 1 }Checklist.intent = ({ DOM }) => ({ ADD: DOM.click('.add') })Checklist.model = { ADD: { STATE: (state) => ({ ...state, rows: [...state.rows, { id: state.next, title: '' }], next: state.next + 1 }), // the new row renders in the same patch, before the command runs ELEMENT: (state) => ({ focus: focusWithin(`[data-id="${state.next}"] .title`) }), },}The reducer for ELEMENT sees the state before the action, so state.next is the new row’s id. Prefer the child’s own model when the child decides; focusWithin is for a parent that does (adding a row, submitting a form whose fields are child components). If nothing matches, nothing happens; renderComponent() reports SYG640 when nothing in the view, children included, matches.
When it runs
Section titled “When it runs”A command runs once the render that its action may cause is on the page, so it reaches an element that the same action renders (the error message under a field, a new row, a dialog that a state flag adds):
- after the action, the command waits for the next patch of the DOM at which its target exists, and runs right after it;
- if the action changes nothing that renders, it runs at the first check, 16 ms later;
- while the target is missing, it keeps checking every 16 ms for about 1 s, then gives up (SYG640 in development);
- the pending commands of a component that is removed are dropped.
During server rendering (renderToString) nothing runs: there are no elements to call.
Recipes
Section titled “Recipes”Focus the first invalid field
Section titled “Focus the first invalid field”After a failed submit, move the focus to the first field with an error. The ELEMENT entry runs after the errors have rendered, so it can focus a field the same action marked invalid:
import { ABORT } from 'sygnal'
const validate = ({ name, email }) => ({ ...(name.trim() ? {} : { name: 'Enter your name' }), ...(email.includes('@') ? {} : { email: 'Enter an email address' }),})
export function Signup({ state }) { return ( <form> <label>Name <input className="name" value={state.name} /></label> {state.errors.name && <p className="error">{state.errors.name}</p>} <label>Email <input className="email" type="email" value={state.email} /></label> {state.errors.email && <p className="error">{state.errors.email}</p>} <button className="submit" type="button">Sign up</button> </form> )}
Signup.initialState = { name: '', email: '', errors: {} }
Signup.intent = ({ DOM }) => ({ NAME: DOM.input('.name').value(), EMAIL: DOM.input('.email').value(), SUBMIT: DOM.click('.submit'),})
Signup.model = { NAME: (state, name) => ({ ...state, name }), EMAIL: (state, email) => ({ ...state, email }), SUBMIT: { STATE: (state) => ({ ...state, errors: validate(state) }), // runs after the errors have rendered; the first invalid field gets the focus ELEMENT: (state) => { const errors = validate(state) if (errors.name) return { focus: '.name' } if (errors.email) return { focus: '.email' } return ABORT }, },}A native dialog
Section titled “A native dialog”showModal opens a <dialog> as a modal: the browser traps the focus inside it, dims the page and closes it on Escape. Its close event tells the component that it closed, whichever way it did:
export function Help({ state }) { return ( <div> <button className="open-help">Keyboard shortcuts</button> <dialog className="help"> <h2>Keyboard shortcuts</h2> <p>Press N for a new card.</p> <button className="close-help">Close</button> </dialog> <p className="status">{state.status}</p> </div> )}
Help.initialState = { status: 'Help is closed' }
Help.intent = ({ DOM }) => ({ OPEN_HELP: DOM.click('.open-help'), CLOSE_HELP: DOM.click('.close-help'), // close doesn't bubble; Sygnal listens on the dialog itself (Escape closes it too) HELP_CLOSED: DOM.close('.help'),})
Help.model = { OPEN_HELP: { STATE: (state) => ({ ...state, status: 'Help is open' }), ELEMENT: { showModal: '.help' }, }, CLOSE_HELP: { ELEMENT: { close: '.help', returnValue: 'done' } }, HELP_CLOSED: (state) => ({ ...state, status: 'Help is closed' }),}The dialog’s open state belongs to the browser here: the component keeps only a status it shows. DOM.close('.help').map(e => e.target.returnValue) gives the returnValue the dialog was closed with.
Scroll a new row into view
Section titled “Scroll a new row into view”A Collection item reaches only its own elements, so a new row scrolls itself into view, from its BOOTSTRAP (which runs once, when the item is created):
import { ABORT, Collection } from 'sygnal'
function TaskRow({ state }) { return <li className="row">{state.text}</li>}
TaskRow.model = { // only rows added by ADD scroll; the ones there at the start don't BOOTSTRAP: { ELEMENT: (state) => (state.added ? { scrollIntoView: '.row', block: 'nearest' } : ABORT) },}
export function TaskList() { return ( <div className="task-list"> <button className="add-task">Add a task</button> <ul> <Collection of={TaskRow} from="tasks" /> </ul> </div> )}
TaskList.initialState = { tasks: [{ id: 1, text: 'Water the plants' }] }
TaskList.intent = ({ DOM }) => ({ ADD: DOM.click('.add-task') })
TaskList.model = { ADD: (state) => { const id = state.tasks.length + 1 return { ...state, tasks: [...state.tasks, { id, text: `Task ${id}`, added: true }] } },}The list here scrolls inside a box of its own, so a new row has somewhere to scroll to:
.task-list ul { max-height: 6.5em; overflow-y: auto; margin-top: 0.5rem; }In a <VirtualCollection> most rows aren’t rendered, so the list jumps instead: its container takes { scrollToIndex: '.rows', index } and { scrollToId: '.rows', id }, sent by the component that renders it.
Testing
Section titled “Testing”renderComponent records every command the rendered tree sends. t.commands('ELEMENT') lists them in order, one entry per command (arrays flattened), as sent. With the mock DOM (the default) they are only recorded; with dom: 'real' they also run, so the test can check the focus or the dialog:
// @vitest-environment jsdomimport { it, expect, afterEach } from 'vitest'import { renderComponent } from 'sygnal'import { Signup } from './Signup.jsx'
let tafterEach(() => t?.dispose())
it('asks for the focus on the first invalid field', async () => { t = renderComponent(Signup) await t.ready() t.simulateEvent('.submit', 'click') await t.next(s => !!s.errors.name) await t.settle() expect(t.commands('ELEMENT')).toEqual([{ focus: '.name' }])})
it('moves the focus there (real DOM)', async () => { t = renderComponent(Signup, { dom: 'real' }) await t.ready() t.simulateEvent('.submit', 'click') await t.next(s => !!s.errors.name) await t.settle() expect(document.activeElement).toBe(t.query('.name'))})- The commands are recorded as the model sent them, so the test compares them with the same objects:
{ focus: '.name' }. - jsdom has no
showModal,show,close, popover methods orscrollIntoView. Withdom: 'real',renderComponentadds stand-ins: a dialog’sopenflips andclosefires itscloseevent, a popover’stoggleevent fires, andscrollIntoViewdoes nothing. Check real scrolling and focus trapping in a browser. - The mock DOM reports SYG640 and SYG641 too, so
t.expectNoDiagnostics()catches a command that missed its target.
Diagnostics
Section titled “Diagnostics”| Code | When |
|---|---|
| SYG640 (warning) | Nothing in the sending instance’s view matched the target within about 1 s, or the command has no target. sygnal-check reports a literal command whose class the view never renders |
| SYG641 (error) | The element has no method of that name (a typo such as fokus, showModal on a <div>; for a widget control, its spec has no such command either), the value isn’t a command object, or the method changes the DOM Sygnal renders. sygnal-check reports a misspelled documented method or spec command |
Both come from the development checks (sygnal/diagnostics, loaded by the Vite plugin in dev) and from sygnal-check. In production a command that can’t run does nothing.