Skip to content
Open
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 { ButtonGroupProps } from "./types";
import Button from "../button/button";

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);
});
});
9 changes: 9 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,9 @@
import { ButtonProps, ButtonSize, ButtonTheme, ButtonVariant } from "../button/types";

export interface ButtonGroupProps {
buttons: (ButtonProps & { id?: string })[];
className?: string;
size?: ButtonSize;
variant?: ButtonVariant;
theme?: ButtonTheme;
}
Loading