Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Form shortcuts #348

Merged
merged 11 commits into from
Sep 19, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ export function CandidatesVotesForm({ group }: CandidatesVotesFormProps) {
};

return (
<Form onSubmit={handleSubmit} ref={formRef} id={`candidates_form_${group.number}`}>
<Form onSubmit={handleSubmit} ref={formRef} id={`candidates_form_${group.number}`} skip={[_IGNORE_WARNINGS_ID]}>
<h2>
Lijst {group.number} - {group.name}
</h2>
Expand Down Expand Up @@ -192,6 +192,12 @@ export function CandidatesVotesForm({ group }: CandidatesVotesFormProps) {
Ik heb de aantallen gecontroleerd met het papier en correct overgenomen.
</Checkbox>
</BottomBar.Row>
<BottomBar.Row>
<KeyboardKeys.HintText>
<KeyboardKeys keys={[KeyboardKey.Shift, KeyboardKey.Down]} />
Snel naar totaal van de lijst
</KeyboardKeys.HintText>
</BottomBar.Row>
<BottomBar.Row>
<Button type="submit" size="lg" disabled={status.current === "saving"}>
Volgende
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ export function DifferencesForm() {
};

return (
<Form onSubmit={handleSubmit} ref={formRef} id="differences_form">
<Form onSubmit={handleSubmit} ref={formRef} id="differences_form" skip={[_IGNORE_WARNINGS_ID]}>
<h2>Verschillen tussen toegelaten kiezers en uitgebrachte stemmen</h2>
{isSaved && hasValidationError && (
<Feedback id="feedback-error" type="error" data={errors.map((error) => error.code)} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ export function VotersAndVotesForm() {
};

return (
<Form onSubmit={handleSubmit} ref={formRef} id="voters_and_votes_form">
<Form onSubmit={handleSubmit} ref={formRef} id="voters_and_votes_form" skip={[_IGNORE_WARNINGS_ID]}>
<h2>Toegelaten kiezers en uitgebrachte stemmen</h2>
{isSaved && hasValidationError && (
<Feedback id="feedback-error" type="error" data={errors.map((error) => error.code)} />
Expand Down
10 changes: 7 additions & 3 deletions frontend/lib/ui/Form/Form.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,12 @@ import { Form } from "./Form";

export const DefaultForm: Story = () => (
<Form>
<input id="test1" />
<input id="test2" />
<button type="submit" />
<input id="inp1" />
<br />
<input id="inp2" />
<br />
<input id="inp3" />
<br />
<button type="submit">Submit</button>
</Form>
);
51 changes: 51 additions & 0 deletions frontend/lib/ui/Form/Form.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,4 +84,55 @@ describe("UI Component: Form", () => {

expect(ref.current).toBeInstanceOf(HTMLFormElement);
});

test("Move focus", async () => {
const onSubmit = vi.fn((e: React.FormEvent) => {
e.preventDefault();
});

render(
<Form onSubmit={onSubmit} id="test-form">
<input id="inp1" defaultValue="fizz1" />
<input id="inp2" defaultValue="fizz2" />
<input id="inp3" defaultValue="fizz3" />
<button id="test-submit-button" type="submit">
Submit
</button>
</Form>,
);

const firstInput = screen.getByTestId("inp1");
const secondInput = screen.getByTestId("inp2");
const thirdInput = screen.getByTestId("inp3");
const submitButton = screen.getByTestId("test-submit-button");

firstInput.focus();
expect(firstInput).toHaveFocus();

await userEvent.keyboard("{arrowdown}");

expect(secondInput).toHaveFocus();

await userEvent.keyboard("{arrowup}");

expect(firstInput).toHaveFocus();

await userEvent.keyboard("{tab}");

expect(secondInput).toHaveFocus();

await userEvent.keyboard("{enter}");

expect(thirdInput).toHaveFocus();

await userEvent.keyboard("{enter}");

expect(submitButton).toHaveFocus();

await userEvent.keyboard("{Shift>}{arrowup}{/Shift}");
expect(firstInput).toHaveFocus();

await userEvent.keyboard("{Shift>}{arrowdown}{/Shift}");
expect(thirdInput).toHaveFocus();
});
});
105 changes: 93 additions & 12 deletions frontend/lib/ui/Form/Form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,112 @@ import * as React from "react";

export interface FormProps extends React.FormHTMLAttributes<HTMLFormElement> {
children: React.ReactNode;
skip?: string[];
}
export const Form = React.forwardRef<HTMLFormElement, FormProps>(({ children, ...formProps }, ref) => {

type Dir = "up" | "down" | "first" | "last";

export const Form = React.forwardRef<HTMLFormElement, FormProps>(({ children, skip, ...formProps }, ref) => {
const innerRef: React.MutableRefObject<HTMLFormElement | null> = React.useRef<HTMLFormElement>(null);

React.useEffect(() => {
const submitButton = innerRef.current?.querySelector("button[type=submit]") as HTMLButtonElement | null;
const inputList = React.useRef<HTMLInputElement[]>([]);
const submitButton = React.useRef<HTMLButtonElement | null>(null);

const moveFocus = React.useCallback((dir: Dir) => {
let activeIndex = inputList.current.findIndex((input) => document.activeElement === input);
if (activeIndex === -1) {
activeIndex = 0;
}
let targetIndex = activeIndex;
switch (dir) {
case "up":
targetIndex = activeIndex - 1;
break;
case "down":
targetIndex = activeIndex + 1;
break;
case "first":
targetIndex = 0;
break;
case "last":
targetIndex = inputList.current.length - 1;
break;
}
if (targetIndex < 0) {
targetIndex = inputList.current.length - 1;
} else if (targetIndex >= inputList.current.length) {
targetIndex = -1; //end of the line
}

if (targetIndex >= 0) {
const next = inputList.current[targetIndex];
if (next) {
next.focus();
setTimeout(() => {
if (next.type === "radio") {
next.checked = true;
} else {
next.select();
}
}, 1);
}
} else {
submitButton.current?.focus();
}
}, []);

React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Enter") {
if (event.shiftKey || document.activeElement === submitButton) {
event.preventDefault();
event.stopPropagation();
//ref.current.submit fails in testing environment (jsdom)
innerRef.current?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
} else {
if (event.target instanceof HTMLInputElement && event.target.type === "radio") {
event.preventDefault();
}
switch (event.key) {
case "ArrowUp":
if (event.shiftKey) {
moveFocus("first");
} else {
moveFocus("up");
}
break;
case "ArrowDown":
if (event.shiftKey) {
moveFocus("last");
} else {
moveFocus("down");
}

break;
jorisleker marked this conversation as resolved.
Show resolved Hide resolved
case "Enter":
event.preventDefault();
}
if (event.shiftKey || document.activeElement === submitButton.current) {
//ref.current.submit fails in testing environment (jsdom)
innerRef.current?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
} else {
moveFocus("down");
}
break;
default:
break;
}
};

document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, []);
}, [moveFocus]);

//cache children inputs and submit
React.useEffect(() => {
const inputs = innerRef.current?.querySelectorAll("input, select, textarea") as NodeListOf<HTMLInputElement>;
inputList.current = Array.from(inputs);

//filter out inputs we should skip
if (skip && skip.length) {
inputList.current = inputList.current.filter((input) => !skip.includes(input.id));
}
submitButton.current = innerRef.current?.querySelector("button[type=submit]") as HTMLButtonElement | null;
}, [children, skip]);

return (
<form
Expand Down
20 changes: 0 additions & 20 deletions frontend/lib/ui/InputGrid/InputGrid.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,24 +31,4 @@ test.describe("InputGrid", () => {
await expect(firstTR).not.toHaveClass("focused");
await expect(secondTR).toHaveClass("focused");
});

test("Move focus arrow up and down and tab and enter", async ({ page, gridPage }) => {
const firstInput = gridPage.getByTestId("input1");
const secondInput = gridPage.getByTestId("input2");
const thirdInput = gridPage.getByTestId("input3");

await firstInput.focus();

await page.keyboard.press("ArrowDown");
await expect(secondInput).toBeFocused();

await page.keyboard.press("ArrowUp");
await expect(firstInput).toBeFocused();

await page.keyboard.press("Tab");
await expect(secondInput).toBeFocused();

await page.keyboard.press("Enter");
await expect(thirdInput).toBeFocused();
});
});
39 changes: 0 additions & 39 deletions frontend/lib/ui/InputGrid/InputGrid.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { userEvent } from "@testing-library/user-event";
import { describe, expect, test } from "vitest";

import { render, screen } from "app/test/unit";
Expand All @@ -23,42 +22,4 @@ describe("InputGrid", () => {
expect(firstInput.parentElement?.parentElement).not.toHaveClass("focused");
expect(secondInput.parentElement?.parentElement).toHaveClass("focused");
});

test("Move focus arrow up and down and tab and enter", async () => {
render(<DefaultGrid />);

const firstInput = screen.getByTestId("input1");
const secondInput = screen.getByTestId("input2");
const thirdInput = screen.getByTestId("input3");

firstInput.focus();

await userEvent.keyboard("{arrowdown}");

expect(secondInput).toHaveFocus();

await userEvent.keyboard("{arrowup}");

expect(firstInput).toHaveFocus();

await userEvent.keyboard("{tab}");

expect(secondInput).toHaveFocus();

await userEvent.keyboard("{enter}");

expect(thirdInput).toHaveFocus();
});

test("Move to last input with shortcut", async () => {
render(<DefaultGrid />);

const firstInput = screen.getByTestId("input1");
const thirdInput = screen.getByTestId("input3");

firstInput.focus();

await userEvent.keyboard("{Shift>}{arrowdown}{/Shift}");
expect(thirdInput).toHaveFocus();
});
});
Loading