diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx index 722ebf5b..da10bfc6 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx @@ -24,7 +24,7 @@ describe("SchemaForm", () => { renderWithMantine( , ); - const input = screen.getByLabelText(/Name/); + const input = screen.getByRole("textbox", { name: /Name/ }); await user.type(input, "a"); expect(onChange).toHaveBeenCalledWith({ name: "a" }); }); @@ -669,7 +669,7 @@ describe("SchemaForm", () => { ); expect(screen.getByText("Address")).toBeInTheDocument(); expect(screen.getByText("Street and city")).toBeInTheDocument(); - expect(screen.getByLabelText(/Street/)).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: /Street/ })).toBeInTheDocument(); }); it("propagates nested object changes back to top-level onChange", async () => { @@ -690,7 +690,7 @@ describe("SchemaForm", () => { renderWithMantine( , ); - await user.type(screen.getByLabelText(/Street/), "1"); + await user.type(screen.getByRole("textbox", { name: /Street/ }), "1"); expect(onChange).toHaveBeenCalled(); const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0]; expect(lastCall.address).toEqual({ street: "1" }); @@ -881,7 +881,7 @@ describe("SchemaForm", () => { disabled />, ); - expect(screen.getByLabelText(/Name/)).toBeDisabled(); + expect(screen.getByRole("textbox", { name: /Name/ })).toBeDisabled(); expect(screen.getByLabelText("Active")).toBeDisabled(); }); @@ -895,7 +895,9 @@ describe("SchemaForm", () => { renderWithMantine( , ); - expect(screen.getByLabelText(/rawField/)).toBeInTheDocument(); + expect( + screen.getByRole("textbox", { name: /rawField/ }), + ).toBeInTheDocument(); }); it("renders nothing inside the form when properties are missing", () => { @@ -1027,7 +1029,7 @@ describe("SchemaForm nullable unions", () => { renderWithMantine( , ); - await user.type(screen.getByLabelText(/Note/), "a"); + await user.type(screen.getByRole("textbox", { name: /Note/ }), "a"); expect(onChange).toHaveBeenCalledWith({ note: "a" }); }); @@ -1096,7 +1098,7 @@ describe("SchemaForm nullable unions", () => { renderWithMantine( , ); - await user.type(screen.getByLabelText(/Nick/), "z"); + await user.type(screen.getByRole("textbox", { name: /Nick/ }), "z"); expect(onChange).toHaveBeenCalledWith({ profile: { nick: "z" } }); }); @@ -1202,7 +1204,7 @@ describe("SchemaForm nullable unions", () => { renderWithMantine( , ); - expect(screen.getByLabelText(/Mode/).tagName).toBe("INPUT"); + expect(screen.getByRole("textbox", { name: /Mode/ }).tagName).toBe("INPUT"); }); it("still renders a MultiSelect when every anyOf branch has a distinct const", () => { @@ -1541,7 +1543,9 @@ describe("JSON editor escaping (#1853, #1856, #1885)", () => { , ); - const jsonInput = screen.getByLabelText(/Note/) as HTMLTextAreaElement; + const jsonInput = screen.getByRole("textbox", { + name: /Note/, + }) as HTMLTextAreaElement; await user.click(jsonInput); await user.keyboard("{End}a"); @@ -1816,17 +1820,21 @@ describe("SchemaForm multiline strings (#2042)", () => { it("renders a single-line input with an enlarge button by default", () => { renderWithMantine(); - expect(screen.getByLabelText(/Note/).tagName).toBe("INPUT"); - expect(screen.getByRole("button", { name: "Enlarge" })).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: /Note/ }).tagName).toBe("INPUT"); + expect( + screen.getByRole("button", { name: "Enlarge Note" }), + ).toBeInTheDocument(); }); it("swaps the input for a text area that accepts newlines", async () => { const user = userEvent.setup(); renderWithMantine(); - await user.click(screen.getByRole("button", { name: "Enlarge" })); + await user.click(screen.getByRole("button", { name: "Enlarge Note" })); - const textarea = screen.getByLabelText(/Note/) as HTMLTextAreaElement; + const textarea = screen.getByRole("textbox", { + name: /Note/, + }) as HTMLTextAreaElement; expect(textarea.tagName).toBe("TEXTAREA"); await user.type(textarea, "one{Enter}two"); expect(textarea.value).toBe("one\ntwo"); @@ -1835,16 +1843,19 @@ describe("SchemaForm multiline strings (#2042)", () => { it("carries the constraints of the field it replaced", async () => { const user = userEvent.setup(); renderWithMantine(); - await user.click(screen.getByRole("button", { name: "Enlarge" })); - expect(screen.getByLabelText(/Note/)).toHaveAttribute("maxlength", "40"); + await user.click(screen.getByRole("button", { name: "Enlarge Note" })); + expect(screen.getByRole("textbox", { name: /Note/ })).toHaveAttribute( + "maxlength", + "40", + ); }); it("is one-way — the enlarge button is gone once used", async () => { const user = userEvent.setup(); renderWithMantine(); - await user.click(screen.getByRole("button", { name: "Enlarge" })); + await user.click(screen.getByRole("button", { name: "Enlarge Note" })); expect( - screen.queryByRole("button", { name: "Enlarge" }), + screen.queryByRole("button", { name: "Enlarge Note" }), ).not.toBeInTheDocument(); }); @@ -1852,9 +1863,11 @@ describe("SchemaForm multiline strings (#2042)", () => { const user = userEvent.setup(); renderWithMantine(); - await user.type(screen.getByLabelText(/Note/), "typed"); - await user.click(screen.getByRole("button", { name: "Enlarge" })); - const textarea = screen.getByLabelText(/Note/) as HTMLTextAreaElement; + await user.type(screen.getByRole("textbox", { name: /Note/ }), "typed"); + await user.click(screen.getByRole("button", { name: "Enlarge Note" })); + const textarea = screen.getByRole("textbox", { + name: /Note/, + }) as HTMLTextAreaElement; expect(textarea.value).toBe("typed"); await user.click(screen.getByRole("button", { name: "Clear" })); @@ -1867,7 +1880,7 @@ describe("SchemaForm multiline strings (#2042)", () => { expect( screen.queryByRole("button", { name: "Clear" }), ).not.toBeInTheDocument(); - await user.type(screen.getByLabelText(/Note/), "x"); + await user.type(screen.getByRole("textbox", { name: /Note/ }), "x"); expect(screen.getByRole("button", { name: "Clear" })).toBeInTheDocument(); }); @@ -1877,19 +1890,68 @@ describe("SchemaForm multiline strings (#2042)", () => { const user = userEvent.setup(); const { rerender } = renderWithMantine(); - await user.click(screen.getByRole("button", { name: "Enlarge" })); - expect(screen.getByLabelText(/Note/).tagName).toBe("TEXTAREA"); + await user.click(screen.getByRole("button", { name: "Enlarge Note" })); + expect(screen.getByRole("textbox", { name: /Note/ }).tagName).toBe( + "TEXTAREA", + ); rerender(); - expect(screen.getByLabelText(/Note/).tagName).toBe("INPUT"); + expect(screen.getByRole("textbox", { name: /Note/ }).tagName).toBe("INPUT"); }); it("stays enlarged while resetKey is unchanged", async () => { const user = userEvent.setup(); const { rerender } = renderWithMantine(); - await user.click(screen.getByRole("button", { name: "Enlarge" })); + await user.click(screen.getByRole("button", { name: "Enlarge Note" })); rerender(); - expect(screen.getByLabelText(/Note/).tagName).toBe("TEXTAREA"); + expect(screen.getByRole("textbox", { name: /Note/ }).tagName).toBe( + "TEXTAREA", + ); + }); + + it("gives each field's enlarge button a distinct accessible name", () => { + renderWithMantine( + , + ); + expect( + screen.getByRole("button", { name: "Enlarge Note" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Enlarge Summary" }), + ).toBeInTheDocument(); + }); + + // The button unmounts in the same commit that mounts the text area, so + // without an explicit hand-off a keyboard user is left focused on nothing. + it("moves focus into the text area, caret last, when activated", async () => { + const user = userEvent.setup(); + renderWithMantine(); + + await user.type(screen.getByRole("textbox", { name: /Note/ }), "typed"); + await user.tab(); + expect(screen.getByRole("button", { name: "Enlarge Note" })).toHaveFocus(); + await user.keyboard("{Enter}"); + + const textarea = screen.getByRole("textbox", { + name: /Note/, + }) as HTMLTextAreaElement; + expect(textarea.tagName).toBe("TEXTAREA"); + expect(textarea).toHaveFocus(); + expect(textarea.selectionStart).toBe("typed".length); + + // And the caret really is at the end: typing appends rather than prepends. + await user.keyboard("{Enter}more"); + expect(textarea.value).toBe("typed\nmore"); }); }); diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx index 6edb9795..2b7e4258 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx @@ -8,6 +8,7 @@ import { Stack, Text, Textarea, + type TextareaProps, TextInput, } from "@mantine/core"; import { @@ -58,6 +59,33 @@ const MultilineStringInput = Textarea.withProps({ rightSectionPointerEvents: "auto", }); +/** + * A string field that has just been enlarged (#2042). + * + * Exists only to take focus on mount. This component mounts as a direct + * consequence of the user activating the enlarge button — which unmounts in the + * same commit, taking the focused element with it. Without this, a keyboard user + * who activates it is left with focus on the document body, and the next Tab + * restarts from the top of the page rather than continuing through the form. + * + * The caret is placed at the end of whatever was already typed, since focusing a + * pre-filled text control does not agree across browsers on where it lands, and + * the one answer that is never right is "before the text the user just wrote". + */ +function EnlargedStringField(props: TextareaProps) { + const inputRef = useRef(null); + + useEffect(() => { + const node = inputRef.current; + /* v8 ignore next -- an effect runs after mount, so the ref is always set */ + if (!node) return; + node.focus(); + node.setSelectionRange(node.value.length, node.value.length); + }, []); + + return ; +} + // Holds the buttons a string field stacks in its `rightSection`. `nowrap` keeps // them on one line inside a section only wide enough for the two. const FieldActions = Group.withProps({ gap: 2, wrap: "nowrap" }); @@ -666,7 +694,7 @@ export function SchemaForm({ // gone rather than disabled — there is nothing left for it to do. if (enlargedFields.has(fieldName)) { return ( - setEnlargedFields( (previous) => new Set([...previous, fieldName]), diff --git a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.test.tsx b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.test.tsx index da3ed278..bdf9f978 100644 --- a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.test.tsx +++ b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.test.tsx @@ -111,7 +111,7 @@ describe("ToolsScreen", () => { const rows = screen.getAllByText("get_weather"); await user.click(rows[1] as HTMLElement); // The second copy's own field renders — the first copy's does not. - expect(screen.getByLabelText(/zip/i)).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: /zip/i })).toBeInTheDocument(); expect(screen.queryByLabelText(/city/i)).toBeNull(); await user.click(screen.getByRole("button", { name: /Execute/ })); @@ -137,7 +137,7 @@ describe("ToolsScreen", () => { renderWithMantine(); await user.click(screen.getByText("gamma")); // gamma seeds { mode: "fast" }; editing the field flows through onUiChange. - const field = screen.getByLabelText(/mode/i); + const field = screen.getByRole("textbox", { name: /mode/i }); await user.clear(field); await user.type(field, "slow"); await user.click(screen.getByRole("button", { name: /Execute/ }));