diff --git a/web/src/components/form/ChoiceField.test.tsx b/web/src/components/form/ChoiceField.test.tsx new file mode 100644 index 0000000000..37de3a90d1 --- /dev/null +++ b/web/src/components/form/ChoiceField.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("ChoiceField", () => { + 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/ChoiceField.tsx b/web/src/components/form/ChoiceField.tsx new file mode 100644 index 0000000000..e95d5c5130 --- /dev/null +++ b/web/src/components/form/ChoiceField.tsx @@ -0,0 +1,125 @@ +/* + * 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 ChoiceOption = { + value: T; + label: React.ReactNode; + description?: React.ReactNode; + isDisabled?: boolean; +}; + +type ChoiceFieldProps = { + /** The field label. */ + label: React.ReactNode; + /** The available options. */ + options: ChoiceOption[]; + /** 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.Field` render prop. + * + * Supports a render prop `children` for dependent content that should appear + * or change based on the selected value. + * + * @example + * + * {(field) => ( + * + * {(value) => value !== "unset" && } + * + * )} + * + */ +export default function ChoiceField({ + label, + options, + helperText, + isDisabled = false, + children, +}: ChoiceFieldProps) { + 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/conventions.md b/web/src/components/form/conventions.md index db02fad446..c09cf02494 100644 --- a/web/src/components/form/conventions.md +++ b/web/src/components/form/conventions.md @@ -29,64 +29,116 @@ 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:** Interface. It is always needed and defaults to the first -available device. +**Current example:** Name. It is always shown and required on submit. An +auto-generated default derived from the selected device is not yet implemented. -**Not yet an example:** Name. It will have an auto-generated default derived -from the selected interface, but that is not implemented yet. +### 2. Always shown, optional or context-dependent -### 2. Conditionally shown, required when shown +The field is always visible. Use this when hiding 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 hiding it would hurt discoverability. + +### 3. Conditionally shown, required when shown The field is hidden 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:** not yet present in `ConnectionForm`. A future example might be a -port or VLAN identifier field that appears only when a specific connection type -is selected and must be filled in. +**Example:** Device name and MAC address selectors. They are hidden 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. -### 3. Conditionally shown, optional when shown +### 4. Conditionally shown, optional when shown The field is hidden until another field reaches a specific value. When it appears it can legitimately be left blank, so it carries the `(optional)` suffix. **Example:** IPv4 Gateway and IPv6 Gateway. They are hidden when the -corresponding method is automatic. When the method is set to manual they -appear, but even then a gateway is not strictly required by NetworkManager. +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 manual -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. +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. -### 4. Always shown, optional or context-dependent +### 5. Choice selector (mode or behavior selection) -The field is always visible. Use this when hiding the field would hurt -discoverability or when its label needs to reflect the current state of the -form. +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. -If the field is always optional, use the `(optional)` suffix. +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. -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. +Use this pattern when: + +- multiple mutually exclusive configurations exist, +- the system already has a meaningful default behavior, +- hiding 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 shown. +- `Manual` — fixed addressing. Addresses and gateway fields appear; the + gateway is optional, the addresses field has no suffix but required + validation is not yet enforced. +- `Advanced` — automatic addressing, with optional addresses and gateway + added on top of what the network provides. + +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. -**Example:** IP Addresses. It is always shown because it can become effectively -required depending on the selected method. The label suffix adapts: -`(optional)` when both methods are automatic, `(IPv4 required)` when only IPv4 -is manual, and so on. +- `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). -### 5. Hidden behind a checkbox +#### Payload behavior + +When a selector represents a default or automatic mode, additional fields +related to other modes might not be included in the submitted payload. The +frontend keeps their values only for user convenience when switching modes. + +### 6. Hidden behind a checkbox A checkbox lets the user explicitly opt into providing a value. The field is hidden 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 +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 @@ -97,68 +149,74 @@ 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. -**Example:** "Use custom DNS servers" checkbox reveals the DNS Servers field. -Most users rely on automatic DNS and will never check this box. +**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, such as a textarea inside a checkbox body. Even then, -every field needs an accessible name for screen readers, voice control software, -and browser translation tools. +the surrounding context. Even then, every field needs an accessible name for +screen readers, voice control software, and browser translation tools. Ideally, a real `