-
-
Notifications
You must be signed in to change notification settings - Fork 298
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
refactor(utils): added tryToSubmitRelatedForm util to help with addit…
…ional a11y
- Loading branch information
Showing
4 changed files
with
197 additions
and
27 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
130 changes: 130 additions & 0 deletions
130
packages/utils/src/wia-aria/__tests__/tryToSubmitRelatedForm.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,130 @@ | ||
import React, { KeyboardEvent, FormHTMLAttributes, ReactElement } from "react"; | ||
import { fireEvent, render } from "@testing-library/react"; | ||
|
||
import { tryToSubmitRelatedForm } from "../tryToSubmitRelatedForm"; | ||
|
||
interface TestProps { | ||
id?: string; | ||
submit?: "external" | "internal" | null; | ||
onSubmit: FormHTMLAttributes<HTMLFormElement>["onSubmit"]; | ||
onNotSubmit(): void; | ||
} | ||
|
||
function Test({ | ||
id = "form", | ||
submit = "internal", | ||
onSubmit, | ||
onNotSubmit, | ||
}: TestProps): ReactElement { | ||
const onKeyDown = (event: KeyboardEvent<HTMLSpanElement>): void => { | ||
if (tryToSubmitRelatedForm(event)) { | ||
return; | ||
} | ||
|
||
onNotSubmit(); | ||
}; | ||
return ( | ||
<> | ||
<form id={id} onSubmit={onSubmit}> | ||
<div | ||
id="radio" | ||
role="radio" | ||
aria-checked={false} | ||
onKeyDown={onKeyDown} | ||
tabIndex={0} | ||
> | ||
Radio | ||
</div> | ||
{submit === "internal" && <button type="submit">Submit</button>} | ||
</form> | ||
{submit === "external" && ( | ||
<button form={id} type="submit"> | ||
Submit External | ||
</button> | ||
)} | ||
</> | ||
); | ||
} | ||
|
||
describe("tryToSubmitRelatedForm", () => { | ||
it("should do nothing if the key is not enter", () => { | ||
const onSubmit = jest.fn(); | ||
const onNotSubmit = jest.fn(); | ||
const { getByRole } = render( | ||
<Test onSubmit={onSubmit} onNotSubmit={onNotSubmit} /> | ||
); | ||
const radio = getByRole("radio", { name: "Radio" }); | ||
|
||
fireEvent.keyDown(radio, { key: "A" }); | ||
fireEvent.keyDown(radio, { key: "Tab" }); | ||
expect(onSubmit).not.toBeCalled(); | ||
expect(onNotSubmit).toBeCalledTimes(2); | ||
}); | ||
|
||
it("should do nothing if the form does not have a submit button", () => { | ||
const onSubmit = jest.fn(); | ||
const onNotSubmit = jest.fn(); | ||
const { getByRole } = render( | ||
<Test onSubmit={onSubmit} onNotSubmit={onNotSubmit} submit={null} /> | ||
); | ||
|
||
const radio = getByRole("radio", { name: "Radio" }); | ||
fireEvent.keyDown(radio, { key: "Enter" }); | ||
expect(onSubmit).not.toBeCalled(); | ||
expect(onNotSubmit).not.toBeCalled(); | ||
}); | ||
|
||
it("should attempt to find a submit button that has the form attribute set to the form id if the form has no submit button inside", () => { | ||
const error = jest.spyOn(console, "error").mockImplementation(() => {}); | ||
|
||
const onSubmit = jest.fn(); | ||
const onNotSubmit = jest.fn(); | ||
const { getByRole } = render( | ||
<Test onSubmit={onSubmit} onNotSubmit={onNotSubmit} submit="external" /> | ||
); | ||
|
||
const radio = getByRole("radio", { name: "Radio" }); | ||
fireEvent.keyDown(radio, { key: "Enter" }); | ||
expect(onSubmit).toBeCalledTimes(1); | ||
expect(onNotSubmit).not.toBeCalled(); | ||
|
||
error.mockRestore(); | ||
}); | ||
|
||
it("should do nothing if the element is not in a form", () => { | ||
function WithoutForm({ | ||
onNotSubmit, | ||
}: { | ||
onNotSubmit(): void; | ||
}): ReactElement { | ||
const onKeyDown = (event: KeyboardEvent<HTMLSpanElement>): void => { | ||
if (tryToSubmitRelatedForm(event)) { | ||
return; | ||
} | ||
|
||
onNotSubmit(); | ||
}; | ||
|
||
return ( | ||
<> | ||
<div | ||
id="radio" | ||
role="radio" | ||
aria-checked={false} | ||
onKeyDown={onKeyDown} | ||
tabIndex={0} | ||
> | ||
Radio | ||
</div> | ||
</> | ||
); | ||
} | ||
|
||
const onNotSubmit = jest.fn(); | ||
const { getByRole } = render(<WithoutForm onNotSubmit={onNotSubmit} />); | ||
|
||
const radio = getByRole("radio", { name: "Radio" }); | ||
fireEvent.keyDown(radio, { key: "Enter" }); | ||
expect(onNotSubmit).not.toBeCalled(); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,61 @@ | ||
/** | ||
* Don't really need the full `event` for this, and picking these parts makes it | ||
* so that both the React keydown listener and native keydown listener can use | ||
* this function if needed. | ||
*/ | ||
type KeyboardSubmitEventPartial = Pick< | ||
KeyboardEvent, | ||
"key" | "preventDefault" | "stopPropagation" | "currentTarget" | ||
>; | ||
|
||
/** | ||
* The default behavior when pressing the `"Enter"` key on a form control | ||
* (`input`, `textarea`, `select`) is to submit the form that the form control | ||
* is in. This util will try to polyfill this behavior for custom widgets that | ||
* use are using a role to act as a form control. | ||
* | ||
* The way this works is: | ||
* - Check if the `event.key` is the `"Enter"` key. Do nothing if it is not. | ||
* - Call `event.preventDefault()` and `event.stopPropagation()` to prevent | ||
* other unwanted keyboard behavior | ||
* - Check the event target to see if it is contained in a `<form>` | ||
* - Try to find a submit button and click it by: | ||
* - First check with `form.querySelector('[type="submit"]')` | ||
* - Fallback to `document.querySelector('[type="submit"][form="{{FORM_ID}}"]')` | ||
* - submit buttons can be placed outside of the form and link it back using | ||
* the `form` attribute pointing to the id of the form | ||
* | ||
* | ||
* The reason the submit button has to be found and clicked is because calling | ||
* `form.submit()` won't actually fire any attached `form.onsubmit` event | ||
* handlers. If you click the submit button though, the `form.onsubmit` handlers | ||
* will be called correctly. | ||
* | ||
* @param event The keyboard event that should attempt to submit the form when | ||
* the enter key is presssed. | ||
* @return `true` if the `event.key` was the `"Enter"` key so that other | ||
* keydown logic can be ignored. | ||
* @since 2.7.0 | ||
*/ | ||
export function tryToSubmitRelatedForm( | ||
event: KeyboardSubmitEventPartial | ||
): boolean { | ||
if (event.key !== "Enter") { | ||
return false; | ||
} | ||
|
||
event.preventDefault(); | ||
event.stopPropagation(); | ||
|
||
/* istanbul ignore next */ | ||
const form = (event.currentTarget as Element)?.closest?.("form"); | ||
let submit = form?.querySelector<HTMLButtonElement>('[type="submit"]'); | ||
if (!submit && form?.id) { | ||
submit = document.querySelector<HTMLButtonElement>( | ||
`[type="submit"][form="${form.id}"]` | ||
); | ||
} | ||
|
||
submit?.click(); | ||
return true; | ||
} |