Skip to content

Commit

Permalink
Configure Submitter disabling
Browse files Browse the repository at this point in the history
Follow-up to [hotwired#386][]

While Turbo's support for disabling a Form Submissions `<input
type="submit">` or `<button>` element is rooted in [Rails UJS][], it
degrades (and has always degraded) the accessibility of those
experiences.

To learn more about the risks involved, read the [Don't Disable
Submits][] section of Adrian Roselli's [Don't Disable Form Controls][]
along with the additional resources mentioned therein.

The risk of degrading accessibility is especially true for Morph-enabled
Form Submissions. If a form submission will trigger a morphing Page
Refresh with the submitter focused, it's likely that the focus *is
intended to* remain on the submitter.

With the current `[disabled]` behavior, that is not possible without a
bespoke event handler like:

```js
addEventListener("submit", ({ target, submitter }) => {
  if (submitter) {
    target.addEventListener("turbo:submit-start", () => {
      submitter.disabled = false
      submitter.focus()
    }, { once: true })
  }
})
```

This commit introduces a `Turbo.config.forms.submitter` object with two
pre-defined keys: `"disabled"` (the default until we can deprecate it),
and `"aria-disabled"`.

When applications specify either `Turbo.config.forms.submitter =
"disabled"` or `Turbo.config.forms.submitter = "aria-disabled"`, they
will be able to leverage those pre-packed hooks. Otherwise, they can
provide their own object with `beforeSubmit(submitter)` and
`afterSubmit(submitter)` functions.

[hotwired#386]: hotwired#386
[Rails UJS]: https://guides.rubyonrails.org/v6.0/working_with_javascript_in_rails.html#automatic-disabling
[Don't Disable Form Controls]: https://adrianroselli.com/2024/02/dont-disable-form-controls.html
[Don't Disable Submits]: https://adrianroselli.com/2024/02/dont-disable-form-controls.html#Submit
  • Loading branch information
seanpdoyle committed Mar 3, 2024
1 parent 49055e3 commit 8807db4
Show file tree
Hide file tree
Showing 4 changed files with 111 additions and 4 deletions.
42 changes: 40 additions & 2 deletions src/core/config/forms.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,41 @@
export const forms = {
mode: "on"
import { cancelEvent } from "../../util"

const submitter = {
"aria-disabled": {
beforeSubmit: submitter => {
submitter.setAttribute("aria-disabled", "true")
submitter.addEventListener("click", cancelEvent)
},

afterSubmit: submitter => {
submitter.removeAttribute("aria-disabled")
submitter.removeEventListener("click", cancelEvent)
}
},

"disabled": {
beforeSubmit: submitter => submitter.disabled = true,
afterSubmit: submitter => submitter.disabled = false
}
}

class Config {
#submitter = null

constructor(config) {
Object.assign(this, config)
}

get submitter() {
return this.#submitter
}

set submitter(value) {
this.#submitter = submitter[value] || value
}
}

export const forms = new Config({
mode: "on",
submitter: "disabled"
})
4 changes: 2 additions & 2 deletions src/core/drive/form_submission.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ export class FormSubmission {

requestStarted(_request) {
this.state = FormSubmissionState.waiting
this.submitter?.setAttribute("disabled", "")
if (this.submitter) config.forms.submitter.beforeSubmit(this.submitter)
this.setSubmitsWith()
markAsBusy(this.formElement)
dispatch("turbo:submit-start", {
Expand Down Expand Up @@ -167,7 +167,7 @@ export class FormSubmission {

requestFinished(_request) {
this.state = FormSubmissionState.stopped
this.submitter?.removeAttribute("disabled")
if (this.submitter) config.forms.submitter.afterSubmit(this.submitter)
this.resetSubmitterText()
clearBusyState(this.formElement)
dispatch("turbo:submit-end", {
Expand Down
64 changes: 64 additions & 0 deletions src/tests/functional/form_submission_tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,22 @@ test("standard POST form submission toggles submitter [disabled] attribute", asy
)
})

test("standard POST form submission toggles submitter [aria-disabled=true] attribute", async ({ page }) => {
await page.evaluate(() => window.Turbo.config.forms.submitter = "aria-disabled")
await page.click("#standard-post-form-submit")

assert.equal(
await nextAttributeMutationNamed(page, "standard-post-form-submit", "aria-disabled"),
"true",
"sets [aria-disabled=true] on the submitter"
)
assert.equal(
await nextAttributeMutationNamed(page, "standard-post-form-submit", "aria-disabled"),
null,
"removes [aria-disabled] from the submitter"
)
})

test("replaces input value with data-turbo-submits-with on form submission", async ({ page }) => {
page.click("#submits-with-form-input")

Expand Down Expand Up @@ -410,6 +426,22 @@ test("standard GET form submission toggles submitter [disabled] attribute", asyn
)
})

test("standard GET form submission toggles submitter [aria-disabled] attribute", async ({ page }) => {
await page.evaluate(() => window.Turbo.config.forms.submitter = "aria-disabled")
await page.click("#standard-get-form-submit")

assert.equal(
await nextAttributeMutationNamed(page, "standard-get-form-submit", "aria-disabled"),
"true",
"sets [aria-disabled] on the submitter"
)
assert.equal(
await nextAttributeMutationNamed(page, "standard-get-form-submit", "aria-disabled"),
null,
"removes [aria-disabled] from the submitter"
)
})

test("standard GET form submission appending keys", async ({ page }) => {
await page.goto("/src/tests/fixtures/form.html?query=1")
await page.click("#standard form.conflicting-values input[type=submit]")
Expand Down Expand Up @@ -692,6 +724,22 @@ test("frame POST form targeting frame toggles submitter's [disabled] attribute",
)
})

test("frame POST form targeting frame toggles submitter's [aria-disabled] attribute", async ({ page }) => {
await page.evaluate(() => window.Turbo.config.forms.submitter = "aria-disabled")
await page.click("#targets-frame-post-form-submit")

assert.equal(
await nextAttributeMutationNamed(page, "targets-frame-post-form-submit", "aria-disabled"),
"true",
"sets [aria-disabled] on the submitter"
)
assert.equal(
await nextAttributeMutationNamed(page, "targets-frame-post-form-submit", "aria-disabled"),
null,
"removes [aria-disabled] from the submitter"
)
})

test("frame GET form targeting frame submission", async ({ page }) => {
await page.click("#targets-frame-get-form-submit")

Expand Down Expand Up @@ -731,6 +779,22 @@ test("frame GET form targeting frame toggles submitter's [disabled] attribute",
)
})

test("frame GET form targeting frame toggles submitter's [aria-disabled] attribute", async ({ page }) => {
await page.evaluate(() => window.Turbo.config.forms.submitter = "aria-disabled")
await page.click("#targets-frame-get-form-submit")

assert.equal(
await nextAttributeMutationNamed(page, "targets-frame-get-form-submit", "aria-disabled"),
"true",
"sets [aria-disabled] on the submitter"
)
assert.equal(
await nextAttributeMutationNamed(page, "targets-frame-get-form-submit", "aria-disabled"),
null,
"removes [aria-disabled] from the submitter"
)
})

test("frame form GET submission from submitter referencing another frame", async ({ page }) => {
await page.click("#frame form[method=get] [type=submit][data-turbo-frame=hello]")
await nextBeat()
Expand Down
5 changes: 5 additions & 0 deletions src/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ export function dispatch(eventName, { target, cancelable, detail } = {}) {
return event
}

export function cancelEvent(event) {
event.preventDefault()
event.stopImmediatePropagation()
}

export function nextRepaint() {
if (document.visibilityState === "hidden") {
return nextEventLoopTick()
Expand Down

0 comments on commit 8807db4

Please sign in to comment.