diff --git a/src/core/config.js b/src/core/config.js new file mode 100644 index 000000000..45245f16b --- /dev/null +++ b/src/core/config.js @@ -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" +}) diff --git a/src/core/drive/form_submission.js b/src/core/drive/form_submission.js index c2ac1a0db..f1061d2f3 100644 --- a/src/core/drive/form_submission.js +++ b/src/core/drive/form_submission.js @@ -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", @@ -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", { @@ -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", { diff --git a/src/core/index.js b/src/core/index.js index a4a4f2d23..952fb1bd4 100644 --- a/src/core/index.js +++ b/src/core/index.js @@ -1,3 +1,4 @@ +import { config } from "./config" import { Session } from "./session" import { PageRenderer } from "./drive/page_renderer" import { PageSnapshot } from "./drive/page_snapshot" @@ -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. diff --git a/src/tests/functional/form_submission_tests.js b/src/tests/functional/form_submission_tests.js index e7447944f..8baeeaeea 100644 --- a/src/tests/functional/form_submission_tests.js +++ b/src/tests/functional/form_submission_tests.js @@ -1,6 +1,7 @@ import { test } from "@playwright/test" import { assert } from "chai" import { + configure, getFromLocalStorage, getSearchParam, hasSelector, @@ -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") @@ -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]") @@ -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") @@ -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() diff --git a/src/tests/helpers/page.js b/src/tests/helpers/page.js index 760b6631f..5ac678b82 100644 --- a/src/tests/helpers/page.js +++ b/src/tests/helpers/page.js @@ -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) diff --git a/src/util.js b/src/util.js index dcb15c31b..4960b53e9 100644 --- a/src/util.js +++ b/src/util.js @@ -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()