Headless components
Repeater submit controls default to Name+"_add" with value "1" and
Name+"_remove" with the zero-based row index. Explicit AddName,
AddValue, and RemoveName override these no-script operation markers.
After an Island swap removes a repeater row, focus returns to the next row's
first enabled input, select, textarea, or button that can receive focus, or to
the preceding row when the last row was removed. Hidden, inert, and unrendered
controls are skipped. Recovery checks that a control actually received focus
before stopping; CSS transparency (opacity: 0) does not itself prevent focus.
When no row control can receive focus, focus returns to Add.
Native modal dialogs, including confirmation prompts, retain Escape and Tab while open above an intercepted pane. Dismissing the dialog keeps the underlying pane open; its own Escape handling resumes afterwards.
framework/headless is the structure half of a design system. A
component there renders tags, roles, labelling relationships, state
attributes and the hooks a runtime binds to, and nothing else: no
classes at a nil Classes, no CSS, no script. A class map dresses it; a runtime
module binds it; a harness proves every registered component against
one contract.
It exists because structure and styling have different lifetimes.
Whether a field's error is tied to its input by aria-describedby does
not change when the palette does, and it is testable without rendering
a pixel. Once the two are separate, a class map can be redrawn or replaced
without putting a single accessibility guarantee back at risk.
This is the same SSR-first, incrementally hydrated model as the rest of the framework (core-ui/ARCHITECTURE.md): first paint is the full markup, behaviour arms by hook on arrival, and an in-page state change is an island RPC, never a route.
Import: github.com/DonaldMurillo/gofastr/framework/headless
The vocabulary
A component is a pure function from its props and a Classes value to HTML.
Seven things are named in its contract, and the harness checks each in
both directions.
- A part (
headless.Part) names an element the component draws. The class map styles those and only those; a part declared and never rendered fails a test, and so does a rendered part nobody declared. - A class map (
headless.Classes) is a flat map from part to class. Nil is valid and renders unstyled. Variants are looked up as<part>--<variant>, so the headless layer passes a variant name through without knowing what any of them mean. - A hook is a
data-hui-*attribute a runtime binds to. A component declares the hooks it publishes in itsSpec; a runtime that binds to an undeclared hook is bound to nothing.
What a caller may set on a component's parts travels as one Parts
value on the props, with three maps keyed by part:
- Slots replace the content of the parts a component lists as fillable. Most list none: a slot exists only where the component composes something no prop can express.
- Attrs add attributes to a part, through a sanitiser that
refuses
id,style, everydata-hui-*hook, everydata-cui-*key and the runtime's privileged unprefixed keys (data-behavior,data-island,data-actionand their family), stores names folded the way the browser reads them, refuses one name under two spellings, and never beats an attribute the component owns.ExtraAttrsgoes through the same refusal. - Binds keep a part in step with a client signal (text, html or one
attribute), refusing reserved signal names and attributes outside the
runtime's own allow-list. Two ownership rules on top: a
textorhtmlBind replaces a part's content, so it is allowed only on a part the component lists as fillable — the same set a Slot may fill — and anattrBind may not name an attribute the runtime rewrites as state moves (aria-pressed,aria-busy,aria-live,aria-invalid,aria-expanded,aria-current,hidden,disabled,data-state, and everydata-hui-*hook). Both refuse at render, with the reason.
Strings are the strings a component says, a prop of its own: one
typed struct, nil for English, for a layer above to resolve once per
request from the framework's i18nui keys. That layer is
ui.StringsFor(ctx) (framework/ui/strings.go): one table maps every
field to its i18nui key, and the call fills the struct through the
request's translator — pass Strings: ui.StringsFor(r.Context()) and
the component says its words in the reader's locale. No translator on
the ctx (or a per-key catalog miss) yields the English defaults, the
same words nil yields. A field left empty falls back to its English
default at runtime — a partial translation is safe, and the one that
misses shows as a stray English word on the translated page, not as a
compile error; the probe golden (spec_golden_strings.txt) is what
catches a component saying a word no Strings field carries. The list
of parts a component draws is its Spec.Anatomy, the word Ark UI,
Chakra and Radix use for the same list.
Two more are not per part. A Button takes an Action, the
framework's request contract, on itself; ExtraAttrs cannot carry one.
A component that changes in-page state takes an Island: the
endpoint that renders the region again and the signal the region is
bound to.
classes := headless.Classes{ headless.PartRoot: "field", headless.PartLabel: "field__label", headless.PartHint: "field__hint", headless.PartError: "field__error", } headless.Field(headless.FieldProps{ Label: "Port", For: "port", Hint: "1024 to 65535", Required: true, }, classes, func(c headless.FieldControl) render.HTML { return headless.Input(headless.InputProps{ Name: "port", ID: c.ID, Required: c.Required, DescribedBy: c.DescribedBy, Invalid: c.Invalid, }, inputClasses) })
The control is built from the wiring the field passes down, so its id,
its aria-describedby and its invalid state cannot disagree with the
label, the hint and the error beside it.
Islands: hard rule 1, at render time
The framework's first hard rule draws one line: state that is the
document's renders as the URL, and state that is not the document's
requires an Island. A list's page, sort and URL-owned filters are the
document's state — a list screen's pager and sort headers are plain
anchors (?p=2, ?sort=name) that work with no script and that the
client router intercepts when script is present, because changing the
address bar IS changing the list's state; an Island enters only for a
table or pager embedded in a region whose page turn must not
navigate the document, and then the SAME anchors carry the framework's
RPC contract (data-cui-rpc, data-cui-rpc-method, data-cui-rpc-signal,
data-cui-push-state) beside their hrefs. First paint is the page;
hydration makes it the island; the region is swapped in place and the
address bar still follows, written by the runtime through
pushState rather than by a navigation.
Everything else — expand, reveal, a dismiss, a filter applied in
place, a search — is in-page state: ToolbarSearch, a Tag with a
dismiss and an Alert with a dismiss refuse to render without an
Island, so the link-only render cannot be built. The carve-out is the
list's alone, and it cannot be stretched: an accordion or a reveal is
not list state, and a plain-link accordion is exactly as wrong under
this rule as it was before the carve-out was written.
headless.Pagination(headless.PaginationProps{ Page: 2, Pages: 9, Path: "/apps", Island: headless.Island{Endpoint: "/island/apps", Signal: "apps"}, }, classes)
On a Form the Island is optional too, for a page that is the
form — and an island form answers a failed validation with
200 and the region's HTML: the errors are the answer, the runtime
swaps them into the signal-bound region, and the arrival pass focuses
the summary. A non-2xx lands in the signal as {ok:false, status, text}
and renders nothing, so it is for transport and server errors, never
for validation.
What a reader is told after an island sort is composed on the server,
never in script. TableProps.Summary (and ui.DataTableConfig.Summary,
which passes it through) is the caller's sentence about the result
window, "Showing 8 of 10", because only the caller knows the total.
The primitive prefixes the sort when SortBy names a column, from
three Strings fields: TableSortedBy ("Sorted by {column},
{direction}", the column named by its Header or its Key),
SortAscending ("ascending") and SortDescending ("descending"),
bridged by ui.StringsFor to i18nui.TableSortedBy,
TableDirAscending and TableDirDescending. The result lands in
data-hui-table-announcement on the root, and the behaviour module
copies it into the table's status after the swap; a table with no sort
and no Summary renders no announcement at all.
Every href a component writes goes through the framework's anchor
policy, urlsafe.CleanAnchor: a Button whose href is rejected
renders the disabled-link posture; a form action, a dismiss href and
a pager's Path that are rejected are refused at render; and a
summary's field link the policy refuses falls back to plain text.
The endpoint keeps the href's query, merged pair by pair onto its own,
so the page and the island answer the same question. State keys (page,
sort, filter) belong in the href and nowhere else: when the endpoint
carries a key the href also carries, both values survive, the endpoint's
first, and a handler that reads Query().Get sees the endpoint's stale
one. data-cui-push-state is rendered only for a GET
with a href to write; a mutation's URL is the server's to set through
X-Gofastr-Push-State.
Headless via framework/ui
Two callers, two surfaces, one package. Pick your scenario by what you want to own:
- Bare headless. You render
headless.Button(props, nil)(or with aClassesof your own) and style it with your own stylesheet. You see parts, hooks,Parts{Attrs, Slots, Binds},Strings,Island,Action,Classes. You never see a theme. Thefui-class prefix is reserved for framework/ui: writingfui-buttonon your own markup gets the framework's styling whenever that sheet is on the page. - framework/ui. You render
ui.Button(ui.ButtonConfig{...})and customise through the theme: tokens for palette and scale, and the typed component options (theme.Overrides.Components) for how the component family draws — density, button treatment, button radius. You never seeClasses, parts, or a class name.
Classes is internal to the styled layer: ui.Button dresses the
headless structure with this package's own fui-button class map,
and the class names are the same under every theme — a theme never
picks classes, it declares option variables that the component's
stylesheet consumes (see theming → "Component
options"). Discovery and styling stay separate there too: the
data-cui-comp="ui-button" marker is what fetches the sheet; the
classes are what the sheet matches. A bare headless button beside a
styled one on the same page stays unstyled — that pair is one of the
fixtures below.
The plain-markup form family renders through headless the same way:
ui.Form, ui.FormField (whose Input is a builder receiving the
field's wiring — the id, the described-by chain, the invalid state,
the required flag), ui.FormSection, the typed fields, ui.Select
(which carries its own data-cui-comp="ui-select" marker on the
control beside the field's marker, so both sheets load wherever it
renders), ui.ValidationSummary and ui.InputGroup. ui.Control is
the styled native input for the types the typed fields do not name,
built inside a FormField builder from the wiring the field hands it.
ui.Form routes its island wiring through headless.FormProps.Request,
the typed request seam (what Button's Action is to a click): the
data-cui-rpc contract, a method that may differ from the native
one, the success effects, and the generator's data-action-mount
hook, each checked for what it deserves.
The choice family and the affix shells render through headless too:
ui.Checkbox, ui.Radio and ui.Switch dress headless.Choice and
headless.Switch (one inline run — the label wraps the control —
which keeps its own structure and ignores the field sheet's columns
variables); ui.RadioGroup and ui.CheckboxGroup hand their rendered
leaves to headless.Group with the group's own message paragraph, a
group's error belonging on the group and never on each leaf;
ui.PasswordInput is headless.Password (the reveal button's
data-hui-reveal is bound by the headless module, and its words come
from ui.StringsFor); ui.ColorField is headless.Color (the
swatch out of the tab order, the hex text the source of truth, and
the headless module's colour sync keeping the two one value for
every value the picker can show — the short #abc form included,
expanded to #aabbcc for the swatch and never an error; a value the
picker cannot show stays verbatim in the text while the swatch falls
back to black and the shell is marked data-invalid — the theme
editor's own sync was deleted for it).
The bespoke-behaviour family renders through headless too:
ui.FileUpload is headless.FileUpload (the drop zone a label for
the real input, the chosen names a role="list" the module fills, the
pick announced by a role="status" span saying the FileSelected /
FilesSelected sentences the strings bridge resolved); ui.FileDropzone
carries the same data-hui-drop hooks around its hero surface and
keeps only the thumbnail strip for itself — a styling concern with no
headless counterpart, bound by framework/ui's own filedropzone
module; ui.ConditionalField is headless.ConditionalField, a region
rendered VISIBLE and hidden by the module until the watched field
matches — one value or any of a list, both spellings one condition per
region (a field only a script can reveal is a field a scriptless
reader never reaches — the module also disables what it hides, so
nothing hidden submits); ui.TextArea is headless.Field +
headless.Textarea the way ui.Select is, with Autogrow reaching
the control through the prop that survives the data-cui-* refusal.
ui.SearchInput has no headless counterpart (the icon, the clear
button and the role="search" wrap are its own) and keeps its own
module, registered from framework/ui (searchinput.js beside the
component) the way FileDropzone's is: it binds the component's
classes, so it is the kit's and not the kernel's.
The stateful family renders through this package the same way, and
its Island rules are the same rule: a TagInput owns its chips
client-side and needs no Island; a Repeater whose add/remove
re-renders the region needs Action plus a complete Island (the
operations post to Action, the answer lands in the Island's
Signal), and the same buttons stay named submit controls so the
surrounding form is the no-script path. Implicit submission (Enter in
a field) clicks a form's first submit control, so Repeater and
StepWizard each render a hidden default button (part
default-action, hidden and out of the tab order; a class map must
not give it a display) ahead of their visible controls: the wizard's
is Next's twin, so Enter moves forward, never Back; the repeater's is
nameless, so Enter never submits a row's Remove, and the collections
module forwards it to the host form's own first submit control. A
Toast with a DismissHref needs a
complete Island exactly as an Alert's dismiss does; a Tag
dismiss is a GET that writes its href to the URL unless
DismissMethod names a mutation (POST, PUT, PATCH, DELETE), which
sends DismissBody as its JSON body and writes no URL; a
StepWizard's Island is optional and, when set, the form keeps its
plain POST shape and the module focuses the new step's heading or
the error summary after the swap. That focus/announcement record belongs
to the island answer: a soft navigation clears it, so a pending or failed
submit cannot focus a wizard on another page. A ToastStack is mounted once by
the layout: it carries the framework's data-cui-toast-stack name
beside its own data-hui-toast-stack (the kernel's response-header
toast path resolves the first, the component module the second), the
SSR rows inside it are visible with no script. Each server row carries
role="status" by default (or for LivePolite), or role="alert" for
LiveAssertive; the stack stays a non-live named region so arrivals
are not announced twice. The rows the module builds at
runtime wear no class of this package's: ToastTemplate renders an
inert <template> the kit dresses with its own class map and
registers under preset.ToastTemplate (registry.RegisterTemplate,
a renderer that takes the request context), preset.ToastSlotHTML
ships it inside every stack, and headless-feedback clones a row
from it. A page with no template gets the same row bare.
The action buttons (OptimisticAction, ToggleAction, Button
with an Action) keep the button contract: with no script they do
nothing on their own. A caller that needs the no-script submit puts
them inside a form whose action and method say where the request
goes — the component never wraps itself in a generated form, because
nested forms are invalid HTML and every caller that already composes
a form would break.
See it live: the product site ships a landing page under each of five
boot-registered themes, /examples/headless/{theme}/landing for
default, dense, soft, editorial and contrast, with a theme
switcher, every variant and size, the
same palette under two option sets, an A → B → A nest, explicit scheme
controls, bare headless beside styled ui, a newsletter form whose
no-script POST is answered with a 303 back to the page (the outcome
rides the route's query and the answer renders inside the site's
chrome; the submitted address never travels in the URL), and a cold
LoadAuto insertion. The browser proofs live in
examples/site/e2e_headless_landing_test.go.
A basic dashboard lives under the same themes,
/examples/headless/{theme}/dashboard: a RecordSummary with a
MetricBand, a LineChart with its values as text, an invoice
DataTable whose sort works as an island swap and as a plain link,
and last a settings form that submits both ways (island RPC with the runtime, plain POST without),
validates on the server, moves focus to the summary on a failed
submit, carries a password field and an upload, and nests conditional
regions two deep. Its browser proofs live in
examples/site/e2e_headless_dashboard_test.go.
The spec and the harness
Every component registers a Spec beside its code: its name, the
parts it draws, the parts a slot may fill, the hooks it publishes, and
its cases, each with a Why. One fixture drives everything that must
hold for every component:
- every case says why it exists and renders something;
- at the nil Classes no case renders a
class, an emptyaria-*, astyle, a<button>with no type, or a duplicate id; - rendering is deterministic;
- every declared part is drawn and every drawn part is declared;
- every declared hook survives rendering;
- every
aria-*reference resolves inside the fixture, and every control has a name; - a filled slot and a hostile part attribute cannot break the contract;
- every exported component has a spec.
Two goldens pin the corpus: testdata/spec_golden.txt at the English
strings and spec_golden_strings.txt with every string replaced by its
own probe token, which proves that every string on the page came
through Strings. strings_test.go also walks the source and refuses an
English phrase in the sinks it knows: text and HTML literals, format
strings, defaults, and the attribute values a reader is told.
go test ./framework/headless/ # every gate GOFASTR_UPDATE_GOLDEN=1 go test ./framework/headless/ # after an intended markup change
Read the golden diff. Every line of it is a change to what assistive technology will be told.
What ships in this package
Button, Field, FieldRow, ConditionalField, Input, Textarea, Select, Password, Color, Choice, Switch, Group, Form, InputGroup, FileUpload, Fieldset, ValidationSummary, Card, Stack, Cluster, Grid, Container, Section, Divider, Spacer, Spinner, Skeleton, Alert, SystemBanner, Badge, Tag, Selection, Toolbar, ToolbarGroup, ToolbarSpacer, ToolbarSearch, Pagination, Table, Steps, Timeline, PageHeader, EmptyState, StatCard, DetailList, OptimisticAction and ToggleAction, plus the stateful family: Counter, NumberInput, Slider, RangeSlider, Rating, TagInput, Repeater, Toast, ToastStack, ToastTemplate, NotificationBell, StepWizard and BackToTop. The navigation-behaviour members: Rail, TableOfContents, Disclosure, Menu, Combobox, DatePicker (with DatePickerMonth, its island answer), Tabs, Carousel, PaneHost, Sidebar and SidebarRegion, and the two pure-render trees JSONTree and Gallery.
A stateful control with no ID builds its ids from its Name.
MultiSelect, NumberInput, Slider, RangeSlider, Rating and
TagInput take a Ctx: through its id scope (render.UniqueID) a
second control with the same Name on the page (a filter form and an
edit form both naming a field tags) builds its ids from tags-2,
so no label's for= reaches the first one. One instance renders the
same ids it always did. RangeSlider's ValueLow and ValueHigh
are *int: nil is unset (the low thumb sits on Min, the high on
Max) and any set value is honoured, a 0..0 pair included, then
clamped into the range and ordered low, high. A Rating with an
ID builds its choices' ids from it (<ID>-1 to <ID>-<Max>,
reserved with the ID), so two ratings sharing a Name but given
their own ids never repeat a radio id. A Rating's choices render
in reading order, 1 to Max, so the browser's radio arrows move the
way the stars read (ArrowRight to the next star, flipped in RTL); a
sheet fills the stars up to the checked or hovered one with :has()
(framework/ui's RatingInput does), and a browser without :has()
still rates, missing only the earlier stars' fill. A TagInput with an ID gives its draft
field <ID>-field (the root keeps ID, so the label names the
field, not the group), and its Help is the field's
aria-describedby. A named Counter's input is an attr binding
(data-cui-signal-mode="attr" writing value), so a step moves what
the form submits. A stepping Counter hands its Value to its signal
when it arrives (data-hui-counter-value, bound by
headless-controls): the kernel steps the signal store, so without it
the first + on a counter showing 4 would show 1. Seeded: true leaves
the seed to the page (ui.Counter sets it; its store.Slice seeds
the signal). A Tree row's link is out of the tab order: the
tree is one tab stop and Enter on the row follows the link, including a
branch with its own Href. Space toggles a branch; Enter also toggles
a branch without a link. Arrow-key expansion and lazy loading are unchanged.
The parts the navigation members draw beyond the shared vocabulary:
- Menu keyboard: Space activates an enabled link row without scrolling the page. During an active type-ahead run, Space remains part of the search prefix, so multiword labels can be matched without activation.
- Carousel:
carousel-stage(the overflow scroller the slides sit in),carousel-track,carousel-slide,carousel-dot,carousel-prev,carousel-next, and avisually-hiddenlive region (data-hui-carousel-live) the module says each slide's status sentence through:aria-live="polite", or"off"while a carousel withAutoRotateMSrotates on its own (the APG carousel pattern), turned polite again whenever the rotation is paused. Its roledescriptions areStrings.CarouselRole/CarouselSlideRole. Both the current slide and total use the page locale's digits, on first render and after keyboard, button or automatic steps. - Combobox:
combobox-form(the no-script GET form),combobox-input,combobox-listbox,combobox-option,combobox-meta(an option'sMetaline, its own part so the option's class never lands on it),combobox-status.NoScriptActionis checked whenever it is set, static or island: same-origin, never#.Pickmakes it a picker inside a host form: a hidden input (data-hui-combobox-value) submits the picked option'sValue, the search input names a form that does not exist so the host form never submits the query,Optionsare the first rows of the island listbox, andNoScriptActionis refused (its form would nest).Controlhands the input to a hostField, whose label and description name it. Escape on an open listbox closes the list and stops there, so the dialog, overlay or disclosure around the combobox stays open;Inline(data-hui-combobox-inline) marks a listbox that is the surface's own content, the command palette's result list, whose Escape goes on to close the dialog in one press.ComboboxRowsrenders an island answer's rows and refuses duplicate option IDs, including collisions between explicit and derived IDs. With script, Enter commits only a highlighted option; with no active option, a closed list, or no results it does nothing and never submits the no-script GET form. The GET fallback remains available without JavaScript. ArrowUp in an open list with no active option selects the last enabled, visible option, just as it does when opening a closed list. A second Escape clears a nonempty query and notifies input listeners without reopening the list or retaining an active option. IME composition owns its Escape key, including Safari's keyCode 229 event afterisComposingbecomes false; both the popup and any enclosing disclosure, widget or intercepted drawer remain open, even when the listbox is already closed. Escape consumers ignore either composition indicator without clearing text or preventing the input method's default action. Pointer picks consume only unmodified primary clicks. Modified and non-primary clicks are left alone. An enabled option with a safeHrefrenders<li role="none"><a role="option" href="…" tabindex="-1">…</a></li>: the anchor owns the option ID, value, classes and active state while staying out of the Tab order, including inside a modal, intercepted drawer, trap disclosure or responsive pane. Tab and Shift+Tab wrap between tabbable controls, never through option links; closing the list cannot strand focus on a hidden option. From a programmatically focused negative-tabindex target, either direction moves to the next tabbable control in DOM order, wrapping inside the trap when necessary; a trap with no Tab stops consumes Tab without moving focus. Visible tabbable controls inside an untrapped nested disclosure remain in the enclosing trap's Tab order, including at its wrap boundaries. All four traps exclude controls that cannot receive focus: closed disclosure content, hidden or visibility-hidden content, skipped content-visibility regions, inert descendants and disabled controls. A closed disclosure whose content CSS makes visible still contributes its tabbable controls; closed state alone does not exclude them. Middle-click and modified clicks retain native link behavior. A plain primary click fills the input and closes the list while letting the link router own navigation exactly once; an already-claimed or cancelled click is not retried by the picker. Such a click never calls the programmatic navigator as a fallback, even when URL deduplication would suppress a second history write. Setting a client signal on the link does not itself claim navigation: an absolute same-origin option still commits the signal and selection and navigates once when the link router declines its absolutehref. A page listener cancelling the option click before the runtime's signal and routing delegates run suppresses navigation and history writes, even on a signal-bearing option; the signal delegate's own default prevention is distinct. A keyboard pick uses the SPA navigator once. An Enter keydown cancelled before the combobox's document delegate runs leaves the selection, open list and history unchanged. Input listeners run before that delegate; document listeners must be registered earlier. A later listener cannot undo a pick or navigation the runtime has already handled. Disabled or unsafe destinations have no native link. Programmatic navigation resolves relative destinations against the document's base URL, including a<base href>when present, before deciding whether the complete destination is already current.ComboboxRowsand the site-search palette use the same link contract. - DatePicker:
datepicker-inputsanddatepicker-input(the native<input type=date>, or aRange's two, which are the value),datepicker-calendar(the island region),datepicker-month(one month, the island answer's single root),datepicker-header,datepicker-nav(four anchors, each a real href to the page with the month in its query beside adata-cui-rpcGET, no push-state, named by visually hidden text so a disabled one, which has no href, is still named),datepicker-caption,datepicker-grid(role=grid,aria-multiselectablefor a range),datepicker-weekday,datepicker-cell(aria-selected),datepicker-day(a button named by its full date,aria-pressedmirroring the selection, one in the tab order),datepicker-noteanddatepicker-status. The shown month is held betweenMin's andMax's months; a nav anchor that would not move is disabled.Controlhands a single date's input to a hostField.DatePickerMonthrenders the island answer from the same props. - JSONTree: under each value's shared parts, one part per JSON
scalar kind so a class map can colour a string without colouring a
number:
json-colon,json-type,json-count,json-str,json-num,json-bool,json-null,json-empty.MaxStringLencounts characters, so a cut never splits one. - Sidebar draws the shell (
sidebar-drawer,sidebar-inline,sidebar-toggle) and, withSidebarRegion, the slot-in version of the same content with no shell hooks; items carrysidebar-item(with a--subvariant),sidebar-nav,sidebar-prepend,sidebar-group,sidebar-group-toggle(the button dialect),sidebar-group-list, andiconwith a--fallbackvariant (the label's initial, for rail states). The drawer trigger (the shell'ssidebar-drawerand the standaloneSidebarDrawerTrigger) is named byDrawerLabel; an empty or blank one takesStrings.SidebarOpenDrawer("Open navigation", theui.sidebar.openDrawerphraseui.Sidebarnames its own trigger with), so the phone's only way into the navigation is never an unnamed button.Gallerytakes aGalleryLightboxwiring: the typed prop that renders the widget runtime'sdata-cui-open/data-cui-deeplink/data-fui-lightbox-grouptrigger family on every item anchor. Each deeplink value is encoded the way the runtime'sdecodeURIComponentreads it back (&,=and+escaped, a space as%20), and the viewer'ssrcpasses the image-source policy the thumbnail does.
The behaviour module
The runtime module that binds the data-hui-* hooks ships in this
package, registered by behavior.go through the same seam a
stylesheet uses (registry.RegisterBehavior). The host serves it as
the module headless at /__gofastr/runtime/headless.js, and the
kernel loads it when one of its markers is on the page. The markers
are [data-hui-reveal], [data-hui-color],
[data-hui-form-errors], [data-hui-action], [data-hui-drop],
[data-hui-system], [data-hui-table] and [data-hui-selection]:
one per behaviour, the root hook of each. The when behaviour is a
separate module, headless-when (when_behavior.go), loaded by its
own marker [data-hui-when].
What it does, one line per behaviour:
- selection writes how many of a floating
Selection's rows are checked into itsdata-hui-selection-countslot on every change, on a form reset and on each scan, written in the page's digits (the server's first0is the reader's zero too); a select-all box is not a row. A CSS counter cannot do it: a table is a size container, and the style containment that brings walls the counter in. - reveal retypes the password input, flips the button's
aria-pressed, and keeps focus and the caret where the reader left them. The button is a toggle button: its name ("Show password") and its word ("Show") never change, so a screen reader hears "Show password, pressed" rather than the state said twice. - color keeps the swatch and the hex text one value in both
directions, and marks the shell
data-invalidwhen the text holds a non-empty value that is neither#rgbnor#rrggbb. - when hides a
data-hui-whenregion whose watched field does not carry the valuedata-hui-when-valuenames — or, when the region carriesdata-hui-when-in, any of the values its JSON list holds — disabling its controls under the runtime-owneddata-hui-when-offmark so only those re-enable. The list is JSON in one attribute, so a member value containing a comma, a quote or a bracket stays one member; a list that cannot be parsed matches nothing and the region hides, the same posture as a value that never arrives. The watched control is looked up in the region's own form first, and only then in the document — preferring controls no form owns, else the first in document order — so two forms with a same-named control cannot decide a region that belongs to neither. Regions nest: a region is shown only when its own condition holds and no enclosingdata-hui-whenregion is hidden, and the controls inside any hidden region stay disabled no matter what an inner region's own condition says. A form reset moves the watched controls with no input event, so the regions re-sync once the reset has run. - form-errors moves focus to the summary inside
data-hui-form-errors, once per form element — and the once-mark is spent only when a summary was found, so a form whoseErrorsis not a summary yet still focuses the summary a later render brings. - action binds
[data-hui-action]buttons through the kernel'sactionprimitive (window.__gofastr.action.bind), reading the endpoint, method, group and untoggle hooks and the two label parts; the module's registrationRequires("action"), so the primitive is there first. A rolled-back mutation — on the optimistic button or a failed toggle commit — writes the root'sdata-hui-action-failedsentence into thedata-hui-action-statusspan. - drop lists the chosen files and says the sentence, both built
from the words the root carries (
data-hui-drop-oneanddata-hui-drop-many, fromStrings.FileSelectedandStrings.FilesSelected), and takes a real drop on the zone with the runtime-owneddata-hui-drop-overstate. The input is resolved fromdata-hui-drop-inputon every event, so a swap that replaced it while the zone survived still lands the drop on the control the form submits. - system keeps a dismissed banner hidden for the session, and
shows the offline one (
data-hui-system-offline) when the framework reports the connection lost with a retry scheduled — reading, on arm, the statesse.jsmirrors ontowindow.__gofastr.sseStatus, so a banner that arrives during an outage shows at once. The dismissed set never applies to it: losing the connection again must show it again, which is also why the offline banner carries no dismiss. - table restores what an island sort or page turn destroys when a
valid answer arrives: the click on a
data-hui-table-sortanchor or the pager'sdata-hui-pageanchor accepts only a signal-bound table, then records the control's identity (the column key, the page number) and replacement region; focus returns to the same control's anchor in that region (the current page'saria-currentanchor when the answer has fewer pages, thedata-hui-table-scrollregion when the answer dropped the control). The sentence the server rendered intodata-hui-table-announcementis copied, clear then frame, into thedata-hui-table-statusspan. A failed answer leaves focus and status where they were. A plain table's status and announcement render for the pager's later use; this module fills neither. Adata-hui-table-select-allheader checkbox (aColumn.SelectAll) checks or clears every enabled checkbox in its own table whose name matches the attribute, firing a change event on each it moves, and shows checked, clear or indeterminate as the rows change.
Three more modules of this package ship beside it, each registered the same way and each owning one family of the stateful controls:
- headless-controls (
[data-hui-counter-animate],[data-hui-number-input-decrement],[data-hui-slider-output],[data-hui-range-slider]): steps the number input through the browser's ownstepUp/stepDown, so the next value is one the input accepts (insidemin/maxand on the step base:min=1 step=5walks 1, 6, 11), and reports real input and change events; mirrors the slider's thumb into its output; cross-clamps the range pair and re-formats its sentence through the shape the output carries; and animates a counter from its recorded start to the SSR value, never touching the number the signals kernel owns: a live write to the signal mid-walk stops the walk, and a rescan of the same counter is not a second arrival. It replaced the retirednumberinput,slider,rangesliderandanimatedcounterruntime modules (the unanimated counter needs no module at all — the signals kernel is its whole increment path). - headless-collections (
[data-hui-tag-input],[data-hui-repeater]): commits the tag draft on Enter, comma, the add control or blur; refuses a duplicate; removes on the chip's × or Backspace-on-empty; returns focus to the field; announces through thedata-hui-tag-input-added/-removedsentences; and after a repeater's island swap puts focus back on the row's control or the add control and says the server's status. The click record waits for its own answer: an unrelated insertion that lands first (a toast, another region's poll) leaves it in place. A click on the repeater's hidden default button (Enter in a row's field) is forwarded to the host form's first submit control outside every repeater. It replaced the retiredtaginputandformrepeaterruntime modules, and the IME guard (an Enter that confirms a composition candidate commits nothing) moved with it. - headless-wizard (
[data-hui-step-wizard]): owns only the island path — it records the step the reader left, and after the swap focuses the failed submit's summary or the new step's heading and says the step-of sentence when the step moved. An unrelated insertion that lands while the step is in flight leaves the record for the answer. The plain POST wizard needs none of it.
Two attributes are the headless module's own, written by it and
rendered by no component: data-hui-when-off and data-hui-drop-over.
One is headless-navigation's: data-hui-back-to-top-visible. One is
headless-copy's: data-hui-copy-state="done" on a copy control after
a successful copy. One is headless-feedback's: data-hui-toast-leaving
on a toast item the moment it is dismissed. The kit's sheets style both.
More modules of this package own the feedback and page-control families:
- headless-copy (
[data-hui-copy]): the copy control (no clipboard mutation without script — the words travel on the wrapper fromStrings). The copied label, the status sentence and the optional toast wait fornavigator.clipboard.writeTextto resolve; a refused or missing clipboard says thedata-hui-copy-failedsentence instead. The toast goes through the kernel's_toastOrFallback, so it ridesheadless-feedback's stack, or the kernel's fallback region on a page with none. Its own module so the toast runtime's budget does not carry the clipboard. - headless-feedback (
[data-hui-copy],[data-hui-toast-stack]and[data-cui-toast-stack],[data-hui-network-retry]; the copy wrapper is a marker so a copy that toasts finds the module loaded): runtime rows announce throughrole="status"(info/success) orrole="alert"(warning/danger), without redundantaria-live. The kernel fallback uses the same role-only announcement when the module cannot load or a page has no toast stack. The toast stack runtime (NS.toast,_initToasts,_dismissToast,_toastTimers,_toastSeq— the API the kernel'sX-Gofastr-Toastdispatch andToastSlotspeak; the kernel'sloadModuletarget is this module now; a row is cloned from the stack'sToastTemplateand the module names no class and mounts no stack — with no stack on the page it returnsnulland the kernel's fallback region takes the toast) and the offline banner's retry link. Withheadless-copyit replaced the retiredcopy,toastsandnetworkretrybannerruntime modules. Dismissing a focused runtime row moves focus to the next available row control, then a previous row, then the last connected control focused outside the stack (including a programmatictabindex="-1"origin). Capacity eviction uses the same recovery. Background expiry never moves external focus, and the exit timer does not reclaim focus moved elsewhere. Server-rendered rows keep their Island dismissal link: when its response removes the focused row, focus returns to the last connected external control. A reader who moved focus elsewhere while the request was pending keeps that focus, and an unsuccessful dismissal leaves the row alone. - headless-bell (
[data-hui-notification-bell]): the bell's spoken count re-said when a signal changes it (the module watches the badge's text, which theUnreadBindsignal writes, and re-formats the anchor'saria-labelfrom it). The requiredLabelremains the name's prefix on the server and after every update, followed by the localized count sentence.data-hui-notification-labelcarries that label separately so a literal{{n}}in it is never substituted. - headless-selection-copy (
[data-hui-selection-copy]): a selection's Copy control fetches its same-origin URL with one_idper checked row and writes the CSV answer to the clipboard, then toasts. Its own module so pages without a bulk bar's Copy never load it. - headless-navigation (
[data-hui-back-to-top],[data-hui-theme-toggle]): the back-to-top link (one sentinel for the document, the visibility mark, the focus return) and the theme group (persisted through the same storage key the bootstrap reads, so the scheme never flashes). It replaced the retiredbacktotopandthemeswitchruntime modules.
The sentinel follows the largest threshold among currently connected links. Soft navigation or an island replacement can lower that threshold; links removed from the document no longer contribute, even when a signal empties its HTML region without inserting any replacement nodes. Removing the final link resets the sentinel to zero.
The navigation-behaviour families ship as their own modules, each registered the same way:
- headless-rail (
[data-hui-rail]): the anchored rail's scroll spy — one intersection observer for every rail on the page, marking the link whose section owns the viewport. It replaced the retiredscrollspyruntime module. - headless-toc (
[data-hui-toc]): the table of contents' current heading, through the same rail watch. It replaced the retiredtocruntime module. - headless-disclosure (
[data-hui-disclosure]): the exclusive accordion group and the trap-and-inert Escape path, with optional session restore through a storage key. Escape closes one level, and a key something inside already answered (a combobox closing its listbox, a pane, a modal widget) is not answered twice; with focus in no disclosure it closes only the open popups (Dismiss, every menu). A followed link closes the popup or trapped drawer it sits in; a reading section (an accordion, an FAQ) stays open, and a click a handler took, a modified or middle click,target,downloadanddata-cui-openlinks close nothing. It replaced the retireddisclosureruntime module. - headless-menu (
[data-hui-menu]): the menu's open/close, arrow traversal, typeahead and focus return, on top of the disclosure primitive. It replaced the retiredmenuruntime module. - headless-combobox (
[data-hui-combobox]): the listbox filter, the count announcement and the loading word. A combobox with a static list whose input already has focus when the module binds opens that list at once (the command palette focuses its input before the module loads). The arrow keys, Home and End scroll the list to keep the active option in sight. A key that belongs to an input method (the composition's own, flaggedisComposingor sent with keyCode 229 by Safari) never picks an option. It replaced the retiredcomboboxruntime module. - headless-datepicker (
[data-hui-datepicker]): the pick into the native inputs (a range's start then end), the APG date-grid keyboard with one tab stop, and focus across the month swap. Keys pressed while a month loads count from the day the last one asked for and the arrival walks on to it; a reader who left the calendar keeps their place; a failed month is toasted by the anchor and ends the wait. It is new, not a port. - headless-navigation also carries the keyboard-shortcut family
(
data-hui-shortcut-*), and headless-tabs ([data-hui-tabs]) owns the roving tabindex and the vacate stash; headless-carousel ([data-hui-carousel]) owns the fragment controls, the status sentence (written into the live region, quiet while the carousel rotates on its own) and one rotation timer per root, paused on hidden documents, reduced motion, hover and focus. They replaced the retiredtabsandcarouselruntime modules. - headless-panehost (
[data-hui-panehost]): the pane lifecycle — open/close/swap through thedata-hui-pane-*controls, focus handoff and restore, the responsive drawer mode and the optional query deep link; a crafted slot value is refused before any selector, so the click is a no-op. It replaced the retiredpanehostruntime module. - headless-sidebar (
[data-hui-sidebar],[data-hui-sidebar-group-toggle]): the collapse state — persisted only under a namespaced, component-encoded storage key the root names; a root with no key is the server's and the module never writes — the custom collapse/expand labels, and the button-dialect group toggle. The group toggle loads the module on its own, so aSidebarRegionslotted into a host's chrome with no sidebar root still opens its groups. It replaced the retiredsidebarruntime module.
Two of this package's members ship no module at all: JSONTree (the
browser's own <details> is the whole behaviour, and its object keys
render sorted so the bytes are deterministic) and Gallery (every
item is a real link; a lightbox click travels the widget runtime's
open contract, which the adapter carries).
Arming is the kernel's. Each module registers a scanner and the kernel
calls it on every inserted subtree and over the document after a
client navigation; a host adds no observer, and no module adds one of
its own. framework/ui is the styled layer on top of this package:
its components render through these structures dressed with their own
class maps, and a bare headless render stays unstyled.
Common mistakes
- Arming the hooks by hand. A MutationObserver or a
gofastr:navigatelistener in a host, or a second module binding the same hooks, arms everything twice: the kernel already hands every inserted subtree and every post-navigation document to the module's scanner. - Saying a sentence in the module. Every string the module writes
travels as a
data-hui-*attribute the component rendered from itsStrings, so a translated page announces in its own language; the gate inbehavior_test.gorefuses an English literal the module writes itself. - Finding an element from script by its class. The runtime binds to
data-hui-*hooks only. A class map may rename every class, and a class used as a hook is the one thing it cannot rename. - Rendering a search form without an Island. That is a route for an in-page state change, and the component refuses it at render time with the rule's reason. A pager on a list screen is the allowed plain posture: its state is the URL's, and the Island is for the pager embedded in a region.
- Smuggling a request through
ExtraAttrsor an Override. Both drop everydata-cui-*key. A request isButtonProps.Action, a signal is aBind, a region's refresh is anIsland. - One attribute under two spellings.
NAMEandnameare one attribute to the browser, which keeps the first it reads. Both ways in store names folded, so the component's own spelling wins, and a key given twice is refused at render. - Building the hint's id by hand.
Fieldpasses aFieldControlwith the id, thearia-describedbyand the invalid state already agreed; a control built from anything else drifts. - Setting an attribute whose presence is the value through
Attrs.Attrsdrops empty values on purpose;hidden,popover,openand everydata-hui-*hook go throughMark, and boolean HTML attributes throughFlag. - Adding an English string to a component. Give it a
Stringsfield with a doc comment saying its shape. The source walk instrings_test.gofails on a phrase said outsideStrings. - Regenerating a golden to make a test pass. Regenerate only after every other test in the package is green, then read the diff.