diff --git a/.changeset/real-maps-strive.md b/.changeset/real-maps-strive.md
new file mode 100644
index 000000000..e43c98d4b
--- /dev/null
+++ b/.changeset/real-maps-strive.md
@@ -0,0 +1,5 @@
+---
+'@drivenets/design-system': minor
+---
+
+Add `DsSplitButton` component
diff --git a/packages/design-system/src/components/ds-button-v3/ds-button-v3.module.scss b/packages/design-system/src/components/ds-button-v3/ds-button-v3.module.scss
index 90ba6c965..13146cce8 100644
--- a/packages/design-system/src/components/ds-button-v3/ds-button-v3.module.scss
+++ b/packages/design-system/src/components/ds-button-v3/ds-button-v3.module.scss
@@ -1,13 +1,11 @@
@use '../../styles/root_updated';
+@use '../../styles/shared/button' as button;
$height-large: 40px;
$height-medium: 36px;
$height-small: 28px;
$border-radius: 4px;
$focus-ring-width: 2px;
-// it looks a bit better with 0.3 than 0.2
-$transition-duration-default: 0.3s;
-$transition-duration-quick: 0.15s;
@mixin focus-ring($outer-color) {
outline: $focus-ring-width solid $outer-color;
@@ -28,10 +26,10 @@ $transition-duration-quick: 0.15s;
text-align: center;
cursor: pointer;
transition:
- background-color $transition-duration-default,
- border-color $transition-duration-quick,
- color $transition-duration-default,
- outline-color $transition-duration-quick;
+ background-color button.$transition-duration-default,
+ border-color button.$transition-duration-quick,
+ color button.$transition-duration-default,
+ outline-color button.$transition-duration-quick;
&:disabled:not([data-loading]) {
cursor: not-allowed;
diff --git a/packages/design-system/src/components/ds-split-button/__tests__/ds-split-button.browser.test.tsx b/packages/design-system/src/components/ds-split-button/__tests__/ds-split-button.browser.test.tsx
new file mode 100644
index 000000000..e8e638d4b
--- /dev/null
+++ b/packages/design-system/src/components/ds-split-button/__tests__/ds-split-button.browser.test.tsx
@@ -0,0 +1,191 @@
+import { useState } from 'react';
+import { describe, expect, it, vi } from 'vitest';
+import { page } from 'vitest/browser';
+import DsSplitButton from '../ds-split-button';
+import type { DsSelectProps } from '../../ds-select';
+
+const refreshOptions = [
+ { label: '30s', value: '30' },
+ { label: '1m', value: '60' },
+ { label: '5m', value: '300' },
+];
+
+const defaultSelect = {
+ options: refreshOptions,
+ value: '30',
+ onValueChange: vi.fn(),
+ multiple: false,
+} satisfies DsSelectProps;
+
+describe('DsSplitButton', () => {
+ it('calls slotProps.button.onClick when primary action is clicked', async () => {
+ const onClick = vi.fn();
+
+ await page.render(
+ ,
+ );
+
+ await page.getByRole('button', { name: 'Refresh' }).click();
+
+ expect(onClick).toHaveBeenCalledOnce();
+ });
+
+ it('updates select value when an option is chosen', async () => {
+ const onValueChange = vi.fn();
+
+ function Controlled() {
+ const [value, setValue] = useState('30');
+
+ return (
+ {
+ onValueChange(v);
+ setValue(v);
+ },
+ multiple: false,
+ },
+ }}
+ />
+ );
+ }
+
+ await page.render();
+
+ await page.getByRole('combobox').click();
+ await page.getByRole('option', { name: /1m/i }).click();
+
+ expect(onValueChange).toHaveBeenCalledWith('60');
+
+ const combobox = page.getByRole('combobox');
+ await expect.element(combobox).toHaveTextContent(/1m/);
+ });
+
+ it('disables primary button and select when disabled', async () => {
+ const onClick = vi.fn();
+ const onValueChange = vi.fn();
+
+ await page.render(
+ ,
+ );
+
+ const primary = page.getByRole('button', { name: 'Refresh', disabled: true });
+ const combobox = page.getByRole('combobox', { disabled: true });
+
+ await expect.element(primary).toBeDisabled();
+ await expect.element(combobox).toBeDisabled();
+
+ await primary.click({ force: true });
+ await combobox.click({ force: true });
+
+ expect(onClick).not.toHaveBeenCalled();
+ expect(onValueChange).not.toHaveBeenCalled();
+ });
+
+ it('sets loading state on primary button and blocks click', async () => {
+ const onClick = vi.fn();
+
+ await page.render(
+ ,
+ );
+
+ const primary = page.getByRole('button', { name: 'Refresh' });
+
+ await expect.element(primary).toHaveAttribute('aria-busy', 'true');
+ await expect.element(primary).toHaveAttribute('data-loading', '');
+
+ await primary.click({ force: true });
+
+ expect(onClick).not.toHaveBeenCalled();
+ });
+
+ it('keeps primary button and select the same height at medium size', async () => {
+ await page.render(
+ ,
+ );
+
+ const buttonHeight = page
+ .getByRole('button', { name: 'Refresh' })
+ .element()
+ .getBoundingClientRect().height;
+ const selectControl = page.getByRole('combobox').element().parentElement as HTMLElement;
+ const selectHeight = selectControl.getBoundingClientRect().height;
+
+ expect(buttonHeight).toBe(selectHeight);
+ });
+
+ it('keeps primary button and select the same height at small size', async () => {
+ await page.render(
+ ,
+ );
+
+ const buttonHeight = page
+ .getByRole('button', { name: 'Refresh' })
+ .element()
+ .getBoundingClientRect().height;
+ const selectControl = page.getByRole('combobox').element().parentElement as HTMLElement;
+ const selectHeight = selectControl.getBoundingClientRect().height;
+
+ expect(buttonHeight).toBe(selectHeight);
+ });
+});
diff --git a/packages/design-system/src/components/ds-split-button/ds-split-button.module.scss b/packages/design-system/src/components/ds-split-button/ds-split-button.module.scss
new file mode 100644
index 000000000..ac00e378f
--- /dev/null
+++ b/packages/design-system/src/components/ds-split-button/ds-split-button.module.scss
@@ -0,0 +1,99 @@
+@use '../../styles/shared/button' as button;
+
+$highlighted-z-index: 1;
+$divider-z-index: $highlighted-z-index + 1;
+$divider-width: 1px;
+$border-width: 1px;
+
+@mixin when-button-disabled {
+ .root:has(.actionButton:disabled) & {
+ @content;
+ }
+}
+
+@mixin when-select-disabled {
+ .root:has(.select[data-disabled]) & {
+ @content;
+ }
+}
+
+@mixin when-button-highlighted {
+ .root:has(.actionButton:not(:disabled):is(:hover, :focus-visible, :active, [data-selected='true'])) & {
+ @content;
+ }
+}
+
+@mixin when-select-highlighted {
+ .root:has(
+ .select:not([data-disabled]):is(:hover, :active, [data-state='open']),
+ .select:not([data-disabled]) :focus-visible
+ )
+ & {
+ @content;
+ }
+}
+
+.root {
+ display: inline-flex;
+
+ .actionButton {
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+ }
+}
+
+.actionButton {
+ @include when-button-highlighted {
+ z-index: $highlighted-z-index;
+ transition: border-color button.$transition-duration-quick;
+ }
+
+ &:not(:disabled):is(:hover, :active, [data-selected='true']) {
+ border-right-color: var(--border-border-secondary-hover);
+ }
+
+ &:not(:disabled):focus-visible {
+ border-right-color: var(--border-border-inverse);
+ }
+}
+
+.select {
+ margin-left: -$divider-width;
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+}
+
+.dividerAnchor {
+ position: relative;
+}
+
+.dividerWrapper {
+ position: absolute;
+ top: $border-width;
+ bottom: $border-width;
+ left: -$divider-width;
+ z-index: $divider-z-index;
+ width: $divider-width;
+ padding: var(--spacing-3xs) 0;
+ background-color: var(--background-background);
+
+ @include when-button-highlighted {
+ display: none;
+ }
+ @include when-select-highlighted {
+ display: none;
+ }
+}
+
+.divider {
+ background-color: var(--color-border-secondary);
+ width: $divider-width;
+ height: 100%;
+
+ @include when-button-disabled {
+ background-color: var(--border-border-disabled);
+ }
+ @include when-select-disabled {
+ background-color: var(--border-border-disabled);
+ }
+}
diff --git a/packages/design-system/src/components/ds-split-button/ds-split-button.stories.tsx b/packages/design-system/src/components/ds-split-button/ds-split-button.stories.tsx
new file mode 100644
index 000000000..2ecdfc96c
--- /dev/null
+++ b/packages/design-system/src/components/ds-split-button/ds-split-button.stories.tsx
@@ -0,0 +1,97 @@
+import { useState } from 'react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { fn } from 'storybook/test';
+import { DsSplitButton } from './';
+import { splitButtonSizes } from './ds-split-button.types';
+import type { DsSelectProps } from '../ds-select';
+
+const refreshOptions = [
+ { label: '30s', value: '30' },
+ { label: '1m', value: '60' },
+ { label: '5m', value: '300' },
+ { label: '10m', value: '600' },
+];
+
+const meta: Meta = {
+ title: 'Design System/SplitButton',
+ component: DsSplitButton,
+ parameters: {
+ layout: 'centered',
+ },
+ args: {
+ size: 'medium',
+ disabled: false,
+ slotProps: {
+ button: { icon: 'refresh' },
+ select: {
+ options: refreshOptions,
+ value: '30',
+ onValueChange: fn(),
+ multiple: false,
+ },
+ },
+ },
+ argTypes: {
+ size: { control: 'radio', options: splitButtonSizes },
+ className: { table: { disable: true } },
+ style: { table: { disable: true } },
+ ref: { table: { disable: true } },
+ slotProps: { table: { disable: true } },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: (args) => {
+ const [value, setValue] = useState('30');
+ const [loading, setLoading] = useState(false);
+
+ const handleAction = () => {
+ setLoading(true);
+ setTimeout(() => setLoading(false), 2000);
+ };
+
+ return (
+
+ );
+ },
+};
+
+export const Loading: Story = {
+ args: {
+ slotProps: {
+ button: {
+ loading: true,
+ },
+ select: {
+ options: refreshOptions,
+ value: '30',
+ onValueChange: fn(),
+ },
+ },
+ },
+};
+
+export const Disabled: Story = {
+ args: {
+ disabled: true,
+ },
+};
diff --git a/packages/design-system/src/components/ds-split-button/ds-split-button.tsx b/packages/design-system/src/components/ds-split-button/ds-split-button.tsx
new file mode 100644
index 000000000..c4b4dff53
--- /dev/null
+++ b/packages/design-system/src/components/ds-split-button/ds-split-button.tsx
@@ -0,0 +1,49 @@
+import classNames from 'classnames';
+import { DsSelect, type SelectSize } from '../ds-select';
+import styles from './ds-split-button.module.scss';
+import type { DsSplitButtonProps, SplitButtonSize } from './ds-split-button.types';
+import { DsButtonV3 } from '../ds-button-v3';
+
+const DsSplitButton = ({
+ ref,
+ className,
+ style,
+ size = 'medium',
+ disabled,
+ slotProps,
+}: DsSplitButtonProps) => {
+ const { className: buttonClassName, disabled: buttonDisabled, ...buttonProps } = slotProps.button;
+
+ const { className: selectClassName, disabled: selectDisabled, ...selectProps } = slotProps.select;
+
+ return (
+
+ );
+};
+
+const getSelectSize = (size: SplitButtonSize): SelectSize => {
+ return size === 'medium' ? 'default' : 'small';
+};
+
+export default DsSplitButton;
diff --git a/packages/design-system/src/components/ds-split-button/ds-split-button.types.ts b/packages/design-system/src/components/ds-split-button/ds-split-button.types.ts
new file mode 100644
index 000000000..e083df832
--- /dev/null
+++ b/packages/design-system/src/components/ds-split-button/ds-split-button.types.ts
@@ -0,0 +1,25 @@
+import type { CSSProperties, Ref } from 'react';
+import type { DsButtonV3Props } from '../ds-button-v3';
+import type { DsSelectProps } from '../ds-select';
+import type { DistributiveOmit } from '../../utils/type-utils';
+
+export const splitButtonSizes = ['medium', 'small'] as const;
+export type SplitButtonSize = (typeof splitButtonSizes)[number];
+
+type ButtonSlotProps = Omit;
+
+type SelectSlotProps = DistributiveOmit;
+
+export interface DsSplitButtonSlotProps {
+ button: ButtonSlotProps;
+ select: SelectSlotProps;
+}
+
+export interface DsSplitButtonProps {
+ ref?: Ref;
+ className?: string;
+ style?: CSSProperties;
+ size?: SplitButtonSize;
+ disabled?: boolean;
+ slotProps: DsSplitButtonSlotProps;
+}
diff --git a/packages/design-system/src/components/ds-split-button/index.ts b/packages/design-system/src/components/ds-split-button/index.ts
new file mode 100644
index 000000000..696d86f7d
--- /dev/null
+++ b/packages/design-system/src/components/ds-split-button/index.ts
@@ -0,0 +1,10 @@
+import type { ComponentProps } from 'react';
+import { withResponsiveProps } from '../../utils/responsive';
+import DsSplitButtonBase from './ds-split-button';
+export { type DsSplitButtonSlotProps, splitButtonSizes } from './ds-split-button.types';
+
+export const DsSplitButton = withResponsiveProps(DsSplitButtonBase, ['size']);
+
+DsSplitButton.displayName = 'DsSplitButton';
+
+export type DsSplitButtonProps = ComponentProps;
diff --git a/packages/design-system/src/index.ts b/packages/design-system/src/index.ts
index 8cd1e97ff..4723e8826 100644
--- a/packages/design-system/src/index.ts
+++ b/packages/design-system/src/index.ts
@@ -43,6 +43,7 @@ export * from './components/ds-select';
export * from './components/ds-skeleton';
export * from './components/ds-smart-tabs';
export * from './components/ds-spinner';
+export * from './components/ds-split-button';
export * from './components/ds-stack';
export * from './components/ds-status-badge';
export * from './components/ds-stepper';
diff --git a/packages/design-system/src/styles/shared/_button.scss b/packages/design-system/src/styles/shared/_button.scss
new file mode 100644
index 000000000..1d9ffa4d1
--- /dev/null
+++ b/packages/design-system/src/styles/shared/_button.scss
@@ -0,0 +1,2 @@
+$transition-duration-default: 0.3s; // it looks a bit better with 0.3 than 0.2
+$transition-duration-quick: 0.15s;
diff --git a/packages/design-system/src/utils/type-utils.ts b/packages/design-system/src/utils/type-utils.ts
new file mode 100644
index 000000000..a4b6bf12e
--- /dev/null
+++ b/packages/design-system/src/utils/type-utils.ts
@@ -0,0 +1 @@
+export type DistributiveOmit = T extends unknown ? Omit : never;