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

Accessibility: ARIA, roles & focus

How to make Rask components accessible — setting ARIA attributes, roles, and keyboard focus on any element, plus the analyzer that catches missing image alt text.


Typed ARIA: AriaLabel, AriaExpanded, AriaLive…

Every aria-* state and property the WAI-ARIA spec defines is a typed step on every element, generated at build time from the spec itself (pinned in src/Rask.Core/Dom/mdn.snapshot.json, refreshed with the MDN data). The name is the DOM's own IDL name — ariaLabelledByElements is written from markup as ids, so it is AriaLabelledBy — and the type is the spec's value type:

Spec value type C# Example
true/false, true/false/undefined bool? .AriaExpanded(open), .AriaHidden() (no argument = true)
tristate, token a generated enum .AriaChecked(AriaChecked.Mixed), .AriaLive(AriaLive.Polite), .AriaCurrent(AriaCurrent.Page)
token list a generated [Flags] enum .AriaRelevant(AriaRelevant.Additions \| AriaRelevant.Text)
integer, number int?, double? (invariant) .AriaLevel(2), .AriaValueNow(0.5)
ID reference(s), string string? .AriaLabelledBy("title"), .AriaDescribedBy("hint err"), .AriaLabel("Close")
Button.AriaExpanded(_open).AriaControls("menu").AriaHasPopup(AriaHasPopup.Menu)["Options"]
// <button aria-controls="menu" aria-expanded="false" aria-haspopup="menu">Options</button>

Div.Role(AriaRole.Status).AriaLive(AriaLive.Polite)[_statusMessage]
// <div role="status" aria-live="polite">…</div>

null leaves an attribute out, so .AriaExpanded(null) is "undefined" in the spec's sense. AriaRole holds every concrete role as a constant (AriaRole.Tablist, AriaRole.Menuitemcheckbox — each name is the role's token, PascalCased); Role stays a string, so a role the constants do not list yet still works.

The keyword enums have no per-keyword steps (Div.Polite would not say what is polite) — pass the value. A boolean or keyword is stored and written as the interned literal it renders as, so the typed steps allocate nothing per render; an element that names none of them pays nothing at all.

The Aria dictionary

The dictionary stays for what the typed steps cannot say — an attribute newer than the snapshot, a value built at run time from a key. Every element exposes an Aria parameter — a string → string? dictionary modelled exactly on the Data (data-*) bag. Each entry renders as aria-{key}="{value}": the key is used verbatim (so you write "label", not "aria-label") and the value is HTML-encoded. A null value emits a bare attribute.

Button.Aria(new() { ["label"] = "Close", ["expanded"] = "false" })["✕"]
// <button aria-label="Close" aria-expanded="false">✕</button>

Span.Class("icon").Aria(new() { ["hidden"] = "true" })["\U0001F5D1"]
// <span class="icon" aria-hidden="true">🗑</span>  — decorative icon, skipped by screen readers.
// The glyph carries no meaning a reader needs; the BUTTON around it carries the accessible name.

A typed step and a bag entry for the same attribute never both render: the typed value wins and the bag's entry is skipped, so a component can take a call site's bag and still set .AriaExpanded(open) itself.

Role and TabIndex

role and tabindex are not aria-* attributes, so they have their own typed parameters on every element:

Div.Role(AriaRole.Dialog).TabIndex(-1).AriaModal(true).AriaLabelledBy("title")[
    H2.Id("title")["Edit product"],
    // ...
]

Role is a string?; TabIndex is an int? (0 to make a non-interactive element focusable, -1 to take it out of the tab order but keep it programmatically focusable).

Language of parts (Lang and Dir)

Lang marks the language of an element's content as a BCP 47 tag, and it belongs on any run of text in a different language from the page — not only on <html>. A screen reader switches pronunciation on it, and without it a French quotation inside an English page is read with English phonetics. That is WCAG 3.1.2 Language of Parts, a Level AA criterion:

P["The exhibition is called ", Span.Lang("fr")["Les Demoiselles"], "."]

Dir is the same idea for direction — "ltr", "rtl" or "auto". Reach for "auto" on text you did not author and whose language you do not know at render time (a display name, a comment, a search query): the browser takes the direction from the first strongly-typed character, which is the only correct answer when the content is arbitrary.

Hidden and Inert belong to the same family. Hidden removes an element from every presentation including the accessibility tree — prefer it to a display-none class, which hides an element visually while leaving a screen reader announcing it. Inert makes a whole subtree unfocusable and unreachable, which is the correct primitive behind a modal (see Focus trapping).

Attribute order

Accessibility attributes render in the universal attribute block, after data-* and before any tag-specific attributes. The full documented order is:

