Skip to content

Commit

Permalink
refactor(utils): added tryToSubmitRelatedForm util to help with addit…
Browse files Browse the repository at this point in the history
…ional a11y
  • Loading branch information
mlaursen committed Feb 17, 2021
1 parent 42e929b commit 0566e14
Show file tree
Hide file tree
Showing 4 changed files with 197 additions and 27 deletions.
32 changes: 5 additions & 27 deletions packages/form/src/select/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
DEFAULT_GET_ITEM_VALUE,
PositionAnchor,
PositionWidth,
tryToSubmitRelatedForm,
useCloseOnOutsideClick,
useEnsuredRef,
useToggle,
Expand Down Expand Up @@ -258,6 +259,10 @@ export const Select = forwardRef<HTMLDivElement, SelectProps>(function Select(
onKeyDown(event);
}

if (tryToSubmitRelatedForm(event)) {
return;
}

switch (event.key) {
case " ":
case "ArrowUp":
Expand All @@ -266,33 +271,6 @@ export const Select = forwardRef<HTMLDivElement, SelectProps>(function Select(
event.preventDefault();
show();
break;
case "Enter": {
const form = event.currentTarget.closest("form");
if (form) {
// the default behavior of pressing the "Enter" key on a form
// control (input, textarea, select) is to submit a form, so that's
// what this is attempting to polyfill. Unfortunately, using the
// form.submit() ignores any `onSubmit` handlers like
// event.preventDefault() so to work around that, try to first find
// a submit button and click that instead. If there isn't a submit
// button as a child of the form, try to find a submit button that
// has the form attribute set to this current form's id.
let submit = form.querySelector<HTMLButtonElement>(
'[type="submit"]'
);

if (!submit && form.id) {
submit = document.querySelector(
`[type="submit"][form="${form.id}"]`
);
}

if (submit) {
submit.click();
}
}
break;
}
// no default
}
},
Expand Down
130 changes: 130 additions & 0 deletions packages/utils/src/wia-aria/__tests__/tryToSubmitRelatedForm.tsx
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();
});
});
1 change: 1 addition & 0 deletions packages/utils/src/wia-aria/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ export * from "./useCloseOnEscape";
export * from "./getFocusableElements";
export * from "./focusElementWithin";
export * from "./extractTextContent";
export * from "./tryToSubmitRelatedForm";
61 changes: 61 additions & 0 deletions packages/utils/src/wia-aria/tryToSubmitRelatedForm.ts
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;
}

0 comments on commit 0566e14

Please sign in to comment.