Skip to content

Commit 0566e14

Browse files
committed
refactor(utils): added tryToSubmitRelatedForm util to help with additional a11y
1 parent 42e929b commit 0566e14

4 files changed

Lines changed: 197 additions & 27 deletions

File tree

‎packages/form/src/select/Select.tsx‎

Lines changed: 5 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
DEFAULT_GET_ITEM_VALUE,
1616
PositionAnchor,
1717
PositionWidth,
18+
tryToSubmitRelatedForm,
1819
useCloseOnOutsideClick,
1920
useEnsuredRef,
2021
useToggle,
@@ -258,6 +259,10 @@ export const Select = forwardRef<HTMLDivElement, SelectProps>(function Select(
258259
onKeyDown(event);
259260
}
260261

262+
if (tryToSubmitRelatedForm(event)) {
263+
return;
264+
}
265+
261266
switch (event.key) {
262267
case " ":
263268
case "ArrowUp":
@@ -266,33 +271,6 @@ export const Select = forwardRef<HTMLDivElement, SelectProps>(function Select(
266271
event.preventDefault();
267272
show();
268273
break;
269-
case "Enter": {
270-
const form = event.currentTarget.closest("form");
271-
if (form) {
272-
// the default behavior of pressing the "Enter" key on a form
273-
// control (input, textarea, select) is to submit a form, so that's
274-
// what this is attempting to polyfill. Unfortunately, using the
275-
// form.submit() ignores any `onSubmit` handlers like
276-
// event.preventDefault() so to work around that, try to first find
277-
// a submit button and click that instead. If there isn't a submit
278-
// button as a child of the form, try to find a submit button that
279-
// has the form attribute set to this current form's id.
280-
let submit = form.querySelector<HTMLButtonElement>(
281-
'[type="submit"]'
282-
);
283-
284-
if (!submit && form.id) {
285-
submit = document.querySelector(
286-
`[type="submit"][form="${form.id}"]`
287-
);
288-
}
289-
290-
if (submit) {
291-
submit.click();
292-
}
293-
}
294-
break;
295-
}
296274
// no default
297275
}
298276
},
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import React, { KeyboardEvent, FormHTMLAttributes, ReactElement } from "react";
2+
import { fireEvent, render } from "@testing-library/react";
3+
4+
import { tryToSubmitRelatedForm } from "../tryToSubmitRelatedForm";
5+
6+
interface TestProps {
7+
id?: string;
8+
submit?: "external" | "internal" | null;
9+
onSubmit: FormHTMLAttributes<HTMLFormElement>["onSubmit"];
10+
onNotSubmit(): void;
11+
}
12+
13+
function Test({
14+
id = "form",
15+
submit = "internal",
16+
onSubmit,
17+
onNotSubmit,
18+
}: TestProps): ReactElement {
19+
const onKeyDown = (event: KeyboardEvent<HTMLSpanElement>): void => {
20+
if (tryToSubmitRelatedForm(event)) {
21+
return;
22+
}
23+
24+
onNotSubmit();
25+
};
26+
return (
27+
<>
28+
<form id={id} onSubmit={onSubmit}>
29+
<div
30+
id="radio"
31+
role="radio"
32+
aria-checked={false}
33+
onKeyDown={onKeyDown}
34+
tabIndex={0}
35+
>
36+
Radio
37+
</div>
38+
{submit === "internal" && <button type="submit">Submit</button>}
39+
</form>
40+
{submit === "external" && (
41+
<button form={id} type="submit">
42+
Submit External
43+
</button>
44+
)}
45+
</>
46+
);
47+
}
48+
49+
describe("tryToSubmitRelatedForm", () => {
50+
it("should do nothing if the key is not enter", () => {
51+
const onSubmit = jest.fn();
52+
const onNotSubmit = jest.fn();
53+
const { getByRole } = render(
54+
<Test onSubmit={onSubmit} onNotSubmit={onNotSubmit} />
55+
);
56+
const radio = getByRole("radio", { name: "Radio" });
57+
58+
fireEvent.keyDown(radio, { key: "A" });
59+
fireEvent.keyDown(radio, { key: "Tab" });
60+
expect(onSubmit).not.toBeCalled();
61+
expect(onNotSubmit).toBeCalledTimes(2);
62+
});
63+
64+
it("should do nothing if the form does not have a submit button", () => {
65+
const onSubmit = jest.fn();
66+
const onNotSubmit = jest.fn();
67+
const { getByRole } = render(
68+
<Test onSubmit={onSubmit} onNotSubmit={onNotSubmit} submit={null} />
69+
);
70+
71+
const radio = getByRole("radio", { name: "Radio" });
72+
fireEvent.keyDown(radio, { key: "Enter" });
73+
expect(onSubmit).not.toBeCalled();
74+
expect(onNotSubmit).not.toBeCalled();
75+
});
76+
77+
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", () => {
78+
const error = jest.spyOn(console, "error").mockImplementation(() => {});
79+
80+
const onSubmit = jest.fn();
81+
const onNotSubmit = jest.fn();
82+
const { getByRole } = render(
83+
<Test onSubmit={onSubmit} onNotSubmit={onNotSubmit} submit="external" />
84+
);
85+
86+
const radio = getByRole("radio", { name: "Radio" });
87+
fireEvent.keyDown(radio, { key: "Enter" });
88+
expect(onSubmit).toBeCalledTimes(1);
89+
expect(onNotSubmit).not.toBeCalled();
90+
91+
error.mockRestore();
92+
});
93+
94+
it("should do nothing if the element is not in a form", () => {
95+
function WithoutForm({
96+
onNotSubmit,
97+
}: {
98+
onNotSubmit(): void;
99+
}): ReactElement {
100+
const onKeyDown = (event: KeyboardEvent<HTMLSpanElement>): void => {
101+
if (tryToSubmitRelatedForm(event)) {
102+
return;
103+
}
104+
105+
onNotSubmit();
106+
};
107+
108+
return (
109+
<>
110+
<div
111+
id="radio"
112+
role="radio"
113+
aria-checked={false}
114+
onKeyDown={onKeyDown}
115+
tabIndex={0}
116+
>
117+
Radio
118+
</div>
119+
</>
120+
);
121+
}
122+
123+
const onNotSubmit = jest.fn();
124+
const { getByRole } = render(<WithoutForm onNotSubmit={onNotSubmit} />);
125+
126+
const radio = getByRole("radio", { name: "Radio" });
127+
fireEvent.keyDown(radio, { key: "Enter" });
128+
expect(onNotSubmit).not.toBeCalled();
129+
});
130+
});