id, class, style, title,
lang, dir, hidden, inert, popover, contenteditable, spellcheck, translate,
data-*, role, tabindex, aria-* (typed, then the Aria bag), Attributes, then tag-specific

The typed aria-* attributes render in the spec's (alphabetical) order, then the bag's entries in its own order, less any a typed step already wrote. Tests assert this order; it is stable across releases.

Images and alt text (RASK023)

Img requires an Alt for accessibility. The RASK023 analyzer warns when an Img chain omits it:

Img.Src("/logo.png").Alt("Rask logo")   // informative image
Img.Src("/divider.png").Alt("")          // decorative: empty alt hides it from assistive tech

Pass Alt: "" for purely decorative images so screen readers skip them; pass a meaningful string for everything else.

Form validation

A Rask UI field wires its label, description, error and validation state to assistive tech on its own — you add nothing. Ui.Input and Ui.Textarea render:

  • aria-invalid="true" when the field is invalid — a bound field whose form holds a message for it, or a controlled field given Invalid() — so the failed state is exposed programmatically, not only as a red border;
  • aria-describedby naming what is visible around the control, error first: the field's Ui.Error (only while it holds a message — a hidden message named by aria-describedby would still be read aloud), then the Description. Omitted when there is nothing; and
  • a real <label for> from Label, which is the control's accessible name.
Ui.Input.Bind(() => model.Email).Label("Email").Description("We never share it.")
// valid   → <input id="f-email" aria-describedby="f-email-description" …>
// invalid → <input id="f-email" aria-invalid="true" data-invalid
//                  aria-describedby="f-email-error f-email-description" …>
//           <div id="f-email-error" role="alert" aria-live="polite">Enter a valid email</div>

The hint, error and message ids derive from the field id — Id if you set one, otherwise the bound member's name or the label text. That id also anchors the <label for> association, so if you render the same bound field more than once on a page (a repeated form, a list of rows), give each field an explicit unique Id so every for and aria-describedby resolves to the right element.

Building your own control from the core Input/Validation.Message primitives? Mirror the same attributes: .AriaInvalid(AriaInvalid.True).AriaDescribedBy(errorId) on the control, and give the message element that id. See forms-validation.md.

Focus trapping (overlays)

Any element that carries data-rask-focus-trap gets accessible-overlay focus management from the runtime — no component library's JavaScript, no per-component wiring. While the element is in the DOM, focus moves into it on open (its [autofocus] element, else the element itself), Tab/Shift+Tab cycle within it (focus can't reach the inert page behind), and focus returns to the previously-focused element when it closes. If the trap (or a descendant) carries data-rask-dismiss, Escape closes it by triggering that element's click handler — no per-keystroke server round-trip. The trap follows the attribute as well as the element: adding or removing data-rask-focus-trap on an element that stays mounted engages or releases it.

Rask UI's Ui.Modal needs none of this: it is a modal <dialog> — opened by a Ui.ModalTrigger's command="show-modal", or by the runtime when the page's state says so (Ui.Modal.Open(…), data-rask-modal-open) — so the browser's own modal dialog makes the page inert, closes on Escape and returns focus to what opened it. While one is open the runtime also stops the page behind it from scrolling (data-rask-lock="scroll").

As Flux UI's does, a Ui.Modal opens with focus on nothing: an empty autofocus placeholder inside the dialog takes the focus the browser would hand the first field, then leaves, so no control is ringed before the reader chose one and the first Tab lands on the first control. A modal has no title of its own — its content carries the heading, exactly as Flux's does. Its close button is named "Close modal".

A dialog should opt in deliberately: an open modal traps focus, is labelled (aria-labelledby its title, or aria-label from the title text), and dismisses on Escape (except with a static backdrop, which keeps Escape inert). Build your own overlay the same way — add data-rask-focus-trap (via the Data dictionary) and mark your close control with data-rask-dismiss.

A menu that must escape an overflow: hidden/auto ancestor uses the platform's own answer instead: a [popover], which the browser lifts into the top layer, dismisses on Escape and on a click outside, and gives a real ::backdrop. Ui.Select's .Listbox and .Combobox variants, Ui.Menu and Ui.Modal are all built that way. C# hears the browser's own dismissal through OnToggle, which is what lets a control keep aria-expanded truthful rather than drifting the moment Escape is pressed.

A second runtime helper contains the navigation keys while such a list is open — the live client never calls preventDefault, so without it ArrowDown would scroll the document behind the list and Enter would submit the surrounding form. It keys off aria-expanded on the closest [role=combobox], and deliberately leaves Escape alone, since Escape's default is the dismissal.

A tree — the DevTools panel's component tree — has the same shape and its own rule: one focusable [role=tree] with the cursor named by aria-activedescendant, the arrows, Home/End, Page keys and Space contained while it has focus (Enter left alone — a focused non-form element has no default for it). Because the cursor is an attribute rather than the focus, the runtime also scrolls the named row back into view when it moves out of sight, and in a virtualized tree — where that row is not rendered at all — it scrolls to where the row will be, which is what loads it.

