Autumn provides first-class helpers for two common query-driven UI patterns: active search (results appear as the user types) and autocomplete/lookup (pick a related record and store its ID). Both are built on htmx and server-rendered Maud fragments — no client-side libraries required.
When to use what
| Situation | Recommended approach |
|---|---|
| Keyword search over a list, rendered server-side | active_search / active_search_input |
| Select a single related record, store its ID | autocomplete_input |
Simple filter that works fine as a plain GET form | axum::extract::Query |
| The htmx wiring needs unusual attributes | Hand-write hx-* attributes |
| Full-text ranking, stemming, or GIN index search | Repository#search paired with active_search_input |
Out of scope: search indexing, ranking, stemming, typo tolerance, vector search, infinite scrolling, client-side autocomplete, or command palettes.
Active search
Page template
use autumn_web::prelude::*;
use autumn_web::widgets::{ActiveSearchConfig, active_search};
#[get("/posts")]
async fn index() -> Markup {
let config = ActiveSearchConfig::new("/posts/search", "#post-results")
.placeholder("Search posts…")
.debounce(400) // ms before request fires (default: 300)
.min_length(2); // minimum characters (default: 1)
html! {
(active_search("post-search", "Search posts", &config))
// ^ emits input + results container + noscript fallback
}
}
active_search emits:
- A
<div id="post-search-wrapper">containing:- A
<label>+<input type="search">with htmx attributes - A
<div id="post-search-results" role="status" aria-live="polite">results container - A
<noscript>fallback<form method="get">for non-JavaScript browsers
- A
htmx attributes emitted
The search input receives:
<input
type="search"
id="post-search"
name="q"
autocomplete="off"
aria-controls="post-results"
hx-get="/posts/search"
hx-trigger="input changed delay:400ms"
hx-target="#post-results"
>
Search handler
Your handler is an ordinary Autumn route that returns a Markup partial:
use autumn_web::prelude::*;
use autumn_web::widgets::active_search_empty_state;
use serde::Deserialize;
#[derive(Deserialize)]
struct SearchQuery { q: String }
#[get("/posts/search")]
async fn search(
Query(params): Query<SearchQuery>,
repo: PgPostRepository, // your repository
) -> AutumnResult<Markup> {
let q = params.q.trim();
if q.is_empty() {
return Ok(active_search_empty_state("Enter a search term"));
}
// Works with the full-text search repository feature:
let results = repo.search(q).await?;
Ok(html! {
@if results.is_empty() {
(active_search_empty_state("No results found"))
} @else {
ul {
@for post in &results {
li { (post.title) }
}
}
}
})
}
Integration with full-text search
If your model uses #[repository(..., searchable)], the generated
repo.search(q) method returns results ranked by relevance. Pass the query
string straight through:
// The handler stays the same — no special plumbing needed.
let results = repo.search(params.q.trim()).await?;
See Full-Text Search for repository configuration.
Configuration options
| Builder method | Default | Effect |
|---|---|---|
.debounce(ms) | 300 | Debounce delay before the request fires |
.min_length(n) | 1 | Minimum characters required (enforced server-side) |
.indicator(selector) | (none) | CSS selector for an htmx-indicator element |
.initial_load() | false | Fire the search immediately on page load |
.placeholder(text) | (none) | Placeholder text for the input |
.param_name(name) | "q" | Query parameter name |
.post() | (GET) | Use hx-post instead of the default hx-get |
POST opt-in
GET is the default because search queries are idempotent, cacheable, and
bookmarkable. Use .post() only when the handler genuinely needs a request
body (e.g. large filter payloads or CSRF-protected endpoints):
let config = ActiveSearchConfig::new("/posts/search", "#post-results").post();
Autocomplete / lookup
Autocomplete lets a user type into a visible search field and pick a related record. The selected record's ID is stored in a hidden field and submitted with the form.
Page template
use autumn_web::widgets::{AutocompleteConfig, autocomplete_input};
let config = AutocompleteConfig::new(
"/tags/autocomplete", // handler URL
"tag_id", // name of the hidden input (stores the selected ID)
)
.placeholder("Search tags…");
html! {
form action="/posts" method="post" {
// … other fields …
(autocomplete_input("tag-picker", "Tag", &config))
button type="submit" { "Save" }
}
}
Rendered HTML (abbreviated):
<div id="tag-picker-wrapper"
data-ac-value-id="tag-picker-value"
data-ac-value-name="tag_id">
<label for="tag-picker-query">Tag</label>
<input type="search" id="tag-picker-query" name="q"
role="combobox" aria-expanded="false" aria-autocomplete="list"
aria-controls="tag-picker-options"
data-ac-query data-ac-min-length="1"
hx-get="/tags/autocomplete"
hx-trigger="input changed delay:300ms"
hx-target="#tag-picker-options">
<input type="hidden" id="tag-picker-value" value="">
<div id="tag-picker-options" role="listbox" aria-live="polite"></div>
<noscript>
<select name="tag_id">…</select>
</noscript>
</div>
Option selection and input sync are handled by the autumn-widgets.js runtime (no inline JS, CSP-compatible). Include it once in your layout:
<script src="/static/js/autumn-widgets.js" defer></script>
Autocomplete handler
Your handler returns option partials using autocomplete_option and
autocomplete_empty_state:
use autumn_web::widgets::{autocomplete_option, autocomplete_empty_state};
#[get("/tags/autocomplete")]
async fn tags_autocomplete(
Query(params): Query<SearchQuery>,
mut db: Db,
) -> AutumnResult<Markup> {
let q = params.q.trim();
if q.is_empty() {
return Ok(autocomplete_empty_state("Type to search tags."));
}
let tags: Vec<Tag> = /* your Diesel query */ ...;
Ok(html! {
@if tags.is_empty() {
(autocomplete_empty_state("No matching tags found."))
} @else {
@for tag in &tags {
(autocomplete_option(&tag.id.to_string(), &tag.name))
}
}
})
}
autocomplete_option(value, label) renders:
<div role="option" tabindex="0" data-value="42">Tag Name</div>
The autumn-widgets.js runtime handles click and keyboard (Enter/Space) selection
on the listbox. It copies the option's textContent into the visible input and
its data-value into the hidden field, then clears the listbox. No additional
wiring is needed beyond including the script in your layout.
Free-text mode (tag-style fields)
By default, the hidden field is only set when an option is selected from the list — typing a value and submitting without selecting leaves the field empty. This is the safe default for foreign-key ID fields (submitting unvalidated text as an ID would break form deserialization).
For tag-style fields where users should be able to submit typed text directly (e.g. creating a new tag), enable free-text mode:
let config = AutocompleteConfig::new("/tags/autocomplete", "tag")
.free_text(); // keep hidden field in sync with typed text
No-JavaScript fallback
Both active_search and autocomplete_input emit a <noscript> block that
works without JavaScript:
- Active search — a plain
<form method="get">that submits the query. Your handler already returns a correct response; wrap it in your layout for the full-page no-JS case. - Autocomplete — a
<select name="...">that submits the value directly. Pass.fallback_options(&[("val", "Label"), …])toAutocompleteConfigto populate its options server-side for the fallback path.
For a seamless no-JS experience, detect whether the request is an htmx
request using HxRequest and wrap the response in a full layout if not:
#[get("/posts/search")]
async fn search(hx: HxRequest, Query(params): Query<SearchQuery>, ...) -> AutumnResult<impl IntoResponse> {
let partial = /* render results */;
if hx.is_htmx {
Ok(partial.into_response())
} else {
Ok(layout("Search results", partial).into_response())
}
}
Accessibility
All widgets include:
<label>associated with the input viafor/idaria-controlspointing at the results containerrole="status"andaria-live="polite"on the results container (so screen readers announce updates without moving focus)aria-atomic="true"on the active search results containerrole="combobox"+aria-expanded+aria-autocomplete="list"on the autocomplete visible inputrole="listbox"on the autocomplete options containerrole="option"+tabindex="0"on each autocomplete optionrole="status"+aria-live="polite"on empty-state partials
Example: bookmarks app
The examples/bookmarks app demonstrates both primitives:
- Active search on
GET /bookmarks— searches acrosstitle,url, andtagfields using an ILIKE query. - Tag autocomplete on
GET /bookmarks/new— lists existing tags matching the typed prefix viaGET /bookmarks/tags/autocomplete.
See examples/bookmarks/src/routes/bookmarks.rs for the full implementation.