From 4954585b88ce100a8f351d0640255790e5d067d1 Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Thu, 5 Feb 2026 13:27:46 +0530 Subject: [PATCH] Add RadioButton component --- .../frappe-ui-react/src/components/index.ts | 1 + .../src/components/radioButton/index.ts | 2 + .../radioButton/radioButton.stories.tsx | 143 ++++++++++++++++++ .../components/radioButton/radioButton.tsx | 93 ++++++++++++ .../radioButton/tests/radioButton.tsx | 90 +++++++++++ .../src/components/radioButton/types.ts | 15 ++ 6 files changed, 344 insertions(+) create mode 100644 packages/frappe-ui-react/src/components/radioButton/index.ts create mode 100644 packages/frappe-ui-react/src/components/radioButton/radioButton.stories.tsx create mode 100644 packages/frappe-ui-react/src/components/radioButton/radioButton.tsx create mode 100644 packages/frappe-ui-react/src/components/radioButton/tests/radioButton.tsx create mode 100644 packages/frappe-ui-react/src/components/radioButton/types.ts diff --git a/packages/frappe-ui-react/src/components/index.ts b/packages/frappe-ui-react/src/components/index.ts index a1a7101c..2285f825 100644 --- a/packages/frappe-ui-react/src/components/index.ts +++ b/packages/frappe-ui-react/src/components/index.ts @@ -23,6 +23,7 @@ export * from "./listview"; export * from "./password"; export * from "./progress"; export * from "./popover"; +export * from "./radioButton"; export * from "./rating"; export * from "./select"; export * from "./sidebar"; diff --git a/packages/frappe-ui-react/src/components/radioButton/index.ts b/packages/frappe-ui-react/src/components/radioButton/index.ts new file mode 100644 index 00000000..5d369661 --- /dev/null +++ b/packages/frappe-ui-react/src/components/radioButton/index.ts @@ -0,0 +1,2 @@ +export { default as RadioButton } from "./radioButton"; +export * from "./types"; diff --git a/packages/frappe-ui-react/src/components/radioButton/radioButton.stories.tsx b/packages/frappe-ui-react/src/components/radioButton/radioButton.stories.tsx new file mode 100644 index 00000000..8b28bc6a --- /dev/null +++ b/packages/frappe-ui-react/src/components/radioButton/radioButton.stories.tsx @@ -0,0 +1,143 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; + +import RadioButton from "./radioButton"; +import type { RadioButtonProps } from "./types"; +import { useState } from "react"; + +export default { + title: "Components/RadioButton", + component: RadioButton, + tags: ["autodocs"], + argTypes: { + className: { + control: "text", + description: + "Additional CSS classes for the radio button group container", + }, + size: { + control: { type: "select", options: ["sm", "md"] }, + description: "Size of the buttons in the group", + }, + flow: { + control: { type: "select", options: ["row", "column"] }, + description: "Layout flow of the radio buttons", + }, + disabled: { + control: "boolean", + description: "Disables all radio buttons when set to true", + }, + options: { + control: "object", + description: "Array of radio button options", + }, + value: { + control: "text", + description: "Currently selected value", + }, + onChange: { + action: "changed", + description: "Callback function when a radio button is selected", + }, + }, + parameters: { docs: { source: { type: "dynamic" } }, layout: "centered" }, +} as Meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + options: [ + { label: "John Doe", value: "john-doe" }, + { label: "Jane Smith", value: "jane-smith" }, + { label: "Bob Wilson", value: "bob-wilson" }, + ], + value: "john-doe", + size: "sm", + }, + render: (args) => { + const [value, setValue] = useState(args.value); + return ( +
+ +
+ ); + }, +}; + +export const WithoutLabel: Story = { + args: { + options: [{ value: "option1" }, { value: "option2" }, { value: "option3" }], + value: "option1", + size: "sm", + }, + render: (args) => { + const [value, setValue] = useState(args.value); + return ( +
+ +
+ ); + }, +}; + +export const Horizontal: Story = { + args: { + options: [ + { label: "John Doe", value: "john-doe" }, + { label: "Jane Smith", value: "jane-smith" }, + { label: "Bob Wilson", value: "bob-wilson" }, + ], + value: "john-doe", + size: "sm", + flow: "row", + }, + render: (args) => { + const [value, setValue] = useState(args.value); + return ( +
+ +
+ ); + }, +}; + +export const DisabledOptions: Story = { + args: { + options: [ + { label: "John Doe", value: "john-doe" }, + { label: "Jane Smith", value: "jane-smith", disabled: true }, + { label: "Bob Wilson", value: "bob-wilson" }, + ], + value: "john-doe", + size: "sm", + }, + render: (args) => { + const [value, setValue] = useState(args.value); + return ( +
+ +
+ ); + }, +}; + +export const GroupDisabled: Story = { + args: { + options: [ + { label: "John Doe", value: "john-doe" }, + { label: "Jane Smith", value: "jane-smith" }, + { label: "Bob Wilson", value: "bob-wilson" }, + ], + value: "john-doe", + size: "sm", + disabled: true, + }, + render: (args) => { + const [value, setValue] = useState(args.value); + return ( +
+ +
+ ); + }, +}; diff --git a/packages/frappe-ui-react/src/components/radioButton/radioButton.tsx b/packages/frappe-ui-react/src/components/radioButton/radioButton.tsx new file mode 100644 index 00000000..a56ec674 --- /dev/null +++ b/packages/frappe-ui-react/src/components/radioButton/radioButton.tsx @@ -0,0 +1,93 @@ +/** + * External dependencies. + */ +import { useMemo } from "react"; +import { Field, Label, Radio, RadioGroup } from "@headlessui/react"; +import clsx from "clsx"; + +/** + * Internal dependencies. + */ +import type { RadioButtonProps } from "./types"; + +const RadioButton = ({ + options, + size = "sm", + flow = "column", + disabled = false, + className, + value, + onChange, +}: RadioButtonProps) => { + const hasAnyLabel = useMemo( + () => options.some((option) => option.label), + [options] + ); + + return ( + onChange?.(val)} + disabled={disabled} + className={clsx( + "flex gap-1", + flow === "row" && "flex-row", + flow === "column" && "flex-col items-start", + className + )} + > + {options.map((option) => ( + + + + ))} + + ); +}; + +export default RadioButton; diff --git a/packages/frappe-ui-react/src/components/radioButton/tests/radioButton.tsx b/packages/frappe-ui-react/src/components/radioButton/tests/radioButton.tsx new file mode 100644 index 00000000..c2492f28 --- /dev/null +++ b/packages/frappe-ui-react/src/components/radioButton/tests/radioButton.tsx @@ -0,0 +1,90 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import RadioButton from "../radioButton"; +import userEvent from "@testing-library/user-event"; + +describe("RadioButton Component", () => { + const options = [ + { label: "Option 1", value: "opt1" }, + { label: "Option 2", value: "opt2" }, + { label: "Option 3", value: "opt3", disabled: true }, + ]; + + it("renders all options", () => { + render(); + options.forEach((option) => + expect(screen.getByText(option.label)).toBeInTheDocument() + ); + }); + + it("handles selection change", async () => { + const handleChange = jest.fn(); + const user = userEvent.setup(); + + render(); + + await user.click(screen.getByText("Option 2")); + expect(handleChange).toHaveBeenCalledWith("opt2"); + }); + + it("renders correctly controlled value", async () => { + const user = userEvent.setup(); + + const Wrapper = () => { + const [value, setValue] = React.useState("opt1"); + return ( + setValue(val || "")} + /> + ); + }; + + render(); + + const radio1 = screen.getByRole("radio", { name: "Option 1" }); + const radio2 = screen.getByRole("radio", { name: "Option 2" }); + + expect(radio1).toBeChecked(); + expect(radio2).not.toBeChecked(); + + // Click another option + await user.click(screen.getByText("Option 2")); + + expect(radio1).not.toBeChecked(); + expect(radio2).toBeChecked(); + }); + + it("respects global disabled state", async () => { + const handleChange = jest.fn(); + const user = userEvent.setup(); + + render(); + + const radio1 = screen.getByRole("radio", { name: "Option 1" }); + expect(radio1).toHaveAttribute("aria-disabled", "true"); + + await user.click(screen.getByText("Option 1")); + expect(handleChange).not.toHaveBeenCalled(); + }); + + it("respects individual option disabled state", async () => { + const handleChange = jest.fn(); + const user = userEvent.setup(); + + render(); + + const radio3 = screen.getByRole("radio", { name: "Option 3" }); + expect(radio3).toHaveAttribute("aria-disabled", "true"); + + // Try clicking disabled option + await user.click(screen.getByText("Option 3")); + expect(handleChange).not.toHaveBeenCalled(); + + // Valid option still works + await user.click(screen.getByText("Option 1")); + expect(handleChange).toHaveBeenCalledWith("opt1"); + }); +}); diff --git a/packages/frappe-ui-react/src/components/radioButton/types.ts b/packages/frappe-ui-react/src/components/radioButton/types.ts new file mode 100644 index 00000000..611dcc28 --- /dev/null +++ b/packages/frappe-ui-react/src/components/radioButton/types.ts @@ -0,0 +1,15 @@ +export interface RadioButtonOption { + label?: string; + value: string; + disabled?: boolean; +} + +export interface RadioButtonProps { + options: RadioButtonOption[]; + size?: "sm" | "md"; + flow?: "column" | "row"; + disabled?: boolean; + className?: string; + value?: string | null; + onChange?: (value: string | null) => void; +}