diff --git a/web/package-lock.json b/web/package-lock.json index c187749290..87b99eadc4 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -12,6 +12,7 @@ "@patternfly/patternfly": "^6.4.0", "@patternfly/react-core": "^6.4.1", "@patternfly/react-table": "^6.4.1", + "@tanstack/react-form": "^1.28.4", "@tanstack/react-query": "^5.90.21", "axios": "^1.13.6", "fast-sort": "^3.4.1", @@ -4846,6 +4847,47 @@ "url": "https://github.com/sponsors/gregberge" } }, + "node_modules/@tanstack/devtools-event-client": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tanstack/devtools-event-client/-/devtools-event-client-0.4.1.tgz", + "integrity": "sha512-GRxmPw4OHZ2oZeIEUkEwt/NDvuEqzEYRAjzUVMs+I0pd4C7k1ySOiuJK2CqF+K/yEAR3YZNkW3ExrpDarh9Vwg==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/form-core": { + "version": "1.28.4", + "resolved": "https://registry.npmjs.org/@tanstack/form-core/-/form-core-1.28.4.tgz", + "integrity": "sha512-2eox5ePrJ6kvA1DXD5QHk/GeGr3VFZ0uYR63UgQOe7bUg6h1JfXaIMqTjZK9sdGyE4oRNqFpoW54H0pZM7nObQ==", + "license": "MIT", + "dependencies": { + "@tanstack/devtools-event-client": "^0.4.0", + "@tanstack/pacer-lite": "^0.1.1", + "@tanstack/store": "^0.9.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/pacer-lite": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@tanstack/pacer-lite/-/pacer-lite-0.1.1.tgz", + "integrity": "sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/query-core": { "version": "5.90.20", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz", @@ -4856,6 +4898,46 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tanstack/react-form": { + "version": "1.28.4", + "resolved": "https://registry.npmjs.org/@tanstack/react-form/-/react-form-1.28.4.tgz", + "integrity": "sha512-ZGBwl9JM2u0kol7jAWpqAkr2JSHfXJaLPsFDZWPf+ewpVkwngTTW/rGgtoDe5uVpHoDIpOhzpPCAh6O1SjGEOg==", + "license": "MIT", + "dependencies": { + "@tanstack/form-core": "1.28.4", + "@tanstack/react-store": "^0.9.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@tanstack/react-start": { + "optional": true + } + } + }, + "node_modules/@tanstack/react-form/node_modules/@tanstack/react-store": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.9.2.tgz", + "integrity": "sha512-Vt5usJE5sHG/cMechQfmwvwne6ktGCELe89Lmvoxe3LKRoFrhPa8OCKWs0NliG8HTJElEIj7PLtaBQIcux5pAQ==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "0.9.2", + "use-sync-external-store": "^1.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@tanstack/react-query": { "version": "5.90.21", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz", @@ -4872,6 +4954,16 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/store": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.9.2.tgz", + "integrity": "sha512-K013lUJEFJK2ofFQ/hZKJUmCnpcV00ebLyOyFOWQvyQHUOZp/iYO84BM6aOGiV81JzwbX0APTVmW8YI7yiG5oA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -18626,6 +18718,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/web/package.json b/web/package.json index 1b8cabd259..c87aab00e4 100644 --- a/web/package.json +++ b/web/package.json @@ -96,6 +96,7 @@ "@patternfly/patternfly": "^6.4.0", "@patternfly/react-core": "^6.4.1", "@patternfly/react-table": "^6.4.1", + "@tanstack/react-form": "^1.28.4", "@tanstack/react-query": "^5.90.21", "axios": "^1.13.6", "fast-sort": "^3.4.1", diff --git a/web/package/agama-web-ui.changes b/web/package/agama-web-ui.changes index 018fffdee4..9233bd1eb9 100644 --- a/web/package/agama-web-ui.changes +++ b/web/package/agama-web-ui.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Tue Apr 14 15:40:28 UTC 2026 - David Diaz + +- Redesign network connection form with improved user experience + (related to gh#agama-project/agama#3386 and bsc#1259067). + ------------------------------------------------------------------- Mon Apr 13 15:25:11 UTC 2026 - José Iván López González diff --git a/web/src/components/core/Interpolate.test.tsx b/web/src/components/core/Interpolate.test.tsx new file mode 100644 index 0000000000..363b4731fc --- /dev/null +++ b/web/src/components/core/Interpolate.test.tsx @@ -0,0 +1,190 @@ +/* + * Copyright (c) [2026] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { plainRender } from "~/test-utils"; +import Interpolate from "./Interpolate"; + +describe("Interpolate", () => { + describe("with a printf placeholder", () => { + it("renders surrounding text for %s", () => { + const { container } = plainRender( + {() => settings}, + ); + expect(container.textContent).toBe("Go to settings page"); + }); + + it("renders surrounding text for %d", () => { + const { container } = plainRender( + {() => 3}, + ); + expect(container.textContent).toBe("There are 3 issues"); + }); + + it("calls children with an empty string", () => { + const received: string[] = []; + plainRender( + + {(text) => { + received.push(text); + return {text}; + }} + , + ); + expect(received).toEqual([""]); + }); + + it("works when the placeholder is at the start", () => { + const { container } = plainRender( + {() => This}, + ); + expect(container.textContent).toBe("This is at the start"); + }); + + it("works when the placeholder is at the end", () => { + const { container } = plainRender( + {() => here}, + ); + expect(container.textContent).toBe("At the end: here"); + }); + + it("works when the whole sentence is the placeholder", () => { + const { container } = plainRender( + {() => everything}, + ); + expect(container.textContent).toBe("everything"); + }); + + it("throws when multiple placeholders are present", () => { + expect(() => + plainRender( + {() => X}, + ), + ).toThrow("Interpolate: only one printf placeholder is supported."); + }); + }); + + describe("with a [marker] placeholder", () => { + it("renders the surrounding text", () => { + const { container } = plainRender( + + {(text) => {text}} + , + ); + expect(container.textContent).toBe("Go to settings page"); + }); + + it("passes the extracted text to children", () => { + const { container } = plainRender( + + {(text) => {text}} + , + ); + expect(container.querySelector("strong")).toHaveTextContent("settings"); + }); + + it("works when the placeholder is at the start", () => { + const { container } = plainRender( + + {(text) => {text}} + , + ); + expect(container.textContent).toBe("Start of the sentence"); + expect(container.querySelector("strong")).toHaveTextContent("Start"); + }); + + it("works when the placeholder is at the end", () => { + const { container } = plainRender( + + {(text) => {text}} + , + ); + expect(container.textContent).toBe("End of the sentence"); + expect(container.querySelector("strong")).toHaveTextContent("sentence"); + }); + + it("works when the whole sentence is the placeholder", () => { + const { container } = plainRender( + {(text) => {text}}, + ); + expect(container.textContent).toBe("only"); + expect(container.querySelector("strong")).toHaveTextContent("only"); + }); + + it("passes an empty string to children when the brackets are empty", () => { + const { container } = plainRender( + + {(text) => {text}} + , + ); + expect(container.querySelector("strong")).toBeEmptyDOMElement(); + }); + + it("throws when multiple placeholders are present", () => { + expect(() => + plainRender( + + {(text) => {text}} + , + ), + ).toThrow("Interpolate: exactly one [marker] placeholder is supported."); + }); + + it("throws when a bracket is unmatched", () => { + expect(() => + plainRender( + {(text) => {text}}, + ), + ).toThrow("Interpolate: exactly one [marker] placeholder is supported."); + }); + }); + + describe("without a placeholder", () => { + it("renders the sentence as plain text", () => { + const { container } = plainRender( + + {(text) => {text}} + , + ); + expect(container.textContent).toBe("No placeholder here"); + }); + + it("does not render any injected content", () => { + const { container } = plainRender( + + {(text) => {text}} + , + ); + expect(container.querySelector("strong")).toBeNull(); + }); + }); + + describe("when children returns null", () => { + it("renders the surrounding text without the injected node", () => { + const { container } = plainRender( + {() => null}, + ); + expect(container.textContent).toBe("Before after"); + expect(container.querySelector("strong")).toBeNull(); + }); + }); +}); diff --git a/web/src/components/core/Interpolate.tsx b/web/src/components/core/Interpolate.tsx new file mode 100644 index 0000000000..745917fb5d --- /dev/null +++ b/web/src/components/core/Interpolate.tsx @@ -0,0 +1,110 @@ +/* + * Copyright (c) [2026] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; + +export type InterpolateProps = { + /** + * A translated sentence containing exactly one placeholder. + */ + sentence: string; + /** + * Render prop called with the text extracted from the placeholder. + * + * For `[marker]` sentences the text is whatever the translator wrote inside + * the brackets. For printf sentences the text is always an empty string. + */ + children: (text: string) => React.ReactNode; +}; + +/** + * Renders a translated sentence that contains a single placeholder, replacing + * it with arbitrary React content via a render prop. + * + * This is the standard way to inject React elements (links, bold text, etc.) + * into a translated string without breaking the translation unit. Keeping the + * full sentence as one string lets translators reorder words freely. + * + * Two placeholder styles are supported: + * + * - `%s` / `%d` / `%f` / `%i`: standard gettext printf placeholders. Use these + * when the injected element has its own content (e.g. a link wrapping a + * dynamic count) and the translation already uses a printf specifier. + * - `[marker]`: the text inside the brackets is extracted and passed to + * `children`. Use this when the translated string already carries the text + * for the injected element and you want to wrap it in a React node. + * + * Only one placeholder per sentence is supported. Passing more than one (or an + * unmatched bracket) throws an error. A sentence with no placeholder is + * rendered as plain text. + * + * @example + * // %d: caller supplies the full content; text argument is always "". + * + * {() => {count}} + * + * + * @example + * // [marker]: extracted text becomes the element content. + * + * {(text) => {text}} + * + * + * @example + * // [marker]: inline action embedded in helper text. + * + * {(text) => } + * + */ +export default function Interpolate({ sentence, children }: InterpolateProps) { + // Supported printf specifiers: %s (string), %d (decimal), %f (float), %i (integer). + const printfParts = sentence.split(/%[sdfi]/); + + if (printfParts.length > 1) { + if (printfParts.length > 2) + throw new Error("Interpolate: only one printf placeholder is supported."); + const [start, end] = printfParts; + return ( + <> + {start} + {children("")} + {end} + + ); + } + + const parts = sentence.split(/[[\]]/); + + if (parts.length === 1) return <>{sentence}; + + if (parts.length !== 3) + throw new Error("Interpolate: exactly one [marker] placeholder is supported."); + + const [start, text, end] = parts; + return ( + <> + {start} + {children(text ?? "")} + {end} + + ); +} diff --git a/web/src/components/form/ArrayField.test.tsx b/web/src/components/form/ArrayField.test.tsx new file mode 100644 index 0000000000..7a06dade8e --- /dev/null +++ b/web/src/components/form/ArrayField.test.tsx @@ -0,0 +1,446 @@ +/* + * Copyright (c) [2026] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { screen } from "@testing-library/react"; +import { installerRender } from "~/test-utils"; +import { useAppForm } from "~/hooks/form"; + +type TestFormProps = { + defaultValues?: string[]; + validateOnChange?: (v: string) => string | undefined; + validateOnSubmit?: (v: string) => string | undefined; + skipDuplicates?: boolean; + helperText?: string; + /** Simulates a TanStack Form field-level error returned by onSubmitAsync. */ + fieldError?: string; +}; + +function TestForm({ + defaultValues = [], + validateOnChange, + validateOnSubmit, + skipDuplicates = false, + helperText, + fieldError, +}: TestFormProps) { + const form = useAppForm({ + defaultValues: { tags: defaultValues }, + validators: { + onSubmitAsync: fieldError ? async () => ({ fields: { tags: fieldError } }) : undefined, + }, + }); + + return ( + +
{ + e.preventDefault(); + form.handleSubmit(); + }} + > + + {(field) => ( + + )} + + + +
+
+ ); +} + +describe("ArrayField", () => { + it("renders label and usage", () => { + installerRender(); + screen.getByText("Tags"); + screen.getByText(/Enter or Tab to add/); + }); + + it("renders given existing values", () => { + installerRender(); + screen.getByText("alpha"); + screen.getByText("beta"); + }); + + describe("adding entries", () => { + it("adds a value on Enter", async () => { + const { user } = installerRender(); + await user.type(screen.getByRole("textbox", { name: "Tags" }), "gamma"); + await user.keyboard("{Enter}"); + screen.getByText("gamma"); + }); + + it("adds a value on Tab", async () => { + const { user } = installerRender(); + await user.type(screen.getByRole("textbox", { name: "Tags" }), "delta"); + await user.tab(); + screen.getByText("delta"); + }); + + it("commits the draft on blur", async () => { + const { user } = installerRender(); + await user.type(screen.getByRole("textbox", { name: "Tags" }), "epsilon"); + await user.click(screen.getByRole("button", { name: "Other" })); + screen.getByText("epsilon"); + }); + + it("does not commit an empty draft on blur", async () => { + const { user } = installerRender(); + await user.click(screen.getByRole("textbox", { name: "Tags" })); + await user.click(screen.getByRole("button", { name: "Other" })); + expect(screen.getAllByRole("option")).toHaveLength(1); + }); + }); + + describe("removing entries", () => { + it("removes a value via the entry removal button", async () => { + const { user } = installerRender(); + await user.click(screen.getByRole("button", { name: "Remove alpha" })); + expect(screen.queryByText("alpha")).not.toBeInTheDocument(); + }); + }); + + describe("editing entries", () => { + it("moves an entry to the draft input when clicked", async () => { + const { user } = installerRender(); + const input = screen.getByRole("textbox", { name: "Tags" }); + await user.click(screen.getByRole("option", { name: "alpha" })); + expect(screen.queryByRole("option", { name: "alpha" })).not.toBeInTheDocument(); + expect(input).toHaveValue("alpha"); + }); + + it("puts the active entry back in the input on Enter", async () => { + const { user } = installerRender(); + const input = screen.getByRole("textbox", { name: "Tags" }); + await user.click(input); + await user.keyboard("{ArrowLeft}{Enter}"); + expect(screen.queryByRole("option", { name: "alpha" })).not.toBeInTheDocument(); + expect(input).toHaveValue("alpha"); + }); + + it("puts the active entry back in the input on Space", async () => { + const { user } = installerRender(); + const input = screen.getByRole("textbox", { name: "Tags" }); + await user.click(input); + await user.keyboard("{ArrowLeft}"); + await user.keyboard(" "); + expect(screen.queryByRole("option", { name: "alpha" })).not.toBeInTheDocument(); + expect(input).toHaveValue("alpha"); + }); + }); + + describe("validateOnChange", () => { + const validateOnChange = (v: string) => + v.startsWith("x") ? "Must not start with x" : undefined; + + it("marks an invalid entry immediately after adding", () => { + installerRender(); + expect(screen.getByRole("option", { name: /invalid/ })).toBeInTheDocument(); + }); + + it("shows the error block when there are invalid entries", () => { + installerRender(); + screen.getByText(/Select entries to edit or remove them/); + }); + + it("removes all invalid entries when clicking the clear button", async () => { + const { user } = installerRender( + , + ); + await user.click(screen.getByRole("button", { name: /remove all invalid entries/i })); + screen.getByText("ok"); + expect(screen.queryByText("xbad")).not.toBeInTheDocument(); + }); + + it("opens an invalid entry for editing on Delete instead of removing it", async () => { + const { user } = installerRender( + , + ); + const input = screen.getByRole("textbox", { name: "Tags" }); + await user.click(input); + await user.keyboard("{ArrowLeft}{Delete}"); + expect(screen.queryByRole("option", { name: "xbad" })).not.toBeInTheDocument(); + expect(input).toHaveValue("xbad"); + }); + }); + + describe("validateOnSubmit", () => { + const validateOnSubmit = (v: string) => + v.startsWith("x") ? "Must not start with x" : undefined; + + it("does not mark entries as invalid before submitting", () => { + installerRender(); + expect(screen.queryByRole("option", { name: /invalid/ })).not.toBeInTheDocument(); + }); + + it("marks entries as invalid after a failed submit", async () => { + const { user } = installerRender( + , + ); + await user.click(screen.getByRole("button", { name: "Submit" })); + await screen.findByRole("option", { name: /invalid/ }); + }); + + it("shows the error block after a failed submit", async () => { + const { user } = installerRender( + , + ); + await user.click(screen.getByRole("button", { name: "Submit" })); + await screen.findByText(/Select entries to edit or remove them/); + }); + + it("removes all invalid entries after a failed submit", async () => { + const { user } = installerRender( + , + ); + await user.click(screen.getByRole("button", { name: "Submit" })); + await user.click(await screen.findByRole("button", { name: /remove all invalid entries/i })); + screen.getByText("ok"); + expect(screen.queryByText("xbad")).not.toBeInTheDocument(); + }); + }); + + describe("helperText", () => { + it("shows helper text always, even when there are no errors", () => { + installerRender(); + screen.getByText(/Some hint/); + }); + + it("shows helper text alongside the error block when there are invalid entries", () => { + const validateOnChange = (v: string) => (v === "bad" ? "Invalid" : undefined); + installerRender( + , + ); + screen.getByText(/Some hint/); + }); + }); + + describe("skipDuplicates", () => { + it("does not add an entry already in the list", async () => { + const { user } = installerRender(); + await user.type(screen.getByRole("textbox", { name: "Tags" }), "alpha"); + await user.keyboard("{Enter}"); + expect(screen.getAllByText("alpha")).toHaveLength(1); + }); + + it("skips duplicates when pasting", async () => { + const { user } = installerRender(); + await user.click(screen.getByRole("textbox", { name: "Tags" })); + await user.paste("alpha beta"); + expect(screen.getAllByRole("option")).toHaveLength(2); + screen.getByText("beta"); + }); + }); + + describe("paste", () => { + it("adds multiple entries from a paste", async () => { + const { user } = installerRender(); + await user.click(screen.getByRole("textbox", { name: "Tags" })); + await user.paste("alpha beta gamma"); + screen.getByText("alpha"); + screen.getByText("beta"); + screen.getByText("gamma"); + }); + + it("does not intercept a single-token paste", async () => { + const { user } = installerRender(); + const input = screen.getByRole("textbox", { name: "Tags" }); + await user.click(input); + await user.paste("alpha"); + expect(input).toHaveValue("alpha"); + expect(screen.queryByRole("option", { name: "alpha" })).not.toBeInTheDocument(); + }); + }); + + describe("keyboard navigation", () => { + it("does not activate navigation on Backspace when the draft is not empty", async () => { + const { user } = installerRender(); + await user.type(screen.getByRole("textbox", { name: "Tags" }), "partial"); + await user.keyboard("{Backspace}"); + expect(screen.getByRole("option", { name: "alpha" })).toHaveAttribute( + "aria-selected", + "false", + ); + }); + + it("does not activate navigation on ArrowLeft when the draft is not empty", async () => { + const { user } = installerRender(); + await user.type(screen.getByRole("textbox", { name: "Tags" }), "partial"); + await user.keyboard("{ArrowLeft}"); + expect(screen.getByRole("option", { name: "alpha" })).toHaveAttribute( + "aria-selected", + "false", + ); + }); + + it("activates the last entry on Backspace when the draft is empty", async () => { + const { user } = installerRender(); + await user.click(screen.getByRole("textbox", { name: "Tags" })); + await user.keyboard("{Backspace}"); + expect(screen.getByRole("option", { name: "alpha" })).toHaveAttribute( + "aria-selected", + "true", + ); + }); + + it("activates the last entry on ArrowLeft when the draft is empty", async () => { + const { user } = installerRender(); + await user.click(screen.getByRole("textbox", { name: "Tags" })); + await user.keyboard("{ArrowLeft}"); + expect(screen.getByRole("option", { name: "alpha" })).toHaveAttribute( + "aria-selected", + "true", + ); + }); + + it("removes the active entry on Delete", async () => { + const { user } = installerRender(); + await user.click(screen.getByRole("textbox", { name: "Tags" })); + await user.keyboard("{ArrowLeft}{Delete}"); + expect(screen.queryByText("alpha")).not.toBeInTheDocument(); + }); + + it("removes the active entry on Backspace", async () => { + const { user } = installerRender(); + await user.click(screen.getByRole("textbox", { name: "Tags" })); + await user.keyboard("{ArrowLeft}{Backspace}"); + expect(screen.queryByText("alpha")).not.toBeInTheDocument(); + }); + + it("moves to the previous entry on ArrowLeft", async () => { + const { user } = installerRender(); + await user.click(screen.getByRole("textbox", { name: "Tags" })); + await user.keyboard("{Backspace}"); + expect(screen.getByRole("option", { name: "beta" })).toHaveAttribute("aria-selected", "true"); + await user.keyboard("{ArrowLeft}"); + expect(screen.getByRole("option", { name: "alpha" })).toHaveAttribute( + "aria-selected", + "true", + ); + }); + + it("moves to the previous entry on ArrowUp", async () => { + const { user } = installerRender(); + await user.click(screen.getByRole("textbox", { name: "Tags" })); + await user.keyboard("{Backspace}{ArrowUp}"); + expect(screen.getByRole("option", { name: "alpha" })).toHaveAttribute( + "aria-selected", + "true", + ); + }); + + it("moves to the next entry on ArrowRight", async () => { + const { user } = installerRender(); + await user.click(screen.getByRole("textbox", { name: "Tags" })); + await user.keyboard("{Backspace}{ArrowLeft}"); + expect(screen.getByRole("option", { name: "alpha" })).toHaveAttribute( + "aria-selected", + "true", + ); + await user.keyboard("{ArrowRight}"); + expect(screen.getByRole("option", { name: "beta" })).toHaveAttribute("aria-selected", "true"); + }); + + it("moves to the next entry on ArrowDown", async () => { + const { user } = installerRender(); + await user.click(screen.getByRole("textbox", { name: "Tags" })); + await user.keyboard("{Backspace}{ArrowLeft}{ArrowDown}"); + expect(screen.getByRole("option", { name: "beta" })).toHaveAttribute("aria-selected", "true"); + }); + + it("deactivates navigation on ArrowRight at the last entry", async () => { + const { user } = installerRender(); + await user.click(screen.getByRole("textbox", { name: "Tags" })); + await user.keyboard("{Backspace}{ArrowRight}"); + expect(screen.getByRole("option", { name: "alpha" })).toHaveAttribute( + "aria-selected", + "false", + ); + }); + + it("jumps to the first entry on Home", async () => { + const { user } = installerRender(); + await user.click(screen.getByRole("textbox", { name: "Tags" })); + await user.keyboard("{Backspace}{Home}"); + expect(screen.getByRole("option", { name: "alpha" })).toHaveAttribute( + "aria-selected", + "true", + ); + }); + + it("jumps to the last entry on End", async () => { + const { user } = installerRender(); + await user.click(screen.getByRole("textbox", { name: "Tags" })); + await user.keyboard("{ArrowLeft}{Home}{End}"); + expect(screen.getByRole("option", { name: "beta" })).toHaveAttribute("aria-selected", "true"); + }); + + it("deactivates navigation on Escape", async () => { + const { user } = installerRender(); + await user.click(screen.getByRole("textbox", { name: "Tags" })); + await user.keyboard("{Backspace}{Escape}"); + expect(screen.getByRole("option", { name: "alpha" })).toHaveAttribute( + "aria-selected", + "false", + ); + }); + + it("deactivates navigation on Tab", async () => { + const { user } = installerRender(); + await user.click(screen.getByRole("textbox", { name: "Tags" })); + await user.keyboard("{Backspace}"); + expect(screen.getByRole("option", { name: "alpha" })).toHaveAttribute( + "aria-selected", + "true", + ); + await user.tab(); + expect(screen.getByRole("option", { name: "alpha" })).toHaveAttribute( + "aria-selected", + "false", + ); + }); + }); +}); diff --git a/web/src/components/form/ArrayField.tsx b/web/src/components/form/ArrayField.tsx new file mode 100644 index 0000000000..f1d82218f5 --- /dev/null +++ b/web/src/components/form/ArrayField.tsx @@ -0,0 +1,676 @@ +/* + * Copyright (c) [2026] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React, { useState, useRef } from "react"; +import { fork, sift, unique } from "radashi"; +import { sprintf } from "sprintf-js"; +import { + FormGroup, + Label, + TextInputGroup, + TextInputGroupMain, + FormHelperText, + HelperText, + HelperTextItem, + Button, +} from "@patternfly/react-core"; +import Text from "~/components/core/Text"; +import Interpolate from "~/components/core/Interpolate"; +import { useFieldContext } from "~/hooks/form-contexts"; +import { _ } from "~/i18n"; + +/** + * Keys owned by the entry navigation handler when an entry is active. + * + * Space is included alongside Enter to match the ARIA listbox pattern + * (https://www.w3.org/WAI/ARIA/apg/patterns/listbox/), where both keys + * activate the focused option. Any key outside this set exits navigation + * without consuming the event, so Tab moves focus away and regular characters + * land in the draft input normally. + */ +const NAVIGATION_KEYS = new Set([ + " ", + "ArrowLeft", + "ArrowUp", + "ArrowRight", + "ArrowDown", + "Home", + "End", + "Enter", + "Delete", + "Backspace", +]); + +/** Applies `normalize` to `value` if provided; otherwise returns `value` unchanged. */ +function normalizeValue(value: string, normalize?: (v: string) => string): string { + return normalize ? normalize(value) : value; +} + +/** + * Trims, normalizes, and optionally validates a raw draft string. + * + * Returns `null` for empty or whitespace-only input so callers can skip + * adding an empty entry. Otherwise returns the normalized value and any + * validation error. + */ +function processDraft( + raw: string, + normalize?: (v: string) => string, + validate?: (v: string) => string | undefined, +): { normalized: string; error: string | undefined } | null { + const trimmed = raw.trim(); + if (!trimmed) return null; + const normalized = normalizeValue(trimmed, normalize); + return { normalized, error: validate?.(normalized) }; +} + +/** Builds the screen-reader announcement for a multi-entry paste. Pure function. */ +function pasteAnnouncement( + added: number, + skipped: number, + valid: string[], + invalid: string[], +): string { + // TRANSLATORS: %d will be replaced with a number of duplicate entries skipped. + if (added === 0) return sprintf(_("%d duplicates skipped."), skipped); + + if (skipped === 0) { + return invalid.length === 0 + ? // TRANSLATORS: %d will be replaced with a number of added entries. + sprintf(_("%d entries added."), valid.length) + : // TRANSLATORS: first %d is the number of added entries, second %d is + // the number of invalid entries. + sprintf(_("%d entries added, %d invalid."), added, invalid.length); + } + + if (invalid.length === 0) + // TRANSLATORS: first %d is the number of added entries, second %d is the number of duplicate entries skipped. + return sprintf(_("%d entries added, %d duplicates skipped."), valid.length, skipped); + + // TRANSLATORS: first %d is the number of added entries, second %d is the number of invalid entries, third %d is the number of duplicates skipped. + return sprintf( + _("%d entries added, %d invalid, %d duplicates skipped."), + added, + invalid.length, + skipped, + ); +} + +/** Splits pasted text on whitespace and commas, returning non-empty entries. */ +function parsePasteEntries(text: string): string[] { + return sift(text.split(/[\s,]+/).map((t) => t.trim())); +} + +/** + * Returns entries from `normalized` not already in `existing`, + * also deduplicating within `normalized` itself. + * + * Prepends `existing` before deduplication so `unique` sees existing entries + * first and drops any later occurrence of the same value. Slicing off the + * first `existing.length` elements then yields only the genuinely new entries. + */ +function filterNew(existing: string[], normalized: string[]): string[] { + return unique([...existing, ...normalized]).slice(existing.length); +} + +type EntryProps = { + /** Raw stored value, not necessarily the display form. */ + item: string; + index: number; + /** Whether this entry is currently focused during keyboard navigation. */ + isActive: boolean; + /** Validation error message; undefined means the entry is valid. */ + error?: string; + /** Formats the raw value for display and aria labels. */ + toLabel: (v: string) => string; + onEdit: (index: number) => void; + onRemove: (index: number) => void; + /** Returns a stable DOM id used for aria-activedescendant. */ + valueId: (index: number) => string; +}; + +/** + * A single committed entry, rendered as a listbox option. + * + * Both the visual color and the aria-label carry validation state, so + * sighted and assistive-technology users receive the same information. + */ +function Entry({ item, index, isActive, error, toLabel, onEdit, onRemove, valueId }: EntryProps) { + // preventDefault keeps focus on the input; the edit moves the value back to draft. + const handleMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); + onEdit(index); + }; + + // preventDefault avoids blur; stopPropagation prevents the span from triggering edit. + const handleCloseMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + }; + + const handleRemove = (e: React.MouseEvent) => { + e.stopPropagation(); + onRemove(index); + }; + + const labelText = toLabel(item); + + return ( + + + + ); +} + +type MultiValueFieldProps = { + /** + * Label rendered by PatternFly's FormGroup. + * + * Can be a plain string or a ReactNode (e.g. `LabelText` with a suffix). + * When a ReactNode is passed, also provide `inputAriaLabel` so assistive + * technologies receive a plain-text version of the label. + */ + label: React.ReactNode; + + /** + * Plain-text label for assistive technologies. + * + * Used as the accessible name of the text input and as the base for the + * listbox accessible name. Inferred from `label` when it is a plain string; + * required when `label` is a ReactNode. + */ + inputAriaLabel?: string; + + /** + * Per-entry validator that runs on every commit. + * + * Invalid entries are marked and announced immediately, before the form is + * submitted. Use for format checks that are always safe to run eagerly, + * such as address or URL format. + */ + validateOnChange?: (value: string) => string | undefined; + + /** + * Per-entry validator that runs only after the first failed form submit. + * + * Stays silent until TanStack Form sets a field-level error on this field. + * Use for checks that would be distracting before the user attempts to + * submit, such as cross-field or server-validated rules. + */ + validateOnSubmit?: (value: string) => string | undefined; + + /** + * Normalizes user input before it is committed. + * + * Runs on every added entry, including pasted ones. Use for trimming, + * casing, or any formatting rule applied at commit time. + */ + normalize?: (value: string) => string; + + /** + * Formats a stored value for display and accessible labels. + * + * When omitted, the stored value is used as-is. Useful when the stored + * form differs from the human-readable form, e.g. a code vs. a name. + */ + displayValue?: (value: string) => string; + + /** + * Converts a stored value back to a draft string for editing. + * + * Called when an entry is moved into the text input for modification. + * When omitted, the stored value is used as-is. + */ + toDraft?: (value: string) => string; + + /** + * When true, skips entries that are already in the list. + * + * Applies to both single commits and multi-token pastes. + */ + skipDuplicates?: boolean; + + /** + * Additional guidance shown alongside the error messages. + * + * Only rendered when the field has errors. Use to explain the expected + * format or other context that helps the user fix invalid entries. + */ + helperText?: React.ReactNode; + + /** Disables the text input and all entry interactions. */ + isDisabled?: boolean; +}; + +/** + * A form field for entering and managing a list of string values. + * + * Users type in a text input and commit entries one at a time via Enter, + * Tab, or blur. Each committed entry appears as a labeled token inside the + * field. Entries can be edited by clicking them or selecting them with the + * keyboard, and removed individually via the close button or Backspace/Delete. + * A clear-invalid button removes all invalid entries at once when any are present. + * + * Keyboard navigation follows the ARIA listbox pattern: ArrowLeft/Right/Up/Down + * move between entries, Enter and Space activate the focused one, Home/End jump + * to the first or last, and Escape exits navigation. Pasting a whitespace- or + * comma-separated string adds all tokens at once. + * + * Two validation modes are available: + * - `validateOnChange`: runs on every commit; marks invalid entries right away. + * - `validateOnSubmit`: runs only after TanStack Form sets a field-level error + * (i.e. after the first failed submit attempt); stays silent until then. + * + * Both sighted and assistive-technology users receive equivalent feedback: + * invalid entries carry an aria-label describing the error; add, edit, and + * remove actions are announced via a live region. + * + * Must be used inside a TanStack Form `AppField` context that provides a + * `string[]` field value. + * + * @remarks + * **Keyboard focus model** + * + * This component uses `aria-activedescendant` instead of roving tabIndex. + * + * The MDN guide on keyboard-navigable widgets + * (https://developer.mozilla.org/en-US/docs/Web/Accessibility/Guides/Keyboard-navigable_JavaScript_widgets) + * describes two approaches: roving tabIndex (move DOM focus between elements) + * and `aria-activedescendant` (keep DOM focus on one element and point to the + * active descendant by id). + * + * Roving tabIndex was tried first but did not work reliably here. When an + * `onClick` prop is passed to PatternFly's `Label` component, PF renders the + * label content inside a ` + )} + + + )} + + )} + + + + ); +} diff --git a/web/src/components/form/CancelButton.test.tsx b/web/src/components/form/CancelButton.test.tsx new file mode 100644 index 0000000000..aad3a5e3ba --- /dev/null +++ b/web/src/components/form/CancelButton.test.tsx @@ -0,0 +1,44 @@ +/* + * Copyright (c) [2026] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { screen } from "@testing-library/react"; +import { installerRender, mockNavigateFn } from "~/test-utils"; +import CancelButton from "~/components/form/CancelButton"; + +jest.mock("react-router", () => ({ + ...jest.requireActual("react-router"), + useNavigate: () => mockNavigateFn, +})); + +describe("CancelButton", () => { + it("renders a Cancel button", () => { + installerRender(); + screen.getByRole("button", { name: "Cancel" }); + }); + + it("navigates back when clicked", async () => { + const { user } = installerRender(); + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect(mockNavigateFn).toHaveBeenCalledWith(-1); + }); +}); diff --git a/web/src/components/form/CancelButton.tsx b/web/src/components/form/CancelButton.tsx new file mode 100644 index 0000000000..2800c53b6b --- /dev/null +++ b/web/src/components/form/CancelButton.tsx @@ -0,0 +1,51 @@ +/* + * Copyright (c) [2026] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { Button } from "@patternfly/react-core"; +import { useNavigate } from "react-router"; +import { _ } from "~/i18n"; + +/** + * A Cancel button for use inside a form's action group. + * + * Navigates back to the previous page when clicked. Registered as a form + * component so it is available as `form.CancelButton`, keeping navigation + * logic out of form components. + * + * @example + * + * + * + * + */ +export default function CancelButton() { + const navigate = useNavigate(); + return ( + + ); +} diff --git a/web/src/components/form/CheckboxField.test.tsx b/web/src/components/form/CheckboxField.test.tsx new file mode 100644 index 0000000000..63e775a346 --- /dev/null +++ b/web/src/components/form/CheckboxField.test.tsx @@ -0,0 +1,61 @@ +/* + * Copyright (c) [2026] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { screen } from "@testing-library/react"; +import { installerRender } from "~/test-utils"; +import { useAppForm } from "~/hooks/form"; + +function CheckboxFieldForm({ initChecked = false }: { initChecked?: boolean }) { + const form = useAppForm({ defaultValues: { licenseAccepted: initChecked } }); + + return ( + + + {(field) => } + + + ); +} + +describe("CheckboxField", () => { + it("renders the label", () => { + installerRender(); + expect(screen.getByLabelText("Accept license")).toBeInTheDocument(); + }); + + it("is unchecked when the value is false", () => { + installerRender(); + expect(screen.getByLabelText("Accept license")).not.toBeChecked(); + }); + + it("is checked when the value is true", () => { + installerRender(); + expect(screen.getByLabelText("Accept license")).toBeChecked(); + }); + + it("toggles when clicked", async () => { + const { user } = installerRender(); + await user.click(screen.getByLabelText("Accept license")); + expect(screen.getByLabelText("Accept license")).toBeChecked(); + }); +}); diff --git a/web/src/components/form/CheckboxField.tsx b/web/src/components/form/CheckboxField.tsx new file mode 100644 index 0000000000..693070a6af --- /dev/null +++ b/web/src/components/form/CheckboxField.tsx @@ -0,0 +1,50 @@ +/* + * Copyright (c) [2026] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { Checkbox } from "@patternfly/react-core"; +import { useFieldContext } from "~/hooks/form-contexts"; + +type CheckboxFieldProps = { + label: string; + description?: string; +}; + +/** + * A checkbox tied to a TanStack Form field via `useFieldContext`. + * Must be used inside a `form.AppField` render prop. + * + * @see useFieldContext for field component conventions. + */ +export default function CheckboxField({ label, description }: CheckboxFieldProps) { + const field = useFieldContext(); + + return ( + field.handleChange(checked)} + /> + ); +} diff --git a/web/src/components/form/DropdownField.test.tsx b/web/src/components/form/DropdownField.test.tsx new file mode 100644 index 0000000000..9de2323fad --- /dev/null +++ b/web/src/components/form/DropdownField.test.tsx @@ -0,0 +1,74 @@ +/* + * Copyright (c) [2026] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { screen } from "@testing-library/react"; +import { installerRender } from "~/test-utils"; +import { useAppForm } from "~/hooks/form"; + +const OPTIONS = [ + { value: "default", label: "Default", description: "System manages this" }, + { value: "custom", label: "Custom", description: "Configure manually" }, +]; + +function TestForm({ defaultValue = "default" }: { defaultValue?: string }) { + const form = useAppForm({ defaultValues: { mode: defaultValue } }); + + return ( + + {(field) => ( + + {(value) => value === "custom" &&
Custom content
} +
+ )} +
+ ); +} + +describe("DropdownField", () => { + it("renders the label", () => { + installerRender(); + screen.getByText("IPv4 Settings"); + }); + + it("shows the selected option label", () => { + installerRender(); + screen.getByText("Custom"); + }); + + it("renders dependent content when the matching option is selected", () => { + installerRender(); + screen.getByText("Custom content"); + }); + + it("does not render dependent content when the option is not selected", () => { + installerRender(); + expect(screen.queryByText("Custom content")).not.toBeInTheDocument(); + }); + + it("renders dependent content when the user selects an option", async () => { + const { user } = installerRender(); + await user.click(screen.getByText("Default")); + await user.click(screen.getByText("Custom")); + screen.getByText("Custom content"); + }); +}); diff --git a/web/src/components/form/DropdownField.tsx b/web/src/components/form/DropdownField.tsx new file mode 100644 index 0000000000..4b4b4f3e64 --- /dev/null +++ b/web/src/components/form/DropdownField.tsx @@ -0,0 +1,150 @@ +/* + * Copyright (c) [2026] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React, { useState } from "react"; +import { + FormGroup, + MenuToggle, + MenuToggleElement, + Select, + SelectList, + SelectOption, +} from "@patternfly/react-core"; +import { useFieldContext } from "~/hooks/form-contexts"; + +export type DropdownOption = { + value: T; + label: React.ReactNode; + description?: React.ReactNode; + isDisabled?: boolean; +}; + +type DropdownFieldProps = { + /** The field label. */ + label: React.ReactNode; + /** The available options. */ + options: DropdownOption[]; + /** Optional helper text shown below the select. */ + helperText?: React.ReactNode; + isDisabled?: boolean; + /** + * Render prop for content that depends on the current value, such as + * nested fields that appear when a specific option is selected. + */ + children?: (value: T) => React.ReactNode; +}; + +/** + * A form field that renders a select tied to a TanStack Form field via + * `useFieldContext`. Must be used inside a `form.AppField` render prop. + * + * Supports a render prop `children` for dependent content that should appear + * or change based on the selected value. + * + * ## Implementation note: PatternFly menu, not a native select + * + * Despite looking similar to a native ``: arrow keys change the value immediately. + * - PatternFly `Select`: requires a two-step interaction — open the menu + * first (Enter, Space, or click), then navigate with arrow keys, then + * confirm with Enter. Values are not committed until confirmed. + * + * The two-step flow is intentional: on a native select, a screen reader user + * landing on the wrong option has already changed the form value before they + * could hear what it said. The W3C pattern separates navigation from + * selection to protect them. + * + * The W3C pattern does allow a middle ground — pressing ↓/↑ on a closed + * toggle should open the menu and focus the first or last item without + * committing a value. This component does not yet implement that behavior. + * + * TODO: implement arrow-key-opens-menu via a `useSelectKeyboard` hook. + * + * @see useFieldContext for field component conventions. + * + * @example + * + * {(field) => ( + * + * {(value) => value !== "unset" && } + * + * )} + * + */ +export default function DropdownField({ + label, + options, + helperText, + isDisabled = false, + children, +}: DropdownFieldProps) { + const field = useFieldContext(); + const [isOpen, setIsOpen] = useState(false); + const toggle = () => setIsOpen(!isOpen); + + const selectedOption = options.find(({ value }) => value === field.state.value); + + return ( + + + {helperText} + {children?.(field.state.value)} + + ); +} diff --git a/web/src/components/form/LabelText.test.tsx b/web/src/components/form/LabelText.test.tsx new file mode 100644 index 0000000000..d5eb198593 --- /dev/null +++ b/web/src/components/form/LabelText.test.tsx @@ -0,0 +1,42 @@ +/* + * Copyright (c) [2026] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { screen } from "@testing-library/react"; +import textStyles from "@patternfly/react-styles/css/utilities/Text/text"; +import { installerRender } from "~/test-utils"; +import LabelText from "~/components/form/LabelText"; + +describe("LabelText", () => { + it("renders the label text", () => { + installerRender(Gateway); + screen.getByText("Gateway"); + }); + + it("renders the suffix when provided", () => { + installerRender(Gateway); + const suffix = screen.getByText("(optional)"); + expect(suffix).toHaveClass(textStyles.textColorSubtle); + expect(suffix).toHaveClass(textStyles.fontSizeXs); + expect(suffix).toHaveClass(textStyles.fontWeightNormal); + }); +}); diff --git a/web/src/components/form/LabelText.tsx b/web/src/components/form/LabelText.tsx new file mode 100644 index 0000000000..81dc5c3952 --- /dev/null +++ b/web/src/components/form/LabelText.tsx @@ -0,0 +1,59 @@ +/* + * Copyright (c) [2026] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import Text from "~/components/core/Text"; + +interface LabelTextProps { + /** The main label text. */ + children: string; + /** + * An optional suffix, e.g. "(optional)". + * + * It is rendered in a subtler style to distinguish it from + * the main label text. + */ + suffix?: string; +} + +/** + * A form field label with an optional styled suffix. + * + * The suffix is rendered in a subtler color to visually distinguish it from + * the main label while keeping both accessible as a single label string. + * + * See `src/components/form/conventions.md` for guidance on when and how to + * use suffixes. + */ +export default function LabelText({ children, suffix }: LabelTextProps) { + return ( + <> + {children} + {suffix && ( + <> + {" "} + {suffix} + + )} + + ); +} diff --git a/web/src/components/form/SubmitButton.test.tsx b/web/src/components/form/SubmitButton.test.tsx new file mode 100644 index 0000000000..a41dde8b76 --- /dev/null +++ b/web/src/components/form/SubmitButton.test.tsx @@ -0,0 +1,54 @@ +/* + * Copyright (c) [2026] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { screen } from "@testing-library/react"; +import { installerRender } from "~/test-utils"; +import { useAppForm } from "~/hooks/form"; +import SubmitButton from "~/components/form/SubmitButton"; + +function TestForm({ label }: { label?: string }) { + const form = useAppForm({ defaultValues: {} }); + return ( + + + + ); +} + +describe("SubmitButton", () => { + it("renders a submit button with the default label", () => { + installerRender(); + expect(screen.getByRole("button", { name: "Accept" })).toHaveAttribute("type", "submit"); + }); + + it("renders a submit button with a custom label", () => { + installerRender(); + screen.getByRole("button", { name: "Save" }); + }); + + it("is enabled and not loading when the form is idle", () => { + installerRender(); + const button = screen.getByRole("button", { name: "Accept" }); + expect(button).not.toBeDisabled(); + }); +}); diff --git a/web/src/components/form/SubmitButton.tsx b/web/src/components/form/SubmitButton.tsx new file mode 100644 index 0000000000..ef32523526 --- /dev/null +++ b/web/src/components/form/SubmitButton.tsx @@ -0,0 +1,62 @@ +/* + * Copyright (c) [2026] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { Button } from "@patternfly/react-core"; +import { useFormContext } from "~/hooks/form-contexts"; +import { _ } from "~/i18n"; + +type SubmitButtonProps = { + /** Button label. Defaults to "Accept". */ + label?: React.ReactNode; +}; + +/** + * A submit button for use inside a form's action group. + * + * Reads `isSubmitting` from the form context to show a loading indicator + * and disable the button while the form is being submitted. Registered as + * a form component so it is available as `form.SubmitButton`, keeping + * submit state handling out of form components. + * + * @example + * + * + * + * + */ +export default function SubmitButton({ + // TRANSLATORS: label for the form submit button. + label = _("Accept"), +}: SubmitButtonProps) { + const form = useFormContext(); + + return ( + s.isSubmitting}> + {(isSubmitting) => ( + + )} + + ); +} diff --git a/web/src/components/form/TextField.test.tsx b/web/src/components/form/TextField.test.tsx new file mode 100644 index 0000000000..9f1f0e5dbf --- /dev/null +++ b/web/src/components/form/TextField.test.tsx @@ -0,0 +1,99 @@ +/* + * Copyright (c) [2026] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { screen } from "@testing-library/react"; +import { installerRender } from "~/test-utils"; +import { useAppForm } from "~/hooks/form"; + +function TextFieldForm({ + defaultValue = "", + helperText, +}: { + defaultValue?: string; + helperText?: string; +}) { + const form = useAppForm({ defaultValues: { text: defaultValue } }); + + return ( + +
{ + e.preventDefault(); + form.setErrorMap({ onSubmit: { fields: {} } }); + form.handleSubmit(); + }} + > + (!value ? "Text is required" : undefined) }} + > + {(field) => } + + +
+
+ ); +} + +describe("TextField", () => { + it("renders the label", () => { + installerRender(); + expect(screen.getByLabelText("My label")).toBeInTheDocument(); + }); + + it("shows the current value", () => { + installerRender(); + expect(screen.getByLabelText("My label")).toHaveValue("Hello"); + }); + + it("updates when the user types", async () => { + const { user } = installerRender(); + await user.type(screen.getByLabelText("My label"), "World"); + expect(screen.getByLabelText("My label")).toHaveValue("World"); + }); + + it("shows a validation error after a failed submit", async () => { + const { user } = installerRender(); + await user.click(screen.getByRole("button", { name: "Submit" })); + expect(await screen.findByText("Text is required")).toBeInTheDocument(); + }); + + describe("helperText", () => { + it("shows helper text when provided", () => { + installerRender(); + screen.getByText("E.g., example@example.com"); + }); + + it("does not show helper text when not provided", () => { + installerRender(); + expect(screen.queryByText("E.g.,")).not.toBeInTheDocument(); + }); + + it("shows both helper text and error when there is an error", async () => { + const { user } = installerRender(); + await user.click(screen.getByRole("button", { name: "Submit" })); + await screen.findByText("Text is required"); + screen.getByText("E.g., example@example.com"); + }); + }); +}); diff --git a/web/src/components/form/TextField.tsx b/web/src/components/form/TextField.tsx new file mode 100644 index 0000000000..159f42eeef --- /dev/null +++ b/web/src/components/form/TextField.tsx @@ -0,0 +1,72 @@ +/* + * Copyright (c) [2026] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { + FormGroup, + FormHelperText, + HelperText, + HelperTextItem, + TextInput, +} from "@patternfly/react-core"; +import Text from "~/components/core/Text"; +import { useFieldContext } from "~/hooks/form-contexts"; + +type TextFieldProps = { + label: React.ReactNode; + helperText?: React.ReactNode; +}; + +/** + * A text input tied to a TanStack Form field via `useFieldContext`. + * Must be used inside a `form.AppField` render prop. + * + * @see useFieldContext for field component conventions. + */ +export default function TextField({ label, helperText }: TextFieldProps) { + const field = useFieldContext(); + const error = field.state.meta.errors[0]; + + return ( + + field.handleChange(value)} + /> + {(error || helperText) && ( + + + {helperText && ( + + {helperText} + + )} + {error && {error}} + + + )} + + ); +} diff --git a/web/src/components/form/conventions.md b/web/src/components/form/conventions.md new file mode 100644 index 0000000000..a155d10a79 --- /dev/null +++ b/web/src/components/form/conventions.md @@ -0,0 +1,267 @@ +# Form conventions + +This document captures the conventions for building forms across the +application. It was written alongside the reimplementation of `ConnectionForm`, +which serves as the running example throughout. + +As more forms are reimplemented, this document should be updated with new +examples and refined patterns. + +## Core principle + +Show only what the user needs, when they need it. A form that shows fewer +fields is easier to fill, easier to understand, and less likely to confuse. +Every field that appears should have a clear reason to be there. + +## Patterns + +The patterns below are ordered from least to most intrusive on the user's +workflow. Prefer patterns near the top when possible. + +### 1. Required field, no suffix + +The field is always shown and always expected to have a value. If a sensible +default can be provided, pre-fill it. The user may change it or leave it as-is, +but it cannot be blank on submit. + +Note: a pre-filled field is still required. Do not add an `(optional)` suffix +just because the field has a default value. + +**Current example:** Name. It is always shown when creating a connection and +required on submit. A default is auto-generated from the selected binding mode +and device using a form-level `onChange` listener; the user may override it at +any time. Once the user edits the field manually, auto-generation stops: +`isDirty` is used rather than `isTouched` so that focusing and blurring without +changing the value does not disable the auto-generation. + +In edit mode the field is not rendered at all: the connection id cannot be +changed after creation, so offering it would be misleading. + +### 2. Always shown, optional or context-dependent + +The field is always visible. Use this when omitting the field would hurt +discoverability or when its label needs to reflect the current state of the +form. + +If the field is always optional, use the `(optional)` suffix. + +If the requirement depends on the state of other fields, use a clarifying +suffix that describes what is currently expected. This is a special case and +should be used sparingly: if you find yourself reaching for it often, the form +likely needs restructuring. + +**Not yet an example in `ConnectionForm`.** A future candidate might be a field +whose visibility is stable but whose requirement changes based on other +selections, and where omitting it would hurt discoverability. + +### 3. Conditionally shown, required when shown + +The field is not rendered until another field reaches a specific value. When it +appears, it is required. No suffix is needed: the user caused it to appear by +their own action, so its purpose is self-evident. + +**Example:** Device name and MAC address selectors. They are not rendered when +the binding mode is "Any". When the user selects "Chosen by name" or "Chosen by +MAC", the corresponding selector appears and must be filled in. Both selectors +are pre-filled with the first available device, but the field is still required +because omitting it would produce an incomplete connection profile. + +### 4. Conditionally shown, optional when shown + +The field is not rendered until another field reaches a specific value. When it +appears it can legitimately be left blank, so it carries the `(optional)` +suffix. When context warrants more explanation, the suffix can be made more +descriptive. For example, a gateway that would be silently dropped on +submission without accompanying addresses might carry `(optional, ignored if no +addresses provided)` instead. + +**Example:** IPv4 Gateway and IPv6 Gateway. They are not rendered when the +corresponding mode is Automatic. When the mode is Manual or Advanced, they +appear, but a gateway is not strictly required by NetworkManager even then. + +Showing the field directly, rather than behind a checkbox, is the right choice +here because the user has already made a related decision: they chose a +configuration mode. Asking them to also check a box to reveal the gateway would +be an extra step with no benefit. The field appears naturally as part of the +consequence of their choice. + +### 5. Choice selector (mode or behavior selection) + +A selector allows the user to choose _how_ a feature should behave rather than +whether a single value should be provided. Each option represents a complete +configuration mode. Selecting an option may reveal additional fields that +refine that choice. + +Unlike pattern 6, this is not an opt-in toggle. The user must always select one +option, and a sensible default should be preselected whenever possible. + +Use this pattern when: + +- multiple mutually exclusive configurations exist, +- the system already has a meaningful default behavior, +- omitting configuration entirely would make the form misleading or ambiguous. + +The selector communicates that the feature is active regardless of whether the +user customizes it. + +Revealed fields are a consequence of the selected option and follow earlier +patterns: + +- required fields use pattern 3, +- optional fields use pattern 4. + +Field values revealed by a choice must remain preserved in form state when the +user switches options. This allows experimentation without losing previously +entered data. + +**Example:** IPv4 Settings selector. + +- `Automatic`: address and gateway come from the network. No additional fields + rendered. +- `Manual`: fixed addressing. Addresses (required, no suffix) and gateway + (`(optional)`) are rendered. At least one address is required on submit. +- `Advanced`: automatic addressing with optional static overrides. Addresses + carry `(optional)` and gateway carries `(optional, ignored if no addresses +provided)`, because a gateway without any addresses is meaningless and dropped + on submission. + +This avoids the confusion of a checkbox such as "Configure IPv4", which may +suggest that no IP configuration exists unless enabled. + +**Example:** Device binding mode selector. + +- `Any`: the connection is available on all devices. No additional fields. +- `Chosen by name`: a device name selector appears (pattern 3). +- `Chosen by MAC`: a MAC address selector appears (pattern 3). + +#### Payload behavior + +The submitted payload only includes values that are meaningful for the current +selections. Binding fields are excluded when the binding mode does not use +them. IP addresses and gateway are excluded when the corresponding mode is +Automatic, and a gateway is additionally excluded when no addresses are +present, since a gateway without addresses has no effect. DNS fields are +excluded when their respective checkboxes are unchecked. + +The form keeps all values in state regardless, so switching modes never loses +what the user has already entered. + +### 6. Revealed by a checkbox + +A checkbox lets the user explicitly opt into providing a value. The field is +not rendered until the checkbox is checked. Once checked, the field is required +and validated on submit. No `(optional)` suffix: the user has signalled intent, +so leaving it blank is a mistake worth reporting. + +The field value is preserved in form state when the checkbox is unchecked so +re-checking restores what the user previously typed. + +Render the revealed content using `NestedContent` as a sibling after the +`Checkbox`, not via the `Checkbox` body prop. The body prop renders inside a +``, which is invalid HTML for block content like a form field. + +Use this pattern for advanced or rarely needed options that most users should +never see. Do not use it when the field is likely to be needed by the majority +of users: that just adds an unnecessary click. + +**Examples:** "Use custom DNS" checkbox reveals the DNS servers field. "Use +custom DNS search domains" checkbox reveals the DNS search domains field. + +## Accessibility notes + +### Fields without a visible label + +Sometimes a field has no visible label because its purpose is clear from the +surrounding context. Even then, every field needs an accessible name for screen +readers, voice control software, and browser translation tools. + +Ideally, a real `