Skip to content

Commit c58f4b8

Browse files
authored
Form shortcuts (#348)
1 parent d4dc2af commit c58f4b8

File tree

13 files changed

+208
-170
lines changed

13 files changed

+208
-170
lines changed

frontend/app/component/form/data_entry/candidates_votes/CandidatesVotesForm.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ export function CandidatesVotesForm({ group }: CandidatesVotesFormProps) {
133133
};
134134

135135
return (
136-
<Form onSubmit={handleSubmit} ref={formRef} id={`candidates_form_${group.number}`}>
136+
<Form onSubmit={handleSubmit} ref={formRef} id={`candidates_form_${group.number}`} skip={[_IGNORE_WARNINGS_ID]}>
137137
<h2>
138138
Lijst {group.number} - {group.name}
139139
</h2>
@@ -192,6 +192,12 @@ export function CandidatesVotesForm({ group }: CandidatesVotesFormProps) {
192192
Ik heb de aantallen gecontroleerd met het papier en correct overgenomen.
193193
</Checkbox>
194194
</BottomBar.Row>
195+
<BottomBar.Row>
196+
<KeyboardKeys.HintText>
197+
<KeyboardKeys keys={[KeyboardKey.Shift, KeyboardKey.Down]} />
198+
Snel naar totaal van de lijst
199+
</KeyboardKeys.HintText>
200+
</BottomBar.Row>
195201
<BottomBar.Row>
196202
<Button type="submit" size="lg" disabled={status.current === "saving"}>
197203
Volgende

frontend/app/component/form/data_entry/differences/DifferencesForm.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ export function DifferencesForm() {
135135
};
136136

137137
return (
138-
<Form onSubmit={handleSubmit} ref={formRef} id="differences_form">
138+
<Form onSubmit={handleSubmit} ref={formRef} id="differences_form" skip={[_IGNORE_WARNINGS_ID]}>
139139
<h2>Verschillen tussen toegelaten kiezers en uitgebrachte stemmen</h2>
140140
{isSaved && hasValidationError && (
141141
<Feedback id="feedback-error" type="error" data={errors.map((error) => error.code)} />

frontend/app/component/form/data_entry/voters_and_votes/VotersAndVotesForm.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ export function VotersAndVotesForm() {
159159
};
160160

161161
return (
162-
<Form onSubmit={handleSubmit} ref={formRef} id="voters_and_votes_form">
162+
<Form onSubmit={handleSubmit} ref={formRef} id="voters_and_votes_form" skip={[_IGNORE_WARNINGS_ID]}>
163163
<h2>Toegelaten kiezers en uitgebrachte stemmen</h2>
164164
{isSaved && hasValidationError && (
165165
<Feedback id="feedback-error" type="error" data={errors.map((error) => error.code)} />

frontend/lib/ui/Form/Form.stories.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,12 @@ import { Form } from "./Form";
66

77
export const DefaultForm: Story = () => (
88
<Form>
9-
<input id="test1" />
10-
<input id="test2" />
11-
<button type="submit" />
9+
<input id="inp1" />
10+
<br />
11+
<input id="inp2" />
12+
<br />
13+
<input id="inp3" />
14+
<br />
15+
<button type="submit">Submit</button>
1216
</Form>
1317
);

frontend/lib/ui/Form/Form.test.tsx

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,4 +84,55 @@ describe("UI Component: Form", () => {
8484

8585
expect(ref.current).toBeInstanceOf(HTMLFormElement);
8686
});
87+
88+
test("Move focus", async () => {
89+
const onSubmit = vi.fn((e: React.FormEvent) => {
90+
e.preventDefault();
91+
});
92+
93+
render(
94+
<Form onSubmit={onSubmit} id="test-form">
95+
<input id="inp1" defaultValue="fizz1" />
96+
<input id="inp2" defaultValue="fizz2" />
97+
<input id="inp3" defaultValue="fizz3" />
98+
<button id="test-submit-button" type="submit">
99+
Submit
100+
</button>
101+
</Form>,
102+
);
103+
104+
const firstInput = screen.getByTestId("inp1");
105+
const secondInput = screen.getByTestId("inp2");
106+
const thirdInput = screen.getByTestId("inp3");
107+
const submitButton = screen.getByTestId("test-submit-button");
108+
109+
firstInput.focus();
110+
expect(firstInput).toHaveFocus();
111+
112+
await userEvent.keyboard("{arrowdown}");
113+
114+
expect(secondInput).toHaveFocus();
115+
116+
await userEvent.keyboard("{arrowup}");
117+
118+
expect(firstInput).toHaveFocus();
119+
120+
await userEvent.keyboard("{tab}");
121+
122+
expect(secondInput).toHaveFocus();
123+
124+
await userEvent.keyboard("{enter}");
125+
126+
expect(thirdInput).toHaveFocus();
127+
128+
await userEvent.keyboard("{enter}");
129+
130+
expect(submitButton).toHaveFocus();
131+
132+
await userEvent.keyboard("{Shift>}{arrowup}{/Shift}");
133+
expect(firstInput).toHaveFocus();
134+
135+
await userEvent.keyboard("{Shift>}{arrowdown}{/Shift}");
136+
expect(thirdInput).toHaveFocus();
137+
});
87138
});

frontend/lib/ui/Form/Form.tsx

Lines changed: 93 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,31 +2,112 @@ import * as React from "react";
22

33
export interface FormProps extends React.FormHTMLAttributes<HTMLFormElement> {
44
children: React.ReactNode;
5+
skip?: string[];
56
}
6-
export const Form = React.forwardRef<HTMLFormElement, FormProps>(({ children, ...formProps }, ref) => {
7+
8+
type Dir = "up" | "down" | "first" | "last";
9+
10+
export const Form = React.forwardRef<HTMLFormElement, FormProps>(({ children, skip, ...formProps }, ref) => {
711
const innerRef: React.MutableRefObject<HTMLFormElement | null> = React.useRef<HTMLFormElement>(null);
812

9-
React.useEffect(() => {
10-
const submitButton = innerRef.current?.querySelector("button[type=submit]") as HTMLButtonElement | null;
13+
const inputList = React.useRef<HTMLInputElement[]>([]);
14+
const submitButton = React.useRef<HTMLButtonElement | null>(null);
15+
16+
const moveFocus = React.useCallback((dir: Dir) => {
17+
let activeIndex = inputList.current.findIndex((input) => document.activeElement === input);
18+
if (activeIndex === -1) {
19+
activeIndex = 0;
20+
}
21+
let targetIndex = activeIndex;
22+
switch (dir) {
23+
case "up":
24+
targetIndex = activeIndex - 1;
25+
break;
26+
case "down":
27+
targetIndex = activeIndex + 1;
28+
break;
29+
case "first":
30+
targetIndex = 0;
31+
break;
32+
case "last":
33+
targetIndex = inputList.current.length - 1;
34+
break;
35+
}
36+
if (targetIndex < 0) {
37+
targetIndex = inputList.current.length - 1;
38+
} else if (targetIndex >= inputList.current.length) {
39+
targetIndex = -1; //end of the line
40+
}
1141

42+
if (targetIndex >= 0) {
43+
const next = inputList.current[targetIndex];
44+
if (next) {
45+
next.focus();
46+
setTimeout(() => {
47+
if (next.type === "radio") {
48+
next.checked = true;
49+
} else {
50+
next.select();
51+
}
52+
}, 1);
53+
}
54+
} else {
55+
submitButton.current?.focus();
56+
}
57+
}, []);
58+
59+
React.useEffect(() => {
1260
const handleKeyDown = (event: KeyboardEvent) => {
13-
if (event.key === "Enter") {
14-
if (event.shiftKey || document.activeElement === submitButton) {
15-
event.preventDefault();
16-
event.stopPropagation();
17-
//ref.current.submit fails in testing environment (jsdom)
18-
innerRef.current?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
19-
} else {
61+
if (event.target instanceof HTMLInputElement && event.target.type === "radio") {
62+
event.preventDefault();
63+
}
64+
switch (event.key) {
65+
case "ArrowUp":
66+
if (event.shiftKey) {
67+
moveFocus("first");
68+
} else {
69+
moveFocus("up");
70+
}
71+
break;
72+
case "ArrowDown":
73+
if (event.shiftKey) {
74+
moveFocus("last");
75+
} else {
76+
moveFocus("down");
77+
}
78+
79+
break;
80+
case "Enter":
2081
event.preventDefault();
21-
}
82+
if (event.shiftKey || document.activeElement === submitButton.current) {
83+
//ref.current.submit fails in testing environment (jsdom)
84+
innerRef.current?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
85+
} else {
86+
moveFocus("down");
87+
}
88+
break;
89+
default:
90+
break;
2291
}
2392
};
2493

2594
document.addEventListener("keydown", handleKeyDown);
2695
return () => {
2796
document.removeEventListener("keydown", handleKeyDown);
2897
};
29-
}, []);
98+
}, [moveFocus]);
99+
100+
//cache children inputs and submit
101+
React.useEffect(() => {
102+
const inputs = innerRef.current?.querySelectorAll("input, select, textarea") as NodeListOf<HTMLInputElement>;
103+
inputList.current = Array.from(inputs);
104+
105+
//filter out inputs we should skip
106+
if (skip && skip.length) {
107+
inputList.current = inputList.current.filter((input) => !skip.includes(input.id));
108+
}
109+
submitButton.current = innerRef.current?.querySelector("button[type=submit]") as HTMLButtonElement | null;
110+
}, [children, skip]);
30111

31112
return (
32113
<form

frontend/lib/ui/InputGrid/InputGrid.e2e.ts

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -31,24 +31,4 @@ test.describe("InputGrid", () => {
3131
await expect(firstTR).not.toHaveClass("focused");
3232
await expect(secondTR).toHaveClass("focused");
3333
});
34-
35-
test("Move focus arrow up and down and tab and enter", async ({ page, gridPage }) => {
36-
const firstInput = gridPage.getByTestId("input1");
37-
const secondInput = gridPage.getByTestId("input2");
38-
const thirdInput = gridPage.getByTestId("input3");
39-
40-
await firstInput.focus();
41-
42-
await page.keyboard.press("ArrowDown");
43-
await expect(secondInput).toBeFocused();
44-
45-
await page.keyboard.press("ArrowUp");
46-
await expect(firstInput).toBeFocused();
47-
48-
await page.keyboard.press("Tab");
49-
await expect(secondInput).toBeFocused();
50-
51-
await page.keyboard.press("Enter");
52-
await expect(thirdInput).toBeFocused();
53-
});
5434
});
Lines changed: 0 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { userEvent } from "@testing-library/user-event";
21
import { describe, expect, test } from "vitest";
32

43
import { render, screen } from "app/test/unit";
@@ -23,42 +22,4 @@ describe("InputGrid", () => {
2322
expect(firstInput.parentElement?.parentElement).not.toHaveClass("focused");
2423
expect(secondInput.parentElement?.parentElement).toHaveClass("focused");
2524
});
26-
27-
test("Move focus arrow up and down and tab and enter", async () => {
28-
render(<DefaultGrid />);
29-
30-
const firstInput = screen.getByTestId("input1");
31-
const secondInput = screen.getByTestId("input2");
32-
const thirdInput = screen.getByTestId("input3");
33-
34-
firstInput.focus();
35-
36-
await userEvent.keyboard("{arrowdown}");
37-
38-
expect(secondInput).toHaveFocus();
39-
40-
await userEvent.keyboard("{arrowup}");
41-
42-
expect(firstInput).toHaveFocus();
43-
44-
await userEvent.keyboard("{tab}");
45-
46-
expect(secondInput).toHaveFocus();
47-
48-
await userEvent.keyboard("{enter}");
49-
50-
expect(thirdInput).toHaveFocus();
51-
});
52-
53-
test("Move to last input with shortcut", async () => {
54-
render(<DefaultGrid />);
55-
56-
const firstInput = screen.getByTestId("input1");
57-
const thirdInput = screen.getByTestId("input3");
58-
59-
firstInput.focus();
60-
61-
await userEvent.keyboard("{Shift>}{arrowdown}{/Shift}");
62-
expect(thirdInput).toHaveFocus();
63-
});
6425
});

0 commit comments

Comments
 (0)