Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/split button #54

Merged
merged 15 commits into from
Jan 7, 2025
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,37 @@

All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.

### [0.0.313](https://github.com/getpingback/ui/compare/v0.0.312...v0.0.313) (2025-01-07)


### Bug Fixes

* changes requested ([65f9c2d](https://github.com/getpingback/ui/commits/65f9c2d25bb8e39dc57c581a8893af148244d337))
* remove props ([ffb5e79](https://github.com/getpingback/ui/commits/ffb5e791a33540d37baade5e9fcb9bf5d4b0436c))
* types ([6c072ff](https://github.com/getpingback/ui/commits/6c072ff427ccdf0d42081621005a3010d9e0fb0e))

### [0.0.311](https://github.com/getpingback/ui/compare/v0.0.310...v0.0.311) (2025-01-06)


### Bug Fixes

* splitbutton font style ([5052395](https://github.com/getpingback/ui/commits/505239574bf2c634e648531a3a409e710674cd06))

### [0.0.309](https://github.com/getpingback/ui/compare/v0.0.308...v0.0.309) (2025-01-06)


### Bug Fixes

* splitbutton styles ([75d1224](https://github.com/getpingback/ui/commits/75d1224157950f57c9420945bf4c5925c7d03d54))

### [0.0.307](https://github.com/getpingback/ui/compare/v0.0.305...v0.0.307) (2025-01-06)


### Features

* create a splitbutton and tests ([ed2e45c](https://github.com/getpingback/ui/commits/ed2e45c04755bedf8e0d657e69d3a3901f63bea6))
* export splitbutton ([e3cdfe3](https://github.com/getpingback/ui/commits/e3cdfe3f5075f3fffe2dd57cbc75744f03ada978))

### [0.0.305](https://github.com/getpingback/ui/compare/v0.0.303...v0.0.305) (2025-01-02)

### [0.0.303](https://github.com/getpingback/ui/compare/v0.0.301...v0.0.303) (2025-01-02)
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@getpingback/ui",
"author": "Pingback Team",
"version": "0.0.306",
"version": "0.0.314",
"license": "MIT",
"main": "dist/cjs/index.js",
"module": "dist/esm/index.js",
Expand Down
1 change: 1 addition & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@ export * from './pagination';
export * from './variable-input';
export * from './radio-group';
export * from './switch';
export * from './split-button';
1 change: 1 addition & 0 deletions src/components/split-button/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './split-button';
54 changes: 54 additions & 0 deletions src/components/split-button/split-button.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import * as React from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { SplitButton } from './split-button';
import { TrashCanIcon, PlusIcon } from '@stash-ui/light-icons';
import { HeartIcon } from '@stash-ui/regular-icons';

const meta = {
title: 'Components/SplitButton',
component: SplitButton,

tags: ['autodocs'],

argTypes: {}
} satisfies Meta<typeof SplitButton>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Solid: Story = {
args: {
prefixIcon: <HeartIcon />,
variant: 'solid',
label: 'Button label',
onPrefixClick: () => {},
menuItems: [
{
key: 'add',
icon: <PlusIcon />,
text: 'Add',
onClick: () => console.log('add')
},
{
key: 'delete',
icon: <TrashCanIcon />,
text: 'Delete',
onClick: () => console.log('delete')
}
]
}
};

export const Outlined: Story = {
args: {
...Solid.args,
variant: 'outlined'
}
};

export const Ghost: Story = {
args: {
...Solid.args,
variant: 'ghost'
}
};
104 changes: 104 additions & 0 deletions src/components/split-button/split-button.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { composeStories } from '@storybook/testing-react';

import * as stories from './split-button.stories';

const { Solid, Outlined, Ghost } = composeStories(stories);

describe('SplitButton Component', () => {
it('should render the SplitButton component', () => {
render(<Solid />);
const buttonContainer = screen.getByTestId('split-button');
const primaryButton = screen.getByTestId('split-button-primary');
const menuTrigger = screen.getByTestId('split-button-menu-trigger');
expect(buttonContainer).toBeInTheDocument();
expect(primaryButton).toBeInTheDocument();
expect(menuTrigger).toBeInTheDocument();

expect(primaryButton).toHaveStyle({ backgroundColor: 'var(--buttons-solid_default)' });
expect(menuTrigger).toHaveStyle({ backgroundColor: 'var(--buttons-solid_default)' });
});

it('should render the SplitButton menu items', async () => {
render(<Solid />);
const menuTrigger = screen.getByTestId('split-button-menu-trigger');

const user = userEvent.setup();
await user.click(menuTrigger);
const menuContent = screen.getByTestId('split-button-menu-content');
const menuItems = screen.getAllByTestId(/split-button-menu-item/i);
expect(menuContent).toBeInTheDocument();
expect(menuItems[0]).toHaveTextContent('Add');
expect(menuItems[1]).toHaveTextContent('Delete');
});

it('should call the onClick function when a menu item is clicked', async () => {
const onClickMock = jest.fn();
render(
<Solid
{...{
menuItems: [
{
key: 'add',
text: 'Add',
onClick: () => onClickMock('add')
},
{
key: 'delete',
text: 'Delete',
onClick: () => onClickMock('delete')
}
]
}}
/>
);

const user = userEvent.setup();
const menuTrigger = screen.getByTestId('split-button-menu-trigger');
await user.click(menuTrigger);
const menuItems = screen.getAllByTestId(/split-button-menu-item/i);
await user.click(menuItems[0]);

expect(onClickMock).toHaveBeenCalledWith('add');
});

it('should call the onClick function when a primary button is clicked', async () => {
const onClickMock = jest.fn();
render(<Solid {...{ onPrefixClick: () => onClickMock('primary action') }} />);
const user = userEvent.setup();
const primaryButton = screen.getByTestId('split-button-primary');
await user.click(primaryButton);

expect(onClickMock).toHaveBeenCalledWith('primary action');
});

it('should render the outlined SplitButton component', () => {
render(<Outlined />);
const buttonContainer = screen.getByTestId('split-button');
const primaryButton = screen.getByTestId('split-button-primary');
const menuTrigger = screen.getByTestId('split-button-menu-trigger');

expect(buttonContainer).toBeInTheDocument();
expect(primaryButton).toBeInTheDocument();
expect(menuTrigger).toBeInTheDocument();

expect(primaryButton).toHaveStyle({ backgroundColor: 'var(--buttons-outlined_default)' });
expect(menuTrigger).toHaveStyle({ backgroundColor: 'var(--buttons-outlined_default)' });
});

it('should render the ghost SplitButton component', () => {
render(<Ghost />);
const buttonContainer = screen.getByTestId('split-button');
const primaryButton = screen.getByTestId('split-button-primary');
const menuTrigger = screen.getByTestId('split-button-menu-trigger');

expect(buttonContainer).toBeInTheDocument();
expect(primaryButton).toBeInTheDocument();
expect(menuTrigger).toBeInTheDocument();

expect(primaryButton).toHaveStyle({ backgroundColor: 'var(--buttons-ghost_bg-color)' });
expect(menuTrigger).toHaveStyle({ backgroundColor: 'var(--buttons-ghost_bg-color)' });
});
});
104 changes: 104 additions & 0 deletions src/components/split-button/split-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import React, { useState } from 'react';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { CaretDownIcon } from '@stash-ui/light-icons';
import { cva } from 'class-variance-authority';
import { DropdownItem } from '../dropdown';

const containerVariants = cva('flex items-center font-primary max-w-fit h-[32px] text-sm rounded-lg ', {
variants: {
type: {
solid: 'bg-button-solid text-button-solid-foreground hover:shadow-solid transition-all duration-200 ease-in-out',
outlined:
'bg-button-outlined text-secondary-foreground border border-button-outlined-border hover:shadow-outlined transition-all duration-200 ease-in-out',
ghost:
'bg-button-ghost-gray text-button-ghost-foreground text-secondary-foreground hover:shadow-ghost transition-all duration-200 ease-in-out'
}
},
defaultVariants: {
type: 'solid'
}
});

const leftButtonVariants = cva(
'h-full w-full flex items-center gap-1 px-3 rounded-l-lg rounded-r-none transition-all duration-200 ease-in-out font-semibold',
{
variants: {
type: {
solid: 'hover:bg-button-solid-hover',
outlined: 'hover:bg-button-outlined-hover opacity-85 hover:opacity-100',
ghost: 'hover:bg-button-ghost-hover opacity-85 hover:opacity-100'
}
}
}
);

const menuTriggerVariants = cva(
'h-full w-full min-w-[32px] max-w-[32px] border-l flex items-center justify-center rounded-r-lg rounded-l-none border-solid transition-all duration-200 ease-in-out',
{
variants: {
type: {
solid: 'hover:bg-button-solid-hover border-[#282C2F1F]',
outlined: 'hover:bg-button-outlined-hover opacity-85 hover:opacity-100 border-[#282C2F1F]',
ghost: 'hover:bg-button-ghost-hover border-[#282C2F1F]'
}
}
}
);

interface SplitButtonProps {
prefixIcon: React.ReactNode;
label: string;
variant?: 'solid' | 'outlined' | 'ghost';
onPrefixClick: () => void;
sufixIcon?: React.ReactNode;
className?: string;
menuItems: {
key: string;
icon: JSX.Element | undefined;
text: string;
onClick: () => void;
}[];
}

function SplitButton({ prefixIcon, label, variant, onPrefixClick, menuItems, sufixIcon, className }: SplitButtonProps) {
const [isMenuActionsOpen, setIsMenuActionsOpen] = useState(false);

return (
<DropdownMenu.Root open={isMenuActionsOpen} modal={false} onOpenChange={(open) => !open && setIsMenuActionsOpen(false)}>
<DropdownMenu.Trigger asChild>
<div className={containerVariants({ type: variant })} data-testid="split-button">
<button className={leftButtonVariants({ type: variant })} onClick={onPrefixClick} data-testid="split-button-primary">
{prefixIcon}
{label}
</button>
<button
onClick={() => setIsMenuActionsOpen(!isMenuActionsOpen)}
className={menuTriggerVariants({ type: variant })}
data-testid="split-button-menu-trigger"
>
{sufixIcon ? sufixIcon : <CaretDownIcon width={20} height={20} />}
</button>
</div>
</DropdownMenu.Trigger>

<DropdownMenu.Portal>
<DropdownMenu.Content
data-testid="split-button-menu-content"
onClick={() => setIsMenuActionsOpen(false)}
side="bottom"
className={`rounded-lg w-[252px] flex-col z-50 min-w-fit overflow-hidden bg-background-accent shadow-modal py-2 ${className}`}
sideOffset={4}
align="center"
>
{menuItems.map((item, index) => (
<DropdownItem icon={item?.icon} key={index} onClick={item.onClick} data-testid="split-button-menu-item">
{item.text}
</DropdownItem>
))}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
}

export { SplitButton };
8 changes: 8 additions & 0 deletions src/styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,15 @@

--buttons-solid_default: #9061f9;
--button-solid-bg: #9061f9;
--button-solid-hover: #7e3af2;
--buttons-label_color: #7e3af2;
--button-solid-text: #fafafa;
--button-ghost-bg: #9061f91f;
--button-ghost-text: #52525b;
--button-ghost-gray: #52525B14;
--button-ghost-gray-hover: #282C2F14;
--button-outlined-border: #D4D4D8;
--button-outlined-hover: #52525B14;
--buttons-label_inverse: #fafafa;
--button-hover-solid-color: #9061f940;
--buttons-ghost_bg-color: #9061f91f;
Expand All @@ -45,6 +50,8 @@
--switch-shadow: 0px 0px 1px 0px rgba(0, 0, 0, 0.161) inset;

--button-solid-shadow: 0px 0px 0px 3px #9061f940;
--button-outlined-shadow: 0px 0px 0px 3px #9CA3AF1F;
--button-ghost-shadow: 0px 0px 0px 3px #9CA3AF14;

--list-hightlight--hover: 0px 0px 0px 3px rgba(14, 159, 110, 0.12);
--list-actived: #9061f914;
Expand Down Expand Up @@ -104,6 +111,7 @@
--buttons-solid_default: #9061f9;
--button-solid-bg: #9061f9;
--button-solid-text: #fafafa;
--button-solid-hover: #7e3af2;
--buttons-label: #52525b;
--button-ghost-bg: #ac94fa3d;
--button-ghost-text: #fafafa;
Expand Down
13 changes: 11 additions & 2 deletions tailwind.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ const config = {
drawer: 'var(--drawer-shadow)',
custom: '0px 0px 0px 1px rgba(0,0,0,0.15)',
solid: 'var(--button-solid-shadow)',
outlined: 'var(--button-outlined-shadow)',
ghost: 'var(--button-ghost-shadow)',
dropdown: 'var(--dropdown-shadow)',
switch: 'var(--switch-shadow)'
},
Expand Down Expand Up @@ -298,11 +300,18 @@ const config = {
button: {
solid: {
DEFAULT: 'var(--button-solid-bg)',
foreground: 'var(--button-solid-text)'
foreground: 'var(--button-solid-text)',
hover: 'var(--button-solid-hover)'
},
ghost: {
DEFAULT: 'var(--button-ghost-bg)',
foreground: 'var(--button-ghost-text)'
foreground: 'var(--button-ghost-text)',
gray: 'var(--button-ghost-gray)',
hover: 'var(--button-ghost-gray-hover)'
},
outlined: {
border: 'var(--button-outlined-border)',
hover: 'var(--button-outlined-hover)'
},
done: {
DEFAULT: 'var(--badge-done-bg)',
Expand Down