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 (
+
+ {
+ if (typeof value === "string") field.handleChange(value as T);
+ setIsOpen(false);
+ }}
+ onOpenChange={setIsOpen}
+ shouldFocusToggleOnSelect
+ toggle={(ref: React.Ref) => (
+
+ {selectedOption?.label ?? field.state.value}
+
+ )}
+ >
+
+ {options.map((opt) => (
+
+ {opt.label}
+
+ ))}
+
+
+ {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 `` element would be kept in the DOM with its text
visually hidden. This is better than `aria-label` because it gets translated
by browser tools, works with voice control software, and does not depend on
ARIA support. However, PatternFly's `FormGroup` reserves visual space for the
label area whenever a label is provided, even when its content is hidden,
-leaving an unwanted gap in the layout. Fighting that with CSS overrides is
-hacky and fragile for little practical benefit in an application that manages
-its own translations via `_()`.
+leaving an unwanted gap in the layout.
For this reason, `aria-label` is used as the fallback for fields without a
-visible label. It is widely supported and works correctly in this context.
+visible label when rendered inside `FormGroup`.
+
+When a component accepts a `label` prop directly and does not reserve visual
+space for it (e.g. `ChoiceField`), pass `{label} ` as the
+label value instead. This preserves translation support and avoids the layout
+side effect.
+
+See:
+
+-
+-
+-
+-
-See:
-See:
-See:
-See:
+---
## Combining patterns
-Patterns 4 and 5 can work well together. If a form has one common optional
-field alongside a group of rarely needed advanced options, show the common
-field directly with an `(optional)` suffix and hide the rest behind a checkbox.
-For example: in the storage partition form, a file system label could sit next
-to the file system selector as a plain optional field, while the remaining
-advanced options might be hidden behind a "More file system options" checkbox.
-The user can set the label without ever seeing the rest.
+Patterns can and should be combined within the same form when different fields
+have different needs.
+
+Patterns 3 and 4 commonly appear inside a choice selector (pattern 5), where
+selecting a mode reveals required or optional refinements of that choice.
+
+Patterns 2 and 6 also combine well when a form has one common optional field
+alongside a group of rarely needed advanced options.
+
+---
## Choosing the right pattern
Work through these questions in order:
1. Is the field needed by most users and always relevant? Use pattern 1.
-2. Does the field only make sense when another field has a specific value, and
- is it required when shown? Use pattern 2.
-3. Does the field only make sense when another field has a specific value, but
- is optional when shown? Use pattern 3.
-4. Should the field always be visible because hiding it would hurt
- discoverability or because its label reflects form state? Use pattern 4.
-5. Is the field an advanced option that most users will never need? Use pattern 5.
-
-These questions apply per field. Patterns can and should be combined within the
-same form when different fields have different needs. See the Combining patterns
-section above for an example.
+2. Should the field always remain visible for clarity or discoverability? Use pattern 2.
+3. Does the field become required only after another choice? Use pattern 3.
+4. Does the field become optional only after another choice? Use pattern 4.
+5. Does the user need to choose between different configuration behaviors or modes? Use pattern 5.
+6. Is the field an advanced option that most users will never need? Use pattern 6.
+
+---
## Summary
-| Pattern | Visibility | Label | Validated on submit |
-| ------------------------------------ | ------------ | --------------------------------- | ------------------- |
-| Required | Always | No suffix | Yes |
-| Conditionally required | On condition | No suffix | Yes |
-| Conditionally optional | On condition | `(optional)` | No |
-| Always optional or context-dependent | Always | `(optional)` or clarifying suffix | No |
-| Checkbox opt-in | On checkbox | No suffix | Yes, when shown |
+| Pattern | Visibility | Label | Validated on submit |
+| --------------------------------- | ------------ | --------------------------------- | ------------------- |
+| Required | Always | No suffix | Yes |
+| Always optional/context-dependent | Always | `(optional)` or clarifying suffix | No |
+| Conditionally required | On condition | No suffix | Yes |
+| Conditionally optional | On condition | `(optional)` | No |
+| Choice selector | Always | No suffix | Depends on choice |
+| Checkbox opt-in | On checkbox | No suffix | Yes, when shown |
diff --git a/web/src/components/network/BindingModeSelector.test.tsx b/web/src/components/network/BindingModeSelector.test.tsx
new file mode 100644
index 0000000000..1cda5b1169
--- /dev/null
+++ b/web/src/components/network/BindingModeSelector.test.tsx
@@ -0,0 +1,43 @@
+/*
+ * 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 { connectionFormOptions } from "~/components/network/ConnectionForm";
+import BindingModeSelector from "./BindingModeSelector";
+
+function TestForm() {
+ const form = useAppForm({ ...connectionFormOptions });
+ return ;
+}
+
+describe("BindingModeSelector", () => {
+ it("shows all options with their descriptions", async () => {
+ const { user } = installerRender( );
+ await user.click(screen.getByLabelText("Device"));
+ screen.getByRole("option", { name: /^Any.*all devices/ });
+ screen.getByRole("option", { name: /^Chosen by name.*Restricted.*specific device/ });
+ screen.getByRole("option", { name: /^Chosen by MAC.*Restricted.*specific device and follows/ });
+ });
+});
diff --git a/web/src/components/network/BindingModeSelector.tsx b/web/src/components/network/BindingModeSelector.tsx
new file mode 100644
index 0000000000..affbc5d6df
--- /dev/null
+++ b/web/src/components/network/BindingModeSelector.tsx
@@ -0,0 +1,73 @@
+/*
+ * 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 { connectionFormOptions } from "~/components/network/ConnectionForm";
+import { withForm } from "~/hooks/form";
+import { _, N_ } from "~/i18n";
+
+const BINDING_MODE_OPTIONS = [
+ {
+ value: "none",
+ label: N_("Any"),
+ description: N_("Available for all devices"),
+ },
+ {
+ value: "iface",
+ label: N_("Chosen by name"),
+ description: N_("Restricted to a specific device name"),
+ },
+ {
+ value: "mac",
+ label: N_("Chosen by MAC"),
+ description: N_("Restricted to a specific device and follows it even if renamed"),
+ },
+];
+
+/**
+ * A `ChoiceField` based selector for the connection binding mode.
+ *
+ * Receives a typed form instance via `withForm`.
+ */
+const BindingModeSelector = withForm({
+ ...connectionFormOptions,
+ render: function Render({ form }) {
+ return (
+
+ {(field) => (
+ ({
+ value,
+ // eslint-disable-next-line agama-i18n/string-literals
+ label: _(label),
+ // eslint-disable-next-line agama-i18n/string-literals
+ description: _(description),
+ }))}
+ />
+ )}
+
+ );
+ },
+});
+
+export default BindingModeSelector;
diff --git a/web/src/components/network/ConnectionForm.test.tsx b/web/src/components/network/ConnectionForm.test.tsx
index d81763b001..8484c733c3 100644
--- a/web/src/components/network/ConnectionForm.test.tsx
+++ b/web/src/components/network/ConnectionForm.test.tsx
@@ -28,12 +28,14 @@ import { ConnectionMethod, ConnectionType, DeviceState } from "~/types/network";
const mockDevice1 = {
name: "enp1s0",
+ macAddress: "00:11:22:33:44:55",
type: ConnectionType.ETHERNET,
state: DeviceState.CONNECTED,
};
const mockDevice2 = {
name: "enp2s0",
+ macAddress: "AA:BB:CC:DD:EE:FF",
type: ConnectionType.ETHERNET,
state: DeviceState.DISCONNECTED,
};
@@ -53,12 +55,46 @@ describe("ConnectionForm", () => {
jest.clearAllMocks();
});
- it("renders common connection fields", () => {
+ it("renders common connection fields and options", () => {
installerRender( );
screen.getByLabelText("Name");
- screen.getByLabelText("Interface");
- screen.getByLabelText("IPv4 Method");
- screen.getByLabelText("IPv6 Method");
+ screen.getByLabelText("Device");
+ screen.getByText("IPv4 Settings");
+ screen.getByText("IPv6 Settings");
+ screen.getByText("Use custom DNS");
+ screen.getByText("Use custom DNS search domains");
+ });
+
+ describe("Device binding", () => {
+ it("does not show device or MAC fields when mode is Any", () => {
+ installerRender( );
+ expect(screen.queryByLabelText("Device name")).not.toBeInTheDocument();
+ expect(screen.queryByLabelText("MAC address")).not.toBeInTheDocument();
+ });
+
+ it("submits with iface when binding by iface name", async () => {
+ const { user } = installerRender( );
+ await user.type(screen.getByLabelText("Name"), "Testing Connection 1");
+ await user.click(screen.getByLabelText("Device"));
+ await user.click(screen.getByRole("option", { name: /^Chosen by name/ }));
+ await user.click(screen.getByRole("button", { name: "Accept" }));
+ await waitFor(() =>
+ expect(mockMutateAsync).toHaveBeenCalledWith(expect.objectContaining({ iface: "enp1s0" })),
+ );
+ });
+
+ it("submits with macAddress when binding by MAC", async () => {
+ const { user } = installerRender( );
+ await user.type(screen.getByLabelText("Name"), "Testing Connection 1");
+ await user.click(screen.getByLabelText("Device"));
+ await user.click(screen.getByRole("option", { name: /^Chosen by MAC/ }));
+ await user.click(screen.getByRole("button", { name: "Accept" }));
+ await waitFor(() =>
+ expect(mockMutateAsync).toHaveBeenCalledWith(
+ expect.objectContaining({ macAddress: "00:11:22:33:44:55" }),
+ ),
+ );
+ });
});
it("submits with the entered values", async () => {
@@ -67,7 +103,7 @@ describe("ConnectionForm", () => {
await user.click(screen.getByRole("button", { name: "Accept" }));
await waitFor(() =>
expect(mockMutateAsync).toHaveBeenCalledWith(
- expect.objectContaining({ id: "Testing Connection 1", iface: "enp1s0" }),
+ expect.objectContaining({ id: "Testing Connection 1" }),
),
);
});
@@ -80,111 +116,77 @@ describe("ConnectionForm", () => {
await screen.findByText("Connection failed");
});
- it("defaults both methods to automatic and does not show gateways", () => {
+ it("does not show IP fields when both settings are automatic", () => {
installerRender( );
- expect(screen.getByLabelText("IPv4 Method")).toHaveValue(ConnectionMethod.AUTO);
- expect(screen.getByLabelText("IPv6 Method")).toHaveValue(ConnectionMethod.AUTO);
- expect(screen.queryByLabelText("IPv4 Gateway (optional)")).not.toBeInTheDocument();
- expect(screen.queryByLabelText("IPv6 Gateway (optional)")).not.toBeInTheDocument();
+ expect(screen.queryByText("IPv4 Addresses")).not.toBeInTheDocument();
+ expect(screen.queryByText("IPv4 Gateway")).not.toBeInTheDocument();
+ expect(screen.queryByText("IPv6 Addresses")).not.toBeInTheDocument();
+ expect(screen.queryByText("IPv6 Gateway")).not.toBeInTheDocument();
});
- it("shows IPv4 gateway when IPv4 method is manual", async () => {
+ it("shows the IPv4 addresses and gateway when IPv4 settings is manual", async () => {
const { user } = installerRender( );
- await user.selectOptions(screen.getByLabelText("IPv4 Method"), ConnectionMethod.MANUAL);
+ await user.click(screen.getByLabelText("IPv4 Settings"));
+ await user.click(screen.getByRole("option", { name: /^Manual/ }));
+ screen.getByText("IPv4 Addresses");
screen.getByLabelText("IPv4 Gateway (optional)");
expect(screen.queryByLabelText("IPv6 Gateway (optional)")).not.toBeInTheDocument();
+ expect(screen.queryByText("IPv6 Addresses")).not.toBeInTheDocument();
});
- it("shows IPv6 gateway when IPv6 method is manual", async () => {
+ it("shows the IPv4 addresses and gateway when IPv4 mode is advanced", async () => {
const { user } = installerRender( );
- await user.selectOptions(screen.getByLabelText("IPv6 Method"), ConnectionMethod.MANUAL);
- screen.getByLabelText("IPv6 Gateway (optional)");
- expect(screen.queryByLabelText("IPv4 Gateway (optional)")).not.toBeInTheDocument();
+ await user.click(screen.getByLabelText("IPv4 Settings"));
+ await user.click(screen.getByRole("option", { name: /^Advanced/ }));
+ screen.getByText("IPv4 Addresses");
+ screen.getByLabelText("IPv4 Gateway (optional, ignored if no addresses provided)");
+ expect(screen.queryByText("IPv6 Addresses")).not.toBeInTheDocument();
});
- it("submits with gateways when both methods are manual", async () => {
+ it("submits empty addresses when both settings are automatic", async () => {
const { user } = installerRender( );
await user.type(screen.getByLabelText("Name"), "Testing Connection 1");
- await user.selectOptions(screen.getByLabelText("IPv4 Method"), ConnectionMethod.MANUAL);
- await user.type(screen.getByLabelText("IPv4 Gateway (optional)"), "192.168.1.1");
- await user.selectOptions(screen.getByLabelText("IPv6 Method"), ConnectionMethod.MANUAL);
- await user.type(screen.getByLabelText("IPv6 Gateway (optional)"), "::1");
await user.click(screen.getByRole("button", { name: "Accept" }));
await waitFor(() =>
- expect(mockMutateAsync).toHaveBeenCalledWith(
- expect.objectContaining({
- method4: ConnectionMethod.MANUAL,
- gateway4: "192.168.1.1",
- method6: ConnectionMethod.MANUAL,
- gateway6: "::1",
- }),
- ),
+ expect(mockMutateAsync).toHaveBeenCalledWith(expect.objectContaining({ addresses: [] })),
);
});
- it("submits addresses parsed from the textarea", async () => {
+ it("submits with given addresses and gateways when both protocols are set to manual", async () => {
const { user } = installerRender( );
await user.type(screen.getByLabelText("Name"), "Testing Connection 1");
- await user.type(
- screen.getByLabelText("IP Addresses (optional)"),
- "192.168.1.1/24 2001:db8::1/64",
- );
- await user.click(screen.getByRole("button", { name: "Accept" }));
- await waitFor(() =>
- expect(mockMutateAsync).toHaveBeenCalledWith(
- expect.objectContaining({
- addresses: [
- { address: "192.168.1.1", prefix: 24 },
- { address: "2001:db8::1", prefix: 64 },
- ],
- }),
- ),
- );
- });
- it("adds default prefix when address has none", async () => {
- const { user } = installerRender( );
- await user.type(screen.getByLabelText("Name"), "Testing Connection 1");
- await user.type(screen.getByLabelText("IP Addresses (optional)"), "192.168.1.1 2001:db8::1");
+ await user.click(screen.getByLabelText("IPv4 Settings"));
+ await user.click(screen.getByRole("option", { name: /^Manual/ }));
+ await user.type(screen.getByLabelText("IPv4 Addresses"), "192.168.1.100 192.168.1.200/12");
+ await user.type(screen.getByLabelText("IPv4 Gateway (optional)"), "192.168.1.1");
+
+ await user.click(screen.getByLabelText("IPv6 Settings"));
+ await user.click(screen.getByRole("option", { name: /^Manual/ }));
+ await user.type(screen.getByLabelText("IPv6 Addresses"), "2001:db8::1 2001:db8::2/24");
+ await user.type(screen.getByLabelText("IPv6 Gateway (optional)"), "::1");
+
await user.click(screen.getByRole("button", { name: "Accept" }));
await waitFor(() =>
expect(mockMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
addresses: [
- { address: "192.168.1.1", prefix: 24 },
+ // adds a default prefix since it has none
+ { address: "192.168.1.100", prefix: 24 },
+ { address: "192.168.1.200", prefix: 12 },
+ // adds a default prefix since it has none
{ address: "2001:db8::1", prefix: 64 },
+ { address: "2001:db8::2", prefix: 24 },
],
+ method4: ConnectionMethod.MANUAL,
+ gateway4: "192.168.1.1",
+ method6: ConnectionMethod.MANUAL,
+ gateway6: "::1",
}),
),
);
});
- describe("IP Addresses label", () => {
- it("shows '(optional)' when both methods are automatic", () => {
- installerRender( );
- screen.getByLabelText("IP Addresses (optional)");
- });
-
- it("shows '(IPv4 required)' when only IPv4 method is manual", async () => {
- const { user } = installerRender( );
- await user.selectOptions(screen.getByLabelText("IPv4 Method"), ConnectionMethod.MANUAL);
- screen.getByLabelText("IP Addresses (IPv4 required)");
- });
-
- it("shows '(IPv6 required)' when only IPv6 method is manual", async () => {
- const { user } = installerRender( );
- await user.selectOptions(screen.getByLabelText("IPv6 Method"), ConnectionMethod.MANUAL);
- screen.getByLabelText("IP Addresses (IPv6 required)");
- });
-
- it("shows '(IPv4 and IPv6 required)' when both methods are manual", async () => {
- const { user } = installerRender( );
- await user.selectOptions(screen.getByLabelText("IPv4 Method"), ConnectionMethod.MANUAL);
- await user.selectOptions(screen.getByLabelText("IPv6 Method"), ConnectionMethod.MANUAL);
- screen.getByLabelText("IP Addresses (IPv4 and IPv6 required)");
- });
- });
-
describe("DNS servers", () => {
it("does not show the DNS servers field by default", () => {
installerRender( );
@@ -193,19 +195,22 @@ describe("ConnectionForm", () => {
it("shows the DNS servers field when the checkbox is checked", async () => {
const { user } = installerRender( );
- await user.click(screen.getByLabelText("Use custom DNS servers"));
+ await user.click(screen.getByLabelText("Use custom DNS"));
screen.getByRole("textbox", { name: "DNS servers" });
});
it("submits with parsed nameservers when checkbox is checked", async () => {
const { user } = installerRender( );
await user.type(screen.getByLabelText("Name"), "Testing Connection 1");
- await user.click(screen.getByLabelText("Use custom DNS servers"));
- await user.type(screen.getByRole("textbox", { name: "DNS servers" }), "8.8.8.8 8.8.4.4");
+ await user.click(screen.getByLabelText("Use custom DNS"));
+ await user.type(
+ screen.getByRole("textbox", { name: "DNS servers" }),
+ "8.8.8.8 1.1.1.1 2001:db8::1",
+ );
await user.click(screen.getByRole("button", { name: "Accept" }));
await waitFor(() =>
expect(mockMutateAsync).toHaveBeenCalledWith(
- expect.objectContaining({ nameservers: ["8.8.8.8", "8.8.4.4"] }),
+ expect.objectContaining({ nameservers: ["8.8.8.8", "1.1.1.1", "2001:db8::1"] }),
),
);
});
@@ -213,12 +218,11 @@ describe("ConnectionForm", () => {
it("submits empty nameservers when checkbox is unchecked", async () => {
const { user } = installerRender( );
await user.type(screen.getByLabelText("Name"), "Testing Connection 1");
- const checkbox = screen.getByRole("checkbox", { name: "Use custom DNS servers" });
- expect(checkbox).not.toBeChecked();
+ const checkbox = screen.getByRole("checkbox", { name: "Use custom DNS" });
await user.click(checkbox);
- expect(checkbox).toBeChecked();
- await user.type(screen.getByRole("textbox", { name: "DNS servers" }), "8.8.8.8 8.8.4.4");
+ await user.type(screen.getByRole("textbox", { name: "DNS servers" }), "8.8.8.8");
await user.click(checkbox);
+ expect(checkbox).not.toBeChecked();
await user.click(screen.getByRole("button", { name: "Accept" }));
await waitFor(() =>
expect(mockMutateAsync).toHaveBeenCalledWith(expect.objectContaining({ nameservers: [] })),
@@ -261,6 +265,7 @@ describe("ConnectionForm", () => {
await user.click(checkbox);
await user.type(screen.getByRole("textbox", { name: "DNS search domains" }), "example.com");
await user.click(checkbox);
+ expect(checkbox).not.toBeChecked();
await user.click(screen.getByRole("button", { name: "Accept" }));
await waitFor(() =>
expect(mockMutateAsync).toHaveBeenCalledWith(
diff --git a/web/src/components/network/ConnectionForm.tsx b/web/src/components/network/ConnectionForm.tsx
index 25ebadd359..1854ac09db 100644
--- a/web/src/components/network/ConnectionForm.tsx
+++ b/web/src/components/network/ConnectionForm.tsx
@@ -27,32 +27,28 @@ import {
ActionGroup,
Button,
Checkbox,
+ Flex,
Form,
FormGroup,
FormHelperText,
- FormSelect,
- FormSelectOption,
HelperText,
HelperTextItem,
- Split,
TextArea,
TextInput,
} from "@patternfly/react-core";
import Page from "~/components/core/Page";
import NestedContent from "~/components/core/NestedContent";
-import LabelText from "~/components/form/LabelText";
-import { Connection, ConnectionMethod } from "~/types/network";
-import { buildAddress } from "~/utils/network";
+import IpSettings from "~/components/network/IpSettings";
+import BindingModeSelector from "~/components/network/BindingModeSelector";
+import DeviceSelector from "~/components/network/DeviceSelector";
+import { Connection, ConnectionBindingMode, ConnectionMethod } from "~/types/network";
import { useConnectionMutation } from "~/hooks/model/config/network";
-import { useAppForm } from "~/hooks/form";
+import { formOptions } from "@tanstack/react-form";
+import { useAppForm, mergeFormDefaults } from "~/hooks/form";
import { useDevices } from "~/hooks/model/system/network";
import { NETWORK } from "~/routes/paths";
-import { _, N_ } from "~/i18n";
-
-const METHOD_OPTIONS = [
- { value: ConnectionMethod.AUTO, label: N_("Automatic (DHCP)") },
- { value: ConnectionMethod.MANUAL, label: N_("Manual") },
-];
+import { buildAddress } from "~/utils/network";
+import { _ } from "~/i18n";
const IPV4_DEFAULT_PREFIX = 24;
const IPV6_DEFAULT_PREFIX = 64;
@@ -75,6 +71,45 @@ const withPrefix = (address: string): string => {
/** Parses a space/newline separated string of addresses into IPAddress objects. */
const parseAddresses = (raw: string) => parseTokens(raw).map(withPrefix).map(buildAddress);
+/**
+ * Shared form options for ConnectionForm and its `withForm` based
+ * sub-components
+ *
+ * Sub-components spread these options in their `withForm` definition so
+ * TanStack Form can infer the field types, enabling type-safe props.
+ */
+export const connectionFormOptions = formOptions({
+ defaultValues: {
+ name: "",
+ iface: "",
+ ifaceMac: "",
+ ipv4Mode: "unset",
+ addresses4: "",
+ gateway4: "",
+ ipv6Mode: "unset",
+ addresses6: "",
+ gateway6: "",
+ nameservers: "",
+ dnsSearchList: "",
+ useCustomDns: false,
+ useCustomDnsSearch: false,
+ bindingMode: "none" as ConnectionBindingMode,
+ },
+});
+
+/**
+ * Maps form mode values to their corresponding {@link ConnectionMethod}.
+ *
+ * "unset" is intentionally absent: omitting it causes the Connection
+ * constructor to write no method, delegating the decision to NetworkManager.
+ * This map can be dropped once the form mode values align with
+ * {@link ConnectionMethod} enum values.
+ */
+const MODE_TO_METHOD: Record = {
+ auto: ConnectionMethod.AUTO,
+ manual: ConnectionMethod.MANUAL,
+};
+
/**
* Form for creating a new network connection.
*
@@ -95,28 +130,29 @@ export default function ConnectionForm() {
const { mutateAsync: updateConnection } = useConnectionMutation();
const form = useAppForm({
- defaultValues: {
- name: "",
- interface: devices[0]?.name ?? "",
- method4: ConnectionMethod.AUTO,
- gateway4: "",
- method6: ConnectionMethod.AUTO,
- gateway6: "",
- addresses: "",
- useCustomDns: false,
- nameservers: "",
- useCustomDnsSearch: false,
- dnsSearchList: "",
- },
+ ...mergeFormDefaults(connectionFormOptions, {
+ iface: devices[0]?.name ?? "",
+ ifaceMac: devices[0]?.macAddress ?? "",
+ }),
validators: {
onSubmitAsync: async ({ value }) => {
+ const ipv4Addresses =
+ value.ipv4Mode === "manual" || value.ipv4Mode === "auto"
+ ? parseAddresses(value.addresses4)
+ : [];
+ const ipv6Addresses =
+ value.ipv6Mode === "manual" || value.ipv6Mode === "auto"
+ ? parseAddresses(value.addresses6)
+ : [];
+
const connection = new Connection(value.name, {
- iface: value.interface,
- method4: value.method4,
- gateway4: value.gateway4,
- method6: value.method6,
- gateway6: value.gateway6,
- addresses: parseAddresses(value.addresses),
+ iface: value.bindingMode === "iface" ? value.iface : "",
+ macAddress: value.bindingMode === "mac" ? value.ifaceMac : "",
+ method4: MODE_TO_METHOD[value.ipv4Mode],
+ gateway4: ipv4Addresses.length > 0 ? value.gateway4 : "",
+ method6: MODE_TO_METHOD[value.ipv6Mode],
+ gateway6: ipv6Addresses.length > 0 ? value.gateway6 : "",
+ addresses: [...ipv4Addresses, ...ipv6Addresses],
nameservers: value.useCustomDns ? parseTokens(value.nameservers) : [],
dnsSearchList: value.useCustomDnsSearch ? parseTokens(value.dnsSearchList) : [],
});
@@ -135,267 +171,135 @@ export default function ConnectionForm() {
return (
- s.errorMap.onSubmit?.form}>
- {(serverError) =>
- serverError && (
-
- {serverError}
-
- )
- }
-
-
-
- {(field) => (
-
- field.handleChange(v)}
- />
-
- )}
-
-
-
- {(field) => (
-
- field.handleChange(v)}
- >
- {devices.map((d) => (
-
- ))}
-
-
- )}
-
-
-
-
- {(field) => (
-
- field.handleChange(v as ConnectionMethod)}
- >
- {METHOD_OPTIONS.map(({ value, label }) => (
- // eslint-disable-next-line agama-i18n/string-literals
-
- ))}
-
-
- )}
-
-
- s.values.method4}>
- {(method4) =>
- method4 === ConnectionMethod.MANUAL && (
-
- {(field) => (
- {_("IPv4 Gateway")}}
- >
- field.handleChange(v)}
- />
-
- )}
-
+
+ s.errorMap.onSubmit?.form}>
+ {(serverError) =>
+ serverError && (
+
+ {serverError}
+
)
}
-
-
-
+
+
+
+ s.values.bindingMode}>
+ {(mode) => mode !== "none" && }
+
+
+
+
{(field) => (
-
-
+ field.handleChange(v as ConnectionMethod)}
- >
- {METHOD_OPTIONS.map(({ value, label }) => (
- // eslint-disable-next-line agama-i18n/string-literals
-
- ))}
-
+ onChange={(_, v) => field.handleChange(v)}
+ />
)}
- s.values.method6}>
- {(method6) =>
- method6 === ConnectionMethod.MANUAL && (
-
- {(field) => (
- {_("IPv6 Gateway")}}
- >
- field.handleChange(v)}
- />
-
- )}
-
- )
- }
-
-
-
- ({
- method4: s.values.method4,
- method6: s.values.method6,
- })}
- >
- {({ method4, method6 }) => {
- const manual4 = method4 === ConnectionMethod.MANUAL;
- const manual6 = method6 === ConnectionMethod.MANUAL;
+
- const addressesLabel = () => {
- if (manual4 && manual6)
- return (
-
- {_("IP Addresses")}
-
- );
- if (manual4)
- return {_("IP Addresses")} ;
- if (manual6)
- return {_("IP Addresses")} ;
- return {_("IP Addresses")} ;
- };
- const label = addressesLabel();
+
- return (
-
- {(field) => (
-
-
+
+ {(dnsToggle) => (
+ <>
+ dnsToggle.handleChange(checked)}
+ />
+ {dnsToggle.state.value && (
+
+
+ {(field) => (
+
+
+ )}
+
+
)}
-
- );
- }}
-
+ >
+ )}
+
-
- {(dnsToggle) => (
- <>
- dnsToggle.handleChange(checked)}
- />
- {dnsToggle.state.value && (
-
-
- {(field) => (
-
-
- )}
-
-
- )}
- >
- )}
-
+
+ {(dnsSearchToggle) => (
+ <>
+ dnsSearchToggle.handleChange(checked)}
+ />
+ {dnsSearchToggle.state.value && (
+
+
+ {(field) => (
+
+
+ )}
+
+
+ )}
+ >
+ )}
+
-
- {(dnsSearchToggle) => (
- <>
- dnsSearchToggle.handleChange(checked)}
- />
- {dnsSearchToggle.state.value && (
-
-
- {(field) => (
-
-
- )}
-
-
+
+ s.isSubmitting}>
+ {(isSubmitting) => (
+
+ {_("Accept")}
+
)}
- >
- )}
-
-
-
- s.isSubmitting}>
- {(isSubmitting) => (
-
- {_("Accept")}
-
- )}
-
- navigate(-1)}>
- {_("Cancel")}
-
-
-
+
+ navigate(-1)}>
+ {_("Cancel")}
+
+
+
+
);
diff --git a/web/src/components/network/DeviceSelector.test.tsx b/web/src/components/network/DeviceSelector.test.tsx
new file mode 100644
index 0000000000..1884286be0
--- /dev/null
+++ b/web/src/components/network/DeviceSelector.test.tsx
@@ -0,0 +1,87 @@
+/*
+ * 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 { ConnectionType, DeviceState } from "~/types/network";
+import { connectionFormOptions } from "~/components/network/ConnectionForm";
+import DeviceSelector from "./DeviceSelector";
+
+const mockDevices = [
+ {
+ name: "enp1s0",
+ macAddress: "00:11:22:33:44:55",
+ type: ConnectionType.ETHERNET,
+ state: DeviceState.CONNECTED,
+ },
+ {
+ name: "enp2s0",
+ macAddress: "AA:BB:CC:DD:EE:FF",
+ type: ConnectionType.ETHERNET,
+ state: DeviceState.DISCONNECTED,
+ },
+];
+
+jest.mock("~/hooks/model/system/network", () => ({
+ useDevices: () => mockDevices,
+}));
+
+function TestForm({ by }: { by: "iface" | "mac" }) {
+ const form = useAppForm({ ...connectionFormOptions });
+ return ;
+}
+
+describe("DeviceSelector", () => {
+ describe("when by is iface", () => {
+ it("shows device names as options", async () => {
+ const { user } = installerRender( );
+ await user.click(screen.getByLabelText("Device name"));
+ screen.getByRole("option", { name: /^enp1s0/ });
+ screen.getByRole("option", { name: /^enp2s0/ });
+ });
+
+ it("shows MAC addresses as option descriptions", async () => {
+ const { user } = installerRender( );
+ await user.click(screen.getByLabelText("Device name"));
+ screen.getByRole("option", { name: /00:11:22:33:44:55/ });
+ screen.getByRole("option", { name: /AA:BB:CC:DD:EE:FF/ });
+ });
+ });
+
+ describe("when by is mac", () => {
+ it("shows MAC addresses as options", async () => {
+ const { user } = installerRender( );
+ await user.click(screen.getByLabelText("MAC address"));
+ screen.getByRole("option", { name: /^00:11:22:33:44:55/ });
+ screen.getByRole("option", { name: /^AA:BB:CC:DD:EE:FF/ });
+ });
+
+ it("shows device names as option descriptions", async () => {
+ const { user } = installerRender( );
+ await user.click(screen.getByLabelText("MAC address"));
+ screen.getByRole("option", { name: /enp1s0/ });
+ screen.getByRole("option", { name: /enp2s0/ });
+ });
+ });
+});
diff --git a/web/src/components/network/DeviceSelector.tsx b/web/src/components/network/DeviceSelector.tsx
new file mode 100644
index 0000000000..83b90daff7
--- /dev/null
+++ b/web/src/components/network/DeviceSelector.tsx
@@ -0,0 +1,68 @@
+/*
+ * 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";
+import { connectionFormOptions } from "~/components/network/ConnectionForm";
+import { withForm } from "~/hooks/form";
+import { useDevices } from "~/hooks/model/system/network";
+import { _ } from "~/i18n";
+
+/**
+ * A `ChoiceField` based selector for picking a network device, either by
+ * interface name or by MAC address.
+ *
+ * Receives a typed form instance via `withForm`.
+ */
+const DeviceSelector = withForm({
+ ...connectionFormOptions,
+ props: {
+ by: "iface" as "iface" | "mac",
+ },
+ render: function Render({ form, by }) {
+ const devices = useDevices();
+ const valueKey = by === "iface" ? "name" : "macAddress";
+
+ const name = by === "iface" ? "iface" : "ifaceMac";
+ const label = by === "iface" ? _("Device name") : _("MAC address");
+ const options = devices.map((d) => {
+ const value = d[valueKey];
+ return {
+ value,
+ label: value,
+ description: (
+
+ {by === "iface" ? d.macAddress : d.name}
+
+ ),
+ };
+ });
+
+ return (
+
+ {(field) => {label}} options={options} />}
+
+ );
+ },
+});
+
+export default DeviceSelector;
diff --git a/web/src/components/network/IpSettings.test.tsx b/web/src/components/network/IpSettings.test.tsx
new file mode 100644
index 0000000000..29bd183367
--- /dev/null
+++ b/web/src/components/network/IpSettings.test.tsx
@@ -0,0 +1,90 @@
+/*
+ * 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 { connectionFormOptions } from "~/components/network/ConnectionForm";
+import IpSettings from "./IpSettings";
+
+function TestForm({ defaultValues = {} }: { defaultValues?: object }) {
+ const form = useAppForm({
+ ...connectionFormOptions,
+ defaultValues: {
+ ...connectionFormOptions.defaultValues,
+ ...defaultValues,
+ },
+ });
+
+ return ;
+}
+
+describe("IpSettings", () => {
+ it("renders the protocol label", () => {
+ installerRender( );
+ screen.getByText("IPv4 Settings");
+ });
+
+ it("does not show addresses or gateway when mode is automatic", () => {
+ installerRender( );
+ expect(screen.queryByText("IPv4 Addresses")).not.toBeInTheDocument();
+ expect(screen.queryByText("IPv4 Gateway")).not.toBeInTheDocument();
+ });
+
+ describe("when mode is manual", () => {
+ const defaultValues = { ipv4Mode: "manual" };
+
+ it("shows IPv4 Addresses as required", () => {
+ installerRender( );
+ expect(screen.getByText("IPv4 Addresses").closest("label")).not.toHaveTextContent(
+ "(optional)",
+ );
+ });
+
+ it("shows IPv4 Gateway as optional", () => {
+ installerRender( );
+ expect(screen.getByText("IPv4 Gateway").closest("label")).toHaveTextContent("(optional)");
+ });
+
+ it("does not note that the gateway is ignored without a static IP", () => {
+ installerRender( );
+ expect(screen.getByText("IPv4 Gateway").closest("label")).not.toHaveTextContent("ignored");
+ });
+ });
+
+ describe("when mode is advanced", () => {
+ const defaultValues = { ipv4Mode: "auto" };
+
+ it("shows IPv4 Addresses as optional", () => {
+ installerRender( );
+ expect(screen.getByText("IPv4 Addresses").closest("label")).toHaveTextContent("(optional)");
+ });
+
+ it("notes on the gateway label that it is ignored if no addresses provided", () => {
+ installerRender( );
+ expect(screen.getByText("IPv4 Gateway").closest("label")).toHaveTextContent(
+ "(optional, ignored if no addresses provided)",
+ );
+ });
+ });
+});
diff --git a/web/src/components/network/IpSettings.tsx b/web/src/components/network/IpSettings.tsx
new file mode 100644
index 0000000000..01695d290a
--- /dev/null
+++ b/web/src/components/network/IpSettings.tsx
@@ -0,0 +1,192 @@
+/*
+ * 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,
+ TextArea,
+ TextInput,
+} from "@patternfly/react-core";
+import NestedContent from "~/components/core/NestedContent";
+import LabelText from "~/components/form/LabelText";
+import { connectionFormOptions } from "~/components/network/ConnectionForm";
+import { withForm } from "~/hooks/form";
+import { _, N_ } from "~/i18n";
+
+/**
+ * Mode options shared by both IPv4 and IPv6 settings.
+ *
+ * - `unset`: no method written to the profile; the network handles IP
+ * configuration automatically. Labeled "Automatic" to avoid exposing
+ * the underlying "no method set" detail to users.
+ * - `manual`: method set to manual, with required addresses and optional gateway.
+ * - `auto`: method set to auto with optional static addresses and gateway,
+ * for the uncommon case of combining automatic and manual addressing.
+ * Labeled "Advanced".
+ *
+ * Labels and descriptions use `N_()` for extraction and `_()` at render time.
+ */
+const modeOptions = () => [
+ {
+ value: "unset",
+ label: N_("Automatic"),
+ description: N_("Address and gateway from the network"),
+ },
+ {
+ value: "manual",
+ label: N_("Manual"),
+ description: N_("Fixed addresses with optional gateway"),
+ },
+ {
+ value: "auto",
+ label: N_("Advanced"),
+ description: N_("Automatic plus optional addresses and gateway"),
+ },
+];
+
+type IpSettingsProps = {
+ protocol: "ipv4" | "ipv6";
+};
+
+/**
+ * Protocol-specific IP settings block for a connection form.
+ *
+ * Shows a selector with three options: Automatic, Manual, and Advanced.
+ *
+ * Receives a typed form instance via `withForm`.
+ *
+ * @remarks
+ * Field labels are prefixed with the protocol name (e.g. "IPv4 Gateway"
+ * instead of "Gateway") because both protocols can be visible at the same
+ * time. Without the prefix, a screen reader navigating between controls loses
+ * the context that sighted users get from the visual grouping. Prefixing makes
+ * each label self-sufficient for both audiences, as recommended by WCAG 2.4.6.
+ * @see https://www.w3.org/WAI/WCAG21/Understanding/headings-and-labels.html
+ */
+const IpSettings = withForm({
+ ...connectionFormOptions,
+ props: {
+ protocol: "ipv4",
+ } as IpSettingsProps,
+ render: function Render({ form, protocol }) {
+ const isIPv4 = protocol === "ipv4";
+ const label = isIPv4 ? _("IPv4 Settings") : _("IPv6 Settings");
+ const addressesLabel = isIPv4 ? _("IPv4 Addresses") : _("IPv6 Addresses");
+ const gatewayLabel = isIPv4 ? _("IPv4 Gateway") : _("IPv6 Gateway");
+ const addressesHint = isIPv4
+ ? _("Space-separated IPv4 addresses with optional prefix, e.g. 192.168.1.1 or 192.168.1.1/24")
+ : _(
+ "Space-separated IPv6 addresses with optional prefix, e.g. 2001:db8::1 or 2001:db8::1/64",
+ );
+
+ const modeField = isIPv4 ? "ipv4Mode" : "ipv6Mode";
+ const addressesField = isIPv4 ? "addresses4" : "addresses6";
+ const gatewayField = isIPv4 ? "gateway4" : "gateway6";
+
+ return (
+ <>
+
+ {(field) => (
+ ({
+ value,
+ // eslint-disable-next-line agama-i18n/string-literals
+ label: _(label),
+ // eslint-disable-next-line agama-i18n/string-literals
+ description: _(description),
+ }))}
+ />
+ )}
+
+
+ s.values[modeField]}>
+ {(mode) =>
+ (mode === "manual" || mode === "auto") && (
+
+
+ {(field) => (
+ {addressesLabel}
+ ) : (
+ addressesLabel
+ )
+ }
+ >
+
+ )}
+
+
+
+ {(field) => (
+
+ {gatewayLabel}
+
+ }
+ >
+ field.handleChange(v)}
+ />
+
+ )}
+
+
+ )
+ }
+
+ >
+ );
+ },
+});
+
+export default IpSettings;
diff --git a/web/src/hooks/form.tsx b/web/src/hooks/form-contexts.ts
similarity index 62%
rename from web/src/hooks/form.tsx
rename to web/src/hooks/form-contexts.ts
index 9494c638b4..c2f89f16d9 100644
--- a/web/src/hooks/form.tsx
+++ b/web/src/hooks/form-contexts.ts
@@ -20,20 +20,20 @@
* find current contact information at www.suse.com.
*/
-import { createFormHook, createFormHookContexts } from "@tanstack/react-form";
-
-const { fieldContext, formContext } = createFormHookContexts();
-
/**
- * Application-wide TanStack Form hook.
+ * Shared TanStack Form contexts.
+ *
+ * Lives in its own module to break a circular dependency:
+ * - `hooks/form.ts` imports field components to register them.
+ * - Field components import `useFieldContext` to read the current field.
+ *
+ * If both lived in `hooks/form.ts`, each side would import the other.
+ * Field components import from here instead, keeping the dependency graph
+ * acyclic.
*
* @see https://tanstack.com/form/latest/docs/framework/react/guides/form-composition
*/
-const { useAppForm } = createFormHook({
- fieldContext,
- formContext,
- fieldComponents: {},
- formComponents: {},
-});
+import { createFormHookContexts } from "@tanstack/react-form";
-export { useAppForm };
+export const { fieldContext, formContext, useFieldContext, useFormContext } =
+ createFormHookContexts();
diff --git a/web/src/hooks/form.ts b/web/src/hooks/form.ts
new file mode 100644
index 0000000000..434356a64f
--- /dev/null
+++ b/web/src/hooks/form.ts
@@ -0,0 +1,65 @@
+/*
+ * 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 { createFormHook } from "@tanstack/react-form";
+import { fieldContext, formContext, useFieldContext, useFormContext } from "~/hooks/form-contexts";
+import ChoiceField from "~/components/form/ChoiceField";
+
+/**
+ * Application-wide TanStack Form hook.
+ *
+ * @see https://tanstack.com/form/latest/docs/framework/react/guides/form-composition
+ */
+const { useAppForm, withForm } = createFormHook({
+ fieldContext,
+ formContext,
+ fieldComponents: {
+ ChoiceField,
+ },
+ formComponents: {},
+});
+
+/**
+ * Merges runtime-derived values into a `formOptions` object's `defaultValues`.
+ *
+ * Use this when some defaults depend on runtime data (e.g. values from a hook)
+ * that cannot be known when the shared options are defined statically.
+ *
+ * @example
+ * const myFormOpts = formOptions({ defaultValues: { name: "", device: "" } });
+ *
+ * function MyForm() {
+ * const device = useCurrentDevice();
+ * const form = useAppForm({
+ * ...mergeFormDefaults(myFormOpts, { device: device.name }),
+ * onSubmit: ...,
+ * });
+ * }
+ */
+function mergeFormDefaults }>(
+ opts: T,
+ runtimeDefaults: Partial,
+): T {
+ return { ...opts, defaultValues: { ...opts.defaultValues, ...runtimeDefaults } };
+}
+
+export { useAppForm, withForm, mergeFormDefaults, useFieldContext, useFormContext };