Interactive patterns

The runtime ships client-side interactive behavior through data-cui-* attributes on regular HTML elements. No JavaScript is required from the application author. The runtime's click delegation, IntersectionObserver, and module system handle everything.

This doc catalogs every interactive pattern the framework provides, grouped by whether the behavior is client-only (no server round-trip) or RPC-backed (fires a fetch, updates the page).

Writing the data-cui-* attributes by hand instead of using a wrapper helper? Jump to Writing a hand-written island, end to end. It walks a complete example and calls out the four things that trip up almost everyone the first time (endpoint registration, the name→JSON-key rule, select triggers, and the two route-param syntaxes).


Client-only patterns

These run entirely in the browser. No network request is made.

Signal state

Signals are named string values stored in the DOM. The runtime provides three mutation primitives triggered by click:

AttributeEffect
data-cui-signal-set="name:value"Sets signal name to value
data-cui-signal-inc="name"Increments signal name by 1, or use "name:delta" for another step
data-cui-signal-toggle="name"Flips signal name between "true" and "false"

Any element carrying a data-cui-signal attribute renders the current value of that signal as its text content. The runtime updates it on mutation. Add data-cui-flash-on-update and it also flashes a brief .cui-flash highlight, styled by the built-in app.css (skipped when prefers-reduced-motion: reduce is active).

Go helpers: interactive.SetLocal(), interactive.IncLocal(), interactive.ToggleLocal().

Counter

framework/ui.Counter renders a numeric counter with +/− buttons. Uses data-cui-signal-inc. Configurable Step for non-unit increments. The kernel steps the signal store, not the markup: ui.Counter over a Slice steps from the page's seed. A bare headless.Counter hands its own Value to the signal when it arrives (data-hui-counter-value, bound by headless-controls), so its first

  • steps from the number on screen; set Seeded when the page seeds

the signal itself.

Tabs

framework/ui.Tabs renders a signal-driven tab strip. Clicking a tab sets the signal to the tab's index; CSS attribute selectors show/hide the matching panel. No JavaScript beyond the runtime's click delegation.

With Slice, the strip selects the slice's value for the request. A value that names no tab (negative, or 5 on a three-tab strip) selects tab 0, and when the headless-tabs module loads it writes 0 back into the signal, so the store and anything else bound to the signal read the tab the page shows. An in-range value is left as it is.

Three opt-in config knobs add the contracts a port from a component library usually needs (every zero value keeps the default output byte-identical):

  • StateAttrs bool — adds data-state="active"/"inactive" to every tab button. The headless-tabs module keeps it in step with the selection after client-side switches, alongside the aria-selected mirroring core already does.
  • ID string — wires tab↔panel semantics: button id="<ID>-tab-<i>" + aria-controls="<ID>-panel-<i>", panel id="<ID>-panel-<i>". You own cross-page uniqueness of ID. Without ID the prefix is the signal name; set Ctx and a second strip on the same signal in one render gets <name>-2 ids.
  • VacateHidden bool — hidden panels ship empty with their content in an adjacent JSON stash, so page-scoped test locators cannot match text inside hidden panels. The headless-tabs module restores content on first show and moves the live nodes out/in on every later switch, so island content the runtime swapped in survives re-show. While a panel is vacated, document-scoped updates targeting it (SSE pushes, in-flight RPC responses) are dropped permanently: nothing is queued for replay, re-show resurrects the panel's pre-vacate nodes, and only updates that arrive after re-show land.

Timing caveat for VacateHidden: the headless-tabs module is demand-loaded on the strip's data-hui-tabs marker, so any signal write that lands before that — an SSE, poll, or RPC-driven update, or a hydration-time signal value differing from what SSR rendered — moves data-active on a strip nobody has touched, and the newly-active panel shows empty until the first interaction heals it. SSR itself is unaffected (the initially-active panel always ships with its content), so server-rendered deep links, anchors, restored scroll, and autofocus are fine. If your app moves tabs without a user touching them, leave VacateHidden off.

Toggle switch

framework/ui.SignalToggle renders a role=switch with aria-checked bound to a boolean signal. Clicking toggles the signal between "true" and "false".

Collapsible

framework/ui.Collapsible wraps native <details> with data-hui-disclosure for keyboard support (Escape to close) and aria-expanded mirroring. The browser handles open/close natively. Sections that share a CollapsibleConfig.Name form an exclusive group (the native <details name> accordion): opening one closes the others, with no script.

2 lines
ui.Collapsible(ui.CollapsibleConfig{Summary: "Billing", Name: "faq"}, billing)
ui.Collapsible(ui.CollapsibleConfig{Summary: "Security", Name: "faq"}, security)

Copy to clipboard

framework/ui.CopyButton renders a button that copies text to the clipboard via navigator.clipboard.writeText(). The copy module (headless-copy, through the data-hui-copy* hooks) shows a brief "Copied!" state and announces it to screen readers once the clipboard has taken the text. A write the browser refuses (a declined permission, an insecure origin with no clipboard) announces the ui.copy.failed sentence instead, with no copied state and no toast. Without script the page promises nothing about the clipboard.

