Rask
Rask
v0.23.1-alpha.0.496
You're reading the Rask v0.23.1-alpha.0.496 guides. Updated .View source
All guides

JS interop — IJSRuntime, typed APIs & refs

Calling JS from C#, the typed browser-API layer, element refs, and wrapping a third-party JS library. The browser-side half is TypeScript — a .js sibling is RASK055.

‹ Back to JavaScript interop

Calling JS from C# (IJSRuntime)

Your component's own scoped .ts needs none of this — its exports are typed private methods (await Width(_box)), see Calling your script from C#. IJSRuntime is for everything else: a browser API, a library's global, another component's script.

Inject IJSRuntime through the constructor (not a property — a non-nullable settable property would become a required chain step) and dispatch from a lifecycle hook or event handler:

public sealed partial class CodeSample : Component
{
    private readonly IJSRuntime _js;
    public CodeSample(IJSRuntime js) => _js = js;

    protected override async Task OnRendered() =>
        await _js.InvokeVoidAsync("hljs.highlightAll");
}

Nothing (no el) is passed automatically — pass what the function needs. For a return value use InvokeAsync<T>. On WASM a non-primitive T must be rooted for the trimmer (DAM annotation or a JsonSerializerContext).

A sessionStorage round-trip through the unified IJSRuntime — set, read, and remove, each a plain InvokeVoidAsync / InvokeAsync<string?> against a built-in browser API, identical on both transports:

JsRuntimeDemo.cs
using Microsoft.JSInterop;

namespace Rask.Site.Features;

/// <summary>
///     Round-trips <see cref="IJSRuntime" /> against <c>sessionStorage</c>. Works on both Server
///     (per-session WS-bound <c>RaskJSRuntime</c>) and WASM (in-process bridge via <c>JSImport</c>) —
///     the unified IJSRuntime surface keeps the component identical across hosts. IJSRuntime is
///     injected through the ctor (the framework's DI seam), mirroring <c>ElementRefDemo</c>.
/// </summary>
public sealed partial class JsRuntimeDemo(IJSRuntime js) : Component
{
    private string _input = string.Empty;
    private string? _lastRead;
    private string? _status;

    protected override async Task OnFirstRender()
    {
        try
        {
            _lastRead = await js.InvokeAsync<string?>("sessionStorage.getItem", "rask.jsruntime.demo");
            _status = _lastRead is null ? "(no value yet — try Set)" : $"Read on mount: {_lastRead}";
        }
        catch (Exception ex)
        {
            _status = "Read failed: " + ex.Message;
        }
    }

    protected override Component? Render() =>
        Ui.Card[
                Div.Class("mb-3")[
                    Ui.Input.Value(_input).Label("sessionStorage value")
                        .Id("demo-input")
                        .OnInput(v => _input = v)
                ],
                Div.Class("flex gap-2 flex-wrap items-center mb-3")[
                    Ui.Button.Primary.Icon(Ui.IconName.ArrowDownOnSquare).Id("demo-set").OnClick(Set)["Set"],
                    Ui.Button.Icon(Ui.IconName.ArrowPath)
                        .Id("demo-read")
                        .OnClick(Read)["Read"],
                    Ui.Button.Red.Icon(Ui.IconName.Trash)
                        .Id("demo-remove")
                        .OnClick(Remove)["Remove"]
                ],
                Div.Class("mb-2")[
                    Span.Class("text-zinc-500 dark:text-white/70 text-sm uppercase")["Last read"],
                    Div[Code.Class("text-base").Id("demo-last-read")[_lastRead ?? "(null)"]]
                ],
                Div[
                    Span.Class("text-zinc-500 dark:text-white/70 text-sm uppercase")["Status"],
                    Div[Code.Class("text-base").Id("demo-status")[_status ?? "(idle)"]]
                ]
            ];

    private async Task Set()
    {
        try
        {
            await js.InvokeVoidAsync("sessionStorage.setItem", "rask.jsruntime.demo", _input);
            _status = $"Set to: {_input}";
        }
        catch (Exception ex)
        {
            _status = "Set failed: " + ex.Message;
        }
    }

    private async Task Read()
    {
        try
        {
            _lastRead = await js.InvokeAsync<string?>("sessionStorage.getItem", "rask.jsruntime.demo");
            _status = _lastRead is null ? "Read: (null)" : $"Read: {_lastRead}";
        }
        catch (Exception ex)
        {
            _status = "Read failed: " + ex.Message;
        }
    }

    private async Task Remove()
    {
        try
        {
            await js.InvokeVoidAsync("sessionStorage.removeItem", "rask.jsruntime.demo");
            _lastRead = null;
            _status = "Removed";
        }
        catch (Exception ex)
        {
            _status = "Remove failed: " + ex.Message;
        }
    }
}
Live result
Last read
(null)
Status
(idle)

Typed browser APIs

For the full map of every wrapper (shared vs WASM-only, one-shot vs subscription), see the Browser APIs overview. This section covers the shared set and the transport "why".

Rather than spelling out raw IJSRuntime identifiers ("localStorage.getItem", "navigator.clipboard.writeText") and getting the JSON shape right by hand, call MDN's own surface from Rask.Web — await LocalStorage.GetItem("theme"), await Navigator.Clipboard.WriteText("hi"), await Crypto.RandomUUID() — or inject one of the built-in typed wrappers below through a component constructor. Each is a thin, awaitable layer over the same unified IJSRuntime, so it behaves identically on Server and WASM. These are the Web APIs that work on both transports; WASM-only PWA APIs (service worker, cache, manifest) are a later step on the same pattern.

Service Wraps Key members
IIndexedDb IndexedDB IsSupported, OpenStore(name) → IKeyValueStore (Set/Get/SetBytes/GetBytes/Delete/Keys/Clear) — large async persistent storage, text or raw bytes

The storage estimate is await Navigator.Storage.Estimate() in Rask.Web, with Persist() and Persisted() beside it. Device tilt and motion are Window events there: await Window.OnDeviceOrientation(e => _angle = e.Alpha, every: 100.Milliseconds), throttled in the browser by every:.

public sealed partial class Drafts(IIndexedDb db) : Component
{
    private async Task Save() => await (await db.OpenStore("drafts")).Set("note", "hi");

    protected override async Task OnFirstRender()
    {
        var note = await (await db.OpenStore("drafts")).Get("note");   // string?, null if absent
        var cookies = await Document.Cookie;                                       // "a=1; b=2", from Rask.Web
        var id = await Crypto.RandomUUID();                                        // string, from Rask.Web
    }
}

Call them from an event handler or lifecycle hook (not from Render()). Some APIs (WebAuthn, the file system pickers, crypto.subtle) are browser-gated — they need a secure context (HTTPS or localhost) and the user's permission; a denial or timeout surfaces as a JSException from the awaited task, so wrap those calls in try/catch.

User-activation and the transport — why one API is WASM-only. Some browser APIs require transient activation: they must run inside the live user-gesture task. On WASM an event handler's interop call runs synchronously in that gesture's call stack, so it qualifies; on Server the click is forwarded over the WebSocket and the interop call runs a round-trip later, after the transient activation has expired. The practical effect:

  • Sharing splits by when you fire it. The headless declarative Shareable (Rask.Core) attaches data-rask-share to your element and the shared client fires navigator.share inside the click's own call stack, so the activation is still live — it therefore works on every host, Server included. The imperative await Navigator.Share(…) (Rask.Web) lets you share from code (a lifecycle hook, after an await), which needs the in-process transport to keep the activation — so it compiles only in a WASM app (on Server navigator.share would reject with "Must be handling a user gesture").
  • Fullscreen and Picture-in-Picture need transient activation too. They are await _stage.RequestFullscreen() and await _video.RequestPictureInPicture() on an element ref in Rask.Web, generated into WASM only. On Server, Trigger.Fullscreen and Trigger.PictureInPicture run them in the click.
  • A custom install button's await _prompt.Prompt() (the kept beforeinstallprompt from Window.OnBeforeInstallPrompt, Rask.Web) needs the click too, so it is WASM-only; on Server, Trigger.Install shows the same kept prompt in the click. Screen capture, await Navigator.MediaDevices.GetDisplayMedia(), is a Rask.Web call generated into WASM only. See the Mobile & PWA guide.
  • The screen wake lock, await Navigator.WakeLock.Request(WakeLockType.Screen) in Rask.Web, needs no transient activation, so it works on both hosts. The app badge is await Navigator.SetAppBadge(3) in Rask.Web.
  • Everything else here (cookies, indexeddb) is unaffected by activation and behaves identically on both transports.

