Skip to content
Home Theme Gallery

Server-side mechanics

Home-UI’s premise is that a Go server owns the state and HTMX moves markup around. This page is that premise written for the host: what the server owns, what it must send, what it gets back, and what happens to a live component when the markup underneath it is replaced.

The host owns state, validation, persistence and routing. Home-UI owns appearance and interaction. A component holds only the interaction state it cannot delegate - which item is highlighted, whether a panel is open - and never application state.

Concretely, the server writes attributes; the component reflects the ones a reader can change back to attributes:

The server writesThe component reflects back
name, value, disabled, required, aria-invalidopen, aria-expanded, aria-selected, aria-checked, data-theme
Every option, item and message the host rendersthe current value after an interaction

Anything the server does not own, it must not pretend to: there is no api, auth-token or endpoint attribute, and a combobox does not fetch.

Attributes are the input surface. A component rendered already open, already selected or already invalid needs no script, because the attribute is in the server’s HTML:

<hui-select name="courseId" value="{{ .CourseID }}">
{{ range .Courses }}
<div role="option" data-value="{{ .ID }}">{{ .Name }}</div>
{{ end }}
</hui-select>
<hui-dialog open side="right" label="Filters">
<button slot="trigger">Filters</button>
<p>…</p>
</hui-dialog>
<hui-input name="project" value="{{ .Project.Name }}"
{{ if .Errors.Project }}aria-invalid="true"{{ end }}></hui-input>

Every value control is form-associated through ElementInternals. Give it a name and it submits like a native control:

<form hx-post="/race-entry" hx-target="#result">
<hui-input name="name" value="Ada" aria-label="Name"></hui-input>
<hui-checkbox name="agree" value="yes" checked aria-label="Agree"></hui-checkbox>
<hui-checkbox name="extra" value="x" aria-label="Extra"></hui-checkbox>
<hui-slider name="volume" value="42" aria-label="Volume"></hui-slider>
<hui-input-otp name="code" length="4" value="1234" aria-label="Code"></hui-input-otp>
<hui-button type="submit">Send</hui-button>
</form>

That is the harness case formScenario (packages/ui/test-gallery/cases.ts), and the integration suite submits it and asserts the request carries name=Ada, agree=yes, volume=42, code=1234 and not extra - an unchecked box contributes nothing, exactly as a native one does.

  • A control with no name submits nothing. This is the silent failure to watch for: the field looks right, the form posts, and the value is absent.
  • hui-slider[range] submits one joined value, low,high, under a single name.
  • hui-toggle-group in multiple mode submits one entry per pressed item under the same name.
  • disabled controls are omitted, as native ones are.
The form the integration suite submits
Source
<form>
<hui-input name="name" value="Ada" aria-label="Name"></hui-input>
<hui-checkbox name="agree" value="yes" checked aria-label="Agree"></hui-checkbox>
<hui-checkbox name="extra" value="x" aria-label="Extra"></hui-checkbox>
<hui-slider name="volume" value="42" aria-label="Volume" style="max-width:12rem"></hui-slider>
<hui-input-otp name="code" length="4" value="1234" aria-label="Code"></hui-input-otp>
<hui-button type="submit">Send</hui-button>
</form>

hui-button[type="submit"] is form-associated for one reason: it calls form.requestSubmit(), so the form fires a real submit event that hx-post sees. A plain <button> inside a component’s shadow root would not. type="reset" calls form.reset(), and every associated control restores the value captured from its value attribute on connect - the server-rendered attribute is the default, not something the component invents.

An hx-swap removes and re-creates the markup. The new elements upgrade and re-associate; the old ones clean up in disconnectedCallback:

  • Listeners and timers are removed. A component never leaves a listener on document.
  • An open overlay leaves the top layer. hui-dialog and the popover-based overlays close on disconnect, so a swap cannot leave the page inert with nothing to dismiss. That was the shape of ISS-007.
  • Focus is not stolen. If the region that held an open overlay’s trigger is swapped away, the shared restore helper falls back rather than throwing, but restoring focus deliberately is the host’s job:
document.body.addEventListener('htmx:afterSwap', () => {
document.querySelector('[autofocus]')?.focus();
});

Business validation is the server’s. On failure it re-renders the same markup with aria-invalid and the error text, and hui-field wires both to the control for a screen reader:

<hui-field>
<span data-label>Project name</span>
<hui-input data-control name="project" value="{{ .Project.Name }}"
{{ if .Errors.Project }}aria-invalid="true"{{ end }}></hui-input>
{{ with .Errors.Project }}<span data-error>{{ . }}</span>{{ end }}
</hui-field>

aria-invalid is the single invalid signal across every control; there is no component-specific error attribute. This is how a Go validation error reaches a screen reader after a swap. See Screen readers.

The rule the composition primitives will follow (UI-CONTRACT-007) is the one to hold now: the markup is the state. What the server rendered is what the component shows. The host persists and echoes back; the component does not store it. Where a component keeps something, it is the interaction state a swap would otherwise interrupt, and it is gone when the element is.

The theme goes on the server’s output, not into script:

<html lang="en" class="{{ .Theme }}">
<body class="hui-surface">

.Theme is "light" or "dark", read from the cookie before first paint, so there is no flash. hui-theme-toggle exists for a host that would rather let the reader choose in the browser; a server-themed host does not load it. See Dark mode.