Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions packages/frappe-ui-react/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
2 changes: 2 additions & 0 deletions packages/frappe-ui-react/src/components/radioButton/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default as RadioButton } from "./radioButton";
export * from "./types";
Original file line number Diff line number Diff line change
@@ -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<typeof RadioButton>;

type Story = StoryObj<RadioButtonProps>;

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 (
<div className="p-4">
<RadioButton {...args} value={value} onChange={setValue} />
</div>
);
},
};

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 (
<div className="p-4">
<RadioButton {...args} value={value} onChange={setValue} />
</div>
);
},
};

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 (
<div className="p-4">
<RadioButton {...args} value={value} onChange={setValue} />
</div>
);
},
};

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 (
<div className="p-4">
<RadioButton {...args} value={value} onChange={setValue} />
</div>
);
},
};

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 (
<div className="p-4">
<RadioButton {...args} value={value} onChange={setValue} />
</div>
);
},
};
Original file line number Diff line number Diff line change
@@ -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 (
<RadioGroup
value={value}
onChange={(val) => onChange?.(val)}
disabled={disabled}
className={clsx(
"flex gap-1",
flow === "row" && "flex-row",
flow === "column" && "flex-col items-start",
className
)}
>
{options.map((option) => (
<Field
key={option.value}
disabled={option.disabled || disabled}
className={clsx(
"group rounded transition-colors focus:outline-none",
option.disabled || disabled
? "text-ink-gray-5"
: option.label &&
"text-ink-gray-8 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus:bg-surface-gray-2 focus:ring-2 focus:ring-outline-gray-3",
size === "sm" && "text-base",
size === "md" && "text-md"
)}
>
<Label
className={clsx(
"flex items-center gap-2",
option.label ? "px-2 py-1.75" : hasAnyLabel ? "pl-2 py-1" : "p-1"
)}
>
<Radio
value={option.value}
className={clsx(
"relative flex justify-center items-center shrink-0 transition-color duration-100 appearance-none border border-gray-500 rounded-full",
"data-checked:border-gray-900 data-checked:bg-gray-900",
!option.disabled &&
!disabled && [
"group-hover:border-gray-600 data-checked:group-hover:border-surface-gray-6 data-checked:group-hover:bg-surface-gray-6",
"group-active:border-gray-700 data-checked:group-active:border-surface-gray-6 data-checked:group-active:bg-surface-gray-6",
"group-focus:border-gray-900 focus:outline-none group-focus:ring-2 group-focus:ring-outline-gray-3",
],
"data-disabled:border-gray-200 data-disabled:bg-surface-gray-1 data-disabled:checked:border-surface-gray-2 data-disabled:checked:bg-surface-gray-2",
size === "sm" ? "w-3.5 h-3.5" : "w-4.25 h-4.25"
)}
>
<span
className={clsx(
"shrink-0 absolute rounded-full pointer-events-none",
size === "sm" ? "w-1.5 h-1.5" : "w-1.75 h-1.75",
value !== option.value && "hidden",
option.disabled || disabled
? "bg-gray-400"
: "bg-surface-white"
)}
></span>
</Radio>
{option.label ? <span>{option.label}</span> : null}
</Label>
</Field>
))}
</RadioGroup>
);
};

export default RadioButton;
Original file line number Diff line number Diff line change
@@ -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(<RadioButton options={options} />);
options.forEach((option) =>
expect(screen.getByText(option.label)).toBeInTheDocument()
);
});

it("handles selection change", async () => {
const handleChange = jest.fn();
const user = userEvent.setup();

render(<RadioButton options={options} onChange={handleChange} />);

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 (
<RadioButton
options={options}
value={value}
onChange={(val) => setValue(val || "")}
/>
);
};

render(<Wrapper />);

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(<RadioButton options={options} disabled onChange={handleChange} />);

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(<RadioButton options={options} onChange={handleChange} />);

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");
});
});
15 changes: 15 additions & 0 deletions packages/frappe-ui-react/src/components/radioButton/types.ts
Original file line number Diff line number Diff line change
@@ -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;
}