Skip to content

Commit

Permalink
feat(ui): add SplitButton component (#27)
Browse files Browse the repository at this point in the history
Introduces a new SplitButton component, combining a primary action button with a dropdown menu for additional options. This component enhances user interface flexibility by providing quick access to a main action while offering related secondary actions in a compact design. The implementation includes various customization options such as different variants, tones, sizes, and icon support, along with Storybook documentation for easy integration and testing.
  • Loading branch information
DGiannaris authored Dec 17, 2024
1 parent 9edb37b commit 4752ed2
Show file tree
Hide file tree
Showing 5 changed files with 304 additions and 0 deletions.
164 changes: 164 additions & 0 deletions examples/storybook/src/stories/SplitButton.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import type { Meta, StoryObj } from "@storybook/react";
import { SplitButton, type SplitButtonProps } from "@synopsisapp/symbiosis-ui";

const meta: Meta<typeof SplitButton> = {
title: "Components/SplitButton",
component: SplitButton,
tags: ["autodocs"],
argTypes: {
variant: {
control: { type: "select" },
options: ["primary", "outline", "ghost"],
description: "Visual style variant of the button",
table: {
type: { summary: "string" },
defaultValue: { summary: "primary" },
},
},
tone: {
control: { type: "select" },
options: ["default", "destructive", "monochrome-light", "monochrome-dark"],
description: "Color tone of the button",
table: {
type: { summary: "string" },
defaultValue: { summary: "default" },
},
},
size: {
control: { type: "select" },
options: ["small-200", "small-100", "base"],
description: "Size of the button",
table: {
type: { summary: "string" },
defaultValue: { summary: "base" },
},
},
leftIcon: {
control: { type: "text" },
description: "Icon to display on the left side of the button",
},
rightIcon: {
control: { type: "text" },
description: "Icon to display on the right side of the button",
},
isDisabled: {
control: { type: "boolean" },
description: "Whether the button is disabled",
},
isLoading: {
control: { type: "boolean" },
description: "Whether the button is in a loading state",
},
},
};

export default meta;

type Story = StoryObj<typeof SplitButton>;

const defaultItems = [
{
text: "Option 1",
icon: "edit",
onSelect: () => console.log("Option 1 selected"),
},
{
text: "Option 2",
icon: "close",
onSelect: () => console.log("Option 2 selected"),
},
{
text: "Section Title",
isSectionTitle: true,
isSeparated: true,
},
{
text: "Option 3",
icon: "symbiosis-chevron-down",
onSelect: () => console.log("Option 3 selected"),
},
];

export const Default: Story = {
args: {
label: "Split Button",
variant: "primary",
tone: "default",
size: "base",
isDisabled: false,
isLoading: false,
leftIcon: "edit",
items: defaultItems as SplitButtonProps["items"],
},
};

export const DifferentVariants: Story = {
render: (args) => (
<div className="flex flex-col gap-4 items-start">
<SplitButton {...args} variant="primary" />
<SplitButton {...args} variant="outline" />
<SplitButton {...args} variant="ghost" />
</div>
),
args: {
...Default.args,
},
};

export const DifferentTones: Story = {
render: (args) => (
<div className="flex flex-col gap-4 items-start">
<SplitButton {...args} tone="default" />
<SplitButton {...args} tone="destructive" />
<div className="bg-red-200 p-2">
(custom background container to showcase)
<SplitButton {...args} tone="monochrome-light" />
</div>
<SplitButton {...args} tone="monochrome-dark" />
<SplitButton {...args} layout="fullwidth" tone="destructive" />
<SplitButton {...args} layout="fullwidth" tone="monochrome-light" />
<SplitButton {...args} layout="fullwidth" tone="monochrome-dark" />
</div>
),
args: {
...Default.args,
},
};

export const DifferentSizes: Story = {
render: (args) => (
<div className="flex flex-col gap-4 items-start">
<SplitButton {...args} size="small-200" />
<SplitButton {...args} size="small-100" />
<SplitButton {...args} size="base" />
</div>
),
args: {
...Default.args,
},
};

export const WithIcons: Story = {
render: (args) => (
<div className="flex flex-col gap-4 items-start">
<SplitButton {...args} leftIcon="edit" />
<SplitButton {...args} rightIcon="google-play" />
<SplitButton {...args} leftIcon="edit" rightIcon="google-play" />
</div>
),
args: {
...Default.args,
},
};

export const States: Story = {
render: (args) => (
<div className="flex flex-col gap-4 items-start">
<SplitButton {...args} isDisabled={true} />
<SplitButton {...args} isLoading={true} />
</div>
),
args: {
...Default.args,
},
};
80 changes: 80 additions & 0 deletions packages/ui/src/components/SplitButton/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@

import { Button } from "../Button";
import { cn } from "../../utils/cn";
import { Dropdown, DropdownMenuLabel, DropdownSimpleItem } from "../Dropdown";
import { IconButton } from "../IconButton";
import { iconButtonLeftBorderIconVariant } from "./styles";
import type { SplitButtonProps } from "./types";

export const SplitButton = ({
label,
variant = "primary",
size = "base",
leftIcon,
rightIcon,
layout = "normal",
type = "button",
className,
tone = "default",
items,
...restProps
}: SplitButtonProps) => {
return (
<div className="flex">
<Button
variant={variant}
layout={layout}
size={size}
leftIcon={leftIcon}
rightIcon={rightIcon}
type={type}
className={cn("rounded-tr-none rounded-br-none", className)}
tone={tone}
label={label}
{...restProps}
/>
<Dropdown.Root>
<Dropdown.Trigger>
<IconButton
icon="symbiosis-chevron-down"
variant={variant}
size={size}
isDisabled={restProps.isDisabled}
tone={tone}
renderAs="div"
className={cn(
"rounded-tl-none rounded-bl-none border-l",
iconButtonLeftBorderIconVariant({
variant,
tone,
}),
)}
/>
</Dropdown.Trigger>
<Dropdown.Portal>
<Dropdown.Content>
{items.map((item) => {
return (
<>
{item.isSeparated && <Dropdown.Separator />}
{item.isSectionTitle ? (
<DropdownMenuLabel key={`label-${item.text}`} label={item.text} />
) : (
<DropdownSimpleItem
key={`item-${item.text}`}
text={item.text}
onSelect={() => {
item.onSelect?.();
}}
icon={item.icon}
/>
)}
</>
);
})}
</Dropdown.Content>
</Dropdown.Portal>
</Dropdown.Root>
</div>
);
};
39 changes: 39 additions & 0 deletions packages/ui/src/components/SplitButton/styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { cva } from "class-variance-authority";

export const iconButtonLeftBorderIconVariant = cva([], {
variants: {
variant: {
primary: [],
outline: ["border-l-transparent"],
ghost: ["border-l-transparent"],
},
tone: {
default: [],
destructive: [],
"monochrome-light": [],
"monochrome-dark": [],
},
},
compoundVariants: [
{
variant: "primary",
tone: "default",
className: ["border-l-mainColors-light-400 hover:border-l-mainColors-light-400"],
},
{
variant: "primary",
tone: "destructive",
className: ["border-l-white hover:border-l-white"],
},
{
variant: "primary",
tone: "monochrome-light",
className: ["border-l-slate-600 hover:border-l-slate-600"],
},
{
variant: "primary",
tone: "monochrome-dark",
className: ["border-l-white hover:border-l-white"],
},
],
});
17 changes: 17 additions & 0 deletions packages/ui/src/components/SplitButton/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { ButtonVariant, BaseProps } from "../Button/types";
import type { IconProps } from "../Icon/types";

export type SplitButtonItemProps = {
text: string;
content?: React.ReactNode;
icon?: IconProps["name"];
onSelect?: () => void;
wrapperClassName?: string;
isSeparated?: boolean;
isSectionTitle?: boolean;
};

export type SplitButtonProps = Omit<BaseProps, "renderAs" | "variant"> & {
items: SplitButtonItemProps[];
variant: Exclude<ButtonVariant, "link">;
};
4 changes: 4 additions & 0 deletions packages/ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,7 @@ export type { TextAreaFieldProps } from "./components/TextAreaField/types";
export * from "./hooks/useToast";
export { Toaster } from "./components/Toaster";
export * from "./components/Toaster/types";

export { SplitButton } from "./components/SplitButton";
export * from "./components/SplitButton/types";
export type { SplitButtonProps } from "./components/SplitButton/types";

0 comments on commit 4752ed2

Please sign in to comment.