CopyButtonConfig.Target is the source element's id, with or without a leading # ("code-1" and "#code-1" are the same). It is not a CSS selector: the module looks the element up with getElementById, and render panics on a value with selector syntax such as ".code" or "pre > code".

Password visibility toggle

framework/ui.PasswordInput renders a password field with a reveal button that toggles between type="password" and type="text". The headless behaviour module (framework/headless/behavior.js) binds the button's data-hui-reveal hook: it retypes the input, swaps the button's visible word and accessible name from the four data-hui-* attributes the component rendered from its Strings, and keeps the caret where the reader left it.

Textarea auto-resize

framework/ui.TextArea accepts Autogrow: true. The runtime module (textarea.js) listens for input and resizes the textarea to fit its content, and refits after reset or focus. A textarea wired in a hidden panel keeps its height until it can be measured. ResizeObserver fits it when the panel opens and when its width changes; focus provides a fallback where ResizeObserver is unavailable. Detached controls release their observer targets. Triggered by the data-cui-autogrow attribute.

Toast notifications

core-ui/widget/preset.ToastStack renders a slide-in notification stack in the bottom-right corner, clear of a page's header actions; .Mount moves it. The feedback module (headless-feedback) owns the pure client-side toast runtime. Toasts auto-dismiss with a TTL, pause on hover/focus, and can be dismissed by clicking the close button.

Theme toggle

framework/ui.ThemeToggle renders a dark/light/auto switch. The navigation module (headless-navigation, through the data-hui-theme-* hooks) persists the preference in localStorage and toggles the color-scheme meta + root attribute. framework/ui.ThemePicker is its sibling for whole-page themes: the same module puts a registered override's class on <html> and persists it (theming → "Page themes: ui.ThemePicker").

Scroll spy

The scroll-spy rail is framework/ui.AnchoredRail over headless.Rail (data-hui-rail on the nav, data-hui-rail-observe naming the observed region): the headless-rail module IntersectionObserves which section is currently in the upper portion of the viewport and marks the corresponding nav link aria-current="true" / .is-active. Below the last section (a page's "next steps" tail) the last section stays marked, and the pick runs again when a scroll settles, so a jump to the end lands on it too. The table of contents (headless.TableOfContents, data-hui-toc) shares the same observer. Initial selection and later scroll updates ignore sections hidden by display:none, the hidden attribute, or closed <details>, even when a hidden descendant still reports layout rectangles. If every section is hidden at initial selection, no entry is marked. On phones AnchoredRailConfig.PhoneSheet folds the rail into a pill that names the section in view and opens the rail as a bottom sheet, or with PhoneSheetTop into a bar under the sticky header that opens it beneath (layouts → the aside's contents list). The observer's band starts below the header and that bar: --ui-sticky-top plus --ui-sheet-bar-offset. The retired scrollspy spelling is gone with its module.


RPC-backed patterns

These fire an HTTP request to the server and update the page based on the response. The runtime handles fetch(), CSRF tokens, and DOM updates.

OnClick (button → server → signal)

interactive.OnClick(html, action) wraps any element so clicking it fires an RPC. The Action specifies the HTTP method, path, and optional effects (set signal, open widget, navigate).

Attributes injected: data-cui-rpc, data-cui-rpc-method, data-cui-rpc-signal.

OnSubmit (form → server → signal)

interactive.OnSubmit(form, action) wraps a <form> so submission fires via fetch() instead of a full-page reload. The response body writes into the named signal.

Attributes injected: data-cui-rpc (on the form element), data-cui-rpc-trigger="submit".

Live Search (debounced input → RPC)

interactive.LiveSearch(form, action, debounceMs) wraps a search form so typing fires debounced RPCs. The input event triggers the fetch after the specified debounce interval (default 300ms).

Attributes injected: data-cui-rpc-trigger="input", data-cui-rpc-debounce-ms (milliseconds; default 300).

Optimistic Update (immediate visual + background RPC)

interactive.OptimisticUpdate(action, idle, success) accepts only the Action's RPC endpoint and method. An Action carrying confirmation, a body, toasts, or success effects panics at render time rather than discarding those options. Use interactive.OnClick for an Action needing that wiring. It renders a button that immediately flips to its success visual on click, then fires the RPC in the background. On failure the button shakes and reverts.

Uses the kernel's action module through the data-hui-action* hooks interactive.OptimisticUpdate (and framework/ui.OptimisticAction) render; see runtime-contract.

Toggle Action (three-state commit/untoggle)

framework/ui.ToggleAction renders a three-state button (idle → pending → committed) for binary server-backed state the user flips in place: Follow/Following, plan pickers, watch/unwatch. Clicking an idle button optimistically shows the committed label, POSTs Endpoint (or Method), and rolls back on non-2xx. With AllowUntoggle: true a second click reverts it, hitting UntoggleEndpoint when set, otherwise flipping locally with no request. Buttons sharing a Group form a client-side mutex: committing one reverts the previously-committed sibling (no extra RPC; the server stays the source of truth). SSR ships the initial state via Committed, and aria-pressed mirrors it for AT users.

