Skip to content

Commit

Permalink
Read FormSubmission.{method,location} from submitter
Browse files Browse the repository at this point in the history
When a [`SubmitEvent` is fired][mdn-submit-event], it encodes the
element responsible for the submission. This element can be an `<input
type="submit">`, a `<button type="submit">` element, or the `<form>`
element itself (in the case of submissions initiated by
`HTMLFormElement.requestSubmit()` that omit the `submitter` argument).

According to the [MDN documentation for the `event.submitter`
property][mdn-submitter]:

> An element, indicating the element that sent the submit event to the
> form. While this is often an `<input>` element whose type or a
> `<button>` whose type is `submit`, it could be some other element which
> has initiated a submission process.

> If the submission was not triggered by a button of some kind, the
> value of `submitter` is `null`.

To support submissions from elements other than the `<form>` that can
declare their own [`formmethod`][mdn-formmethod] and
[`formaction`][mdn-formaction], extend the `FormSubmission` object to
encode a reference to the submitter, and add an `HTMLElement` argument
to the `FormSubmitObserver` and `FormSubmissionDelegate` methods.

Invokes [HTMLFormElement.method][mdn-method] instead of
`getAttribute("method")` to defer gracefully handling missing value
fallbacks to the [HTMLFormElement.method][mdn-method] implementation.

[mdn-request-submit]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/requestSubmit#Parameters
[mdn-submit-event]: https://developer.mozilla.org/en-US/docs/Web/API/SubmitEvent
[mdn-submitter]: https://developer.mozilla.org/en-US/docs/Web/API/SubmitEvent/submitter
[mdn-formmethod]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#attr-formmethod
[mdn-formaction]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#attr-formaction
[mdn-method]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/method

Include Submitter in FormData
===

While constructing the `FormData` during a submission, attempt to read
the submitting `<button>` element's [`[name]`][button-name] and
[`[value]`][button-value] attributes, and encode them as part of the
submission.

While an [`<input type="submit">` element][input-submit] can have a
`[name]` and `[value]` attribute, the `value` is rendered as the
"button"'s text content.

[form-data]: https://developer.mozilla.org/en-US/docs/Web/API/FormData
[button-name]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#attr-name
[button-value]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#attr-value
[input-submit]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/submit

Form Submitter polyfill
===

Extend the `FormSubmitObserver` event listening to track `<button
type="submit">` and `<input type="submit">` clicks [in Browsers that
have spotty support][support].

