Focus
Focus in the Accessibility guide.
Focus is DOM state the framework does not manage. Three rules cover most cases:
- Move focus when something the user acted on appears or disappears: a dialog opens, a form error appears, a route changes.
- Return it when that thing goes away, to the element that caused it.
- Do it after the element exists in the document. A
refcallback runs while the element is being built, before it is inserted, soel.focus()there does nothing.onMountruns after the first flush, untracked, once the tree is in the document.
src/dialog.tsxtsx
import { onCleanup, onMount, useStore } from "@shadow-js/core";
import { Portal, Show } from "@shadow-js/web";
function Dialog(props: { onClose: () => void }) {
let close: HTMLButtonElement | undefined;
// Remember who had focus, to give it back.
const opener = document.activeElement;
onMount(() => close?.focus());
onCleanup(() => {
if (opener instanceof HTMLElement) opener.focus();
});
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
onKeyDown={(event) => {
if (event.key === "Escape") props.onClose();
}}
>
<h2 id="dialog-title">Delete item?</h2>
<button type="button" ref={close} onClick={props.onClose}>
Cancel
</button>
</div>
);
}
export function Page() {
const [open, setOpen] = useStore(false);
return (
<div>
<button type="button" onClick={() => setOpen(true)}>
Delete
</button>
<Show when={open()}>
<Portal>
<Dialog onClose={() => setOpen(false)} />
</Portal>
</Show>
</div>
);
}
Things the example does that you should copy, and what it leaves out:
ref={close}assigns the element to the variable (a callbackref={(el) => ...}also works);onMount(() => close?.focus())uses it after insertion.onCleanupruns when the dialog is removed, so focus returns however it closes (button, Escape, navigation).aria-modal="true"tells assistive technology the rest of the page is inert, but it does not stop keyboard focus from leaving. A real modal also needs a focus trap (Tab and Shift+Tab cycle inside), to make the backgroundinert, to lock body scroll, and to close on outside press. The framework provides none of these.Portalrenders nothing in place on the server. A dialog open at render time is emitted at the end ofbodywhen the render has a portal collector (see the SSR guide, "Portals on the server"). Events follow the DOM tree, not the component tree: a handler on an ancestor of the call site does not see the portal's events.