8 lines
ui.ToggleAction(ui.ToggleActionConfig{
    Endpoint:         "/api/follow/42",
    IdleLabel:        "Follow",
    CommittedLabel:   "Following ✓",
    Committed:        alreadyFollowing, // SSR initial state
    AllowUntoggle:    true,
    UntoggleEndpoint: "/api/unfollow/42",
})

Uses the kernel's action module through the data-hui-action* hooks (data-hui-action-untoggle, data-hui-action-group) the button renders; see runtime-contract.

Inline Edit helpers

interactive.EditToggle(html, signalName) and interactive.CancelEdit(html, signalName) provide semantic wrappers for click-to-edit patterns. EditToggle uses data-cui-signal-toggle to enter edit mode; CancelEdit uses data-cui-signal-set="name:false" to close it. The actual save uses interactive.OnSubmit.

interactive.OnClick with a Navigate effect replaces the page content via the runtime's SPA navigation, with no full browser reload. The navigation bypasses the screen cache, so the destination always shows post-mutation state.

Navigate covers the screen the user lands on; a mutation that also stales other cached screens names them on the response:

7 lines
func createOrder(w http.ResponseWriter, r *http.Request) {
    // …write the order…
    // Drop every cached query variant of /orders in this tab, so
    // back-nav re-fetches instead of showing the pre-create list.
    ui.InvalidateScreens(w, "/orders")
    w.WriteHeader(http.StatusNoContent)
}

The two compose: InvalidateScreens evicts, Navigate (or data-cui-rpc-navigate) fetches the destination fresh. Eviction alone never re-renders the visible page. Selector rules and scope are in the runtime contract.

Confirm (pre-flight confirmation dialog)

Action.WithConfirm(message) gates the action behind a pre-flight confirmation. The gate runs before the request is dispatched: the runtime opens the kit's confirm dialog, and cancelling (the Cancel button, Escape or a click on the backdrop) aborts, so the RPC never fires.

4 lines
interactive.OnClick(publishBtn,
    interactive.Post("/api/items/42/publish").
        WithConfirm("Publish this item to every customer?"),
)

Attribute injected: data-cui-confirm="message".

For a destructive action (delete, revoke, purge), spell the dialog in full with Action.WithConfirmDialog: a title, the message, the accept button's label, and Danger, which draws the accept button in the danger variant.

7 lines
interactive.Delete("/api/invoices/42").
    WithConfirmDialog(interactive.Confirm{
        Title:   "Delete this invoice?",
        Message: "This cannot be undone.",
        Accept:  "Delete",
        Danger:  true,
    })

Attributes injected: data-cui-confirm, data-cui-confirm-title, data-cui-confirm-accept and data-cui-confirm-tone="danger". An empty Title or Accept keeps the dialog's own ("Are you sure?", "Confirm"); an empty Message panics.

The dialog is framework/ui's, registered as a template the host renders once per page, so it wears the theme in light and dark with no CSS of yours. Cancel takes focus when it opens: Enter on an open confirm never answers yes. A page with no kit imported falls back to window.confirm, so the gate never opens.

The runtime honors the attribute on any form submit (native POST, SPA, or RPC) and on RPC triggers; on a form, a submit button's message takes precedence over the form's.

Menu items have the same gate as a config field: ui.MenuItem.Confirm emits data-cui-confirm alongside the item's RPC wiring (ignored on non-RPC items — the gate covers form submits and RPC triggers, and a plain menu link is neither).

A confirmation that needs more than a title and a sentence (a form field, a list of what goes) is a modal of its own: ui.ConfirmAction.

OnErrorToast (say why a request was refused)

A button RPC that gets a non-2xx answer does nothing visible by default (a form reports its refusals beside the fields instead). Action.OnErrorToast(title) makes the failure visible: the runtime shows an error toast titled title, with the server's JSON error message as its body. A network failure shows the same title with a network-error body.

4 lines
interactive.Delete("/api/orders/42").
    WithConfirm("Delete this order?").
    OnSuccess(interactive.Navigate("/orders")).
    OnErrorToast("Could not delete this order.")

Attribute injected: data-cui-rpc-error-toast="title". An empty title falls back to Request failed (<status>). The resource detail page's Delete and state-transition buttons carry it, so a 409 from a record that other records still reference reaches the user.

OnSuccessToast and OnSuccessToastAction (say what happened, offer Undo)

Action.OnSuccessToast(title) shows a success toast on a 2xx answer, before any OnSuccess(Navigate(…)) runs: the toast stack sits outside the swapped region, so the toast outlives the navigation. OnSuccessToastAction(label, next) puts one button on that toast (and panics without one); pressing it runs next, with next's own toasts and navigation. A toast with a button stays up ten seconds instead of six, paused while hovered or focused.

6 lines
interactive.Delete("/api/invoices/42").
    OnSuccessToast("Invoice deleted").
    OnSuccess(interactive.Navigate("/invoices")).
    OnSuccessToastAction("Undo", interactive.Post("/api/invoices/42/_restore").
        WithBody(`{}`).
        OnSuccessToast("Invoice restored"))