These are one-shot request/response calls. A subscription — ISignaling, IWebRtc, or a Rask.Web event such as Window.OnDeviceMotion — hands back an IAsyncDisposable and the browser pushes each change to a C# handler. Open it from a lifecycle hook and dispose of it on unmount. See the push pattern.

This is the rule for the whole surface: shared APIs live in Rask.Core.Browser; APIs that can't work on Server live in Rask.Wasm.Browser (the home for upcoming PWA-only APIs too).

Under the hood: the wrappers' helpers (and __raskEl) live in src/Rask.Core/Resources/rask-api.ts and are imported by both client runtimes at build time, so the two transports never drift. The types they deserialize are rooted for the WASM trimmer by the framework, so they work in a PublishTrimmed app.

Runnable demos: the Browser APIs section of the showcase at rask.sh/docs — one demo per API, from src/Rask.Site/Features/Browser/.


Element refs

A ref is a handle on one rendered element. Type it to the element's MDN interface and it carries that interface's DOM members, generated from MDN — the same data the elements and their events come from:

public sealed partial class RefDemo : Component
{
    private readonly ElementRef<HTMLDialogElement> _dialog = new();   // a field: the id is stable across renders
    private readonly ElementRef<HTMLInputElement> _name = new();
    private readonly ElementRef<HTMLVideoElement> _video = new();

    protected override Component? Render() =>
        Div[
            Input.Of<string>().Ref(_name),
            Video.Src("/intro.mp4").Ref(_video),
            Button.OnClick(Open)["Open"],
            Dialog.Ref(_dialog)[Button.OnClick(async () => await _dialog.Close())["Close"]]
        ];

