Skip to content
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions web/src/components/form/ChoiceField.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<form.AppField name="mode">
{(field) => (
<field.ChoiceField label="IPv4 Settings" options={OPTIONS}>
{(value) => value === "custom" && <div>Custom content</div>}
</field.ChoiceField>
)}
</form.AppField>
);
}

describe("ChoiceField", () => {
it("renders the label", () => {
installerRender(<TestForm />);
screen.getByText("IPv4 Settings");
});

it("shows the selected option label", () => {
installerRender(<TestForm defaultValue="custom" />);
screen.getByText("Custom");
});

it("renders dependent content when the matching option is selected", () => {
installerRender(<TestForm defaultValue="custom" />);
screen.getByText("Custom content");
});

it("does not render dependent content when the option is not selected", () => {
installerRender(<TestForm defaultValue="default" />);
expect(screen.queryByText("Custom content")).not.toBeInTheDocument();
});

it("renders dependent content when the user selects an option", async () => {
const { user } = installerRender(<TestForm defaultValue="default" />);
await user.click(screen.getByText("Default"));
await user.click(screen.getByText("Custom"));
screen.getByText("Custom content");
});
});
126 changes: 126 additions & 0 deletions web/src/components/form/ChoiceField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/*
* 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<T> = {
value: T;
label: React.ReactNode;
description?: React.ReactNode;
isDisabled?: boolean;
};

type ChoiceFieldProps<T> = {
/** The field label. */
label: React.ReactNode;
/** The available options. */
options: ChoiceOption<T>[];
/** 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. Field values are preserved in form
* state when hidden, so switching back to a previous option restores what the
* user entered.
*
* @example
* <form.AppField name="ipv4Mode">
* {(field) => (
* <field.ChoiceField label={_("IPv4 Settings")} options={IPV4_MODE_OPTIONS}>
* {(value) => value === "custom" && <CustomIpv4Fields />}
* </field.ChoiceField>
* )}
* </form.AppField>
*/
export default function ChoiceField<T extends string>({
label,
options,
helperText,
isDisabled = false,
children,
}: ChoiceFieldProps<T>) {
const field = useFieldContext<T>();
const [isOpen, setIsOpen] = useState(false);

const selectedOption = options.find((o) => o.value === field.state.value);

return (
<FormGroup fieldId={field.name} label={label}>
<Select
isOpen={isOpen}
selected={field.state.value}
onSelect={(_, value) => {
if (typeof value === "string") field.handleChange(value as T);
setIsOpen(false);
}}
onOpenChange={setIsOpen}
shouldFocusToggleOnSelect
toggle={(ref: React.Ref<MenuToggleElement>) => (
<MenuToggle
id={field.name}
ref={ref}
onClick={() => setIsOpen((o) => !o)}
isExpanded={isOpen}
isDisabled={isDisabled}
>
{selectedOption?.label ?? field.state.value}
</MenuToggle>
)}
>
<SelectList>
{options.map((opt) => (
<SelectOption
key={opt.value}
value={opt.value}
description={opt.description}
isDisabled={opt.isDisabled}
>
{opt.label}
</SelectOption>
))}
</SelectList>
</Select>
{helperText}
{children?.(field.state.value)}
</FormGroup>
);
}
Loading
Loading