Auto-Submit Form
stimeo--auto-submit
Debounces input/change and submits the form via Turbo — no submit button.
The stimeo--auto-submit controller submits its form a configurable delay after input or change, so Rails search/filter forms refresh through Turbo without a submit button. It owns only triggering the submit (debounce + requestSubmit), leaving the submission itself and validation to Turbo / the server. Rapid keystrokes coalesce into one request; the `on` value is an allowlist so a wired event type can be ignored via config. It never moves focus (WCAG 2.2 3.2.2 / 4.1.3), so auto-submitting cannot yank the caret out of the field. It marks the debounce window with data-auto-submit-pending and the in-flight window with aria-busy, dispatches stimeo--auto-submit:submit and :done, and can bridge the silent result swap to the shared stimeo--announcer when announce + message are set. The debounce timer and turbo:submit-end listener are torn down on disconnect (Turbo included). Behavior only — styling is owned by this Playground.
- Rails
- Stimulus
- Turbo
- Hotwire
- Importmap
- Propshaft
- Sidekiq
- Kamal
<%# Markup for the debounced auto-submit demo.
The controller debounces input and then calls form.requestSubmit(). This
Playground has no search backend, so demo.js cancels the native submit, filters
the list in the browser, completes the Turbo lifecycle with a synthetic
turbo:submit-end (so the controller clears aria-busy and emits done), and
announces the result count through a shared stimeo--announcer. %>
<%
items = t("components.auto_submit.demo.items")
count_template = t("components.auto_submit.demo.count_template")
%>
<div class="auto-submit-demo">
<form class="auto-submit-demo__form" role="search" action="#"
data-controller="stimeo--auto-submit"
data-stimeo--auto-submit-debounce-value="400"
data-action="input->stimeo--auto-submit#submit"
data-auto-submit-count="<%= count_template %>">
<label class="auto-submit-demo__label" for="auto-submit-q">
<%= t("components.auto_submit.demo.label") %>
</label>
<input class="demo-input" id="auto-submit-q" type="search" name="q"
autocomplete="off" data-auto-submit-demo="input"
placeholder="<%= t("components.auto_submit.demo.placeholder") %>">
</form>
<ul class="auto-submit-demo__list" data-auto-submit-demo="list">
<% items.each do |item| %>
<li data-auto-submit-value="<%= item.downcase %>"><%= item %></li>
<% end %>
</ul>
<%# Shared announcer: the result count is read out without moving focus. %>
<div data-controller="stimeo--announcer">
<div class="visually-hidden"
data-stimeo--announcer-target="polite" aria-live="polite" aria-atomic="true"></div>
</div>
</div>
/*
* Presentation-only styles for the debounced auto-submit demo.
* The library debounces and triggers the submit; it only reflects state via
* data-auto-submit-pending (debounce window) and aria-busy (in flight). This CSS
* reacts to those hooks and owns the list/result styling.
*/
.auto-submit-demo {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 32rem;
}
.auto-submit-demo__form {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.auto-submit-demo__label {
font-size: 0.9rem;
font-weight: 600;
}
/* Visualize the debounce window: the input glows while a submit is pending. */
.auto-submit-demo__form[data-auto-submit-pending] .demo-input {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgb(var(--vital-rgb) / 0.2);
}
.auto-submit-demo__list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.auto-submit-demo__list li {
padding: 0.4rem 0.6rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
font-size: 0.95rem;
}
.auto-submit-demo__list li[hidden] {
display: none;
}
// Consumer-side JS for the debounced auto-submit demo (demo-only).
// The controller debounces input then calls form.requestSubmit(). This Playground
// has no search endpoint, so we cancel the native submit, filter the list in the
// browser, finish the Turbo lifecycle with a synthetic turbo:submit-end (so the
// controller clears aria-busy and emits stimeo--auto-submit:done), and announce
// the result count through the shared stimeo--announcer (polite, no focus move).
const form = document.querySelector(".auto-submit-demo__form");
const input = document.querySelector("[data-auto-submit-demo='input']");
const items = Array.from(document.querySelectorAll("[data-auto-submit-value]"));
// Localized "{count} results" template passed from the ERB via a data attribute.
const countTemplate = form?.dataset.autoSubmitCount ?? "{count}";
if (form && input) {
form.addEventListener("submit", (event) => {
event.preventDefault(); // No real navigation in the demo.
const query = input.value.trim().toLowerCase();
let visible = 0;
for (const li of items) {
const match = li.dataset.autoSubmitValue.includes(query);
li.hidden = !match;
if (match) visible += 1;
}
// Complete the Turbo cycle the controller is waiting on (clears aria-busy).
form.dispatchEvent(new CustomEvent("turbo:submit-end"));
// Announce the count so screen-reader users hear the silent result swap.
window.dispatchEvent(
new CustomEvent("stimeo--announcer:announce", {
detail: { message: countTemplate.replace("{count}", String(visible)) },
}),
);
});
}
These demo styles use shared design tokens (light + dark). Copy the shared styles too, then toggle data-theme on your root element for dark mode.
The data-* attributes you add to your own HTML to wire this component. Put the data-controller below on a root element, then place its targets / values / actions inside that element.
On the root element
data-controller="stimeo--auto-submit"
Targets
| Name | Description | Attribute |
|---|---|---|
form
|
The form to submit; optional — the controller element is used when absent. | data-stimeo--auto-submit-target="form" |
Values
| Name | Description | Attribute |
|---|---|---|
debounce
|
Milliseconds to wait before submitting (default 300). | data-stimeo--auto-submit-debounce-value |
on
|
Space-separated event types that trigger a submit (default input change). |
data-stimeo--auto-submit-on-value |
announce
|
Whether to bridge completion to stimeo--announcer (default false). |
data-stimeo--auto-submit-announce-value |
message
|
Message announced on completion when announce is on (default empty). |
data-stimeo--auto-submit-message-value |
Actions
| Name | Description | Action |
|---|---|---|
submit
|
Schedules a debounced requestSubmit; wired to input/change. |
stimeo--auto-submit#submit |
Events
| Name | Description | Event |
|---|---|---|
submit
|
Fires just before submitting; detail carries the trigger element. |
stimeo--auto-submit:submit |
done
|
Fires on turbo:submit-end; detail carries an optional message. |
stimeo--auto-submit:done |
State hooks
The library only manages these ARIA/data attributes and custom properties. Your CSS reads them to render the look — selectors like [aria-selected], [aria-expanded], or var(--stimeo--…) hook into this state.
| Hook | Target | Meaning |
|---|---|---|
data-auto-submit-pending |
Form element | Present while a debounced submit is pending. |
aria-busy |
Form element | "true" while the submit is in flight (cleared on turbo:submit-end). |