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
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { ArrowUpDown, ChevronDown, ListFilter, SmilePlus } from "lucide-react";

import ButtonGroup from "./buttonGroup";
import type { ButtonGroupProps } from "./types";

export default {
title: "Components/ButtonGroup",
component: ButtonGroup,
tags: ["autodocs"],
argTypes: {
className: {
control: "text",
description: "Additional CSS classes for the button group container",
},
buttons: {
control: "object",
description: "Array of button configurations",
},
size: {
control: { type: "select", options: ["sm", "md", "lg", "xl", "2xl"] },
description: "Size of the buttons in the group",
},
variant: {
control: {
type: "select",
options: ["solid", "subtle", "outline", "ghost"],
},
description: "Variant style of the buttons",
},
theme: {
control: { type: "select", options: ["gray", "blue", "green", "red"] },
description: "Theme color of the buttons",
},
},
parameters: { docs: { source: { type: "dynamic" } }, layout: "centered" },
} as Meta<typeof ButtonGroup>;

type Story = StoryObj<ButtonGroupProps>;

export const Default: Story = {
args: {
buttons: [
{
id: "btn-1",
label: "Group by",
},
{
id: "btn-2",
label: "Sort",
},
{
id: "btn-3",
label: "Filters",
},
],
size: "sm",
variant: "subtle",
},
render: (args) => (
<div className="p-4">
<ButtonGroup {...args} />
</div>
),
};

export const IconSubtle: Story = {
args: {
buttons: [
{
id: "btn-1",
icon: "phone",
},
{
id: "btn-2",
icon: "mail",
},
{
id: "btn-3",
icon: "external-link",
},
],
size: "sm",
variant: "subtle",
theme: "gray",
},
render: (args) => (
<div className="p-4">
<ButtonGroup {...args} />
</div>
),
};

export const IconMixedVariant: Story = {
args: {
buttons: [
{
id: "btn-1",
icon: "corner-up-left",
},
{
id: "btn-2",
icon: "map-pin",
},
{
id: "btn-3",
variant: "subtle",
icon: () => <SmilePlus className="w-4 h-4" />,
},
{
id: "btn-4",
icon: "more-horizontal",
},
],
size: "sm",
variant: "ghost",
theme: "gray",
},
render: (args) => (
<div className="p-4">
<ButtonGroup {...args} />
</div>
),
};

export const IconWithLabelSubtle: Story = {
args: {
buttons: [
{
id: "btn-1",
label: "Save view",
iconLeft: "plus",
},
{
id: "btn-2",
label: "Sort",
iconLeft: () => <ArrowUpDown className="w-4 h-4" />,
},
{
id: "btn-3",
label: "Filter",
iconLeft: () => <ListFilter className="w-4 h-4" />,
iconRight: () => <ChevronDown className="w-4 h-4" />,
},
{
id: "btn-4",
label: "Column",
iconLeft: "columns",
},
{
id: "btn-5",
icon: "more-horizontal",
},
],
size: "md",
variant: "subtle",
theme: "gray",
},
render: (args) => (
<div className="p-4">
<ButtonGroup {...args} />
</div>
),
};

