Keyboard shortcuts

Press ← or → to navigate between chapters

Press ? to show this help

Press Esc to hide this help

Keyboard Focus

Exactly one thing in a rinch window owns the keyboard at a time. The runtime calls that the focus arbiter: an <input>, a <select> with its popup open, a rich-text editor, a render surface, or a generic focusable DOM node. Moving focus tears the previous owner down before installing the next, so two widgets can never both believe they are typing.

Most of the time you never touch this — put tabindex="0" on an element and it joins the Tab order, paints :focus, and activates on Enter/Space. This page is about the other case: a custom component that takes keyboard input of its own (a code editor, a canvas grid, a keyboard-driven list) and needs to know when it gained the keyboard, when it lost it, and what keys arrived meanwhile.

Making an element focusable

#![allow(unused)]
fn main() {
rsx! {
    div { tabindex: "0", "I can be tabbed to" }
}
}

Focusability comes from the tag or from an explicit tabindex, and an explicit one always wins — the browser rule. The value is read the browser’s way too (HTML’s rules for parsing integers): leading whitespace is skipped and the number ends at the first non-digit, so " 3", "2.5" and "3abc" are all tab indices (3, 2, 3); a value with no leading digits, or past the 32-bit range, is not a tabindex at all.

Focusable by tag
<button>, <select>, <textarea>, <input>always
<a>only with a non-empty href — a bare <a> is not a link

<summary> is deliberately not in the set: rinch has no <details> disclosure behaviour, so a focusable <summary> would be a Tab stop that does nothing.

A focusable element that is visually hidden but laid out — opacity: 0, or position: absolute off-screen — is in the Tab order, and that is correct: .sr-only text and skip links depend on exactly that, as they do in a browser. What leaves the order is a zero-sized box, display: none, and visibility: hidden/collapse. Neither is data-rid a focusability signal — the DropdownMenu’s full-screen dismissal backdrop carries one, and so do clickable cards, table rows and list items; none of those should be Tab stops.

AttributeEffect
tabindex="0"Reachable by Tab, focusable by click and by NodeHandle::focus(). Needed on anything that is not focusable by tag — a div you are driving yourself
tabindex="-1"Not in the Tab order, but still focusable by click and programmatically — the standard “focus this dialog when it opens” idiom, and the way to take a <button> out of the Tab order
disabled / data-disabledTakes no focus at all, and accepts no keyboard edit. Both spellings count — the component library writes the HTML one, the runtime’s own widgets write the data- one
readonlyFocuses, moves its caret, selects and copies like any other field — and refuses every command that would change its text (typing, delete, cut, paste, undo/redo)
data-nofocusA press here takes the click but not the keyboard: whatever is focused stays focused. Read anywhere on the pressed element’s ancestor chain, so a toolbar carries it once
data-trap-focusTab and Shift+Tab cycle inside this element instead of walking the page. What Modal, Drawer and Popover write while they are open — see Containing Tab inside an overlay

All five are boolean attributes: their presence is their value, so disabled, disabled="" and disabled="disabled" say the same thing. To say enabled, remove the attribute — a bool in rsx! does that for you (button { disabled: {move || busy.get()} }).

