Submit Once
stimeo--submit-once
Lets a submit button be pressed once, blocking double submits and restoring itself afterwards.
Makes the submit button unpressable the moment it is pressed, so nothing is sent twice by accident. That the form is working is announced too. It restores itself when the work ends: Turbo tells it, or you can say so yourself, or a timeout can. A button with an icon inside keeps its icon while its wording changes, and that wording can be announced as well. A Turbo form needs no setup. Moving within the page or having Turbo remember it never leaves a permanently dead button or stranded focus behind.
Keyboard
This component has no keyboard interactions of its own.
<%# Submit-once demo. A structured idle / busy target pair keeps the DOM the
consumer authored while the button is disabled. The busy ↔ ready transition
goes to the shared stimeo--announcer your app seats once, in its layout,
where it is read without moving focus; aria-busy stays state the reader can
query, not a notification. This catalog has no server, so demo.js fires
Turbo's start / end lifecycle around a short fake round trip. %>
<% announce_start = t("components.submit_once.demo.announce_start") %>
<% announce_ready = t("components.submit_once.demo.announce_ready") %>
<% submit_label = t("components.submit_once.demo.submit") %>
<% busy_label = t("components.submit_once.demo.busy") %>
<div class="submit-once-demo">
<form
data-controller="stimeo--submit-once"
data-stimeo--submit-once-announce-text-value="<%= announce_start %>"
data-stimeo--submit-once-announce-ready-text-value="<%= announce_ready %>">
<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">
<span data-stimeo--submit-once-target="idle"><%= submit_label %></span>
<span data-stimeo--submit-once-target="busy" hidden><%= busy_label %></span>
</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).
//
// This catalog has no server, so the demo fires the same two events Turbo would
// around a submission. Every busy-state change belongs to the controller.
document.querySelectorAll(".submit-once-demo form").forEach((form) => {
form.addEventListener("submit", (event) => {
event.preventDefault();
if (form.hasAttribute("data-submitting")) return;
const submitter = event instanceof SubmitEvent ? event.submitter : null;
form.dispatchEvent(
new CustomEvent("turbo:submit-start", {
bubbles: true,
detail: { formSubmission: { formElement: form, submitter } },
}),
);
window.setTimeout(() => {
form.dispatchEvent(
new CustomEvent("turbo:submit-end", { bubbles: true, 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
|
Optional explicit submit controls; native form controls are always discovered. | data-stimeo--submit-once-target="submit" |
idle
|
Normal structured-button content; pair with busy inside the same submit button. | data-stimeo--submit-once-target="idle" |
busy
|
Busy structured-button content; starts hidden and pairs with idle. | data-stimeo--submit-once-target="busy" |
Values
| Name | Description | Attribute |
|---|---|---|
announceText
|
Optional start message sent to the shared Announcer. Empty stays silent. | data-stimeo--submit-once-announce-text-value |
announceReadyText
|
Optional completion message sent to the shared Announcer. | data-stimeo--submit-once-announce-ready-text-value |
busyLabel
|
Default for safe plain-text/value/aria-label swapping. Empty keeps the label. | 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
|
Returns focus only if the focused submitter lost it and the user did not move elsewhere. | data-stimeo--submit-once-restore-focus-value |
Actions
| Name | Action |
|---|---|
cancel
|
stimeo--submit-once#cancel |
finish
|
stimeo--submit-once#finish |
start
|
stimeo--submit-once#start |
Events
| Name | Description | Event |
|---|---|---|
start
|
Fires with the submitting form and submitter. | stimeo--submit-once:start |
end
|
Fires with form, submitter, completion reason, and optional success. | stimeo--submit-once:end |
reconcile
|
Fires when the Turbo cache rewind abandons an in-flight submission; detail carries the forms. |
stimeo--submit-once:reconcile |
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. |