An element with role="menu" is a list with a cursor inside it, and the cursor is where FOCUS is: the row the cursor is on carries data-active and tabindex="0", and the runtime moves focus to it whenever a render moves the attribute — roving focus, as Flux UI's menus have it, so a screen reader follows the arrow keys row by row. The runtime treats a menu like one: the arrows, Home/End, Page keys and Space are contained so they move the cursor rather than scrolling the page, and Enter or Space press the row that has focus — its own click handler, link or checkbox runs exactly as a pointer would run it. ArrowDown on the closed button that opens a menu (an invoker with aria-haspopup whose popover is, or holds, a role="menu") opens it onto its first row; a pick closes the popover the menu sits in unless the row, or something around it, carries data-rask-keep-open; Tab out of an open menu closes it; a click outside closes it and focus is handed back to the trigger. Escape is the browser's, and hands focus back too. Rask UI's Ui.Menu — what a Ui.Dropdown opens — builds on this, with menuitem, menuitemcheckbox and menuitemradio rows and Flux's keyboard: the arrows stop at the ends and step over disabled rows, a letter jumps to the row starting with it, ArrowRight or Enter opens a submenu and ArrowLeft closes it. A Ui.Navmenu is not a menu: it is a <nav> of links, reached with Tab. Ui.Context is the same menu opened by a right-click: the runtime shows its popover at the pointer (data-rask-contextmenu), the ContextMenu key and Shift+F10 open it at the focused element, the menu takes focus as it opens, and focus goes back where it was when it closes — so give its area something focusable, or a keyboard user has no way in. Ui.Command, the command palette, keeps focus in its search box instead: the commands are the options of a listbox, the lit one is the box's aria-activedescendant, and Enter presses it — as Flux writes it, the box has no combobox role.

A [popover] carrying data-rask-popover-open="true"|"false" is shown or hidden to match whenever the attribute changes — how a controlled menu opens from C#, which cannot call showPopover(). Escape and a press outside are the browser's and win until the server has answered them: a reply rendered before it heard of one does not open the popover again, and the toggle the runtime's own show or hide raises is not reported as the reader's.

Tooltips

A tooltip that is only drawn is a tooltip a screen reader never says. Rask UI's Ui.Tooltip joins its trigger to its content at render: a trigger with text of its own carries aria-describedby, so the tooltip is read after its name, and a trigger without — an icon button — carries aria-labelledby, so the tooltip IS its name. The content is role="tooltip" and aria-hidden, read through that reference and not a second time in the reading order. It shows on keyboard focus (:focus-visible) as well as on hover, stays while that focus lasts even if the pointer passes over and leaves, and Escape dismisses it — for any trigger, since the runtime shows it (data-rask-tooltip). A Ui.Button with a Tooltip is wired the same way: an icon-only one is named by aria-labelledby, not by an aria-label copied from the tooltip.

The trigger has to be one element for this — an HTML element or a kit component that is one; around a composite nothing is wired, as Flux wires nothing but the trigger. And a tooltip is a hint: a touch screen has no hover, so what matters there is Toggleable(), which a tap opens. See ui-kit.md for the little that is left undone.

Tabs

An element with role="tablist" is a ring of <button role="tab">s of which one — the selected one — is a tab stop (tabindex="0", the rest -1), and the runtime gives it the arrow keys (a behaviour hook, loaded by a page that carries a tablist): ArrowRight and ArrowDown move to the next tab, ArrowLeft and ArrowUp to the previous, past any tab that is disabled and around the ends. The tab arrived at is focused and pressed, so its own click handler runs exactly as a pointer would run it: selection follows focus, with no Enter or Space. The key's default is prevented, so an arrow does not scroll the page behind the row. From a button in the row that is not a tab — an "Add tab" action — the arrows start from the selected tab.

Tab leaves the row for whatever comes next, which in a Ui.TabGroup is the shown role="tabpanel" (tabindex="0", named by its tab through aria-labelledby; the tab names it back with aria-controls). Home and End are not handled and stay the page's — the behaviour of the Flux tabs Rask UI mirrors. Only buttons take part: a link you give role="tab" goes somewhere, and an arrow key must not navigate. Rask UI's Ui.Tabs builds on this; so does any tablist you write by hand. Ui.Tabs also leaves the selecting to the runtime (data-rask-tabs): aria-selected, the tab stop and the shown panel move in the press itself, before any answer from the server, so a screen reader hears the new tab at once. See ui-kit.md.

Controls that are waiting