    private async Task Open()
    {
        await _dialog.ShowModal();                     // MDN's showModal()
        var open = await _dialog.Open;                 // MDN's open, read from the live element
        await _name.Focus();                           // HTMLElement's focus(), which a dialog ref has too
        var box = await _name.GetBoundingClientRect(); // a DOMRect record
        await _video.SetCurrentTime(12);               // a write, where the render does not own the value
        await _video.Play();
    }
}
  • An operation is a method, an attribute an awaitable read, named as MDN names them, with no Async suffix and no IJSRuntime to inject: the ref finds its element's session itself. Each member's doc comment gives its browser support and links to MDN.
  • Bases come with it. IElementRef<out T> is covariant, so an ElementRef<HTMLDialogElement> has HTMLElement's Focus() and Element's ScrollIntoView(…), GetBoundingClientRect() and ScrollTop. An untyped ElementRef.New() carries Element's.
  • Options are MDN's dictionaries, as records; IDL enums are enums: ScrollIntoView(new ScrollIntoViewOptions { Behavior = ScrollBehavior.Smooth, Block = ScrollLogicalPosition.Nearest }).
  • The render stays in charge. There is no setter for what the render writes — Open, Id, a control's Value — and nothing that rewrites the tree, the attributes or the content (innerHTML, append, setAttribute). Drive those from state; keep the ref for what only the live node knows or does.
  • Only members whose values cross the wire are there. A member that takes a kept Rask.Web object takes its handle (await _video.SetSrcObject(stream)); one that hands back a live Node is left out, and a ref still hands the element to your own TypeScript for those.
  • A typed ref on the wrong element throws where it is put: an ElementRef<HTMLVideoElement> on a Div.
  • A member a browser only allows during a click (RequestFullscreen(), RequestPointerLock(), an input's ShowPicker()) is generated into Rask.Wasm alone, where the handler runs in the click. On the Server host the call would reach the browser after the click has ended and be refused, so there it does not compile; Trigger.Fullscreen and Trigger.PictureInPicture run it in the click from markup instead.

A ref still serializes as {"__raskRef__":"id"}, and both clients revive it to the live DOM element, so it passes to IJSRuntime or to your scoped TypeScript as the element itself:

var width = await Width(_box);   // RefDemo.ts's `export function width(el: HTMLElement | null)`

Focus an input, measure a box, and open a dialog from C#, then measure the box again in a sibling .ts:

namespace Rask.Site.Features;

// Element refs end to end: a ref typed to the element's MDN interface carries that interface's DOM members, generated
// from MDN (Focus, GetBoundingClientRect), and any ref still hands the element to scoped TypeScript. The refs are
// fields so their ids stay stable across renders.
public sealed partial class ElementRefDemo : Component
{
    private readonly ElementRef<HTMLInputElement> _input = new();
    private readonly ElementRef<HTMLElement> _box = new();
    private readonly ElementRef<HTMLDialogElement> _dialog = new();
    private string _measured = "";

    protected override Component? Render() =>
        Div[
            Ui.Input.Of<string>().Label("Focus me from C#")
                .Type(InputType.Text)
                .Placeholder("Focus me from C#")
                .Ref(_input).Class("mb-2"),
            Div.Class("flex gap-2 flex-wrap items-center mb-3")[
                Ui.Button.Primary.OnClick(FocusInput)["Focus the input"],
                Ui.Button.OnClick(MeasureBox)["Measure the box"],
                Ui.Button.OnClick(MeasureInJs)["Measure it in TypeScript"],
                Ui.Button.OnClick(OpenDialog)["Open the dialog"]
            ],
            Div.Ref(_box).Class("border rounded p-3 bg-zinc-50 dark:bg-zinc-900")[
                "A box carrying an ElementRef — measured from C# through MDN's getBoundingClientRect, or in TypeScript."
            ],
            Dialog.Ref(_dialog).Class("rounded-xl border border-zinc-200 bg-white p-6 text-zinc-800 shadow-lg dark:border-white/10 dark:bg-zinc-800 dark:text-white")[
                P.Class("mb-3")["Opened with MDN's showModal(), from C#."],
                Ui.Button.OnClick(CloseDialog)["Close"]
            ],
            _measured.Length > 0
                ? P.Class("text-sm text-zinc-500 dark:text-white/70 mt-2 mb-0")[_measured]
                : null
        ];

    // MDN's HTMLDialogElement.showModal(), then its open attribute read back from the live element.
    private async Task OpenDialog()
    {
        await _dialog.ShowModal();
        _measured = $"Dialog open: {await _dialog.Open}";
    }

    private async Task CloseDialog()
    {
        await _dialog.Close();
        _measured = $"Dialog open: {await _dialog.Open}";
    }

    // MDN's HTMLElement.focus(), on the live input.
    private async Task FocusInput() => await _input.Focus();

    // MDN's Element.getBoundingClientRect(), returned as a DOMRect record.
    private async Task MeasureBox()
    {
        var rect = await _box.GetBoundingClientRect();
        _measured = $"Box width: {rect.Width:F0}px (MDN's getBoundingClientRect, from C#)";
    }

    // User scoped TS: Width is generated from ElementRefDemo.ts's `export function width`, and the ref
    // resolves to the element before the script sees it.
    private async Task MeasureInJs()
    {
        var width = await Width(_box);
        _measured = $"Box width: {width:F0}px (measured in TypeScript from the passed element)";
    }
}
Live result
A box carrying an ElementRef — measured from C# through MDN's getBoundingClientRect, or in TypeScript.

Opened with MDN's showModal(), from C#.


Wrapping a third-party JS library

Everything above is enough to wrap a library that owns its own DOM — a chart, a code editor, a map. Two questions come up every time: what happens to the DOM the library builds, and what happens to the <style> it injects. Rask answers the second for you; the first is one rule.

Describing the library to TypeScript

There is no node_modules in a Rask app, so a library's own typings are not there to install. Write a .d.ts beside your component describing only what you actually call — any .d.ts in the project is compiled alongside your scoped files, and a narrow declaration that is true is worth more than a complete one copied from upstream that drifts, because the compiler believes either equally.

A hand-written .d.ts beside the scoped file is a worked example: about fifty lines covering one constructor, two methods and three callbacks. Rask's own globals (window.DotNet, window.Rask) are already declared for you and need no work.

Give the library a leaf to own

Render the host element with no children and let the library fill it. That's the whole rule, and it works because of how the diff addresses nodes: ops are computed from your C# render tree and applied by positional path, so a node your components never render is a node the diff can never reach.

private readonly ElementRef _host = ElementRef.New();   // a field — the id must be stable across renders

// A leaf: no children here, ever. The library owns everything inside it.
protected override Component? Render() => Div.Ref(_host).Class("chart");

// Mount the library in OnFirstRender, not OnMount — OnMount runs *before* the first render, so the element
// doesn't exist yet and the ref would resolve to null. OnFirstRender runs once, so it never mounts twice.
// Mount and Update are Chart.ts's own exports, called as typed methods.
protected override async Task OnFirstRender()
{
    await Mount(_host, DataAsJson());
}

// Fires only on a real prop change — push new data at the library instead of re-mounting it.
protected override async Task OnUpdated() => await Update(_host, DataAsJson());

// Sync and fire-and-forget — see the note below on why this must not be an awaited DisposeAsync.
protected override async Task OnUnmount() => _ = DestroyQuietly();

There is one exception to "the diff can't reach it", and it is not optional. Not every frame is a diff: the first interactive frame after page load always ships the body in full, and a structural change can too. The client applies a full frame by morphing the document, and a morph pairs each live child against the rendered one — your host has live children where the render says none, so the morph clears it. Skip that and the chart is deleted seconds after it draws. Tag the library's nodes; the reconciler leaves marked nodes alone:

// Right after the library builds its DOM. Mark what it created — never the host itself, which your
// component *does* render (marking that makes the morph treat it as missing and append a duplicate).
for (const child of host.children) child.setAttribute("data-rask-managed", "");

One more identity rule, because it bites stateful wrappers specifically: a component's identity is its (type, position) among its parent's children. A sibling rendered as cond ? node : null shifts every later child's position when it vanishes, so the wrapper gets matched against the wrong slot and rebuilt — remounting the widget on an unrelated click. Prefer disabling to un-rendering a sibling above a stateful component.

For events coming back the other way, give your scoped export a callback parameter — mount(host, data, onSelect: (id: string) => void) is Mount(_host, data, id => _selected = id) in C#. It re-renders the component after it runs and is released when the component unmounts; nothing to register.

A script you do not own can't reach an instance method — the JS shim dispatches to static [JSInvokable]s by assembly and name. Hand JS a token at mount, keep a static ConcurrentDictionary<string, YourComponent>, route on it, and unregister on unmount. Two things to get right, because a [JSInvokable] is callable by any script on the page with any arguments:

  • Make the token unguessable (Guid.NewGuid().ToString("N")). That dictionary is static, so on the Server host it is shared by every live session — with a sequential int, one visitor could drive another's widget by counting from 1. Holding the token is what proves ownership.
  • Unregister on unmount, or the entry pins the component for the life of the process.

Keep the boundary to primitives and JSON strings and a trimmed WASM publish stays clean. Prefer callbacks that take one argument (bundle extras into a record): the generated chain step only wraps arity-≤1 delegates for auto-re-render, so a two-arg callback silently leaves the caller reaching for StateHasChanged().

Finally, tear down from OnUnmount without awaiting the interop call. An IAsyncDisposable component is awaited by the framework's dispose walk, and that walk also runs for a session whose socket has already closed — where an interop call has nobody to answer it and never completes.

A scoped {Component}.css cannot style the library's internals: scoping works by stamping data-{scopeId} on the elements your component renders, and the library's nodes never get it. Size the host in scoped CSS; let the library's own stylesheet handle the rest.

What it injects into <head>

Rask treats <head> as authoritative: on every re-render the live-diff reconciler morphs the live head back to what your components rendered, which keeps <title>/<meta>/scoped-CSS links correct. A <style>/<link>/<script> that a JS library injects into <head> at runtime (a code editor's theme colours, a charting library, a syntax highlighter, an analytics tag) isn't part of that render — so Rask preserves it for you automatically. The reconciler watches <head> and tags anything a library injects with data-rask-managed (the same marker it uses for its own scoped-asset tags), so it survives every re-render with no code on your side. A code editor keeping its injected theme across a re-render is the usual case.

The mechanism only preserves nodes injected after an initial render (the common case — libraries set up once your component has mounted). If you need to keep something present at first paint, or want to be explicit, mark it yourself — the reconciler never touches a head child carrying data-rask-managed:

// You rarely need this — runtime-injected head nodes are preserved automatically. Use it only to opt a
// node out explicitly (e.g. one present before the app's first render).
styleEl.setAttribute("data-rask-managed", "");

An event that outlived its render

A click leaves the browser naming a handler by the id in the markup it was read from (data-rask-on-click="h3"). The id is a position — the fourth handler its component rendered — so when an earlier button leaves or arrives, the id names another handler. An event that left before a re-render and arrived after it used to run whichever handler held the id by then.

The rule: such an event reaches the handler it was sent to, or nothing. Never another handler. Every event says which page it was read from (v, a number the page carried when it was put on screen), and Rask finds the handler that page had under that id as the page has it now:

  • It is still there — in the same place, or moved because something before it left or arrived: it runs. A click on Delete sent just before Cancel disappeared still deletes.
  • It is gone — its button, its row or its whole component left the page: nothing runs. The event is still answered, so a button waiting on it stops waiting, and no error is shown. The second click of a double click on a row that the first click removed does nothing.

"The same handler" is the same lambda or method of the same component. Where one lambda makes several handlers in a render — a row's button, written once and rendered per row — the one an event was sent to is found by what tells those handlers apart: the values they captured that differ from row to row. A value every row shares (a view of the list the render worked out, a page number) is not which row it is, and may change between renders. Captured values are compared for equality: the same instance, a type that says two instances are equal (a number, a string, a record), or a plain object whose own fields are all equal.

What follows for a list of rows:

  • Capture what identifies the row. .OnClick(() => Remove(row)) works when row is the same object, an equal record, or an object with equal fields after a reload. A row whose captured object changed in any field between the two renders — its name was edited while the click was on its way — is not found, and the click is dropped rather than risked. Taking the id into a local first — var id = row.Id; … .OnClick(() => Remove(id)) — captures only what never changes.
  • Do not capture a position. .OnClick(() => RemoveAt(i)) is the same handler in every render: after the first row is removed, i names the next row, and no rule can tell that it should not.
  • Key rows that are components (.Key(row.Id)). A row that is a component of its own and carries no key is the same instance by position, whatever record it is handed — so after the first row is removed, the instance in second place shows the third record, and a click sent to "the second row" reaches it. A key ties the instance to its record, and that click then reaches its own row or nothing.

Three cases are worth knowing:

  • One button over "the current item" — a lambda rendered once that captures the item on screen — is one handler whatever item it shows. A second click sent before the next item arrived acts on the item that is there now. A handler that must not run twice checks for itself (if (item.Id != _current.Id) return;), or renders one keyed button per item.
  • Older than two pages. Rask remembers what a place held for the last two pages sent whose handlers moved — enough for a handler that awaits, which is rendered while it waits and again when it is done. A click that was on its way through three such renders (a page that ticks several times a second on a slow link) is dropped. Handlers that are methods of the component (.OnClick(Save)), and lambdas that capture only the component, are the same handler in every render and are never dropped for this.
  • A list longer than 4,096 handlers in one component is not searched for a handler that moved.

A dropped event is counted in the rask.handlers.stale metric (observability), and in Development it is logged on Rask.Live at Information, naming the component, the handler's position, the page the event was read from and the page as it is.

Frames written by hand — a test, a script — that carry no v are read against the page as it is, as before.

Behaviour hooks (data-rask-*)

Some behaviour can be written neither as a render (which only writes attributes) nor as a handler (which runs a round trip later, after the gesture is gone): showing a popover under the pointer, writing the clipboard, keeping a caret in place. For those the runtime carries small generic hooks. An element asks for one by carrying an attribute; every hook is a delegated listener on the document. Rask UI is built on them, and they are just as usable from your own markup: Div.Data("rask-tooltip", "tip-1")[…].

They live in src/Rask.Core/Resources/rask-hooks.ts (one module per concern) beside the older ones in rask-dom.ts — data-rask-dismiss, data-rask-dismiss-after, data-rask-dismiss-hold, data-rask-focus-trap, data-rask-popover-open, data-rask-dropzone, data-rask-shortcut, data-rask-contextmenu — which are part of the runtime itself.

How the hooks load

A page that carries none of these attributes does not download them. The hooks in the tables below are a script of their own, rask-hooks.js (49 kB, 15 kB gzipped), beside the runtime every page loads (rask.js on the Server host, rask.wasm.js in a WebAssembly app). The runtime keeps only the list of attributes that ask for a hook, and fetches the script the first time the page carries one — at most once per document:

The attribute is… What the network tab shows
nowhere on the page rask.js only. No request for the hooks, however long the page lives.
in the page the server rendered rask.js and /rask/rask-hooks.js?v=… side by side: the server writes the second <script> after the first in that response, so the hooks run straight after the runtime, as when they were one file. A dialog rendered open is a modal, and a remembered checkbox is restored, by the time the page has been read.
added later, by a render or an in-app navigation one request for rask-hooks.js, when the attribute arrives. What the reader did to the page in between — a hover, a key, a press — is kept and handed to the hooks when they run: the tooltip under the pointer shows, the character typed into a one-time code moves on. Only what fires per pixel (a drag in flight, a chart's cursor) picks up at the next move.
in a WebAssembly app rask-hooks.js from beside rask.wasm.js, requested when the runtime starts and finds one (a prerendered page usually has) or when a render adds one.

Nothing is asked of you: there is no tag to write and nothing to register. Both scripts are served with the same caching (one ?v= names the pair on the Server host and both are immutable under it; a WebAssembly app serves rask-hooks.js as it serves rask.wasm.js, and its service worker keeps it for offline use once fetched), from your own origin, with the runtime's nonce when its <script> has one. Under a Content-Security-Policy the hooks need what the runtime already needs — script-src 'self', or the nonce — and no inline script.

Any element counts, whoever wrote it: your own markup, a Raw fragment, a node a script of yours inserted. Besides the data-rask-* names, six of the platform's own ask for a hook, because a hook improves them unasked: popover, commandfor, aria-activedescendant, role="switch" on a checkbox, role="tablist" and role="menu".

What a hook module may import

For whoever adds a hook to the runtime: a hook module imports other hook modules, rask-bound.ts and rask-owned.ts, and nothing else. The hooks are bundled apart from the runtime, and a bundle gets its own copy of every module it imports. A hook that imported rask-host.ts to ask inRoot() would be asking a copy no host was ever installed in; one that imported rask-morph.ts or rask-loading.ts would read dirty fields and loading buttons nobody recorded there. What the two bundles must agree on hangs off one object on the global, __raskHookSeam, declared in rask-owned.ts — the only module both carry, which itself imports nothing. Something new a hook needs from the runtime goes on that seam. HookBundleContractTests fails for a second module in both bundles.

The runtime itself runs a document once. Its shared modules bind their document listeners as the script is read, so a second copy in the same document — the Server host's tag is a child of the render root, and a navigation to a page with more or fewer top-level nodes puts it back — is never run by the morph, and a copy that arrives some other way stands down: its listeners answer nothing and send nothing (standDown in rask-host.ts).

Pointer-opened popovers

Attribute On What the runtime does
data-rask-tooltip="<popover id>" the element wrapping a trigger and its popover="manual" bubble Shows the bubble in the pointer's own task when it enters the wrapper and hides it when it leaves; shows it on keyboard focus (:focus-visible) and keeps it while that focus lasts; Escape hides it; a press on the trigger hides it until the pointer has left and come back. The element inside carrying aria-expanded has it kept true or false. Any element can be the trigger — the bubble reaches the top layer. A touch never hovers.
data-rask-hover="<popover id>" the element wrapping a trigger and its panel Opens the panel at once when the pointer comes over the wrapper (trigger or panel). It closes the way Flux UI's does: a pointer move is looked at no more than once in 100 ms, and the panel closes when the move looked at is over neither. A pointer that rested and then moves into the gap closes it with that move; one that is moving crosses a narrow gap unseen and arrives. Nothing looks again by itself, so a pointer that stops outside after a move that was passed over leaves the panel open until it moves again. A press on the trigger's own popovertarget button leaves it open; Enter opens it the platform's way; focus alone does not. A panel the pointer opened takes no focus, even one that carries autofocus: focus stays where it was — and when the pointer leaves and it closes, focus is handed to nobody.
data-rask-open-mark a [popover]; or, as data-rask-open-mark="<popover id>", any other element data-open on the popover — or on the element that names it — while the popover shows: written in the task that opens it and removed in the one that closes it, however it was opened, and held against the render. Ui.Dropdown names its panel on itself and on its trigger, and Ui.Context on its area, so an open menu or popover is drawn without the page being told it opened. What a stylesheet lays an open panel out by (data-open:flex) without waiting for the page to hear the toggle. Ui.Popover writes it. A dialog run by data-rask-modal gets the same mark with nothing to ask, and a <dialog> is a panel here too: what names it, and its invokers' aria-expanded, follow it (Ui.ColorPicker).
data-rask-hover-if="<selector>" the same element The hover opens only while the element matches the selector — a rail item that opens its menu only while the sidebar is collapsed: "input:checked ~ *".
aria-expanded on the trigger inside a data-rask-tooltip an interactive tooltip (one whose bubble holds links or buttons) Mirrored while the bubble shows. It also marks the tooltip as interactive: when focus drops to nothing (blur(), the window losing focus) the bubble stays, until a press outside it. A tooltip without it closes.

Popovers, dialogs and the page behind them

Open and shut has two hands, and the reader's comes first. A popover with data-rask-popover-open, a dialog with data-rask-modal-open and a <details> whose open the page renders are opened and shut by a render — and by the reader, in the browser, at once. From the moment the browser opens or shuts one until the server has answered that event (its toggle, or a dialog's close), no render drives it: a reply rendered before the server heard Escape cannot open the list again. What a render was refused meanwhile is carried out once the answer is on the page. And the toggle the runtime raises by opening or shutting one to match a render is not sent back: OnToggle and OnBeforeToggle hear what the reader did, not the page's own change of mind. A dialog's OnClose hears every close, the page's own included.

Attribute On What the runtime does
(none) any popover / popover="auto" When focus leaves an open one for anything but its own popovertarget button it closes, and so it does when focus leaves that button for anything but the popover; when a press outside closed it and focus is on <body>, focus goes to that button. A popover="manual" is left alone.
data-rask-modal-open="true" | "false" a <dialog> showModal() / close() to match, when the attribute changes or the dialog arrives — a real modal with a ::backdrop, where a rendered open is not. Removing the attribute closes it too. The dialog's own close and cancel events fire as usual.
data-rask-modal="any" | "press" | "escape" | "none" a <dialog> How a reader dismisses it: Escape and a press outside, a press outside only, Escape only, neither. A press outside fires a cancelable cancel, then closes. With no value the dialog is the platform's.
data-open (written) a dialog carrying either attribute above Present while the dialog is shown, however it was opened or closed.
command / commandfor a <button> Where the engine has no invoker commands: show-modal, close, request-close, and the popover three.
data-rask-toggle="<popover id>" any element that is not a <button popovertarget> A click on it toggles that popover — unless the click is in a text field, a <select> or an editable element inside it, which takes it for itself — and so do Enter and Space while it — or something inside it that is not a control of its own — has focus (Space does not scroll). A popover="manual" it opened is closed by Escape, by a press outside both, and by focus moving outside both; a press inside the popover leaves it open. A popover="auto" gets those from the platform, and a click on its toggle closes it rather than reopening it. Focus is yours: give the element a tabindex and a role. Do not also write popovertarget.
aria-expanded (rewritten) an element that invokes a popover (popovertarget, commandfor, data-rask-toggle) and already carries aria-expanded "true" while that popover shows and "false" when it does not, however it was opened or closed. Never added where the render did not write it; once written, a render no longer overwrites it.
data-rask-lock | data-rask-lock="scroll" a popover or dialog While it is open <html> does not scroll, keeps its scrollbar's gutter where a scrollbar was showing (data-rask-gutter, written; none is reserved where none was, so the page stays exactly as wide and scrolled exactly as far as it was) and — unless "scroll" — takes no pointer; the overlay itself stays usable. Counted, so two open overlays, one removed by a render, and a navigation all end unlocked.
data-rask-place="<side> <align> <gap> <offset>" ("bottom start 5 0") a popover that names its anchor (position-anchor in its style, against an element whose style says that anchor-name); one whose style names none is placed against what shows it — the first element of the data-rask-tooltip or data-rask-toggle that names its id, or its popovertarget button — and one nothing names sits at the pointer of the right-click that opened it, where it is turned and slid but never held to a height Keeps the open panel inside the viewport, as Flux UI's script does: the side asked for, then the other alignment, then the opposite side — the first that fits, or the roomier one — then slid back to stay 5 px inside the edge it runs along, and held to the height that is left, scrolling inside. The CSS anchor rules stay what places it with no script. Written as one rule per open panel in a stylesheet of the runtime's own, keyed on data-rask-placed (written, held against the render); placed in the task that opens it and before the next frame, then once a frame while something scrolls, the window is resized or the panel or its anchor changes size. Nothing is sent to the page. Every anchored panel of Rask.Ui writes it. A panel whose --rask-place custom property computes to none is left to the stylesheet for as long as it does — max-sm:[--rask-place:none] is how Ui.DatePicker's popup, a sheet centred over the page under 640 px, is not placed there; asked again on every pass, so a resize across the breakpoint hands it over.
Attribute On What the runtime does
role="menu" an element holding [role=menuitem] / menuitemcheckbox / menuitemradio rows The navigation keys (arrows, Home, End, Page keys, Space, Enter) do not scroll the page — your key handler still receives every one. A row that gains data-active inside an open popover is focused — unless it says tabindex="-1", which is how a row the POINTER lit is told from the keyboard's — and so is the row a render gives tabindex="0". Enter and Space click the focused row, or the one aria-activedescendant names, unless it opens a submenu. ArrowDown on a closed [popovertarget][aria-haspopup] button opens its menu and hands the menu the key. A click on a row closes the popover the menu is in; Tab out of it closes it; and a popover that closes with focus nowhere hands it to its button, unless it is a data-rask-hover panel the pointer left.
data-rask-keep-open a row, or anything around it up to the menu A click on the row leaves the popover open.
data-rask-menu-cursor the outermost [role=menu], whose rows are rendered with tabindex="-1" and no data-active The runtime keeps the menu's cursor, and the page is sent nothing while the reader moves about it. Lit row (data-active), focused row (tabindex="0", and focus) and open flyouts (data-open on the element holding a submenu's row and the [role=menu] right after it) are written in the browser and held against the next render. Keys: with focus on the menu either arrow lands on the row the pointer lit, or the first; from a row the arrows count from the LIT row, a row at a time, stopping at the ends and over disabled / aria-disabled rows; ArrowRight or Enter on a submenu's row opens it onto its first row, Space opens it and stays, ArrowLeft closes the flyout the cursor is in; a letter jumps to the row that starts with it (letters within 500 ms are a prefix; the same letter again goes on to the next); Home, End and the page keys do nothing. Pointer (with data-rask-menu-pointer): entering a row lights it, opens its flyout, and closes every flyout the row is not inside; a flyout stays open after the pointer leaves the menu. A press puts the cursor on its row; on a submenu's row a tap opens the flyout and the next closes it. All of it is forgotten when the popover closes. Do not render data-active, tabindex="0" or data-open yourself, and write no key or pointer handler: your page hears the rows' own clicks.
data-rask-menu-pointer a [role=menu] The row under the pointer gets data-active at once and every other row of that menu loses it; once the pointer leaves the menu none has it but the row that has focus, which is the keyboard's. Focus is not moved. A flyout is a menu of its own: give it the attribute too. With data-rask-menu-cursor there is nothing else to do. A menu whose cursor your PAGE keeps instead moves it from the row's OnPointerEnter — a round trip per row — and renders the lit row with tabindex="-1" until the keyboard takes it.
data-rask-safe-area="<flyout id>" | data-rask-safe-area a row that opens a submenu; with no value the flyout is the element right after the row While the flyout is showing, the triangle between the pointer and the flyout's near edge belongs to the row, so the diagonal towards the flyout does not touch the rows it crosses.

Fields

Attribute On What the runtime does
data-rask-copy="<id>" a button Copies that element's value (or text) in the click itself, then carries data-copied for 2 s.
data-rask-view="<id>" a button A click shows that password field as text and the next hides it again; the button carries data-viewable-open while it shows. Both are held against the render and nothing is sent. Ui.Input.Viewable() writes it.
data-rask-clear="<id>" a button Empties that field, fires input and change on it, and focuses it.
data-rask-clear-keys="Escape" an <input> or <textarea> A listed key (KeyboardEvent.key, a space between) pressed in the field empties it, in the browser and at the key. Your OnKeyDown hears the key; no input is fired. Empty it from the handler instead and it loses what was typed during the round trip.
data-rask-focus="<id>" any element Focuses that element after a click.
data-rask-offline <html>, written by the runtime — never by hand Livewire's wire:offline. On while the browser reports itself offline (navigator.onLine, and the online / offline events) and, on the Server host, while its socket is down (until it is open again); off otherwise. Both hosts. For style: hidden in-data-rask-offline:block. Nothing is sent to the page.
<script type="application/json" id="rask-texts"> the first response's <body>, written by the Server host — never by hand The reconnect overlay's eight texts (ConnectionReconnecting … ConnectionSessionTimedOut) as a JSON array, in the page's culture. Written with the first response only, so a live update neither builds nor sends it; the runtime reads it when the overlay shows, and a page without it shows the English. A language switched without a reload is not re-read.
data-rask-confirm="<question>" (+ data-rask-confirm-expect="<answer>") an element with data-rask-on-click, a <form> with data-rask-on-submit, or its submit button — written by .Confirm(…) Livewire's wire:confirm. Before the click or the submit leaves the browser, the question is asked in confirm() — or, with -expect, in prompt(), and only an exact, case-sensitive answer goes. Refused, nothing is sent: not the event, not the controls waiting for the next action, and the default action (a popover, a command) is cancelled too. A submit asks the form first, then its submit button, before the form is held or anything uploads. Not a hook: both runtimes ask it in their own click and submit listeners. With Ui.Confirm on the page the question is asked in that dialog instead (rask-confirm-dialog.ts, a hook, answering through the seam): the action is refused at once, and Yes makes it again — el.click(), or form.requestSubmit(submitter) — which is then let through. Opening, typing and closing send nothing.
data-rask-confirm-dialog="message \| field \| go" the parts of Ui.Confirm's dialog — written by the kit, never by hand Where the question is written, the box an expect: answer is typed into (shown only then; Yes is disabled until it matches exactly, and Enter there is Yes), and the button that says yes. Every other way the dialog closes is no. data-rask-leave="message \| go" are the same two parts of Ui.ConfirmLeave's dialog; both are opened by rask-ask.ts.
data-rask-bind-on="action" a bound control with data-rask-on-change and no timing step — written by a plain .Bind(…) The control waits for the next action. Typing into it, choosing in it and leaving it send nothing: its own change is not sent. The runtime remembers it, and whatever a host sends next for the reader — a click, a submit, a handled key, another control's change, a navigation, what a behaviour hook raises for a key or a press — is preceded by the change of every control remembered, in the order they were last touched, each once. What the runtime does by itself carries nothing ahead of it and is sent with "unasked":true, so the page writes nothing its own controls hold either: a toast's timed dismissal (data-rask-dismiss-after), a measured size, the drawer a navigation unchecks, a scroll, a media, animation, transition, load or error event, focus a render moved, a dialog the runtime closed, a field restored after a reload, a jsResult, a DotNet.invokeMethodAsync call. Of a radio group only the one left checked is sent. A render that knows nothing newer than the reader does (a push, the reply to another event) leaves what they typed or chose where it is, focused or not.
data-rask-debounce="<ms>" a field with data-rask-on-input — written by .Live() (150) and .Debounce(…) What is typed is sent once typing has paused that long, as one input. Sent at once, ahead of it, by anything that has to follow the value: a submit, a click or key handler, a navigation, and the change of leaving the field. No pause is counted while a character is being composed (IME). Not a hook: the runtime's own input listener reads it, and on WASM a pause that ends while .NET still owes the last answer is held like any typed value.
data-rask-on-pause="<handler>" a field with data-rask-debounce="<ms>" — written by the Debounce(…) of Ui.SelectSearch, Ui.SelectInput, Ui.PillboxSearch, Ui.PillboxInput and Ui.Autocomplete A handler that hears the pause, where the row above is a bound value sent at one: a search field that asks a store. It is sent what the field says once typing has paused that long (type: "input"), and nothing else sends it — not the next action, not leaving the field. No handler reads a search before it runs, and sent ahead of the press on a result it would redraw the list under that press. A data-rask-on-input beside it hears every key, as if no pause were counted. No pause is counted while a character is being composed.
data-rask-bind-on="blur" a field with data-rask-on-change and no data-rask-on-input — written by .Blur() As "action", except that the change the browser fires on leaving the field sends it too, whichever comes first. The change fired afterwards for a value that already went is not sent twice.
data-rask-draws="<events>" an element of a control that draws itself on the page's side, beside its data-rask-on-<event> handlers — written by the kit, never by hand The events named (toggle, click, keydown, input, change, mouseenter, …, by event name with a space between) are heard only to draw the control again: a list opened, the row under the pointer, the next month, what is typed into a list's search, the pick of a bound control that waits. Each is sent alone and marked "unasked": true: nothing that waits for the reader's next action is sent ahead of it, and the page writes none of what its own controls hold either. An event of the same element that is not named is an action as always.
data-rask-on-edit="<handler>" a waiting field while a validation message shows under it The first keystroke is reported, once, as an edit with no value: the page takes the message away and runs no rule. The attribute goes with it.
data-rask-on-edit="<handler>" a waiting field, only while a message shows under it The first input in the field is reported once (type: "edit", no value) and the attribute is removed; C# takes the field's messages away and runs no rule. A valid field does not carry it.
data-rask-mask="(999) 999-9999" an <input> Shapes what is typed: 9 a digit, a a letter, * either, anything else itself. The handler receives the shaped value; the caret stays where the reader was typing.
data-rask-mask-money | =".,2" an <input> Groups an amount: decimal mark, thousands mark, decimals.
role="switch" an <input type="checkbox"> Enter toggles it, as Space does.
data-rask-big-step="<n>" an <input type="range"> Shift+Arrow and PageUp / PageDown move by n, firing input then change.
data-rask-otp | ="alpha" | ="alphanumeric" the group around one-character inputs The cells behave as one field: a character moves on, Backspace walks back, deleting closes up to the left, the arrows stop at the first empty cell, a paste fills from the first. Render the cells with no value and no handler, and bind ONE <input type="hidden"> inside the group: the runtime keeps it equal to the code and fires input and change on it, and refills the cells when you change its value.
data-rask-segments + data-rask-segment="month" | "day" | "year" | "hour" | "minute" | "meridiem" the group around the small inputs of a typed date or time, and each input The parts behave as one field: digits only, zero-padded; a part that can take no further digit moves on (3 is March, 9 is nine o'clock); ArrowLeft / ArrowRight walk the parts and stop at the ends; ArrowUp / ArrowDown step a part and wrap; Backspace empties a part and, on an empty one, steps back; a / p set the meridiem; a pasted date is shared out; a day the month does not have becomes its last; a one- or two-digit year is read within twenty years ahead. Render the parts in your locale's order with a placeholder, NO value and NO handler, and bind ONE <input type="hidden"> inside the group: it carries yyyy-mm-dd, HH:mm (24-hour) or the two joined by T once every part is there, fires input and change, and refills the parts when you write it. A readonly part is left alone.

Keys and focus

Attribute On What the runtime does
data-rask-contain-keys="Arrows Home End PageUp PageDown" a widget that handles those keys in its own handler Cancels the browser's default for a listed key pressed inside it — the page does not scroll behind a calendar, Enter does not press a trigger — and nothing else: your handler still receives the key. Names are KeyboardEvent.key's, plus Space and Arrows (all four). A key held with Ctrl, Alt or Meta is left alone, and so is one typed into a text field inside the widget. It is a list, not a rule per role, because no two widgets keep the same keys; render a different list when the widget's keys change (open / closed).
data-rask-keys="ArrowDown ArrowUp Enter Escape Tab" an element with OnKeyDown or OnKeyUp The handler hears only the listed keys (KeyboardEvent.key, a space between); no other is sent. A combobox acts on five keys, and without the list every letter typed into it is a round trip and a render of its own ahead of the input that follows. A key that is not listed does not flush a .Debounce(…) field either; one that is sent does, so an unfiltered OnKeyDown on a debounced field sends the value at every key. A key written Backspace:empty is heard only from a field that holds nothing (a pillbox's input, where it takes the last pill off). Not a hook: the runtime's own key listener reads it.
data-rask-list="<words>" the root of a control that opens a list of options: it holds what opens the list (a button, a text input, or an element around an input, saying aria-haspopup="listbox"), one [popover] that is or holds the [role=listbox], and the rows — [role=option], each with an id The runtime keeps where the reader is in the list, and the page is sent nothing while they move about it: the lit row (data-active, and aria-activedescendant on the role="combobox" that takes the keys), whether the list is open (data-open and aria-expanded, in the task it opens), and — with filter — the rows a search leaves (data-hidden), all held against the next render. Opening lights the first aria-selected="true" row, or the first; typing lights the first row left. ArrowDown / ArrowUp go a row at a time over aria-disabled and hidden rows and stop at either end (with no row lit: the first, or the last); Home, End and the page keys do nothing; Enter CLICKS the lit row, so your row's own OnClick is the pick; Tab closes; on a list with no text field a letter jumps to the next row that starts with it (letters within 500 ms are a prefix) and on a CLOSED one clicks it. The pointer lights the row it enters — not one that slid under a pointer at rest — and that row goes dark when the pointer leaves the list, where one the keys lit stays. With focus on the list itself the keys do nothing. The words: multiple (a pick leaves the list open and empties what was typed, unless stay), filter (typing hides the rows whose text and data-rask-list-keywords do not hold it, whatever the case or the accents; without it your page answers the search), strict (a closed text input says the value it was rendered with again), clears (a closed text input is emptied), text (the answer is the input's text: a pick writes the row's words into it and raises input and change, so bind the input itself), top (after a pick emptied the search the cursor returns to the first row). On a row: data-rask-list-text (its words, where its text holds more), -keywords, -says (what a strict input says once it is picked), -keep (no search hides it), data-rask-list-create="<min length>" (the row offered once what is typed is that long and names no option; no value: always there). Inside the list: data-rask-list-empty (shown while no row is), data-rask-list-group="<rows>" (a heading hidden with the rows after it), and data-rask-list-remove on a pill's cross (Backspace in the empty input presses the last). Do not render data-active, data-open, aria-activedescendant or an open aria-expanded yourself, and write no key, pointer or toggle handler.
data-rask-color="<format>" the root of Ui.ColorPicker (hex hexa rgb rgba hsl hsla), its FIRST hidden input the one field you bind, each part named by data-rask-color-part (area, hue-layer, hue, alpha, fade, text, popover-text, button, opener, preview, value, placeholder, swatches, clear, dropper, announce, select, sheet), the words it writes in data-rask-color-says (JSON) The runtime keeps the colour picker and the page hears only the colour, when Flux's element announces one: input and change for each key of the area (2, 10 with Shift; the page keys and Home / End to its edges) and of a slider (1, 10 with Shift), input on every move of a drag and change when it lets go, the colour typed into either text field when that field changes (Enter, which also leaves it, or leaving it; empty clears), a swatch at a press or on Enter / Space, the dropper's colour. Opening, closing, the swatch walk (eight to a row, stopping at the edges), the live region and the opener's name say nothing. On the phone sheet (the sheet box is two pixels wide) everything waits for Select but a swatch, which is taken and closes the sheet. Text that is no colour is put back, or announced as it is when the root says data-rask-color-refuses, for the page to refuse. Render the parts from the colour the page holds and write no handler on them.
data-rask-calendar="<json>" the root of Ui.Calendar — or of Ui.DatePicker, around its trigger and its popup — its FIRST hidden input the one field you bind (yyyy-mm-dd, days joined by commas, a range as start/end with a preset's key after a third slash, nothing for none); the JSON says the mode, today, the limits, the week, the culture's names and .NET date patterns and the classes each state is drawn with; parts by data-rask-calendar-part (header and months, which the page renders once and never diffs again, previous, next, today, month, year, narrow, text, presets — rows with data-rask-calendar-range — preset-select, confirm, clear) The runtime draws every month after the first paint, and the page hears only the value, when Flux's element announces one: a day picked (input and change), the same day again clears it, of several a day is added or taken out, a range's last day writes the range — its first day, and the stretch the pointer or the arrows draw to the end, announce nothing. The steps, the header's selects and the paging keys page the month; Home / End / PageUp / PageDown leave the focus to the page; the arrows walk the days over what cannot be picked, paging with them. A pick that finishes the choice closes a date picker's popup, unless it confirms or the popup is the phone's sheet (the narrow box two pixels wide), where the choice waits for confirm; Cancel, Escape and a press outside drop it on a desktop and keep it on a phone. ArrowDown / ArrowUp on the closed button open the popup. A value your page writes into the field is drawn. Render no handler on any part.
data-rask-listbox-button a button[role=combobox] The list Enter ArrowUp ArrowDown, while aria-expanded is not true: Enter does not press it and the arrows do not scroll the page.
data-rask-roving a [role=radiogroup] of [role=radio] elements that are not native radios ArrowDown / ArrowRight focus the next radio and ArrowUp / ArrowLeft the one before, wrapping at both ends; the radio focused is clicked, so your OnClick selects it; tabindex moves with it (0 on the focused one, -1 on the rest). Space clicks the focused one. Disabled radios are skipped.
role="tablist" an element holding button[role=tab]s ArrowRight / ArrowDown focus the next tab and ArrowLeft / ArrowUp the one before, past a disabled tab and around the ends; the tab arrived at is clicked, so your OnClick selects it (or the runtime does, below), and the page does not scroll. From a button in the row that is not a tab the arrows start at the aria-selected="true" one. Home and End are left to the page. Only buttons take part.
data-rask-command the root of a command palette, holding its search <input> and a [role=listbox] of [role=option]s, with a [data-rask-command-empty] row (Ui.Command writes it) Flux's palette, kept in the browser. Focus in the box selects its text and lights the first command shown; focus leaving it lights none. Typing hides, by data-hidden, the options whose data-rask-match (their text, without one) does not contain it — whatever the case or the accents — shows the empty row while none is left, and lights the first one left. ArrowDown / ArrowUp move the lit command among those shown and stop at the ends; the pointer lights the one under it, and leaving takes away a light the pointer gave. Enter clicks the lit command (and never submits a form); a pressed command empties the box. The lit command is data-active on it and aria-activedescendant on the box. Nothing is sent; all of it is held against the render.
data-rask-tabs a role="tablist" whose tabs each say their name in data-rask-tab (Ui.Tabs writes both) Selection is the browser's: a press on a tab — or an arrow arriving at it, or find-in-page about to reveal its hidden="until-found" panel — writes aria-selected, tabindex and data-selected on the tabs and hidden, tabindex and data-selected on the panel each tab's aria-controls names, all held against the render, and sends nothing. When the tablist's FIRST child is an <input type="hidden">, the tab's name is written into it and announced with input and change, so it is bound like any field (a plain Bind waits for the next action). The value is "<turn> <name>", the tab the page says is selected: a change within one turn answers what the browser told it and is not drawn over a later choice; a new turn is the page choosing a tab itself, and is shown. Paint the selected tab from [data-selected].
data-rask-focus-follows + data-rask-focus-target a container, and the one element in it that should hold focus When a render moves the target mark — or replaces the element that carried it — while focus is ON that element, the new target is focused. Focus anywhere else is never taken. A render that leaves no target in that container lets focus fall where the browser drops it — another container's target is never taken, so two calendars on a page keep to themselves. (No kit calendar asks for it any more: data-rask-calendar keeps its own focus.)
aria-activedescendant a [role=combobox] or [role=listbox] When it changes, the option it names is scrolled into view inside its nearest scrolling ancestor, by the least movement; the page never scrolls. (A [role=tree] has its own rule, which also handles virtualized rows.)
data-rask-press-keeps-focus any element A mouse press on it or inside it does not move focus.

Gestures, capabilities and uploads

Attribute On What the runtime does
data-rask-drag="x y" | "x" | "y" a surface holding ONE <input type="hidden"> you bind On a press the runtime captures the pointer and, on every move, writes where it is along each named axis — 0 to 1, clamped, 0 at the left / top edge — into --rask-drag-x / --rask-drag-y on the surface, with no round trip: draw the thumb from those. The hidden field carries the same numbers ("0.25 0.5", or the one axis) and fires input at most once per animation frame while the pointer moves and change once on release, so OnInput / OnChange on it are the whole C# side. Write the field's value yourself (a key handled in C#) and the properties follow, except while the surface is held. Give the surface touch-action: none.
data-rask-drag-inset="<px>" the same surface The track is that much shorter at both ends, so a thumb centred on the value stays inside the surface.
data-rask-requires="<global>" a control for an API the browser may lack (EyeDropper) hidden is set where window has no property of that name and removed where it has, when the control arrives. Render it hidden. The name must be a plain identifier; nothing is evaluated.
data-rask-plot="0 0.25 0.5 1" with data-rask-plot-area, data-rask-plot-row="<index>", data-rask-plot-text, data-rask-plot-tooltip="<px>", data-rask-plot-frame a chart's root: each row's place from 0 to 1 across the area; the element whose box is the plot (it may carry the places itself, data-rask-plot-area="0 0.25 …" with the root's attribute bare, and data-rask-plot-axis="y" when the rows run down it); everything that belongs to one row; an element that reads differently per row, the attribute holding every row's text, one per line; an absolutely placed box; the box that one is kept inside (the root without it) While the pointer is inside the area the root carries data-active, --rask-plot-x (the nearest row's x; --rask-plot-y when the rows run down), --rask-plot-at (that row as a fraction of the area), --rask-pointer-x and --rask-pointer-y (px from the root's corner); every element of the row nearest along the axis carries data-active — the switch is at the midpoint between two rows; each data-rask-plot-text shows that row's line, and what was rendered again when the pointer leaves; the tooltip carries data-active and transform: translate(x, y), beside the row along the axis and beside the pointer across it, each <px> away and flipped to the other side when the box would pass the frame's right or bottom edge. A root with NO area is a plot of shapes (a pie): the row is the data-rask-plot-row element under the pointer, every other row's elements carry data-inactive, and the tooltip follows the pointer both ways. Outside, the marks come off. No round trip: style [data-active].
data-rask-measure an element holding ONE <input type="hidden"> you bind The field is kept at "<width> <height>" of the element's content box, in CSS px, with input and change: when the element arrives, and after that once its box has stopped changing for 100 ms (a ResizeObserver; a redraw per frame of a window being dragged would be a round trip per frame). Nothing is announced for an element with no box (hidden), or for a box within half a pixel of what the field already says — render the size you drew at into the field and a chart drawn at its real size is told nothing. What a chart needs to redraw at its real size.
data-rask-loading around an <input type="file"> with OnFiles the dropzone From the moment files are chosen until the handler that receives them has rendered, the element carries data-loading, --rask-progress (a whole percentage, 12%) and --rask-progress-as-string ('12%', for content:). On the Server host the percentage is the upload request's own progress. In a WebAssembly app nothing is sent: it is how much of the files your handler has read through OpenReadStream, and stays 0% for a handler that never opens them. data-rask-loading="off" opts out.
data-loading on a field an <input>, <textarea> or <select> whose input or change event was sent (.Live(), .Debounce(…), OnInput, a change) Livewire's data-loading on a wire:model.live input: written after 200 ms, until that event's ack (Server) or dispatch promise (WASM) — the release a pressed button's mark has. data-loading only, no aria-busy, nothing around it; a field that waits for the next action sends nothing and is not marked; data-rask-loading="off" on it or above opts out. Held against the render like a button's.
data-rask-loading | "off" | "within" a control; a container Bare, on an element that carries a handler: it waits like a button — data-loading and aria-busy="true" once its dispatch has gone 200 ms unanswered, a second press dropped until it is. "off", on a control or anything above it: none of that. "within", on a container: every dispatch that starts inside it marks the container with data-loading after the same 200 ms — and nothing else: no aria-busy, no refused press. The nearest one above the press is marked, the pressed control keeps its own mark, and the mark outlives the render that answers the press, so it works where the control itself is replaced (a pager's page number). All of them end when the dispatch does: on the Server host with the handler's ack, which is sent once the reads the page went without are back; in a WebAssembly app when the dispatch and those reads have returned. Style it with [data-loading] / has-data-loading: — the model is Livewire's data-loading.
data-rask-on-submit (a <form> with OnSubmit) the form's own controls (form.elements) From the press until that submit is done (the same end as the loading mark), disabled on every submit button and readonly on every text field and textarea that the render had not already marked; a submit that reaches the form meanwhile — a second press, Enter, requestSubmit() — is dropped, never sent. Both are held against the morph, and each control is left with what the last render said of it. The model is Livewire's wire:submit.

Toasts

Attribute On What the runtime does
data-rask-dismiss-hold="pointer" an element with data-rask-dismiss-after Only the pointer holds its countdown; focus inside it does not.
data-rask-dismiss-scope an ancestor of several The pointer anywhere over it holds every countdown inside, and they run on from where they stopped.
data-rask-stack the element whose children are stacked Each child gets, in its style, --rask-stack-index (0 at the front, the last child), --rask-stack-height (its own natural height), --rask-stack-offset (the natural heights in front of it) and --rask-stack-front (the front child's natural height), measured again when a child joins or leaves and when the window is resized — what a stylesheet needs to cut every card to the front one's height and glide the stack open. While it measures, the stack carries data-rask-measuring: write the rule that cuts a card as [data-rask-stack]:not([data-rask-measuring]) …, or the hook is handed the cut height back.

State the reader owns

Attribute On What the runtime does
data-rask-persist="<key>" a checkbox Checked as the reader last left it, from localStorage[key] ("true" / "false"), and stored on every change; a render never resets it — the runtime every page loads leaves the box alone from the start, whether or not the hooks have arrived. The hooks restore it when they load — in a WebAssembly app that is after boot, so a page that must not flash restores the same key from a script of its own in <head> (Ui.SidebarScript does for a sidebar), and that script is enough on its own: nothing puts the box back before the hooks come.
data-rask-uncheck-on-navigate a checkbox Unchecked (with a change event) when the app navigates to another path, and on a press on a link to the page the reader is already on (same path and query, no fragment), which navigates nowhere.
data-rask-confirm-leave="<message>" a <form> — written by Form.Model(m).ConfirmLeave("…") From the first input or change inside it the form is unsaved, and a nav link, __raskHost.navigate, Back and Forward ask first — with confirm(message), or in the dialog of a Ui.ConfirmLeave on the page (its parts carry data-rask-leave="message" and "go": the message is written into the first, the dialog is shown modally, and a press on the second makes the navigation that was refused); staying sends nothing and, after Back, puts the address back. Closing the tab, a reload and a link out of the app get the browser's own prompt (beforeunload, listened to only while a form is unsaved). The form's data-rask-saved, which the server changes after each accepted submit, makes the edits sent before it saved. The form's data-rask-edited, which the server writes while a value the reader chose in a control it draws (a listbox, a calendar: no input is raised for those) has not been carried by an accepted submit, makes the form unsaved by itself. An input or change on or inside an element that says data-rask-unguarded is not counted — the kit writes it on the fields that hold none of a form's data: a list's search field, a combobox's and a pillbox's input (the text narrows the list; the option picked is the value), a calendar's month and year lists, the field a tab row writes the chosen tab into (a tab is never data the reader would lose, bound or not), a box it measures into — and neither is one the runtime raises by itself.

Carousels

Attribute On What the runtime does
data-rask-carousel the root On a scroll of its [data-rask-carousel-track] and on a resize: data-at-start / data-at-end on the root and on every [data-direction] wrapper, disabled on each wrapper's button, data-selected on the slide nearest the start and on its button in [data-rask-carousel-indicators] (with aria-current="true"). A press on a [data-direction="next" \| "previous"] button scrolls a slide; on an indicator, to that slide.
data-advance="page", data-wrap="rewind", data-scroll="instant" the root Move by the slides in view; go back to the first from the end; do not animate.
data-autoplay="<ms>" the root Advances on that interval and rewinds at the end; stops under the pointer and while focus is inside the root, starts afresh when both are gone, and never starts under prefers-reduced-motion.
data-rask-carousel-controls + data-name an element outside the root Its wrappers and indicators drive the root with the same data-name.

Scrolling

Attribute On What the runtime does
data-rask-switch a <button role="switch"> with no handler A press flips aria-checked and data-checked in the browser, both held against the morph; nothing is sent. Enter and Space are the button's own; a disabled one takes no press.
data-rask-phone="<engine url>" (+ data-rask-phone-country, -countries, -order) Ui.Phone's root Fetches the phone engine (intl-tel-input) from the URL the first time a page carries one, and hands it the field: the tel input formats as it is typed, and ONE bound <input type="hidden"> inside is kept equal to the number (E.164 once possible) and announced with input and change; a value the page writes there is shown. The country list (a [popover] opened by popovertarget) is built, searched, walked and picked from in the browser; nothing is sent.
data-rask-submit-keys="Mod+Enter" / ="Enter" an element around a <textarea> or an editable area — a message box A keydown typed into it that names the key submits the field's form, as its submit button would (validation first, then submit), and the key is kept from the field: Mod+Enter is Ctrl+Enter or Cmd+Enter, Enter is any Enter but Shift+Enter. Outside a form the key is left alone.
data-rask-grow="<lines>" a <textarea> rendered with rows As the reader types it is made as tall as its text — never shorter than its rows, never taller than <lines> lines, where it scrolls (overflow-y hidden below that, auto there). The height is written in its style, held against the morph; nothing is sent.
data-rask-time-list (+ data-rask-time-start on one row) a time picker's root, around a [popover] list of [role=option] rows with ids and, for a button trigger, the role="combobox" that opens it The list's cursor is the browser's, as Flux UI's script keeps it: opening lights the start row (data-active, named by the combobox's aria-activedescendant) and brings it into view by the least movement; the pointer lights the row it is over and never scrolls the list, and that row goes dark when the pointer leaves; closed, ArrowUp / ArrowDown open the list; open, they walk the rows past a disabled one and stop at the ends (from no row, the first or the last), Enter presses the lit row, Tab closes, Home / End / letters do nothing. All held against the morph; nothing is sent but the press on a row.
data-rask-scroll-to="<selector>" an element holding buttons or links — a pager A press on a <button> or an <a> inside it brings the first element the selector matches into view ("body" for the top of the document, "#orders" for a table). The press is not stopped: the handler or the link runs after it. A press elsewhere in the element, and a value that is no selector, scroll nothing.

A render removes only what the server wrote (rask-written.ts): an attribute a script put on a rendered element itself — a canvas's width and height, sized by its scoped TypeScript — stays through every diff, morph and whole-page reply until a render writes that attribute; from then on it is the render's, and a render that leaves it out removes it. One attribute watch on the document tells the two apart; the runtime's own patch writes are not counted.

A hook that writes an attribute holds it against the morph (rask-owned.ts): data-open, data-copied, a tooltip's aria-expanded and a carousel's disabled are not what the page rendered, and a re-render neither strips them nor puts the rendered value back over them.

Confirm