‎packages/utils/src/wia-aria/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ export * from "./useCloseOnEscape";
1010
export * from "./getFocusableElements";
1111
export * from "./focusElementWithin";
1212
export * from "./extractTextContent";
13+
export * from "./tryToSubmitRelatedForm";
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/**
2+
* Don't really need the full `event` for this, and picking these parts makes it
3+
* so that both the React keydown listener and native keydown listener can use
4+
* this function if needed.
5+
*/
6+
type KeyboardSubmitEventPartial = Pick<
7+
KeyboardEvent,
8+
"key" | "preventDefault" | "stopPropagation" | "currentTarget"
9+
>;
10+
11+
/**
12+
* The default behavior when pressing the `"Enter"` key on a form control
13+
* (`input`, `textarea`, `select`) is to submit the form that the form control
14+
* is in. This util will try to polyfill this behavior for custom widgets that
15+
* use are using a role to act as a form control.
16+
*
17+
* The way this works is:
18+
* - Check if the `event.key` is the `"Enter"` key. Do nothing if it is not.
19+
* - Call `event.preventDefault()` and `event.stopPropagation()` to prevent
20+
* other unwanted keyboard behavior
21+
* - Check the event target to see if it is contained in a `<form>`
22+
* - Try to find a submit button and click it by:
23+
* - First check with `form.querySelector('[type="submit"]')`
24+
* - Fallback to `document.querySelector('[type="submit"][form="{{FORM_ID}}"]')`
25+
* - submit buttons can be placed outside of the form and link it back using
26+
* the `form` attribute pointing to the id of the form
27+
*
28+
*
29+
* The reason the submit button has to be found and clicked is because calling
30+
* `form.submit()` won't actually fire any attached `form.onsubmit` event
31+
* handlers. If you click the submit button though, the `form.onsubmit` handlers
32+
* will be called correctly.
33+
*
34+
* @param event The keyboard event that should attempt to submit the form when
35+
* the enter key is presssed.
36+
* @return `true` if the `event.key` was the `"Enter"` key so that other
37+
* keydown logic can be ignored.
38+
* @since 2.7.0
39+
*/
40+
export function tryToSubmitRelatedForm(
41+
event: KeyboardSubmitEventPartial
42+
): boolean {
43+
if (event.key !== "Enter") {
44+
return false;
45+
}
46+
47+
event.preventDefault();
48+
event.stopPropagation();
49+
50+
/* istanbul ignore next */
51+
const form = (event.currentTarget as Element)?.closest?.("form");
52+
let submit = form?.querySelector<HTMLButtonElement>('[type="submit"]');
53+
if (!submit && form?.id) {
54+
submit = document.querySelector<HTMLButtonElement>(
55+
`[type="submit"][form="${form.id}"]`
56+
);
57+
}
58+
59+
submit?.click();
60+
return true;
61+
}

0 commit comments

Comments
 (0)