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.submitter` object with two
pre-defined keys: `"disabled"` (the default until we can deprecate it),
and `"aria-disabled"`.

When applications specify either `Turbo.config.submitter = "disabled"`
or `Turbo.config.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#Submit
  • Loading branch information
seanpdoyle committed Mar 3, 2024
1 parent 00527e5 commit eb9b70b
Show file tree
Hide file tree
Showing 6 changed files with 120 additions and 3 deletions.
40 changes: 40 additions & 0 deletions src/core/config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
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
}
}

export class Config {
#submitter = null

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

get submitter() {
return this.#submitter
}

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

export const config = new Config({
submitter: "disabled"
})
5 changes: 3 additions & 2 deletions src/core/drive/form_submission.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { expandURL } from "../url"
import { clearBusyState, dispatch, getAttribute, getMetaContent, hasAttribute, markAsBusy } from "../../util"
import { StreamMessage } from "../streams/stream_message"
import { prefetchCache } from "./prefetch_cache"
import { config } from "../config"

export const FormSubmissionState = {
initialized: "initialized",
Expand Down Expand Up @@ -116,7 +117,7 @@ export class FormSubmission {

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

requestFinished(_request) {
this.state = FormSubmissionState.stopped
this.submitter?.removeAttribute("disabled")
if (this.submitter) config.submitter.afterSubmit(this.submitter)
this.resetSubmitterText()
clearBusyState(this.formElement)
dispatch("turbo:submit-end", {
Expand Down
3 changes: 2 additions & 1 deletion src/core/index.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { config } from "./config"
import { Session } from "./session"
import { PageRenderer } from "./drive/page_renderer"
import { PageSnapshot } from "./drive/page_snapshot"
Expand All @@ -7,7 +8,7 @@ import { fetch, recentRequests } from "../http/fetch"

const session = new Session(recentRequests)
const { cache, navigator } = session
export { navigator, session, cache, PageRenderer, PageSnapshot, FrameRenderer, fetch }
export { navigator, session, cache, PageRenderer, PageSnapshot, FrameRenderer, fetch, config }

/**
* Starts the main session.
Expand Down
66 changes: 66 additions & 0 deletions src/tests/functional/form_submission_tests.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { test } from "@playwright/test"
import { assert } from "chai"
import {
configure,
getFromLocalStorage,
getSearchParam,
hasSelector,
Expand Down Expand Up @@ -240,6 +241,23 @@ test("standard POST form submission toggles submitter [disabled] attribute", asy
)
})

test("standard POST form submission toggles submitter [aria-disabled=true] attribute", async ({ page }) => {
await configure(page, { disableWith: "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"
)
await noNextEventNamed(page, "standard-form", "turbo:submit-start")
})

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 +428,22 @@ test("standard GET form submission toggles submitter [disabled] attribute", asyn
)
})

test("standard GET form submission toggles submitter [aria-disabled] attribute", async ({ page }) => {
await configure(page, { disableWith: "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 +726,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 configure(page, { disableWith: "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 +781,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 configure(page, { disableWith: "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
4 changes: 4 additions & 0 deletions src/tests/helpers/page.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ export function cancelNextEvent(page, eventName) {
)
}

export function configure(page, config) {
return page.evaluate((config) => Object.assign(window.Turbo.config, config), config)
}

export function clickWithoutScrolling(page, selector, options = {}) {
const element = page.locator(selector, options)

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 eb9b70b

Please sign in to comment.