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 its Spec; 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, every data-hui-* hook, every data-cui-* key and the runtime's privileged unprefixed keys (data-behavior, data-island, data-action and 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. ExtraAttrs goes 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 text or html Bind 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 an attr Bind 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 every data-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.

15 lines
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.

4 lines
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 a Classes of 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. The fui- class prefix is reserved for framework/ui: writing fui-button on 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 see Classes, 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 empty aria-*, a style, 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.

2 lines
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 a visually-hidden live region (data-hui-carousel-live) the module says each slide's status sentence through: aria-live="polite", or "off" while a carousel with AutoRotateMS rotates on its own (the APG carousel pattern), turned polite again whenever the rotation is paused. Its roledescriptions are Strings.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's Meta line, its own part so the option's class never lands on it), combobox-status. NoScriptAction is checked whenever it is set, static or island: same-origin, never #. Pick makes it a picker inside a host form: a hidden input (data-hui-combobox-value) submits the picked option's Value, the search input names a form that does not exist so the host form never submits the query, Options are the first rows of the island listbox, and NoScriptAction is refused (its form would nest). Control hands the input to a host Field, 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. ComboboxRows renders 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 after isComposing becomes 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 safe Href renders <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 absolute href. 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. ComboboxRows and the site-search palette use the same link contract.
  • DatePicker: datepicker-inputs and datepicker-input (the native <input type=date>, or a Range'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 a data-cui-rpc GET, 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-multiselectable for a range), datepicker-weekday, datepicker-cell (aria-selected), datepicker-day (a button named by its full date, aria-pressed mirroring the selection, one in the tab order), datepicker-note and datepicker-status. The shown month is held between Min's and Max's months; a nav anchor that would not move is disabled. Control hands a single date's input to a host Field. DatePickerMonth renders 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. MaxStringLen counts characters, so a cut never splits one.
  • Sidebar draws the shell (sidebar-drawer, sidebar-inline, sidebar-toggle) and, with SidebarRegion, the slot-in version of the same content with no shell hooks; items carry sidebar-item (with a --sub variant), sidebar-nav, sidebar-prepend, sidebar-group, sidebar-group-toggle (the button dialect), sidebar-group-list, and icon with a --fallback variant (the label's initial, for rail states). The drawer trigger (the shell's sidebar-drawer and the standalone SidebarDrawerTrigger) is named by DrawerLabel; an empty or blank one takes Strings.SidebarOpenDrawer ("Open navigation", the ui.sidebar.openDrawer phrase ui.Sidebar names its own trigger with), so the phone's only way into the navigation is never an unnamed button. Gallery takes a GalleryLightbox wiring: the typed prop that renders the widget runtime's data-cui-open / data-cui-deeplink / data-fui-lightbox-group trigger family on every item anchor. Each deeplink value is encoded the way the runtime's decodeURIComponent reads it back (&, = and + escaped, a space as %20), and the viewer's src passes 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 its data-hui-selection-count slot on every change, on a form reset and on each scan, written in the page's digits (the server's first 0 is 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-invalid when the text holds a non-empty value that is neither #rgb nor #rrggbb.
  • when hides a data-hui-when region whose watched field does not carry the value data-hui-when-value names — or, when the region carries data-hui-when-in, any of the values its JSON list holds — disabling its controls under the runtime-owned data-hui-when-off mark 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 enclosing data-hui-when region 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 whose Errors is not a summary yet still focuses the summary a later render brings.
  • action binds [data-hui-action] buttons through the kernel's action primitive (window.__gofastr.action.bind), reading the endpoint, method, group and untoggle hooks and the two label parts; the module's registration Requires("action"), so the primitive is there first. A rolled-back mutation — on the optimistic button or a failed toggle commit — writes the root's data-hui-action-failed sentence into the data-hui-action-status span.
  • drop lists the chosen files and says the sentence, both built from the words the root carries (data-hui-drop-one and data-hui-drop-many, from Strings.FileSelected and Strings.FilesSelected), and takes a real drop on the zone with the runtime-owned data-hui-drop-over state. The input is resolved from data-hui-drop-input on 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 state sse.js mirrors onto window.__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-sort anchor or the pager's data-hui-page anchor 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's aria-current anchor when the answer has fewer pages, the data-hui-table-scroll region when the answer dropped the control). The sentence the server rendered into data-hui-table-announcement is copied, clear then frame, into the data-hui-table-status span. 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. A data-hui-table-select-all header checkbox (a Column.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 own stepUp/stepDown, so the next value is one the input accepts (inside min/max and on the step base: min=1 step=5 walks 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 retired numberinput, slider, rangeslider and animatedcounter runtime 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 the data-hui-tag-input-added/-removed sentences; 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 retired taginput and formrepeater runtime 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 from Strings). The copied label, the status sentence and the optional toast wait for navigator.clipboard.writeText to resolve; a refused or missing clipboard says the data-hui-copy-failed sentence instead. The toast goes through the kernel's _toastOrFallback, so it rides headless-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 through role="status" (info/success) or role="alert" (warning/danger), without redundant aria-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's X-Gofastr-Toast dispatch and ToastSlot speak; the kernel's loadModule target is this module now; a row is cloned from the stack's ToastTemplate and the module names no class and mounts no stack — with no stack on the page it returns null and the kernel's fallback region takes the toast) and the offline banner's retry link. With headless-copy it replaced the retired copy, toasts and networkretrybanner runtime 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 programmatic tabindex="-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 the UnreadBind signal writes, and re-formats the anchor's aria-label from it). The required Label remains the name's prefix on the server and after every update, followed by the localized count sentence. data-hui-notification-label carries 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 _id per 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 retired backtotop and themeswitch runtime 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 retired scrollspy runtime module.
  • headless-toc ([data-hui-toc]): the table of contents' current heading, through the same rail watch. It replaced the retired toc runtime 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, download and data-cui-open links close nothing. It replaced the retired disclosure runtime 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 retired menu runtime 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, flagged isComposing or sent with keyCode 229 by Safari) never picks an option. It replaced the retired combobox runtime 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 retired tabs and carousel runtime modules.
  • headless-panehost ([data-hui-panehost]): the pane lifecycle — open/close/swap through the data-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 retired panehost runtime 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 a SidebarRegion slotted into a host's chrome with no sidebar root still opens its groups. It replaced the retired sidebar runtime 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:navigate listener 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 its Strings, so a translated page announces in its own language; the gate in behavior_test.go refuses 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 ExtraAttrs or an Override. Both drop every data-cui-* key. A request is ButtonProps.Action, a signal is a Bind, a region's refresh is an Island.
  • One attribute under two spellings. NAME and name are 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. Field passes a FieldControl with the id, the aria-describedby and the invalid state already agreed; a control built from anything else drifts.
  • Setting an attribute whose presence is the value through Attrs. Attrs drops empty values on purpose; hidden, popover, open and every data-hui-* hook go through Mark, and boolean HTML attributes through Flag.
  • Adding an English string to a component. Give it a Strings field with a doc comment saying its shape. The source walk in strings_test.go fails on a phrase said outside Strings.
  • Regenerating a golden to make a test pass. Regenerate only after every other test in the package is green, then read the diff.