The implementation is largely ported from both [basecamp/turbolinks#4][]
and [rails/rails#33413][].

The `FormSubmitter` type definition is deliberately scoped to the
`FormSubmitObserver` module, since the [Browser-native
`SubmitEvent.submitter` is only as specific as
`HTMLElement`][SubmitEvent], so it's least disruptive to scope
limitations to the polyfilling logic.

[support]: https://developer.mozilla.org/en-US/docs/Web/API/SubmitEvent/submitter#Browser_compatibility
[basecamp/turbolinks#4]: https://github.com/basecamp/turbolinks/pull/4
[rails/rails#33413]: rails/rails#33413
[SubmitEvent]: https://developer.mozilla.org/en-US/docs/Web/API/SubmitEvent#Properties
  • Loading branch information
seanpdoyle committed Dec 19, 2020
1 parent b21dfb4 commit bf257f3
Show file tree
Hide file tree
Showing 11 changed files with 86 additions and 30 deletions.
26 changes: 22 additions & 4 deletions src/core/drive/form_submission.ts
Expand Up @@ -27,27 +27,33 @@ export enum FormSubmissionState {
export class FormSubmission {
readonly delegate: FormSubmissionDelegate
readonly formElement: HTMLFormElement
readonly submitter?: HTMLElement
readonly formData: FormData
readonly fetchRequest: FetchRequest
readonly mustRedirect: boolean
state = FormSubmissionState.initialized
result?: FormSubmissionResult

constructor(delegate: FormSubmissionDelegate, formElement: HTMLFormElement, mustRedirect = false) {
constructor(delegate: FormSubmissionDelegate, formElement: HTMLFormElement, submitter?: HTMLElement, mustRedirect = false) {
this.delegate = delegate
this.formElement = formElement
this.formData = new FormData(formElement)
this.formData = buildFormData(formElement, submitter)
this.submitter = submitter
this.fetchRequest = new FetchRequest(this, this.method, this.location, this.formData)
this.mustRedirect = mustRedirect
}

get method(): FetchMethod {
const method = this.formElement.getAttribute("method") || ""
const method = this.submitter?.getAttribute("formmethod") || this.formElement.method
return fetchMethodFromString(method.toLowerCase()) || FetchMethod.get
}

get action(): string {
return this.submitter?.getAttribute("formaction") || this.formElement.action
}

get location() {
return Location.wrap(this.formElement.action)
return Location.wrap(this.action)
}

// The submission process
Expand Down Expand Up @@ -124,6 +130,18 @@ export class FormSubmission {
}
}

function buildFormData(formElement: HTMLFormElement, submitter?: HTMLElement): FormData {
const formData = new FormData(formElement)
const name = submitter?.getAttribute("name")
const value = submitter?.getAttribute("value")

if (name && formData.get(name) != value) {
formData.append(name, value || "")
}

return formData
}

function getCookieValue(cookieName: string | null) {
if (cookieName != null) {
const cookies = document.cookie ? document.cookie.split("; ") : []
Expand Down
4 changes: 2 additions & 2 deletions src/core/drive/navigator.ts
Expand Up @@ -33,9 +33,9 @@ export class Navigator {
this.currentVisit.start()
}

submitForm(form: HTMLFormElement) {
submitForm(form: HTMLFormElement, submitter?: HTMLElement) {
this.stop()
this.formSubmission = new FormSubmission(this, form, true)
this.formSubmission = new FormSubmission(this, form, submitter, true)
this.formSubmission.start()
}

Expand Down
13 changes: 7 additions & 6 deletions src/core/frames/form_interceptor.ts
@@ -1,6 +1,6 @@
export interface FormInterceptorDelegate {
shouldInterceptFormSubmission(element: HTMLFormElement): boolean
formSubmissionIntercepted(element: HTMLFormElement): void
shouldInterceptFormSubmission(element: HTMLFormElement, submitter?: HTMLElement): boolean
formSubmissionIntercepted(element: HTMLFormElement, submitter?: HTMLElement): void
}

export class FormInterceptor {
Expand All @@ -20,14 +20,15 @@ export class FormInterceptor {
this.element.removeEventListener("submit", this.submitBubbled)
}

submitBubbled = (event: Event) => {
submitBubbled = <EventListener>((event: SubmitEvent) => {
if (event.target instanceof HTMLFormElement) {
const form = event.target
if (this.delegate.shouldInterceptFormSubmission(form)) {
const submitter = event.submitter || undefined
if (this.delegate.shouldInterceptFormSubmission(form, submitter)) {
event.preventDefault()
event.stopImmediatePropagation()
this.delegate.formSubmissionIntercepted(form)
this.delegate.formSubmissionIntercepted(form, submitter)
}
}
}
})
}
4 changes: 2 additions & 2 deletions src/core/frames/frame_controller.ts
Expand Up @@ -42,12 +42,12 @@ export class FrameController implements FetchRequestDelegate, FormInterceptorDel
return this.shouldInterceptNavigation(element)
}

formSubmissionIntercepted(element: HTMLFormElement) {
formSubmissionIntercepted(element: HTMLFormElement, submitter?: HTMLElement) {
if (this.formSubmission) {
this.formSubmission.stop()
}

this.formSubmission = new FormSubmission(this, element)
this.formSubmission = new FormSubmission(this, element, submitter)
if (this.formSubmission.fetchRequest.isIdempotent) {
this.navigateFrame(element, this.formSubmission.fetchRequest.url)
} else {
Expand Down
10 changes: 5 additions & 5 deletions src/core/frames/frame_redirector.ts
Expand Up @@ -34,18 +34,18 @@ export class FrameRedirector implements LinkInterceptorDelegate, FormInterceptor
}
}

shouldInterceptFormSubmission(element: HTMLFormElement) {
return this.shouldRedirect(element)
shouldInterceptFormSubmission(element: HTMLFormElement, submitter?: HTMLElement) {
return this.shouldRedirect(element, submitter)
}

formSubmissionIntercepted(element: HTMLFormElement) {
formSubmissionIntercepted(element: HTMLFormElement, submitter?: HTMLElement) {
const frame = this.findFrameElement(element)
if (frame) {
frame.formSubmissionIntercepted(element)
frame.formSubmissionIntercepted(element, submitter)
}
}

private shouldRedirect(element: Element) {
private shouldRedirect(element: Element, submitter?: HTMLElement) {
const frame = this.findFrameElement(element)
return frame ? frame != element.closest("turbo-frame") : false
}
Expand Down
6 changes: 3 additions & 3 deletions src/core/session.ts
Expand Up @@ -151,12 +151,12 @@ export class Session implements NavigatorDelegate {

// Form submit observer delegate

willSubmitForm(form: HTMLFormElement) {
willSubmitForm(form: HTMLFormElement, submitter?: HTMLElement) {
return true
}

formSubmitted(form: HTMLFormElement) {
this.navigator.submitForm(form)
formSubmitted(form: HTMLFormElement, submitter?: HTMLElement) {
this.navigator.submitForm(form, submitter)
}

// Page observer delegate
Expand Down
4 changes: 2 additions & 2 deletions src/elements/frame_element.ts
Expand Up @@ -28,8 +28,8 @@ export class FrameElement extends HTMLElement {
}
}

formSubmissionIntercepted(element: HTMLFormElement) {
this.controller.formSubmissionIntercepted(element)
formSubmissionIntercepted(element: HTMLFormElement, submitter?: HTMLElement) {
this.controller.formSubmissionIntercepted(element, submitter)
}

get src() {
Expand Down
4 changes: 4 additions & 0 deletions src/globals.d.ts
@@ -1,3 +1,7 @@
interface SubmitEvent extends Event {
submitter: HTMLElement | null
}

interface Node {
// https://github.com/Microsoft/TypeScript/issues/283
cloneNode(deep?: boolean): this
Expand Down
13 changes: 7 additions & 6 deletions src/observers/form_submit_observer.ts
@@ -1,6 +1,6 @@
export interface FormSubmitObserverDelegate {
willSubmitForm(form: HTMLFormElement): boolean
formSubmitted(form: HTMLFormElement): void
willSubmitForm(form: HTMLFormElement, submitter?: HTMLElement): boolean
formSubmitted(form: HTMLFormElement, submitter?: HTMLElement): void
}

export class FormSubmitObserver {
Expand Down Expand Up @@ -30,15 +30,16 @@ export class FormSubmitObserver {
addEventListener("submit", this.submitBubbled, false)
}

submitBubbled = (event: Event) => {
submitBubbled = <EventListener>((event: SubmitEvent) => {
if (!event.defaultPrevented) {
const form = event.target instanceof HTMLFormElement ? event.target : undefined
const submitter = event.submitter || undefined
if (form) {
if (this.delegate.willSubmitForm(form)) {
if (this.delegate.willSubmitForm(form, submitter)) {
event.preventDefault()
this.delegate.formSubmitted(form)
this.delegate.formSubmitted(form, submitter)
}
}
}
}
})
}
1 change: 1 addition & 0 deletions src/polyfills/index.ts
@@ -1 +1,2 @@
import "./custom-elements-native-shim"
import "./submit-event"
31 changes: 31 additions & 0 deletions src/polyfills/submit-event.ts
@@ -0,0 +1,31 @@
type FormSubmitter = HTMLElement & { form?: HTMLFormElement }

const submittersByForm: WeakMap<HTMLFormElement, HTMLElement> = new WeakMap

function findSubmitterFromClickTarget(target: EventTarget | null): FormSubmitter | null {
const element = target instanceof Element ? target : target instanceof Node ? target.parentElement : null
const candidate = element ? element.closest("input, button") as FormSubmitter | null : null
return candidate?.getAttribute("type") == "submit" ? candidate : null
}

function clickCaptured(event: Event) {
const submitter = findSubmitterFromClickTarget(event.target)

if (submitter && submitter.form) {
submittersByForm.set(submitter.form, submitter)
}
}

(function() {
if ("SubmitEvent" in window) return

addEventListener("click", clickCaptured, true)

Object.defineProperty(Event.prototype, "submitter", {
get(): HTMLElement | undefined {
if (this.type == "submit" && this.target instanceof HTMLFormElement) {
return submittersByForm.get(this.target)
}
}
})
})()

0 comments on commit bf257f3

Please sign in to comment.