Submit Once
stimeo--submit-once
Disables the submit button on submit to block double submits, then restores on completion.
The stimeo--submit-once controller disables the form's submit button(s) on submit to prevent a double submission, sets aria-busy on them and data-submitting on the form, and swaps the triggering button's label for a busy label (per-button data-submit-once-busy-label overrides the form's busyLabel Value). It restores on Turbo's turbo:submit-end — re-enabling the buttons and putting labels back — or after timeout ms, and can return focus to the button with restoreFocus. The Headless superset of Rails' disable_with; it dispatches stimeo--submit-once:start / :end. Behavior only — no spinner is drawn (pair with Spinner). The submit-end listener and timeout are torn down on disconnect, and connect clears any busy state left in a restored cache snapshot so a button is never stuck disabled.
<%# Submit-once demo: the submit button disables and swaps its label on submit, then
restores. This catalog has no server, so demo.js intercepts the submit and simulates
a round trip by firing turbo:submit-end after a short delay. The library owns the
disable / aria-busy / data-submitting / label state; this demo only styles it. %>
<div class="submit-once-demo">
<form
data-controller="stimeo--submit-once"
data-stimeo--submit-once-busy-label-value="<%= t('components.submit_once.demo.busy') %>"
data-action="submit->stimeo--submit-once#start">
<label class="submit-once-demo__field">
<span><%= t("components.submit_once.demo.label") %></span>
<input type="text" name="title" class="submit-once-demo__input">
</label>
<button type="submit" class="demo-trigger" data-stimeo--submit-once-target="submit">
<%= t("components.submit_once.demo.submit") %>
</button>
</form>
</div>
/*
* Presentation-only styles for the submit-once demo.
* The library disables the button, sets aria-busy, swaps the label, and marks the
* form with data-submitting; this CSS only reflects that busy state.
*/
.submit-once-demo {
max-width: 28rem;
}
.submit-once-demo form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.submit-once-demo__field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.submit-once-demo__input {
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
font: inherit;
}
/* Dim and show a progress cursor on the disabled, busy button. */
.submit-once-demo button[aria-busy="true"] {
opacity: 0.6;
cursor: progress;
}
// Submit-once demo (consumer-side JS).
//
// The controller disables the submit button and swaps its label on submit, then
// restores on Turbo's turbo:submit-end. This catalog has no server, so here we
// intercept the submit, prevent the real navigation, and simulate a round trip by
// dispatching turbo:submit-end after a short delay — letting you see the busy →
// restored cycle the controller drives.
document.querySelectorAll(".submit-once-demo form").forEach((form) => {
form.addEventListener("submit", (event) => {
event.preventDefault();
window.setTimeout(() => {
form.dispatchEvent(new CustomEvent("turbo:submit-end", { detail: { success: true } }));
}, 1500);
});
});
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--submit-once"
Targets
| Name | Description | Attribute |
|---|---|---|
submit
|
Submit button(s) to disable; falls back to the form's native submit controls. | data-stimeo--submit-once-target="submit" |
Values
| Name | Description | Attribute |
|---|---|---|
busyLabel
|
Form default busy label; placed on the form. Empty keeps the label as-is. | data-stimeo--submit-once-busy-label-value |
timeout
|
Milliseconds before a forced restore; 0 disables it (relies on turbo:submit-end). |
data-stimeo--submit-once-timeout-value |
restoreFocus
|
Whether to return focus to the triggering button on restore. Default false. |
data-stimeo--submit-once-restore-focus-value |
Actions
| Name | Action |
|---|---|
start
|
stimeo--submit-once#start |
Events
| Name | Description | Event |
|---|---|---|
start
|
Fires when the submission starts. | stimeo--submit-once:start |
end
|
Fires on restore, with detail.success from turbo:submit-end when available. |
stimeo--submit-once:end |
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 |
|---|---|---|
aria-busy |
Submit button(s) | Set to "true" while the form is submitting. |
disabled |
Submit button(s) | Added while submitting to block a second submit. |
data-submitting |
Form (root) | Present while a submission is in flight. |