export const IconWithLabelOutline: Story = {
args: {
buttons: [
{
id: "btn-1",
label: "Save view",
iconLeft: "plus",
},
{
id: "btn-2",
label: "Sort",
iconLeft: () => <ArrowUpDown className="w-4 h-4" />,
},
{
id: "btn-3",
label: "Filter",
iconLeft: () => <ListFilter className="w-4 h-4" />,
iconRight: () => <ChevronDown className="w-4 h-4" />,
},
{
id: "btn-4",
label: "Column",
iconLeft: "columns",
},
{
id: "btn-5",
icon: "more-horizontal",
},
],
size: "md",
variant: "outline",
theme: "gray",
},
render: (args) => (
<div className="p-4">
<ButtonGroup {...args} />
</div>
),
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* External dependencies.
*/
import clsx from "clsx";

/**
* Internal dependencies.
*/
import Button from "../button/button";
import type { ButtonGroupProps } from "./types";

const ButtonGroup = ({
buttons,
className,
size,
variant,
theme,
}: ButtonGroupProps) => {
return (
<div className={clsx("flex gap-1 items-center", className)}>
{buttons.map((buttonProps, index) => (
<Button
key={buttonProps.id ?? index}
size={size}
variant={variant}
theme={theme}
{...buttonProps}
/>
))}
</div>
);
};

export default ButtonGroup;
2 changes: 2 additions & 0 deletions packages/frappe-ui-react/src/components/buttonGroup/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default as ButtonGroup } from "./buttonGroup";
export * from "./types";
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { render, screen, fireEvent } from "@testing-library/react";
import "@testing-library/jest-dom";
import ButtonGroup from "../buttonGroup";

describe("ButtonGroup Component", () => {
const defaultButtons = [
{ label: "Button 1", onClick: jest.fn() },
{ label: "Button 2", onClick: jest.fn() },
];

const renderButtonGroup = (props = {}) => {
return render(<ButtonGroup buttons={defaultButtons} {...props} />);
};

it("renders multiple buttons", () => {
renderButtonGroup();
expect(screen.getByText("Button 1")).toBeInTheDocument();
expect(screen.getByText("Button 2")).toBeInTheDocument();
});

it("applies shared props to all buttons", () => {
renderButtonGroup({ size: "sm", variant: "outline", theme: "red" });

const button1 = screen.getByText("Button 1").closest("button");
const button2 = screen.getByText("Button 2").closest("button");

expect(button1).toHaveClass("h-7");
expect(button1).toHaveClass("text-red-700");
expect(button1).toHaveClass("border-outline-red-1");

expect(button2).toHaveClass("h-7");
expect(button2).toHaveClass("text-red-700");
expect(button2).toHaveClass("border-outline-red-1");
});

it("applies custom global className", () => {
const { container } = renderButtonGroup({
className: "custom-group-class",
});
// ButtonGroup wraps buttons in a div
expect(container.firstChild).toHaveClass("custom-group-class");
expect(container.firstChild).toHaveClass("flex");
expect(container.firstChild).toHaveClass("gap-1");
});

it("handles click events on individual buttons", () => {
const handleClick1 = jest.fn();
const handleClick2 = jest.fn();

const buttons = [
{ label: "Action 1", onClick: handleClick1 },
{ label: "Action 2", onClick: handleClick2 },
];

render(<ButtonGroup buttons={buttons} />);

fireEvent.click(screen.getByText("Action 1"));
expect(handleClick1).toHaveBeenCalledTimes(1);
expect(handleClick2).not.toHaveBeenCalled();

fireEvent.click(screen.getByText("Action 2"));
expect(handleClick2).toHaveBeenCalledTimes(1);
});
});
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The component allows individual button props to override group-level props (size, variant, theme), as demonstrated in the IconMixedVariant story. However, there's no test verifying this override behavior. Consider adding a test case that verifies individual buttons can override group-level properties.

Copilot uses AI. Check for mistakes.
14 changes: 14 additions & 0 deletions packages/frappe-ui-react/src/components/buttonGroup/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type {
ButtonProps,
ButtonSize,
ButtonTheme,
ButtonVariant,
} from "../button/types";

export interface ButtonGroupProps {
buttons: (ButtonProps & { id?: string })[];
className?: string;
size?: ButtonSize;
variant?: ButtonVariant;
theme?: ButtonTheme;
}
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 @@ -4,6 +4,7 @@ export * from "./avatar";
export * from "./badge";
export * from "./breadcrumbs";
export * from "./button";
export * from "./buttonGroup";
export * from "./calendar";
export * from "./charts";
export * from "./checkbox";
Expand Down
Loading