Skip to content

Commit

Permalink
Feat(web-react): Introduce UncontrolledSplitButton component
Browse files Browse the repository at this point in the history
- Solves #DS-1671
  • Loading branch information
pavelklibani committed Feb 18, 2025
1 parent fcac75a commit 9539fbd
Show file tree
Hide file tree
Showing 8 changed files with 376 additions and 3 deletions.
57 changes: 56 additions & 1 deletion packages/web-react/src/components/SplitButton/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ const onDropdownToggle = () => setIsOpen(!isOpen);
</SplitButton>;
```

## API
### API

| Name | Type | Default | Required | Description |
| ------- | ------------------------------------------ | --------- | -------- | ------------- |
Expand All @@ -115,7 +115,62 @@ On top of the API options, the components accept [additional attributes][readme-
If you need more control over the styling of a component, you can use [style props][readme-style-props]
and [escape hatches][readme-escape-hatches].

## Uncontrolled Split Button

Uncontrolled Split Button is combination of Button component and Dropdown component.
It is used when you want to have a button with additional actions in a dropdown menu.

Simple variant:

```jsx
<UncontrolledSplitButton
id="uncontrolled-split-button-id"
buttonLabel="Label"
buttonOnClick={() => alert('Button clicked')}
>
{/* Dropdown content */}
</UncontrolledSplitButton>
```

Full example:

```jsx
<UncontrolledSplitButton
buttonIconName="check-plain"
buttonLabel="Button"
buttonOnClick={() => alert('Button clicked')}
color="secondary"
dropdownIconName="more"
dropdownLabel="More"
dropdownPlacement="bottom-start"
id="uncontrolled-split-button"
isDisabled={false}
size="large"
>
{/* Dropdown content */}
</UncontrolledSplitButton>
```

### API

| Name | Type | Default | Required | Description |
| ------------------- | -------------------------------------------- | -------------- | -------- | -------------------------------------------------------- |
| `buttonIconName` | `string` | - |\* | Name of the icon to be displayed in the Button |
| `buttonLabel` | `string` | - |\* | Label of the Button |
| `buttonOnClick` | `function` | - || Function to be called when the Button is clicked |
| `children` | `ReactNode` | - || Dropdown content |
| `color` | \[`primary` \| `secondary` \| `tertiary` ] | `primary` || Color variant |
| `dropdownIconName` | `string` | `chevron-down` || Name of the icon to be displayed in the Dropdown Trigger |
| `dropdownLabel` | `string` | - || Label of the Dropdown Trigger |
| `dropdownPlacement` | [Placement dictionary][dictionary-placement] | `bottom-end` || Placement of the Dropdown |
| `id` | `string` | - || Id of the Split Button and part of Dropdown id |
| `isDisabled` | `boolean` | `false` || Disables the Split Button |
| `size` | [Size dictionary][dictionary-size] | `medium` || Size variant |

(\*) Conditionally required: either `buttonIconName` or `buttonLabel` must be provided.

[dictionary-size]: https://github.com/lmc-eu/spirit-design-system/tree/main/docs/DICTIONARIES.md#size
[dictionary-placement]: https://github.com/lmc-eu/spirit-design-system/tree/main/docs/DICTIONARIES.md#placement
[readme-additional-attributes]: https://github.com/lmc-eu/spirit-design-system/blob/main/packages/web-react/README.md#additional-attributes
[readme-button]: https://github.com/lmc-eu/spirit-design-system/blob/main/packages/web-react/src/components/Button/README.md
[readme-dropdown]: https://github.com/lmc-eu/spirit-design-system/blob/main/packages/web-react/src/components/Dropdown/README.md
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import React, { useState } from 'react';
import { UncontrolledSplitButtonProps } from '../../types';
import { Button } from '../Button';
import { Dropdown, DropdownPopover, DropdownTrigger } from '../Dropdown';
import { Icon } from '../Icon';
import { VisuallyHidden } from '../VisuallyHidden';
import SplitButton from './SplitButton';

const defaultProps: Partial<UncontrolledSplitButtonProps> = {
dropdownPlacement: 'bottom-end',
dropdownIconName: 'chevron-down',
};

const UncontrolledSplitButton = (props: UncontrolledSplitButtonProps) => {
const propsWithDefaults = { ...defaultProps, ...props };
const {
children,
dropdownIconName,
dropdownLabel,
dropdownPlacement,
id,
isDisabled,
buttonLabel,
buttonOnClick,
buttonIconName,
...restProps
} = propsWithDefaults;
const [openDropdownStates, setOpenDropdownStates] = useState(false);

return (
<SplitButton id={id} {...restProps}>
<Button onClick={buttonOnClick} isDisabled={isDisabled}>
{buttonIconName && <Icon name={buttonIconName} marginRight="space-400" />}
{buttonLabel && buttonLabel}
</Button>
<Dropdown
id={`${id}-dropdown`}
isOpen={openDropdownStates}
onToggle={() => setOpenDropdownStates(!openDropdownStates)}
placement={dropdownPlacement}
>
<DropdownTrigger elementType={Button} isDisabled={isDisabled}>
{!dropdownLabel && <VisuallyHidden>{dropdownIconName}</VisuallyHidden>}
{dropdownLabel && dropdownLabel}
<Icon name={dropdownIconName} marginLeft={dropdownLabel ? 'space-400' : undefined} />
</DropdownTrigger>
<DropdownPopover>{children}</DropdownPopover>
</Dropdown>
</SplitButton>
);
};

export default UncontrolledSplitButton;
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import React from 'react';
import { classNamePrefixProviderTest, restPropsTest, stylePropsTest } from '@local/tests';
import { ComponentButtonColors, Sizes } from '../../../constants';
import UncontrolledSplitButton from '../UncontrolledSplitButton';

describe('SplitButton', () => {
const splitButtonColors = Object.values(ComponentButtonColors).filter(
(color) => color !== ComponentButtonColors.PLAIN,
);

const onClick = jest.fn();

classNamePrefixProviderTest(UncontrolledSplitButton, 'SplitButton');

stylePropsTest(UncontrolledSplitButton);

restPropsTest(UncontrolledSplitButton, 'div');

it('should have default classname', () => {
render(
<UncontrolledSplitButton
id="uncontrolled-split-button-id"
buttonLabel="Button"
buttonOnClick={onClick}
data-testid="test"
>
Content
</UncontrolledSplitButton>,
);

expect(screen.getByTestId('test')).toHaveClass('SplitButton');
});

it('should render dropdown content', () => {
render(
<UncontrolledSplitButton id="uncontrolled-split-button-id" buttonLabel="Button" buttonOnClick={onClick}>
Content
</UncontrolledSplitButton>,
);

expect(screen.getByText('Content')).toHaveClass('DropdownPopover');
});

it.each(Object.values(Sizes))('should render size %s on buttons', (size) => {
render(
<UncontrolledSplitButton
id="uncontrolled-split-button-id"
buttonLabel="Button"
buttonOnClick={onClick}
size={size}
>
Content
</UncontrolledSplitButton>,
);

expect(screen.getByText('Button')).toHaveClass(`Button--${size}`);
});

it.each(splitButtonColors)('should render color %s on buttons', (color) => {
render(
<UncontrolledSplitButton
id="uncontrolled-split-button-id"
buttonLabel="Button"
buttonOnClick={onClick}
color={color}
>
Content
</UncontrolledSplitButton>,
);

expect(screen.getByText('Button')).toHaveClass(`Button--${color}`);
});

it('should render color and size on buttons', () => {
render(
<UncontrolledSplitButton
id="uncontrolled-split-button-id"
buttonLabel="Button"
buttonOnClick={onClick}
color="secondary"
size="small"
>
Content
</UncontrolledSplitButton>,
);

expect(screen.getByText('Button')).toHaveClass('Button--secondary');
expect(screen.getByText('Button')).toHaveClass('Button--small');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import React from 'react';
import { Item } from '../../Item';
import UncontrolledSplitButton from '../UncontrolledSplitButton';

const UncontrolledSplitButtonDemo = () => {
return (
<UncontrolledSplitButton
buttonIconName="check-plain"
buttonLabel="Button"
buttonOnClick={() => alert('Button clicked')}
color="secondary"
dropdownIconName="more"
dropdownLabel="More"
dropdownPlacement="top-end"
id="uncontrolled-split-button"
isDisabled={false}
size="large"
>
<Item label="Item 1" />
<Item label="Item 2" />
</UncontrolledSplitButton>
);
};

export default UncontrolledSplitButtonDemo;
1 change: 1 addition & 0 deletions packages/web-react/src/components/SplitButton/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
'use client';

export { default as SplitButton } from './SplitButton';
export { default as UncontrolledSplitButton } from './UncontrolledSplitButton';
export * from './useSplitButtonStyleProps';
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { Tooltip, TooltipPopover, TooltipTrigger } from '../../Tooltip';
import { VisuallyHidden } from '../../VisuallyHidden';
import { dropdownContent } from '../demo/constants';

const DropdownContent = () => {
export const DropdownContent = () => {
return (
<>
{dropdownContent.map(({ icon, text }) => (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { Markdown } from '@storybook/blocks';
import type { Meta, StoryObj } from '@storybook/react';
import React from 'react';
import { ComponentButtonColors, Placements, Sizes } from '../../../constants';
import ReadMe from '../README.md';
import { UncontrolledSplitButton } from '..';
import { DropdownContent } from './SplitButtonParts';

const meta: Meta<typeof UncontrolledSplitButton> = {
title: 'Components/SplitButton',
component: UncontrolledSplitButton,
parameters: {
docs: {
page: () => <Markdown>{ReadMe}</Markdown>,
},
controls: { exclude: ['ElementTag', 'UNSAFE_className', 'UNSAFE_style', 'transferClassName'] },
},
argTypes: {
id: {
control: 'text',
description: 'The ID of the Uncontrolled Split Button.',
table: {
type: { summary: 'string' },
},
},
size: {
control: 'select',
options: [...Object.values(Sizes)],
description: 'Size of the button.',
table: {
defaultValue: { summary: Sizes.MEDIUM },
type: { summary: 'ButtonSize' },
},
},
color: {
control: 'select',
options: [...Object.values(ComponentButtonColors).filter((color) => color !== ComponentButtonColors.PLAIN)],
description: 'Color of the button.',
table: {
defaultValue: { summary: ComponentButtonColors.PRIMARY },
type: { summary: 'ButtonColor' },
},
},
buttonLabel: {
control: 'text',
description: 'The label for the button.',
table: {
type: { summary: 'string' },
},
},
buttonIconName: {
control: 'text',
description: 'The name of the icon to display on the button.',
table: {
type: { summary: 'string' },
},
},
dropdownLabel: {
control: 'text',
description: 'The label for the dropdown button.',
table: {
type: { summary: 'string' },
},
},
children: {
control: 'text',
description: 'The content of the dropdown.',
table: {
type: { summary: 'ReactNode' },
},
},
dropdownPlacement: {
control: 'select',
options: Object.values(Placements),
table: {
defaultValue: { summary: Placements.BOTTOM_END },
},
description: 'The placement of the dropdown.',
},
isDisabled: {
control: 'boolean',
description: 'Whether the button is disabled.',
table: {
type: { summary: 'boolean' },
},
},
dropdownIconName: {
control: 'text',
description: 'The name of the icon to display on the dropdown button.',
table: {
type: { summary: 'string' },
},
},
buttonOnClick: {
description: 'Function to call when the button is clicked.',
table: {
type: { summary: '() => void' },
},
},
},
args: {
buttonIconName: undefined,
buttonLabel: 'Button',
buttonOnClick: () => {},
children: <DropdownContent />,
color: ComponentButtonColors.PRIMARY,
dropdownIconName: 'chevron-down',
dropdownLabel: undefined,
dropdownPlacement: Placements.BOTTOM_END,
id: 'uncontrolled-split-button',
isDisabled: false,
size: Sizes.MEDIUM,
},
};

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

export const UncontrolledSplitButtonPlayground: Story = {
name: 'UncontrolledSplitButton',
render: (args) => {
const { children, ...rest } = args;

return <UncontrolledSplitButton {...rest}>{children}</UncontrolledSplitButton>;
},
};
Loading

0 comments on commit 9539fbd

Please sign in to comment.