Attributes injected: data-cui-rpc-success-toast="title" and data-cui-rpc-success-action (JSON: the label and next's attributes). The button carries next's data-cui-rpc* wiring and nothing else: OnSuccessToastAction panics on a confirm, a signal or any other key, and the runtime drops any such key from the JSON it reads. entityui's Undo() on a soft delete is built on it.

A server answer offers the same button: interactive.NewToastAction(label, next) builds it under the same rules, and ui.ToastTrigger.Action carries it in the X-Gofastr-Toast header. entityui's bulk delete answers its Undo this way. ui.AddToast drops a toast whose action carries a control byte, as it does for the title.

6 lines
ui.AddToast(w, ui.ToastTrigger{
    Variant: ui.StatusSuccess, Title: "3 deleted",
    Action: interactive.NewToastAction("Undo",
        interactive.Post("/api/notes/_bulk").WithBody(body).
            OnSuccess(interactive.Navigate("/notes"))),
})

AfterText (one-shot button label swap on success)

interactive.AfterText(text) replaces the trigger element's text content with text after a 2xx response. One-shot: re-clicks are idempotent. Pair with AfterDisable for "Saved ✓" feedback.

6 lines
interactive.OnClick(saveBtn,
    interactive.Post("/api/save").OnSuccess(
        interactive.AfterText("Saved ✓"),
        interactive.AfterDisable(),
    ),
)

Attribute injected: data-cui-rpc-after-text="text".

AfterDisable (permanently disable trigger on success)

interactive.AfterDisable() sets aria-disabled="true" and disabled on the trigger after a 2xx response. Use with AfterText to prevent re-submission.

Attribute injected: data-cui-rpc-after-disable (boolean).

ScrollTo (scroll to newly-added content on success)

interactive.ScrollTo(selector) smooth-scrolls the element matching selector into view after a 2xx response. Use to direct the user's eye at content that the RPC just inserted.

5 lines
interactive.OnClick(addBtn,
    interactive.Post("/api/items").OnSuccess(
        interactive.ScrollTo("#items-list"),
    ),
)

Attribute injected: data-cui-rpc-scroll-to="selector".

PushState (update URL without re-fetch on success)

interactive.PushState(path) applies a URL change via history.pushState after a 2xx response without triggering a navigation fetch. Use for actions that know the canonical URL ahead of time (e.g. pagination).

The server-supplied X-Gofastr-Push-State response header takes precedence when both are present.

Inside an intercepted pane, the write belongs to that pane's history span; closing the pane returns to the page underneath. Query-only (?p=2), fragment-only (#details) and same-origin absolute URLs resolve against the current browser URL. Subsequent query links stay inside the pane when the write keeps the pane's route; a same-origin absolute URL naming an unrelated route does not provide that guarantee.

5 lines
interactive.OnClick(page2Btn,
    interactive.Post("/islands/items/page").OnSuccess(
        interactive.PushState("?p=2"),
    ),
)

Attribute injected: data-cui-push-state="path".


Polling patterns

Polling is passive freshness without a held connection. The runtime re-fetches a region on a Go-duration interval and swaps the body, using the same code path an RPC signal swap uses.

Page-level polling

Two attributes on the region you want to refresh:

3 lines
<div data-cui-poll="30s" data-cui-poll-src="/islands/orders/today">
  …initial SSR content…
</div>
  • data-cui-poll is a Go duration (30s, 5m, 1h). Five seconds is the floor.
  • data-cui-poll-src is the URL the runtime fetches; the response body replaces the region's innerHTML.
  • The interval is jittered, pauses while the tab is hidden, and doubles the interval on a failed fetch (capped at 5x the base, reset on the next success).
  • Any replica can answer the fetch from the DB. No fanout, no held connection.

Use a read endpoint. A poll fires on a timer, so a write endpoint would write on every tab on every interval.

Widget-level polling

For a widget that tracks a server-side value, Builder.Poll(interval) re-fetches the widget's /state on the cadence and re-applies changed signals. See Widgets → Polling.

When to pick polling

Polling is the recommended tier for a surface that needs to stay fresh on a cadence: a counter, a status pill, a dashboard. Reserve SSE push for semantics that need the connection: presence, collaborative editing, sub-second updates. See Reactivity model for the full ladder.


Sortable list (single + kanban)

framework/ui.SortableList (on the headless.SortableList primitive, bound by the registered headless-sortablelist module) renders a reorderable <ol> with HTML5 drag-and-drop plus a keyboard fallback (Space to grab, Arrow keys to move, Space to drop, Esc to cancel). After a successful reorder the module POSTs the new key sequence to RPCPath as form-encoded order=<comma-sep-keys>. A non-2xx response reverts the DOM. The Items slice may be empty. An empty column renders a valid, sortable <ol> wrapper with no <li> children and remains a drop target (empty Kanban columns, issue #82). ui.SortableListItems with no items returns an empty fragment, so an authoritative conflict-reconciliation endpoint can replace a column with an empty response.

Single list (back-compat)

5 lines
ui.SortableList(ui.SortableListConfig{
    Label:   "Priorities",
    RPCPath: "/api/reorder",
    Items:   []ui.SortableItem{{Key: "a", Label: "A"}, {Key: "b", Label: "B"}},
})

The POST body is order=a,b. Existing single-list callers compile and behave identically; a list without Container never sends a container= field on same-container reorders (back-compat).

Kanban (cross-container)

Render one list per column, all sharing the same Group (the board id), each with a unique Container (the column id the server writes to). Lists with the same non-empty Group allow drag and keyboard moves between them; lists with no group (or different groups) stay isolated. A column may start empty (Items: nil). It still renders the sortable wrapper and accepts drops.

9 lines
for _, col := range board.Columns {
    ui.SortableList(ui.SortableListConfig{
        Label:     col.Title,           // aria-label = column name
        Group:     "board-1",           // same for every column
        Container: col.ID,              // unique per column
        RPCPath:   "/api/board/move",
        Items:     col.Items,           // may be empty
    })
}

A cross-container drop POSTs to the destination list's RPCPath:

1 lines
order=<dest-keys>&moved=<key>&container=<col-id>

The server knows which item moved (moved), to which column (container), and the new destination order (order). It removes the item from its previous column and inserts it into the destination at the right position. A same-container reorder on a list that has a Container configured also sends container=<col-id> (issue #84) so the server can route the write without inferring the column from the key set; a list without Container keeps the legacy order=<keys> payload exactly. Cross-container commits always carry container= (empty when the list has no configured id).

Keyboard: Arrow Left/Right moves a grabbed item to the adjacent column in the same group (including an empty one); Arrow Up/Down reorders within the column.

Version-aware 409 conflict recovery

When Version is set, the runtime appends version=<token> to every commit. A 409 Conflict response then fires a distinct conflict path instead of a blanket rollback:

  1. The 409 response body is read under hard safety bounds (issue #83): the Content-Type MUST be application/json, at most ~4 KB is read, the body MUST parse as the error envelope every route answers with ({"error":"…","code":409,"reason":"…"}, what handler.WriteError writes), and error (which must be a string) is capped at ~300 characters. Anything malformed, oversized, non-JSON, or unreadable, including an empty body, falls back to the generic copy (today's behavior, backward compatible).
  2. When a valid message is present, it is shown through the polite aria-live region (replacing the generic copy) and the framework's toast helper (__gofastr.toast, loaded on demand) when one is wired.
  3. If ConflictRPC is set, the runtime then GET-fetches it and replaces the destination list's innerHTML with the response (server-rendered reconciliation: an empty response reconciles the column to zero items, #82). The source list is restored from its snapshot. If ConflictRPC is absent, the runtime falls back to rollback + a console.warn.
1 lines
{"error": "Cannot move ORB-12 to Done because ORB-9 is incomplete.", "code": 409, "reason": "transition_blocked", "success": false}

Without Version, 409 is treated like any other non-2xx (rollback), for back-compat. A polite aria-live region announces grab, move (position

  • column), drop-commit success, and rollback/conflict (with the

server's message when available) for screen-reader users.


Writing a hand-written island, end to end

The wrapper helpers above cover the common cases, but sometimes you write the data-cui-* attributes by hand: a bespoke widget, a generated screen, a one-off control. The runtime is happy to drive raw attributes, but four things trip up almost everyone the first time. This section walks a complete example and calls each one out.

The example: a product list with a category <select> that swaps the list via an RPC (an in-page state change: an island, not a route).

1. Register the endpoint yourself

data-cui-rpc is just a string the runtime POSTs to. Nothing registers that route for you. The auto-wiring you may have seen belongs to widget.Mount / widget.MountBuilder, which register a widget's /style.css, /state, and /chrome routes. A hand-written data-cui-rpc path has no widget behind it, so you add the handler on the app router:

1 lines
app.Router().Post("/islands/products/filter", http.HandlerFunc(filterProducts))

Forget this and the click fires a request that 404s, with no compile error and nothing in the page to hint at the missing route. If a data-cui-rpc button "does nothing", check the server log for a 404 first.

2. The JSON key is the input's name, not its id

When the runtime submits a form-backed RPC, it serializes the form to JSON using each control's name attribute as the key (non-multipart forms go out as application/json; see the forms note in runtime-contract). The id is for CSS/labels and never appears in the body.

8 lines
<form data-cui-rpc="/islands/products/filter" data-cui-rpc-method="POST"
      data-cui-rpc-trigger="input" data-cui-rpc-debounce-ms="1">
  <select id="cat" name="category">   <!-- key is "category" (name), not "cat" (id) -->
    <option value="all">All</option>
    <option value="tools">Tools</option>
    <option value="parts">Parts</option>
  </select>
</form>
5 lines
func filterProducts(w http.ResponseWriter, r *http.Request) {
    var body struct{ Category string `json:"category"` } // matches name=, not id=
    _ = json.NewDecoder(r.Body).Decode(&body)
    // …render the filtered rows, write them back…
}

Why this bites: curl -d 'category=tools' hits the handler fine, so the server looks correct in isolation. The mismatch only surfaces in a real browser, where the runtime keys the body off name. Test the wired-up form in a browser, not just the endpoint in isolation.

3. A <select> (or checkbox/radio) needs no change trigger

There is deliberately no data-cui-rpc-trigger="change". Selects, checkboxes, and radios all emit an input event on commit in every modern browser, so data-cui-rpc-trigger="input" already fires for them. Wrap the control in a <form data-cui-rpc … data-cui-rpc-trigger="input"> (as above) and you're done. Adding a second change trigger would be redundant behavior for a control the input trigger already covers, and the core runtime is gzip-budget-locked, so the framework does not ship one.

For a <select> the input/change distinction doesn't matter (both fire once, on selection), so set a small debounce, data-cui-rpc-debounce-ms="1", to fire promptly instead of waiting out the 250 ms default meant for keystroke typeahead. This recipe is covered end-to-end by TestInputTrigger_SelectFiresRPC in core-ui/runtime.

4. Route-param syntax: {id} on the HTTP router, both on the screen router

Two routers, two placeholder conventions to keep straight:

RouterWhere you use itPlaceholder syntax
Framework HTTP router (app.Router())RPC endpoints, API routes, anything you .Get/.PostGo 1.22 style only: /islands/products/{id}/filter
UI screen router (app.Register / screen groups)Page routes whose params reach SetParamsEither /products/:id or /products/{id}

So an RPC handler is registered with braces:

2 lines
app.Router().Post("/islands/products/{id}/filter", http.HandlerFunc(filterProducts))
// read it with r.PathValue("id")

…while a page route may use either form, and they match identically:

3 lines
app.Register("/products/:id", &ProductScreen{}, layout)     // colon form
app.Register("/products/{id}", &ProductScreen{}, layout)    // brace form, same route
// param arrives via ParamSetter.SetParams either way

The screen router normalizes {id} to :id at registration so every downstream consumer (resolve, the route manifest, llm.md) sees one shape. The HTTP router is Go 1.22's ServeMux and accepts only {id}. A :id segment on an app.Router().Post(...) path simply never matches (no error, just a 404). When an RPC route "isn't hit", check that it uses {id}, not :id.


Themed confirmation (ui.ConfirmAction)

Action.WithConfirm and WithConfirmDialog (above) ask in the kit's confirm dialog, which holds a title, one sentence and two buttons. When the confirmation needs more, framework/ui.ConfirmAction renders a widget alertdialog with a body of its own: a modal that matches your theme (light + dark, via tokens, zero bespoke CSS), traps focus, closes on Escape, and is drivable by tests.

It's the same island contract you already know, composed from two existing primitives: the trigger carries data-cui-open (open the modal), and the modal's Confirm button carries the real data-cui-rpc. Cancel just closes; only Confirm dispatches. No new runtime attributes, no core JS.

17 lines
trigger, modal := ui.ConfirmAction(ui.ConfirmActionConfig{
    Name:         "delete-user-42",   // unique per action; qualify per row
    TriggerLabel: "Delete",
    Title:        "Delete user?",
    Body:         "This permanently removes the user and their data.",
    ConfirmLabel: "Delete it",        // defaults to "Confirm"
    CancelLabel:  "Keep it",          // defaults to "Cancel"
    RPCPath:      "/users/42/delete", // the Confirm button POSTs here
})

// Mount the modal ONCE at app startup (registers its widget routes):
def := modal.Build()
widget.Mount(app.Router(), &def)

// Render `trigger` wherever the destructive button belongs; register the
// RPC handler yourself (rule 1 above still applies):
app.Router().Post("/users/42/delete", http.HandlerFunc(deleteUser))

Cancel is the initially-focused button by default (safer for destructive flows: a stray Enter can't fire the action); set AutofocusConfirm: true for benign "Apply changes?" confirmations. The returned pair is deliberately two values: the trigger renders inline, the modal mounts once. Forgetting the widget.Mount is the usual "the dialog never opens" cause.


Presence rosters (who is here)

ui.Avatar and ui.AvatarGroup can show a presence dot, the visual half of "who is viewing this". Set AvatarConfig.Status to ui.AvatarOnline, AvatarAway, AvatarBusy, or AvatarOffline; the dot colors come from the status tokens, so a themed app recolors them for free. StatusLabel overrides the dot's accessible name. The default is the status word ("Online", "Away", "Busy", "Offline", the ui.avatar.status* phrases) in the language of AvatarConfig.Ctx; AvatarGroup and Dropdown hand their own Ctx to an avatar that sets none.

7 lines
ui.AvatarGroup(ui.AvatarGroupConfig{
    Avatars: []ui.AvatarConfig{
        {Name: "Ada Lovelace", Status: ui.AvatarOnline},
        {Name: "Grace Hopper", Status: ui.AvatarAway},
        {Name: "Alan Turing",  Status: ui.AvatarBusy, StatusLabel: "In a review"},
    },
})

To make the roster live, feed the group's HTML through a signal, the same pattern NotificationBell uses: render the AvatarGroup inside a data-cui-signal="…" data-cui-signal-mode="html" region and push new HTML when the roster changes (via an RPC response signal or an island re-render). The status values are just data you supply.

Presence transport is app-owned today. The framework gives you the roster rendering (the status dot) and the live-region plumbing (signals + SSE), but it does not yet track who holds an open connection to a given entity. Deriving the live roster is work an app wires itself for now: binding the authenticated user to their SSE connection, observing connect/disconnect, and aggregating that across replicas. Adding this to the framework is tracked in issue #47.

Complex interactive components

These are full components (not wrapper functions) that ship with their own runtime modules for client-side behavior.

ComponentRuntime moduleBehavior
Carouselheadless-carouselPrev/next navigation, pagination dots, keyboard, auto-rotation
Comboboxheadless-comboboxDebounced search RPC, listbox navigation, type-ahead
Command Palette(uses Modal + Combobox)⌘K overlay with search
Drag Sortable Listheadless-sortablelist (framework/headless)Native drag-and-drop + keyboard reorder, cross-container kanban, version-aware 409 conflict recovery, RPC commit, per-move announcements through Strings
File Dropzonefiledropzone.js (framework/ui)Image thumbnail strip; the drop, the chosen-files list and the pick announcement are the headless module's data-hui-drop hooks
Gallery + Lightboxframework/ui/lightbox.js (registered behaviour)Image zoom overlay, prev/next, keyboard
Menuheadless-menuKeyboard navigation (arrows, Home/End, type-ahead), submenu open/close (ArrowRight/Left, swapped in RTL), menuitemradio group arbitration
Multi-selectheadless-multiselect (framework/headless)Checkbox group with chip display; plain-form submit, disclosure via headless-disclosure
Notification Bell(uses Popover)Bell + unread badge + dropdown
Popoverpopover.jsAnchored positioning, auto-flip, arrow drawing
Range Sliderheadless-controlsDual-thumb with cross-clamp, live output sentence
Sliderheadless-controlsLive value mirror
Tag Inputheadless-collectionsFree-form chips, Enter/comma/blur to commit, removal announcements
Treeheadless-tree (framework/headless)WAI-ARIA tree pattern, roving tabindex, arrows/Home/End/type-ahead, expand/collapse through the toggle (lazy branches keep the kernel rpc wiring)
Network Retry Bannerheadless-feedback (offline SystemBanner)Shows on the framework's lost-connection report, retry link probes health
Animated Counterheadless-controlsNumber tick animation toward the SSR text, reduced-motion aware
Bannerheadless (SystemBanner)Dismissible, session-persisted dismissal memory

Command palette

ui.CommandPalette returns a trigger and a modal component. The trigger is a link to FallbackHref that opens the modal with script and binds Shortcut (default Meta+K). By default it is visually hidden, for chrome that draws its own search button. Set Trigger: ui.PaletteTriggerField to draw a search field instead: a magnifier, TriggerText (default: the placeholder) and the shortcut's keycaps (⌘K). Below 48rem it shrinks to a 44px icon button. The knob --ui-cmd-trigger-width (default 16rem) sets the field's width.

The panel keeps one height while the reader types, so a filter that leaves one row does not move the field or footer: the list fills what they leave and scrolls. --ui-cmd-palette-height (default 30rem, capped by the viewport) and --ui-cmd-palette-width (default 36rem) size it; on a phone it fills the screen.

A palette with RPCPath searches on the server. The endpoint reads q from the form body and answers option rows built by ui.PaletteResults(ctx, name, cmds, empty), where name is the palette's Name. Each row navigates to its command's Href on pick; an href that is not a safe link (a javascript: URL) renders no navigation. With no commands it answers one disabled row reading empty, or the localized "No matches" when empty is "".

A palette with Search: true searches the pages instead: the site search battery's index, live or from a static export's index files. It needs Commands, the rows shown before the first keystroke, and adds page and section rows below the matching commands as the reader types. They fade in as they arrive, except a row that stays across keystrokes, and not at all under reduced motion. On a phone or tablet the footer drops its keyboard hints and keeps the close button.

20 lines
paletteConfig := ui.CommandPaletteConfig{
    Name: "admin-palette", RPCPath: "/admin/_palette",
    FallbackHref: "/admin/search", Trigger: ui.PaletteTriggerField,
}
_, palette := ui.CommandPalette(paletteConfig)
widget.MountBuilder(app.Router(), palette) // once at startup

// In the page chrome, on every request:
trigger := ui.CommandPaletteTrigger(r.Context(), paletteConfig)

func search(w http.ResponseWriter, r *http.Request) {
    r.Body = http.MaxBytesReader(w, r.Body, 4<<10)
    if err := r.ParseForm(); err != nil {
        http.Error(w, "bad form", http.StatusBadRequest)
        return
    }
    cmds := find(r.Context(), r.FormValue("q")) // []ui.PaletteCommand
    w.Header().Set("Content-Type", "text/html; charset=utf-8")
    w.Write([]byte(ui.PaletteResults(r.Context(), "admin-palette", cmds, "")))
}

CommandPalette's returned trigger is a snapshot of cfg.Ctx, not a request slot. CommandPaletteTrigger(ctx, cfg) renders the same markup without rebuilding the modal; its explicit ctx resolves the default accessible label and field placeholder even when cfg.Ctx differs. Explicit TriggerLabel, TriggerText and Placeholder still win. A nil context uses English fallbacks. Keep the name and shortcut consistent with the mounted body. For translated commands, mount CommandPaletteFunc and pass the request-built config to CommandPaletteTrigger.


Using the interactive package

An Action is built with the method constructors (Post, Get, Put, Delete, Patch) and refined with chained methods (.OnSuccess(...), .WithConfirm(...)). Its fields are unexported, so there is no struct literal.

20 lines
import "github.com/DonaldMurillo/gofastr/core-ui/interactive"

// Button that increments a client-side counter (delta 1)
btn := interactive.IncLocal(
    html.Button(html.ButtonConfig{Label: "+1"}),
    "my-counter", 1,
)

// Form that submits via RPC without page reload
form := interactive.OnSubmit(
    myForm,
    interactive.Post("/api/save"),
)

// Live search with 300ms debounce
search := interactive.LiveSearch(
    searchForm,
    interactive.Post("/api/search"),
    300,
)

Action body and attribute maps

.WithBody(json) attaches a static JSON body to a non-form RPC (a button click with no surrounding <form>). It validates the JSON at build time. Attributes injected: data-cui-rpc-body.

2 lines
interactive.OnClick(btn,
    interactive.Post("/api/transition/42").WithBody(`{"status":"shipped"}`))

.Attrs() returns the data-cui-* attributes an Action would inject, as a map[string]string. Use it to merge RPC wiring into an existing attribute map (a render.Tag/ExtraAttrs map) so the renderer's sorted output stays byte-identical. Prefer it over the OnClick/OnSubmit wrappers whenever the element already carries other attributes, since the wrappers splice at the tag's first > and can reorder attributes relative to a sorted map.

4 lines
del := ui.Button(ui.ButtonConfig{Label: "Delete", Variant: ui.ButtonDanger,
    ExtraAttrs: interactive.Delete("/api/items/42").
        WithConfirm("Delete this item?").
        OnSuccess(interactive.SetSignal("items")).Attrs()})

Click-to-open triggers and toasts

OpenOnClick opens a registered widget on click (no RPC). Attributes injected: data-cui-open. Distinct from the OpenWidget effect, which opens a widget only after a successful RPC (data-cui-rpc-open).

ToastOnClick fires a toast on click. Attributes injected: data-cui-toast (compact JSON of the non-zero Toast fields: variant, title, body, stack, ttl).

3 lines
interactive.OpenOnClick(ui.Button(ui.ButtonConfig{Label: "Edit"}), "edit-drawer")
interactive.ToastOnClick(ui.Button(ui.ButtonConfig{Label: "Saved!"}),
    interactive.Toast{Variant: "success", Title: "Saved", TTLMs: 5000})

Pane triggers

OpenPaneOnClick / ClosePaneOnClick / SwapPaneOnClick drive a PaneHost side pane on click. All validate the pane name ("secondary" / "tertiary"; ClosePaneOnClick also accepts "" to close the topmost pane, emitting data-hui-pane-close="", matched by presence). Attributes injected: data-hui-pane-open-control (OpenPaneOnClick), data-hui-pane-close (ClosePaneOnClick), data-hui-pane-swap (SwapPaneOnClick); PaneKey adds data-hui-pane-key for deep-linked hosts.

Signal display bindings

BindHTML, BindText, and BindAttr wrap an island content region so its content is driven by a named client signal (the value an RPC writes via SetSignal). They inject data-cui-signal plus data-cui-signal-mode (html / text / attr); BindAttr also injects data-cui-signal-attr. The names mirror core-ui/store's typed Slice.Bind* methods: reach for a store.Slice when the signal is seeded server-side and read by typed code; reach for these wrappers when binding an island HTML region to an RPC signal.

2 lines
// An island slot an RPC re-renders and returns as a fragment.
listRegion := interactive.BindHTML(html.Div(html.DivConfig{}, list), "items")

See also

Common mistakes

  • Assuming a hand-written data-cui-rpc route is auto-registered. Only widget.Mount wires routes automatically (for a widget's own style/state/chrome). A raw data-cui-rpc="/x" you write by hand needs its own app.Router().Post("/x", …). Otherwise the click 404s silently. See Writing a hand-written island, end to end.
  • Decoding the RPC body by the input's id. The runtime keys the JSON body off each control's name, not its id. curl testing hides the mismatch; only a real browser exposes it. Same section, rule 2.
  • Turning in-page state changes into routes. Sort, paginate, expand, tab-switch stay on their route: an embedded region is an island (RPC swaps one fragment), and a list screen's own sort and page live in its query string as intercepted navigations. Adding a new route (or location.href = …) for them is the architecture's named failure mode #1.
  • Re-implementing pagination/sort/filter math in JS. The server owns that logic; the client's job is to fire the RPC and swap the returned HTML. Duplicated math drifts from the server's the first time either changes.
  • Treating signals as typed values. Signals are strings stored in the DOM: data-cui-signal-toggle flips between the strings "true" and "false", and data-cui-signal-inc parses-then-stringifies. Compare against string values (in CSS attribute selectors too), not booleans or numbers.
  • Using SSE to deliver an action's response. SSE is push-only. It carries background events for other clients. The result of a user action arrives in the RPC response itself (data-cui-rpc-signal, island swap), never via the event stream.
  • Inventing a new data-cui-* attribute without updating the contract. Every attribute the runtime reads must land in core-ui/ARCHITECTURE.md and the runtime test suite. Undocumented attributes are drift the next author can't discover.