A <button> (or <input type=button|submit>) whose own handler is still running after 200 ms is marked by the runtime with aria-busy="true" and data-loading, on both hosts, and a second activation is dropped until the first handler's render has landed. It is deliberately not disabled: disabling the control the reader just pressed moves keyboard focus to the document body, so the next Tab starts from the top of the page. The mark comes off when the dispatch finishes, when the connection drops, or after a 30 s backstop. Opt a control — or a container of them — out with data-rask-loading="off" (Ui.Button.Loading(false)), and opt a non-button element in with data-rask-loading. See ui-kit.md.

The mark lasts until the page the handler answered with is whole: when a slow read outlives the handler, the control stays busy until its rows have arrived. A container can ask to be marked too (data-rask-loading="within", which Ui.Pagination writes): it gets data-loading only — a styling hook, which is what a table dims its rows by. It is not aria-busy, because it is not the control, and nothing inside it becomes unpressable or leaves the tab order: the rows stay readable and clickable, and only the control that was pressed says it is busy.

A field whose value was sent — .Live(), .Debounce(…), a server OnInput search, a change — is marked too, after the same 200 ms and until the answer to that event: data-loading only, as Livewire puts it on a wire:model.live input. It is not aria-busy (a field being typed into is not unavailable) and nothing around it is marked. A field that waits for the next action sends nothing and is never marked; data-rask-loading="off" opts a field out as it does a button.

Toasts

Ui.Toast is Flux's toast, announced as Flux announces it: the host is role="status" and each toast is aria-atomic="true", so a toast is read politely and whole — heading and text together — whatever its variant. A variant is its icon's shape as well as its colour. A timed toast waits while the pointer is over it — or, in a stack, over any toast of the stack — and then runs out what it had left; focus inside it holds nothing, as on Flux, so one that must not be missed is raised with .UntilDismissed(). A toast on its own closes on Escape; its close button and any action or link are ordinary controls in the tab order. See ui-kit.md and Toast messages.

A NavLink to the page being shown writes aria-current="page" beside its active class — what a screen reader announces as "current page", where a class says nothing. An empty ActiveClass opts out of both, and an aria-current the call site sets wins. Rask UI's Ui.NavbarItem and Ui.NavlistItem are built on it.

Client-side (SPA) route changes on the Server live runtime are handled accessibly without any wiring:

  • Progress. A slow server-side route render surfaces the top progress bar (the same one a slow handler round-trip uses), after a ~300 ms grace so a fast navigation never flashes it.
  • Focus. A forward navigation moves focus into the new page's <main> (or its first <h1>), so a keyboard user continues from the new page instead of the now-removed nav link at the top of the document. Give your layout a <main> (Main[...]) to anchor this. A page that asks for focus itself gets it instead — see autofocus below.
  • Announcement. The new page's <title> is announced through a polite aria-live region, so a screen-reader user hears the route changed — wherever focus went.

Back/Forward (popstate) navigation leaves scroll to the browser's native restoration, and moves focus only to an autofocus field that arrives with the page. (The progress bar, the move to <main> and the announcement are the Server host's today; the WASM navigation path is a follow-up. autofocus is acted on by both hosts.)

autofocus on a page reached in place

A browser acts on autofocus when it loads a document. A page reached in place — a NavLink, Go() from a handler, a redirect — is a patch of the document that is already open, where the attribute would be only an attribute. So the runtime acts on it, on the Server host and in WebAssembly:

Ui.Input.Bind(() => Model.Name).Label("Name").Autofocus(true)   // focused by a load AND by a link to the page
  • After a navigation, the first autofocus element in document order that is on screen and enabled has focus, as it has after a load — in place of the move to <main>. The route is still announced. A link to a #fragment focuses the fragment's element, as a browser loading that address does.
  • On the page the reader is on, an element that arrives with autofocus — a row a handler added, a section a condition revealed, a field in a dialog that is already open — is focused once, as it arrives. An element that merely keeps the attribute from one render to the next is never focused again.
  • A reader who is typing is not interrupted. While focus is in a text field, a <textarea>, a <select> or editable content, an arrival does not take it (and is not owed it later). A navigation does.
  • What is not on screen is the browser's. A closed <dialog> or [popover] gives focus to its own autofocus element when it opens, natively, so the runtime leaves an element that arrives hidden alone. (Ui.Modal therefore opens as described above: with focus on nothing.)
  • Back and Forward focus an autofocus field that arrives with the page — a browser going back to a document does — without scrolling to it, so the restored scroll position stands.

What's not covered yet

This is the framework primitive layer. Higher-level affordances — skip links and automated axe-core scans in the sample E2E suite — are tracked as follow-up work. Today you build those from the typed Aria*/Role/TabIndex primitives above (plus the focus trap) and standard semantic HTML (Nav, Main, Aside, Label.For(…), Th.Scope(…), …).

Confirm