The two HTML attributes, disabled and readonly, are read by presence alone, exactly as a browser reads them: disabled="false" disables and readonly="false" is read-only, on desktop and on the web alike (issue #612). That applies to every tag the attribute reaches, so a <textarea readonly="false"> is read-only too.

rinch’s own data-disabled, data-nofocus, data-trap-focus, data-backdrop (an overlay backdrop that answers a press of any button, #1093) and data-scroll-lock-exempt (a scroll container an overlay’s scroll lock leaves alone, below) are the exception, and the only one: there the literal "false" turns the attribute off. data-nofocus, data-trap-focus and data-backdrop are read that way on both backends; data-disabled is a desktop attribute with no web reader, because the browser does not know it, and so is data-scroll-lock-exempt, because the web’s lock has nothing to exempt. Only "false" is excused — "0" is on, matching the web’s [data-nofocus="false" i] selector. Reach for the escape when you are writing the attribute by hand with set_attribute and would otherwise have to branch between writing and removing it; a bool in rsx! already removes it for you.

A disabled <fieldset> disables every control below it, which is what the element is for. HTML’s carve-out is honoured: controls inside the fieldset’s first <legend> stay enabled, so the control that re-enables the section can live there. No other tag’s disabled reaches its subtree — a disabled <button> does not disable a <span> inside it.

A press on a disabled control also paints no DOM :focus, so a focus ring never appears on something that owns no keyboard.

The same rule drives the CSS :disabled and :enabled pseudo-classes, so a control that refuses input also looks disabled — input:disabled { ... } matches, and a control below a disabled <fieldset> matches with it (the first <legend> exemption included). Styling is deliberately narrower than focus in one way: HTML defines both pseudo-classes over form elements only, so a <div data-disabled> is still removed from the Tab order but does not match :disabled — matching it would style on desktop what a browser, and therefore rinch-web, leaves alone.

A native <select> refuses the whole popup interaction, not just the last step: a disabled one opens no popup, and one disabled while its popup is already up dismisses it and commits nothing rather than firing oninput / onchange.

Disabled is re-checked at edit time, not only at focus time, so a field that goes disabled while focused — a reactive disabled prop re-rendering under a live caret — stops accepting keys immediately, and releases the keyboard, the way a browser moves focus to the body. The one thing that release does not do is fire the field’s data-onchange commit: everywhere else that commit is load-bearing (a window blur deliberately keeps the claim so alt-tabbing cannot fire it), but a control going disabled is not the user committing an edit, and browsers dispatch no change for it either.

Focus arrives three ways, and all three go through the same arbiter:

  • Tab / Shift+Tab — walks the focusable elements in the browser’s sequential order, and paints the :focus-visible keyboard ring: every positive tabindex first, in ascending order, then everything at 0 or focusable by its tag, ties in DOM order — tabindex="2" before "3" before a plain <button>, wherever each sits in the document. Inside a trap_focus overlay the same order applies to the overlay’s own controls. Tab from an element that is not in that order (a clicked tabindex="-1" node, even one inside another Tab stop, or one whose box has just gone) goes to the next Tab stop after it in DOM order, and Shift+Tab to the previous one, as Chrome does. What an opening dialog focuses is still its first control in DOM order, not the first in Tab order — HTML picks it that way.

  • A mouse press — claims the nearest focusable ancestor-or-self of whatever was hit, exactly as a browser does, and with any button: a right or middle press focuses too, as a browser’s mousedown does (measured in Chrome 153), and it does so before a data-oncontextmenu handler or the built-in text menu runs. Only :active is the primary button’s alone, and a press inside a focused rich-text editor leaves the keyboard with the editor whatever the button. Pointer focus does not paint the :focus-visible ring. A press that resolves to something other than the current claim holder moves the keyboard there — a nested focusable inside a focused node counts as “somewhere else” — and a press that resolves to nothing releases it.

    This applies to tabindex="-1" too — it keeps an element out of the Tab order, but it does not keep a click from focusing it, in rinch or in a browser. A control that must not steal focus from the field it sits beside — a toolbar button over a rich-text editor, a spinner next to a number input — needs data-nofocus.

  • node.focus() / request_focus(node_id) — programmatic, also no ring.

Selecting a text control’s text programmatically

NodeHandle::select() and NodeHandle::set_selection_range(start, end, direction) (issue #552) are focus()’s selection counterparts, for the “a rename box opens with its name filled in and selected, so the first keystroke replaces it” shape — set_selection_range matches the DOM’s own HTMLInputElement.setSelectionRange(start, end, direction) exactly, including its units: start/end are UTF-16 code units, not bytes and not chars, so a component written once targets desktop and rinch-web with the same offsets ("🙂".encode_utf16().count() is 2, not 1). direction is SelectionDirection::Forward (the anchor is start), Backward (the anchor is end), or None (unspecified, applied the same way Forward is). select() selects the control’s whole text — exactly set_selection_range(0, <the UTF-16 length>, Forward).

#![allow(unused)]
fn main() {
// `name_input` is the captured `NodeHandle` for the rename `<input>` (built
// once, outside the handler, the way `email_field` is above).
button { onclick: move || {
    editing.set(true);   // an `if` effect mounts the rename input
    name_input.focus();
    name_input.select(); // the whole name, ready to be typed over
}, "Rename" }
}

That ordering works because touching the DOM inside a handler flushes the queued effects first: by the time name_input.focus() runs, the if effect has already mounted the input editing.set(true) asked for.

Both calls work on a control that does not hold the keyboard yet, the same way a browser’s own setSelectionRange()/select() do: the range is set immediately and simply shows up the first time the control is focused — a component does not need to call focus() first, or in any particular order relative to select()/set_selection_range(). Like focus(), both are no-ops before this handle is mounted — and, on desktop, also a no-op on a node that is not a text control (a checkbox, a <select>, a generic tabindex node): such a node never installs the state a selection would apply to, so there is nothing to apply it to or hold it for, the same divergence a browser’s own thrown-and-discarded InvalidStateError would be papering over. A mouse click that focuses a field places its own caret and clears a selection that was pre-armed for it but never applied — calling set_selection_range() on a field and then clicking somewhere else in it does what you would expect: the click wins.

A focused <select> is closed, like a browser’s: Enter, Space or Alt+Down opens its popup, and the popup then owns the keyboard until it commits or is dismissed — at which point focus returns to the closed control, so Tab carries on from there rather than restarting. (A click outside the popup is the exception: it belongs to whatever it landed on.) The pointer opens and picks with the primary button only, as in a browser: a right or middle press on the closed control does not open it, and one on an option picks nothing and leaves the list open — but a press of any button outside the list dismisses it. Everything else focusable activates the nearest ancestor-or-self data-rid on Enter/Space, which is what makes div { tabindex: "0", onclick: … } behave like a button — and what makes Space on a Checkbox’s visually hidden <input> toggle the <label> that wraps it.

The Select component (not a native <select>) behaves the same way on both backends (issue #434): its trigger keeps the focus while the list is open, ArrowUp/ArrowDown move a highlight (wrapping at the ends), Home/End jump, Enter commits the highlighted option, Escape closes without committing, Tab closes and moves on, and typing jumps to the first option whose label starts with what was typed. Space commits like Enter, except while a type-ahead prefix is being typed, when it extends it (“new y” finds New York). The highlight starts at the selected option and is announced through aria-activedescendant. A key with Ctrl, Alt or Meta held is left to the app. The list owns keys only while the focus is in the Select: if something moves the focus elsewhere while it is open (a shortcut focusing a search box, a script), the list closes and the keys go to wherever the focus went.

Taking the click without the keyboard

An editor toolbar has a problem every GUI toolkit has to answer: pressing Bold must run the command without blurring the editor, or the command reads a selection that is no longer there. Browsers answer it with preventDefault() on mousedown, which suppresses the focus change while still delivering the click.

data-nofocus is that mechanism:

#![allow(unused)]
fn main() {
rsx! {
    // The whole toolbar opts out at once — every control inside it takes its
    // click without taking the keyboard.
    div { data-nofocus: "", class: "toolbar",
        button { tabindex: "0", onclick: move || ed.command("toggleBold"), "B" }
        button { tabindex: "0", onclick: move || ed.command("toggleItalic"), "I" }
    }
    Editor { editor: ed.clone() }
}
}

The rules:

  • It can be written on a component: Group { data-nofocus: "", … } or Paper { data-nofocus: "", … } puts it on the component’s root element, so a toolbar built from components needs no wrapper div (Attributes on a component).
  • It is read anywhere on the pressed element’s ancestor chain, so a toolbar carries it once instead of every button in it. Put it on the toolbar, not on a big content region — a press inside it suppresses the browser’s default, which includes starting a text selection.
  • It protects whatever holds the keyboard — the rich-text editor, an <input>, a render surface, another focusable node — not just the editor.
  • The click still fires. data-rid dispatch is untouched.
  • A text field inside a data-nofocus region still focuses normally. A link-URL field in a toolbar has to be usable, so the field’s own claim wins over the region’s opt-out.
  • Boolean attribute, same rule as data-disabled: present means on whatever the value, and the explicit "false" opts out — one of rinch’s own three attributes where that escape exists.
  • It works on both backends. On the web it becomes preventDefault() on the pointerdown.

Containing Tab inside an overlay

A dialog that lets Tab walk out into the page behind it is not really a dialog: the keyboard user ends up editing a form they cannot see. Modal, Drawer and Popover all take a trap_focus prop for this — true by default on the first two, false on Popover, which is not modal.

#![allow(unused)]
fn main() {
rsx! {
    Modal {
        opened_fn: move || open.get(),
        onclose: move || open.set(false),
        trap_focus: true,          // the default; `false` lets Tab out
        TextInput { placeholder: "Name" }
        Button { onclick: move || save(), "Save" }
    }
}
}

While the overlay is open its root carries data-trap-focus, and both backends read it: Tab and Shift+Tab cycle the focusable elements inside that element and wrap at its ends, instead of walking the whole document. When it closes the attribute is removed and Tab goes back to the page.

You can put the attribute on anything, not just these three components — it is a plain contract, the way data-nofocus is:

#![allow(unused)]
fn main() {
div { data-trap-focus: "", class: "command-palette",
    input { }
    button { "Run" }
}
}

The rules:

  • Which trap wins. Whichever one currently holds the keyboard; if none does, the last one in document order. Nesting therefore resolves inside-out — a modal opened from a modal is rendered deeper, so later — and when the inner one closes the outer takes over with focus unmoved. z-index is not consulted, so a raised-but-earlier overlay loses to a later one; that matches the dismiss stack, which answers Escape in the same order.
  • A trap with no box is not a trap. A closed Modal’s root is display: none, so it is skipped even if the attribute were left on it. Both guards are real, and the component relies on the first: it removes the attribute rather than writing "false" into it.
  • Only Tab is contained. A click outside the overlay still moves focus out of it, and so does a scripted focus(). That is what a non-modal <dialog> does; a browser’s showModal() goes further and marks the rest of the page inert, so even a scripted focus behind it is refused — measured in Chrome 150. rinch has no inert and no showModal semantics, so trap_focus is a Tab rule, not a modality barrier. The backdrop’s close_on_click_outside is what an outside click is for; full modality would need inert, which rinch does not have.
  • A trap with nothing focusable inside it swallows Tab. That is what containment means when there is nowhere to go — and on the web the same is true of a trap whose every control the browser refuses to focus (all of them inside a <fieldset disabled>, say): the key is consumed and focus stays put, rather than the stepping-on above running forever.
  • Boolean attribute, same rule as data-nofocus: present means on whatever the value, and the explicit "false" opts out.
  • Both backends, different code. Desktop starts its own focusable walk at the trap; rinch-web collects the trap’s focusable descendants with a CSS selector and calls focus() itself. The two sets are therefore computed separately and agree only as far as each is written to — desktop’s notion of focusable is already documented as broader than HTML’s in places. Trapping inherits that difference rather than creating it. On the web the browser is the authority, not rinch’s selector: a focus() it declines (a control inside a <fieldset disabled>, a tabindex it parsed differently) is stepped over rather than trusted, so an element rinch listed and the browser will not focus cannot stall the cycle.
  • Some browser Tab stops are not rinch Tab stops, on either backend: contenteditable elements, <iframe>, <summary>, <audio controls> and <area href> are reachable by Tab in a browser and are in neither backend’s focusable set. An overlay containing one loses it while trapped — and outside a trap those elements are not desktop Tab stops either, so this is the focusable set’s shape rather than something trapping introduces. Give such an element an explicit tabindex="0" if it has to be reachable.

Moving focus in, and giving it back

Containment is half of what a dialog does. The other half is that opening one takes the keyboard and closing it gives it back (issue #695), and both are tied to the same trap_focus prop — trap_focus is rinch’s spelling of “this overlay is modal”, and a browser moves focus for showModal() and not for show(). An overlay that wants one without the other cannot ask for it.

On open, the overlay remembers whatever holds the keyboard — usually the button that was clicked — and then focuses inside itself:

ComponentWhat it focuses
Modal, Drawerthe autofocus descendant, else the first focusable
Popoverthe autofocus descendant, and nothing without one

Popover’s rule is the HTML popover API’s, not the dialog’s: an auto popover runs its focusing steps only for an element that asked. A popover that grabbed the keyboard on every open would interrupt whatever the user was typing in.

On close — and on unmount while still open, which is what if show { Modal { … } } does — the keyboard goes back to the remembered element, provided it can still take it (see below). If it cannot, the claim is released rather than left inside the overlay that has just gone. Focus the user moved out of the overlay before it closed is left alone entirely: neither released nor restored over, which is HTML’s own dialog rule.

Nesting needs no special case: an inner overlay remembers whatever the outer one focused, so closing the inner restores into the outer and closing the outer restores to the page.

Three things to know about the machinery:

  • Desktop decides both a turn later. An overlay opening or closing is a class change in the same effect flush, so at that instant its children still carry the zero-size boxes their display: none ancestor gave them — and on a close, whether the remembered opener can still take the keyboard is the same kind of question about boxes. The desktop backend therefore parks the request and resolves it after the next layout, which is the turn a signal write already triggers. rinch-web answers on the spot, because the browser lays out on demand and refuses a focus() it should refuse. The browser’s focus listeners run inside these focus moves (NodeHandle::focus, an overlay opening or closing) with the document free: a blur, focusin or onchange handler of yours that they run may use any NodeHandle. The same holds when a NodeHandle verb takes the focused element out of place (see When the focused element goes).
  • “Still there” means it can still take focus, not merely that it is attached. An opener that went disabled while the dialog worked, or one that lives inside an outer overlay closed before this one, is connected and unreachable; the keyboard is released instead. What is not checked is identity: a node id freed and handed to a different, attached node would still be focused — the recycled-slot hazard of issue #304, which desktop has independently of this.
  • A Popover that declines the move is still a Tab trap when trap_focus is on. Focus stays where it was, and the next Tab enters the popover and is contained there. That is containment’s own rule (the last visible trap wins), not a consequence of the move policy.

The portable API this rests on is three DomDocument methods, reachable on any NodeHandle: active_element(), focus_into(policy) and restore_focus(opener). A component that builds its own overlay can use them directly. There is deliberately no bare blur(): releasing the keyboard is never the whole answer, only the fallback half of a restore, and a caller that could only blur would have to make the decision restore_focus exists to make.

Registering a focus target

register_focus_target attaches callbacks to a focusable node. It does not change who can be focused — it changes what your component is told about it.

#![allow(unused)]
fn main() {
use rinch::prelude::*;

#[component]
fn key_grid() -> NodeHandle {
    let focused = Signal::new(false);
    let cursor = Signal::new(0usize);

    let grid = rsx! {
        div {
            tabindex: "0",
            class: {move || if focused.get() { "grid focused" } else { "grid" }},
            "cell "
            {move || cursor.get().to_string()}
        }
    };

    register_focus_target(
        &grid,
        FocusEntry::new()
            .on_focus_gained(move || focused.set(true))
            .on_focus_lost(move || focused.set(false))
            .on_key(move |k| match k.key.as_str() {
                "ArrowRight" => {
                    cursor.update(|c| *c += 1);
                    true // consumed
                }
                "ArrowLeft" => {
                    cursor.update(|c| *c = c.saturating_sub(1));
                    true
                }
                _ => false, // let the runtime have it
            }),
    );

    grid
}
}

Every callback is optional; FocusEntry::new() with none of them is legal and registers the node without asking for anything back.

What fires, and when

CallbackFires
on_focus_gainedTab onto the node, a press on it (or on any of its children), focus() / request_focus, and when the window regains OS focus while this target still holds the claim
on_focus_lostAnything else takes the keyboard — another registered widget, an <input>, a <select>, the rich-text editor, a render surface — a press landing outside, and when the window loses OS focus
on_keyEvery KeyDown and KeyUp while this target holds focus, before the runtime’s own handling — read k.kind (or k.is_up()) to tell them apart
on_imeEvery IME composition event while this target holds focus (see IME below)

Presses and releases

k.kind is KeyEventKind::Down or Up; k.is_down() / k.is_up() are the shorthands. OS auto-repeat arrives as Down, and KeyEventData does not yet distinguish it from a fresh press. The platform event does, since issue #463 — PlatformEvent::KeyDown carries a three-state KeyRepeat (Fresh / Repeat / Unknown), filled in by winit on desktop and by repeat_count() on Android, and that is what the runtime’s own once-per-press Enter/Space activation reads. Surfacing it to on_key needs the same three-state answer (a plain bool would report a held key as fresh wherever a backend cannot see, which is the divergence a document-level hook exists to avoid) and so needs a home for the type that rinch-core can reach. Tracked as #797.

A press and its release are spelled by the same rule, from the same fields, so pairing them by k.key works by construction — which is what “is W still held” needs. Concretely: the platform event’s logical_key carries the full layout-produced KeyboardEvent.key value on the press and the release alike, case included. On AZERTY, the key labelled A is "a" on the way down and on the way up, not "a" down and "q" up; Shift+A is "A" both ways, and Shift+1 is "!" both ways — the same strings a browser reports, so the same consumer code works against rinch-web. Case is identity, so a Shift pressed mid-hold changes what the eventual release spells ("w" down, "W" up) — exactly as in a browser; track held keys by the physical k.code, or fold case at the comparison, if that matters to you.

On Android a hardware key’s release is spelled as its press was reported, whatever happened in between: "W" down with Shift held comes up as "W" even if Shift went up first, and a letter that combined with a dead key ("é") comes up as that same string. A release whose press the app never saw (a key already held when the app came to the front) is spelled from the layout with the modifiers held at the release. An auto-repeat is still spelled from what it types: after a dead key the press is "é", the repeats are "e" and the release is "é", so track held keys by k.code there too (#1418). Text the soft keyboard commits goes through InputConnection and has no key events; a raw key event an IME sends (sendKeyEvent, typically Backspace and Enter) comes through the same translation as a hardware key, release included.

Two things to know:

  • A release is delivered to whoever holds the claim at release time. A focus change mid-chord can therefore hand a target a release it never saw pressed — treat on_focus_lost as “everything is up” if you track held keys.
  • A release’s return value is ignored. There is nothing downstream of it to suppress, and the runtime’s own release work (clearing the Enter/Space activation latch) must happen whatever a handler thinks — otherwise a consumed release would strand the latch and swallow the next press.

on_key returns true to consume the key. A consumed key stops there: no Tab navigation, no Enter/Space activation, no DevTools shortcut. Returning false (or not setting on_key at all) leaves every one of those working exactly as it would for an unregistered node, so registering costs you nothing you did not ask for.

k.key is spelled the way the browser spells KeyboardEvent.key, on desktop as on rinch-web (which forwards event.key() unchanged), and k.code the way it spells KeyboardEvent.code. That includes the spacebar: k.key is " " and k.code is "Space", and k.is_space() answers it on every backend. (Desktop reported k.key == "Space" until issue #1161; code that matched that should match k.code == "Space" or k.is_space() instead.) The key is resolved in four steps:

  1. A key with a fixed spelling wins over the text it would insert — "ArrowLeft", "Enter", "Escape", "Tab", "PageUp", "F1"…"F12", "Shift", and the spacebar’s " ", so a release (no text) and a chord (text suppressed) spell it like a plain press.
  2. Otherwise the inserted text wins, so a non-QWERTY layout reports the letter actually typed rather than the physical QWERTY position: the AZERTY key at the QWERTY-Q position is k.key == "a", and Shift+A is "A".
  3. Otherwise the layout’s key value. A modifier suppresses the inserted text, but the layout-produced KeyboardEvent.key value survives it — so a chord keeps step 2’s promise: on AZERTY, Ctrl plus the key labelled A is k.key == "a" (and Ctrl+Shift makes it "A" — the value is case-accurate), the same letter the editor’s own keymap acts on. It also names shifted punctuation (Ctrl+Shift+1 is "!" where the layout puts one), a dead key ("Dead"), and keys rinch has no KeyCode of its own for but the platform names — CapsLock, media keys.
  4. Otherwise the physical key’s US-layout character, for events that carry no layout value at all (the debug channel, injected and embedded events): Ctrl+S is "s", Cmd+1 is "1", Ctrl+- is "-".

k.code is always the physical key ("KeyS", "Digit1").

Two things to know. k.key is case-accurate, browser-style — Ctrl+S is "s", Ctrl+Shift+S is "S" — and a press and its release spell alike (both read the modifier state of their own moment), which is what pairing them by k.key relies on. And a key bound to a native menu accelerator is consumed by the menu before the document sees it — but only the press: the release still arrives (the menu consumes nothing on the way up), so it is one more source of a release with no visible press.

The one key that still reports nothing is one rinch has no KeyCode for (k.code == "Other") arriving with no layout value and no inserted text. A real keystroke carries the layout value (unless the platform itself cannot identify the key), so this is mostly the injected regime: the debug channel names only single characters and the named keys, so an injected Ctrl+/ has no spelling to fall back to and never reaches the hook. From the keyboard those keys are fine: Ctrl+/ reports "/" through step 3, and unmodified the character arrives as the inserted text.

Both focus callbacks run after the transition is complete: the arbiter and the DOM :focus state are already installed, so a callback may re-enter the runtime freely — move focus again, mutate the DOM, save a document.

Enter and Space

A focused node that does not consume Enter/Space gets the runtime’s default: they dispatch the onclick handler of the nearest ancestor-or-self that has one, once per physical press. That is what makes div { tabindex: "0", onclick: … } behave like a button. If your widget wants Enter for itself, consume it in on_key.

Unmounting

Unmounting the component deregisters silently. on_focus_lost does not fire, even if the node held the keyboard at the time — by then the component’s scope has been disposed and its signals freed, and calling back into it would panic. Do your teardown in the component’s own cleanup path, not in on_focus_lost.

The arbiter notices the vanished target on its next key dispatch and drops the claim, so keys fall back to the global handlers rather than disappearing.

Window focus

When the window loses OS focus, the focused widget keeps its claim — it is only notified, and notified again when the window comes back. This is browser behaviour, and it is deliberate: releasing focus on every alt-tab would fire onchange on whatever field the user was typing in each time they switched windows.

So the pair to expect is on_focus_lost on window blur, on_focus_gained on window refocus, with no key routing in between. Use it to hide a caret and idle a blink timer. While the window is blurred, rinch reports the OS IME disabled, so a candidate box follows the window that actually has the keyboard.

The runtime does the same for its own caret: the rich-text editor’s caret stops blinking while the window is blurred and shows solid, resuming from the solid phase on refocus. The blink is the event loop’s only timed wake, so a backgrounded rinch app now idles completely instead of waking twice a second to animate a caret nobody can type into. The selection highlight is unaffected — the claim is still held, so a blurred window still shows what is selected.

IME

A widget with its own text model can take IME composition — CJK conversion, autocorrect, dead keys, a swipe keyboard — by registering on_ime. That is what declares the target a text target: the runtime then switches the platform input method on while the widget holds the keyboard, and routes every one of the five portable ImeEvent variants to it — the same contract the rich-text editor and a built-in <input> consume. A registration without on_ime turns nothing on, so a focusable card or a custom checkbox never pops a candidate window.

#![allow(unused)]
fn main() {
use rinch::prelude::*;

register_focus_target(
    &node,
    FocusEntry::new()
        // Where the OS puts its candidate box, in logical window pixels.
        .caret_rect(move || Some(caret.get()))
        .on_ime(move |e| match e {
            // A transient overlay you render and discard — never document text.
            ImeEvent::Preedit { text, cursor } => model.set_preedit(text, *cursor),
            // The conversion the user chose. Insert it as one edit.
            ImeEvent::Commit(text) => model.insert(text),
            // Composition ended with nothing committed.
            ImeEvent::Disabled => model.clear_preedit(),
            _ => {}
        })
        .on_focus_lost(move || model.clear_preedit()),
);
}

caret_rect is (x, y, w, h) in logical window space — CSS pixels from the window’s top-left, the same space layout bounds are reported in. Do not pre-multiply by the display scale factor; the shell hands the rect to the platform as a logical size and the platform scales it. Return None when there is no caret; placement then falls back to the platform’s default. The provider is polled every time rinch reconciles the window’s IME state (once per event-loop iteration), so the candidate box follows the caret with nothing to notify — keep it cheap, and do not mutate the DOM from it.

Two things the runtime deliberately does not do:

  • It never fabricates an event. It holds no preedit on your behalf, so a focus change is not an ImeEvent::Disabled. When another target claims the keyboard the window’s input method may stay enabled throughout and nothing ends your composition — clear the preedit in on_focus_lost, as above.
  • ImeEvent::DeleteSurrounding is inert on desktop today. Rinch asks winit only for cursor-area support, so no desktop backend advertises surrounding-text and the variant never arrives. Android’s InputConnection does send it.

Everything under Window focus applies: while the window is blurred, IME reports disabled and no composition is routed, but the claim — and on_ime with it — comes back on refocus.

The dismiss stack

Escape is the one key an overlay has to answer without holding the keyboard: a Modal is dismissed by Escape whether the focus is on a field inside it, on the modal itself, or nowhere at all. That is a different job from both APIs above, and it has its own registry (issue #474).

#![allow(unused)]
fn main() {
use rinch::prelude::*;

let opened = Signal::new(true);
let handle = push_dismiss_handler(root.doc_key(), move || {
    if opened.get() {
        opened.set(false);
        true            // consumed: the key stops here
    } else {
        false           // not open: pass it to the overlay below, then the app
    }
});
__scope.on_cleanup(move || drop(handle));
}
  • It is a stack, last in first out. The most recently registered handler is asked first, so two nested modals close innermost-first. The first handler that returns true consumes the key and the ones beneath it are never asked.
  • Answer false while you are closed. An overlay usually stays mounted when it closes — opened_fn only toggles a class — so the check belongs inside the handler, at dispatch time, not at registration. A handler that always returns true swallows Escape for the rest of the session.
  • It is released on unmount, twice over: the DismissHandle releases it when it drops, and an entry whose owning scope has been disposed is dropped as the scan passes it rather than run (issue #183 — its captured signals are already freed).
  • It is scoped per document, on desktop and embed. Two RinchContexts on one thread do not answer each other’s Escape (issues #134, #139). On rinch-web there is nothing to scope and nothing doing the scoping: a page dispatches with no document marked, and the rule is deliberately permissive when either side is unmarked, so every handler is reached. The explicit key you pass at registration is what carries the distinction where one exists.
  • Both backends, no branch. Dispatch lives inside dispatch_keyboard_event, which desktop calls ahead of the focus arbiter and rinch-web calls from its document keydown listener.

Some things are dispatched before the stack and will take Escape from it: a set_keyboard_interceptor that consumes the key, and an in-progress drag, which Escape cancels. Both apply on either backend. rinch-web has one more — a focused RenderSurface swallows every key there, where on desktop a surface is routed by the arbiter, i.e. after the stack. That asymmetry is older than the stack (the interceptor already had it) and is not something this API changed.

Register at mount, or at open

The snippet above registers once, when the overlay mounts, and answers false while it is closed. That is what Modal, Drawer and Popover do, and it is right for an overlay whose open state arrives as a prop: render runs once, so there is no later moment to hang a registration on.

It is the wrong choice for an overlay statically nested inside another one. A component renders after its children, so Modal { ColorInput { … } } pushes the input’s entry first and the modal’s on top of it — and the stack is LIFO, so the modal answers Escape while the colour picker is the thing on screen.

An overlay that owns its open state avoids that by pushing when it opens and releasing when it closes. Opening happens after every ancestor has mounted, so LIFO puts the entry where the user expects it with no precedence rule anywhere. This is what the native <select> popup does (issue #671) and what ColorInput’s dropdown does (issue #465); in a component, rinch_components::overlay_dismiss::arm_close_on_escape_while_open is that policy written out. Such a handler consumes unconditionally — it exists only while the overlay is open, so it has no closed state to decline in — and the release must cover unmount-while-open as well as close.

Modal, Drawer and Popover already do all of this for you through their close_on_escape prop — reach for push_dismiss_handler when you are building an overlay of your own. Prefer it over set_keyboard_interceptor for Escape: the interceptor is a single slot per document, so a second overlay registering there silently disables the first, and when it unmounts it clears the slot rather than restoring what it displaced.

A popup that owns every key while it is open and focused

#![allow(unused)]
fn main() {
let handle = push_key_handler(
    &root,                                   // the owner: the popup's component root
    move |k: &KeyEventData| k.key == "ArrowDown" && { step(); true },
    move || opened.set(false),               // focus left the owner
);
}

push_key_handler puts an entry on the same stack that is offered every key press (never a release), ahead of the Escape scan; true consumes the key, which on rinch-web also preventDefaults it. It is for a popup whose focus stays on something outside the popup’s list — the Select component’s option list, whose combobox trigger keeps the focus while the arrows move a highlight.

It is offered a key only while the focused element is the owner or inside it. A field focused while the popup is open — by a shortcut, a script, an autofocus — keeps its own keys, and on the web a key typed outside the rinch mount is never touched. When focus moves and is no longer inside the owner, the entry’s on_focus_leave runs (the backends report every focus move), which is where a listbox closes; pass || {} if yours should stay open.

Register it at open and release it at close and on unmount — rinch_components::overlay_dismiss::arm_keys_while_open is that policy. Decline Escape in it and give Escape its own dismiss entry, so “which overlay is on top” stays one question.

When the focused element goes

On the web, a NodeHandle verb that removes or moves the focused element, or an element around it, lets the focus go first: remove, discard, remove_child, replace_with (for either node), set_inner_html and set_text over it, and append_child / insert_before / insert_after of a node that is already in the document. That covers a reactive if hiding a panel, a keyed for moving a row, and unmounting a root. The focused element is blurred, with the document free, and then the node goes. So:

  • a key entry whose owner is in the part that goes gets its on_focus_leave (an open Select in a hidden panel closes). Chrome fires the focus events by itself when a focused node is removed; Firefox fires nothing, and rinch’s own blur() makes the two agree;
  • a modified field in it gets its onchange only while its handler is still alive: a field removed directly, or a captured one a branch hides (if show { {field} }). A field the hidden branch built (if show { input { onchange: … } }) has had its handlers freed before it goes, and commits nothing;
  • a handler those events run may use any NodeHandle, and the node it is about is still where it was. A handler that keeps putting the focus back inside the part that goes is stopped the fourth time: that part is held inert for the last blur, so the focus cannot return to it;
  • a verb the browser refuses (an insert_before whose reference is not a child of the parent, a node appended into itself) moves nothing and keeps the focus;
  • a node moved inside the document keeps the focus where the browser has Node.moveBefore (Chrome 133 and later; check your other target browsers), as it always has on desktop. append_child, insert_before and insert_after of a node that is already in the document — a keyed for moving a row — are then that call: no focus event fires, an open Select in the row stays open and an <iframe> in it is not reloaded (measured in Chrome 153 and Firefox 157); a scroller in it keeps its offset in Chrome and is reset to the top in Firefox 157. A running CSS animation or transition in the row carries on and an IME composition in its <input> is not ended (measured in Chrome 153). One exception to “keeps the focus”: a node moved under a parent where it cannot be focused (display: none, hidden, inert, visibility: hidden, a disabled <fieldset>, a closed <details> or <dialog>) keeps it through the move and the browser blurs it afterwards, so a key entry’s on_focus_leave runs then, once. A page’s own moveBefore polyfill written over insertBefore is recognised and not used. In a browser without the method the move is a removal and an insertion, the moved node loses the focus and the list above applies; call focus() on it again after the move if it must keep the keyboard there. A move into or out of a detached subtree, and replace_with, take that path in every browser.

Three attribute writes can cost the focused element its focus in place: hidden on it, and removing the tabindex or contenteditable that made it focusable (Chrome blurs inside the write; disabled, inert and display: none fire nothing there). set_attribute, write_attribute and remove_attribute make those writes with the document free too. A tabindex write that leaves the element focusable (a roving tabindex) keeps the focus.

Not covered: UpdateBatch::apply, which is handed the document already borrowed, and any other code that calls a DomDocument method directly. A SetText or attribute write there that unfocuses an element still runs the browser’s listeners under that borrow.

Locking the page behind an overlay

The other thing an open dialog owes the page behind it is stillness. Modal and Drawer do it for you through their lock_scroll prop (default on, issue #474); a custom overlay takes the same lock on its own root:

#![allow(unused)]
fn main() {
// On open — the node matters: it names the subtree that stays scrollable.
root.set_scroll_locked(true);
// On close, and again on unmount, or the page never scrolls again.
root.set_scroll_locked(false);
}
  • Locks are counted. Two overlays open and the inner one closing leaves the page locked. Every true must be matched by exactly one false.
  • Release it on unmount as well as on close. An overlay can be taken out of the tree while still open, which is what if show { Modal { .. } } does. The effect that would have unlocked it never runs again, so the release belongs in an on_cleanup too. Modal and Drawer already do both.
  • The two backends do different things, and this is the one overlay behaviour where that is visible. Desktop refuses the gesture: a wheel (which is what a touch scroll arrives as too) and a scrollbar-thumb press are rejected for any container that is not inside a locking overlay, so the dialog’s own overflow: auto body still scrolls. rinch-web sets overflow: hidden on the real <html>, because rinch cannot gate the browser’s own wheel.
  • So web also removes the page’s scrollbar and desktop does not. A web page with a classic (non-overlay) scrollbar shifts sideways when an overlay opens. Desktop’s bars are overlays with no gutter, and a locked page’s bar stays painted — visible, and inert.
  • Input only. Programmatic scrolling (set_scroll_top, a list the app scrolls itself) is untouched on both.
  • A drag already in flight ends when a lock arrives. A scrollbar thumb the user is holding when something else opens a dialog is released rather than left to go on scrolling the page.
  • In island mode the lock is still the whole page. There is one <html>, so a rinch Modal inside an island freezes the host page, not just rinch’s own region — one lock, counted across every island on the page. That is what every web overlay library does, but an embedder should know it before shipping a dialog into someone else’s page.
  • On iOS Safari, overflow: hidden on <html> is not always enough to stop rubber-band scrolling; libraries there add a position: fixed body trick. We have not tested it and rinch does not do it, so treat the web half as “the page stops scrolling in every browser we have measured” rather than as universal.

Do not reach for RenderScope::body_handle() and set overflow: hidden there: on the web that handle is <div id="rinch-body">, a descendant of the real <body>, and styling it does not stop the page scrolling.

A scroll container outside the overlay: data-scroll-lock-exempt

On desktop the lock refuses every scroll container that is not inside a locking overlay’s root. That is the page behind the overlay, which is the point, but it is also any scroll container of yours that sits outside the overlay in the tree and is still meant to be used while it is open: a popup portalled to <body>, a menu hanging from window chrome, a panel that paints over the dialog. Mark it:

#![allow(unused)]
fn main() {
div {
    class: "my-popup-list",
    style: "max-height: 300px; overflow-y: auto;",
    data-scroll-lock-exempt: "",
    // ...
}
}
  • The attribute exempts its subtree, so a popup carries it once on its root and every scroller inside scrolls. On a component, write it the same way: a hyphenated attribute goes on the component’s root element.
  • It exempts everything under the marked node and nothing else: an unmarked scroll container outside the overlay stays locked.
  • It cannot unlock the page. A mark on a node that contains the locking overlay — <body>, an app root, a wrapper around the dialog — is ignored, so the page behind the overlay stays locked whatever its ancestors carry. Mark the popup, not something above it.
  • It is not checked against paint order. A marked scroller that the overlay covers also scrolls when the wheel is over it (the pointer is on the backdrop, and the wheel finds the scroller underneath). Mark only what is shown above the overlay.
  • It is one of rinch’s data- boolean attributes: on unless its value is "false", and a reactive data-scroll-lock-exempt: {|| flag.get()} removes it when flag is false. rinch_core::events::SCROLL_LOCK_EXEMPT_ATTRIBUTE is the name, for code that writes it through a NodeHandle.
  • There is nothing to release: the exemption goes with the node.
  • Desktop only, and the web needs none. The web’s lock is overflow: hidden on <html>, which stops the page and leaves every scroll container inside it scrolling, so the attribute is inert there.

The DOM menu bar (the in-app bar of a Linux window) marks its own dropdowns and submenu flyouts, so a menu too long for the window scrolls while a dialog is open (issue #701). The runtime’s native <select> popup, which it appends to <body>, is exempt the same way through the runtime’s own list (NodeTree::push_scroll_lock_exempt); you do not need to mark a <select>.

Undo and redo in a text field

A focused <input> or <textarea> undoes with Ctrl+Z and redoes with Ctrl+Shift+Z or Ctrl+Y (Cmd on macOS) on desktop, as a browser field does on the web (issue #288).

  • oninput fires with the restored text, exactly as for a keystroke, so a controlled field’s signal follows the undo. The eventual onchange compares what the field shows at the commit with what it showed when focus arrived, so undoing back to the starting text commits nothing.
  • A readonly field refuses both, like every other text-changing command, and a field that goes disabled while focused releases the keyboard instead.
  • One key press is one undo step. Each typed character is its own step — desktop does not merge a run of typing into one the way a browser does. Typing over a selection is one step: the selected text comes back whole.
  • A keystroke and the rewrite its own oninput made are one step. A normalizing field (upper-casing, say) therefore undoes to the text before the keystroke, never to the raw text the handler is about to rewrite again. A keystroke the handler rejects — writes the field back to what it showed, as a digits-only filter does with a letter — leaves no step at all. This is where desktop and the web differ: a browser treats that rewrite as a script write to .value and drops the field’s whole undo history, so a normalizing field has no undo on the web.
  • Any other write to the field’s value clears its undo and redo history — a value_fn reset, a clear after onsubmit, a write from a timer or another handler. It is the app’s change, not the user’s, so Ctrl+Z cannot bring back text the app replaced. That is what a browser does for a script write to .value, so for a write made outside a keystroke desktop and the web agree.
  • Not with Alt, and not mid-composition. Ctrl+Alt+Z is left alone (it is AltGr on Windows, which types a character on some layouts), and Ctrl+Z does nothing while an input method is showing a composition.
  • The built-in right-click menu (below) has no Undo row; the chords are the way in.

Right-clicking a text field

A right press on a text target — an <input> of a text-like type (text, search, url, tel, email, password, number, or no type at all), a <textarea>, or the rich-text Editor — opens a built-in Cut / Copy / Paste / Select all menu on desktop (issue #813). It is a menu the runtime draws itself, as a DOM overlay in the same shape as the native <select> popup, so it is the same on Linux, macOS and Windows, works in a borderless or transparent window, and follows the theme’s palette when there is one.

  • A data-oncontextmenu handler still wins. If the pressed node or any ancestor carries a live one, it runs exactly as before and the built-in menu stays shut. Attach one to a field to give it a menu of your own.
  • Each item runs the code its chord runs — Ctrl+X / Ctrl+C / Ctrl+V / Ctrl+A (Cmd on macOS), including the editor’s asynchronous, anchored paste. Today that is literally one function per operation on both paths, and a fixture pins item and chord to the same value, selection and clipboard; nothing in the code enforces it, so a check added to one path alone would drift the other silently.
  • Enabled states follow the field. Cut and Copy need a non-empty selection; a readonly field greys Cut and Paste; a password field greys Cut and Copy; Select all needs content; a disabled field takes no focus and gets no menu. Without the clipboard feature Cut, Copy and Paste are all greyed — there is no clipboard for them to reach, and an enabled Cut would delete text it never copied. Paste is not decided by reading the clipboard — a read can block for up to four seconds on X11 — so it stays enabled over an empty clipboard, where a native menu would grey it out; pasting nothing does nothing.
  • The press places the caret like a native editor’s. Outside the current selection it moves the caret to the press point; inside it, the selection is kept, so Cut and Copy act on what you selected.
  • The field keeps the keyboard while the menu is open and after an item runs. Up / Down / Home / End move over the enabled rows (wrapping), Enter or Space runs the highlighted one, and Escape closes through the dismiss stack — the menu pushes its entry at open time, above an enclosing Modal’s, and releases it on close. Any press outside the menu closes it without acting and is swallowed; a wheel closes it too.
  • It goes with its target. The menu closes when the field’s claim moves (a click elsewhere, a programmatic focus), when the window loses focus, and when the field leaves the document — the last is checked on every event rather than waiting for a second event that might never arrive.

On rinch-web the browser’s own menu covers text fields and the runtime draws none (issue #812). On Android a long press in a field arrives as the same right press; which presentation the Android shell uses is that platform’s half of #813 — the runtime exposes the state and the actions (RinchApp::text_edit_state, perform_text_edit) for a native toolbar to build on, with this menu as the fallback.

Where this does not apply

  • The browser backend (rinch-web). There is no arbiter there because the browser is one: register_focus_target is a desktop / Android / embed API. On web, give the element a real tabindex and use the DOM’s own focus, blur and keydown events. data-trap-focus is the exception — it works on both backends, because it is a contract the backend reads rather than a registration against the arbiter.
  • The document-level keyboard hook. set_keyboard_interceptor is a capture-phase hook for the whole document, dispatched before the arbiter and regardless of focus. It is for global shortcuts; on_key is for a focused widget. They are different jobs and both still exist. It routes per document (issue #340): a hook registered while a document’s code is running — its event dispatch, its mount (issue #295), or an effect it owns, wherever that effect happens to be flushed — intercepts only that document’s keys, and one registered from main is the thread-global fallback that intercepts for every document without its own — so two windows that each register at mount or from their own event handling no longer clobber each other. Registrations made outside any document (from main, a timer or run_on_main_thread closure armed there, or on rinch-web, which marks no document) still share the single fallback slot, last-wins. A timer, run_on_main_thread closure or http/ws completion armed inside a document runs as that document’s code (issue #963), so what it registers or clears is that document’s. A document is served whichever of its own hook and the fallback was registered later, so a single-window app keeps last-registration-wins wherever it registers from; clear_keyboard_interceptor from outside any document clears every entry, and from inside one leaves that document with none. The cost with several documents on one thread (two embedded contexts, an app and its DevTools): code outside any document — main, a timer armed there, a closure queued from a worker thread, a native menu or tray callback — cannot say which document it belongs to, so its registration overrides every document’s own hook and its clear wipes every document’s. Registrations made inside documents stay isolated from each other. Its lifetime does match the arbiter’s, though: registering it during a render releases it when that component unmounts, exactly as a FocusEntry is deregistered (issue #183). Registering it from main keeps app lifetime. For Escape, use the dismiss stack instead — one slot cannot nest.
  • IME on the browser backend. on_ime is desktop / Android / embed only, like the rest of this API. On web, attach compositionstart / compositionupdate / compositionend to your element yourself — the browser delivers composition to whatever it considers focused.
  • The Android soft keyboard. A registered target participates in desktop IME, but does not yet raise Android’s on-screen keyboard: the shell still watches for a focused <input> or the rich-text editor.
  • Modality. Tab is contained by an open Modal or Drawer — see Containing Tab inside an overlay — but a click still reaches controls behind the backdrop wherever the backdrop itself does not cover them, and a click or a scripted focus() outside an overlay moves focus out of it. A browser’s showModal() refuses both, because it marks the rest of the page inert; rinch has no inert and models no showModal. DropdownMenu has no trap_focus prop and contains nothing.