From 51977d04466430fd518094309b46aba01ed042fe Mon Sep 17 00:00:00 2001 From: Ihor Romanchuk Date: Fri, 3 Apr 2026 20:42:46 +0200 Subject: [PATCH 01/10] feat(design-system): add `DsSplitButton` component [AR-54024] --- .changeset/real-maps-strive.md | 5 + .../ds-split-button.module.scss | 107 +++++++++++++++++ .../ds-split-button.stories.module.scss | 5 + .../ds-split-button.stories.tsx | 110 ++++++++++++++++++ .../ds-split-button/ds-split-button.tsx | 59 ++++++++++ .../ds-split-button/ds-split-button.types.ts | 22 ++++ .../ds-split-button/ds-split-button.utils.ts | 6 + .../src/components/ds-split-button/index.ts | 2 + packages/design-system/src/index.ts | 1 + 9 files changed, 317 insertions(+) create mode 100644 .changeset/real-maps-strive.md create mode 100644 packages/design-system/src/components/ds-split-button/ds-split-button.module.scss create mode 100644 packages/design-system/src/components/ds-split-button/ds-split-button.stories.module.scss create mode 100644 packages/design-system/src/components/ds-split-button/ds-split-button.stories.tsx create mode 100644 packages/design-system/src/components/ds-split-button/ds-split-button.tsx create mode 100644 packages/design-system/src/components/ds-split-button/ds-split-button.types.ts create mode 100644 packages/design-system/src/components/ds-split-button/ds-split-button.utils.ts create mode 100644 packages/design-system/src/components/ds-split-button/index.ts 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-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..a9cf86af7 --- /dev/null +++ b/packages/design-system/src/components/ds-split-button/ds-split-button.module.scss @@ -0,0 +1,107 @@ +$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)) & { + @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: none !important; // TODO: remove once DsButtonV3 is used +} + +.root .actionButton:disabled .actionContent { + border-color: var(--color-border-disabled); // TODO: remove once DsButtonV3 is used +} + +.actionButton { + .actionContent { + height: 36px !important; // TODO: remove once DsButtonV3 is used + width: 36px !important; // TODO: remove once DsButtonV3 is used + border-top-right-radius: 0; + border-bottom-right-radius: 0; + + @include when-button-highlighted { + z-index: $highlighted-z-index; + } + } +} + +.select { + margin-left: -$divider-width; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + + // TODO: fix these styles inside the DsSelect component + @include when-select-disabled { + background-color: var(--background-background); + + & * { + color: var(--color-font-disabled) !important; + } + } +} + +.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(--color-border-disabled); + } + @include when-select-disabled { + background-color: var(--color-border-disabled); + } +} diff --git a/packages/design-system/src/components/ds-split-button/ds-split-button.stories.module.scss b/packages/design-system/src/components/ds-split-button/ds-split-button.stories.module.scss new file mode 100644 index 000000000..fbbbef45d --- /dev/null +++ b/packages/design-system/src/components/ds-split-button/ds-split-button.stories.module.scss @@ -0,0 +1,5 @@ +@use '../../styles/root_updated'; + +.spinner { + color: var(--background-background-primary); +} 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..534533494 --- /dev/null +++ b/packages/design-system/src/components/ds-split-button/ds-split-button.stories.tsx @@ -0,0 +1,110 @@ +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { fn } from 'storybook/test'; +import DsSplitButton from './ds-split-button'; +import { splitButtonSizes } from './ds-split-button.types'; +import { DsIcon } from '../ds-icon'; +import { DsSpinner } from '../ds-spinner'; +import styles from './ds-split-button.stories.module.scss'; +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: { + children: , + 'aria-label': 'Refresh now', + }, + 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 ( + + ) : ( + + ), + onClick: handleAction, + disabled: loading, + 'aria-label': loading ? 'Refreshing' : 'Refresh now', + }, + select: { + ...args.slotProps.select, + value, + onValueChange: setValue, + } as DsSelectProps, + }} + /> + ); + }, +}; + +export const Loading: Story = { + args: { + slotProps: { + button: { + children: , + disabled: true, + 'aria-label': 'Refreshing', + }, + 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..3065e6d71 --- /dev/null +++ b/packages/design-system/src/components/ds-split-button/ds-split-button.tsx @@ -0,0 +1,59 @@ +import classNames from 'classnames'; +import { DsButton } from '../ds-button'; +import { DsSelect } from '../ds-select'; +import styles from './ds-split-button.module.scss'; +import type { DsSplitButtonProps } from './ds-split-button.types'; +import { getSelectSize } from './ds-split-button.utils'; + +const DsSplitButton = ({ + ref, + className, + style, + size = 'medium', + disabled, + slotProps, +}: DsSplitButtonProps) => { + const { + className: buttonClassName, + contentClassName, + size: buttonSize, + disabled: buttonDisabled, + ...buttonRest + } = slotProps.button; + + const { + className: selectClassName, + size: selectSize, + disabled: selectDisabled, + ...selectRest + } = slotProps.select; + + return ( +
+ + +
+
+
+
+
+ + +
+ ); +}; + +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..9ed526348 --- /dev/null +++ b/packages/design-system/src/components/ds-split-button/ds-split-button.types.ts @@ -0,0 +1,22 @@ +import type { CSSProperties, Ref } from 'react'; +import type { DsButtonUnifiedProps } from '../ds-button'; +import type { DsSelectProps } from '../ds-select'; + +type ButtonV12Props = Extract; + +export const splitButtonSizes = ['medium', 'small'] as const; +export type SplitButtonSize = (typeof splitButtonSizes)[number]; + +export interface DsSplitButtonSlotProps { + button: Partial; + select: DsSelectProps; +} + +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/ds-split-button.utils.ts b/packages/design-system/src/components/ds-split-button/ds-split-button.utils.ts new file mode 100644 index 000000000..53d9c9baa --- /dev/null +++ b/packages/design-system/src/components/ds-split-button/ds-split-button.utils.ts @@ -0,0 +1,6 @@ +import type { SelectSize } from '../ds-select'; +import type { SplitButtonSize } from './ds-split-button.types'; + +export const getSelectSize = (size: SplitButtonSize): SelectSize => { + return size === 'medium' ? 'default' : 'small'; +}; 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..a4c0fd2af --- /dev/null +++ b/packages/design-system/src/components/ds-split-button/index.ts @@ -0,0 +1,2 @@ +export { default as DsSplitButton } from './ds-split-button'; +export type { DsSplitButtonProps, DsSplitButtonSlotProps } from './ds-split-button.types'; diff --git a/packages/design-system/src/index.ts b/packages/design-system/src/index.ts index ac8f389ec..d5aac1d31 100644 --- a/packages/design-system/src/index.ts +++ b/packages/design-system/src/index.ts @@ -42,6 +42,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-status-badge'; export * from './components/ds-stepper'; export * from './components/ds-system-status'; From 6f82b6cd834ee2db6c21b44430e50e24b71ece27 Mon Sep 17 00:00:00 2001 From: Ihor Romanchuk Date: Fri, 3 Apr 2026 21:15:00 +0200 Subject: [PATCH 02/10] Update inline comment --- .../src/components/ds-split-button/ds-split-button.module.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index a9cf86af7..ec1da65a2 100644 --- 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 @@ -61,7 +61,7 @@ $border-width: 1px; border-top-left-radius: 0; border-bottom-left-radius: 0; - // TODO: fix these styles inside the DsSelect component + // TODO: remove once PR is merged https://github.com/drivenets/design-system/pull/346 @include when-select-disabled { background-color: var(--background-background); From cca6e3c522404f33c0bd06ffde2a50ab0376557b Mon Sep 17 00:00:00 2001 From: Ihor Romanchuk Date: Wed, 8 Apr 2026 14:53:01 +0200 Subject: [PATCH 03/10] use DsButtonV3 and fixes to Input based components --- .../ds-split-button.module.scss | 38 ++++++------------- .../ds-split-button.stories.module.scss | 5 --- .../ds-split-button.stories.tsx | 21 ++-------- .../ds-split-button/ds-split-button.tsx | 9 ++--- .../ds-split-button/ds-split-button.types.ts | 6 +-- .../src/components/ds-split-button/index.ts | 6 ++- 6 files changed, 25 insertions(+), 60 deletions(-) delete mode 100644 packages/design-system/src/components/ds-split-button/ds-split-button.stories.module.scss 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 index ec1da65a2..a5795fd7f 100644 --- 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 @@ -2,6 +2,7 @@ $highlighted-z-index: 1; $divider-z-index: $highlighted-z-index + 1; $divider-width: 1px; $border-width: 1px; +$button-transition-duration: 0.15s; @mixin when-button-disabled { .root:has(.actionButton:disabled) & { @@ -33,26 +34,18 @@ $border-width: 1px; .root { display: inline-flex; -} - -.actionButton { - border: none !important; // TODO: remove once DsButtonV3 is used -} -.root .actionButton:disabled .actionContent { - border-color: var(--color-border-disabled); // TODO: remove once DsButtonV3 is used -} - -.actionButton { - .actionContent { - height: 36px !important; // TODO: remove once DsButtonV3 is used - width: 36px !important; // TODO: remove once DsButtonV3 is used + .actionButton { border-top-right-radius: 0; border-bottom-right-radius: 0; + } +} - @include when-button-highlighted { - z-index: $highlighted-z-index; - } +.actionButton { + @include when-button-highlighted { + z-index: $highlighted-z-index; + border-right-color: var(--border-border-secondary-hover); + transition: border-color $button-transition-duration; } } @@ -60,15 +53,6 @@ $border-width: 1px; margin-left: -$divider-width; border-top-left-radius: 0; border-bottom-left-radius: 0; - - // TODO: remove once PR is merged https://github.com/drivenets/design-system/pull/346 - @include when-select-disabled { - background-color: var(--background-background); - - & * { - color: var(--color-font-disabled) !important; - } - } } .dividerAnchor { @@ -99,9 +83,9 @@ $border-width: 1px; height: 100%; @include when-button-disabled { - background-color: var(--color-border-disabled); + background-color: var(--border-border-disabled); } @include when-select-disabled { - background-color: var(--color-border-disabled); + background-color: var(--border-border-disabled); } } diff --git a/packages/design-system/src/components/ds-split-button/ds-split-button.stories.module.scss b/packages/design-system/src/components/ds-split-button/ds-split-button.stories.module.scss deleted file mode 100644 index fbbbef45d..000000000 --- a/packages/design-system/src/components/ds-split-button/ds-split-button.stories.module.scss +++ /dev/null @@ -1,5 +0,0 @@ -@use '../../styles/root_updated'; - -.spinner { - color: var(--background-background-primary); -} 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 index 534533494..051524501 100644 --- 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 @@ -3,9 +3,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { fn } from 'storybook/test'; import DsSplitButton from './ds-split-button'; import { splitButtonSizes } from './ds-split-button.types'; -import { DsIcon } from '../ds-icon'; -import { DsSpinner } from '../ds-spinner'; -import styles from './ds-split-button.stories.module.scss'; import type { DsSelectProps } from '../ds-select'; const refreshOptions = [ @@ -25,10 +22,7 @@ const meta: Meta = { size: 'medium', disabled: false, slotProps: { - button: { - children: , - 'aria-label': 'Refresh now', - }, + button: { icon: 'refresh' }, select: { options: refreshOptions, value: '30', @@ -66,14 +60,9 @@ export const Default: Story = { slotProps={{ button: { ...args.slotProps.button, - children: loading ? ( - - ) : ( - - ), + loading, + icon: 'refresh', onClick: handleAction, - disabled: loading, - 'aria-label': loading ? 'Refreshing' : 'Refresh now', }, select: { ...args.slotProps.select, @@ -90,9 +79,7 @@ export const Loading: Story = { args: { slotProps: { button: { - children: , - disabled: true, - 'aria-label': 'Refreshing', + loading: true, }, select: { options: refreshOptions, 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 index 3065e6d71..91cac4cfc 100644 --- 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 @@ -1,9 +1,9 @@ import classNames from 'classnames'; -import { DsButton } from '../ds-button'; import { DsSelect } from '../ds-select'; import styles from './ds-split-button.module.scss'; import type { DsSplitButtonProps } from './ds-split-button.types'; import { getSelectSize } from './ds-split-button.utils'; +import { DsButtonV3 } from '../ds-button-v3'; const DsSplitButton = ({ ref, @@ -15,7 +15,6 @@ const DsSplitButton = ({ }: DsSplitButtonProps) => { const { className: buttonClassName, - contentClassName, size: buttonSize, disabled: buttonDisabled, ...buttonRest @@ -30,14 +29,12 @@ const DsSplitButton = ({ return (
-
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 index 9ed526348..b0576bd40 100644 --- 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 @@ -1,14 +1,12 @@ import type { CSSProperties, Ref } from 'react'; -import type { DsButtonUnifiedProps } from '../ds-button'; +import type { DsButtonV3Props } from '../ds-button-v3'; import type { DsSelectProps } from '../ds-select'; -type ButtonV12Props = Extract; - export const splitButtonSizes = ['medium', 'small'] as const; export type SplitButtonSize = (typeof splitButtonSizes)[number]; export interface DsSplitButtonSlotProps { - button: Partial; + button: Partial; select: DsSelectProps; } diff --git a/packages/design-system/src/components/ds-split-button/index.ts b/packages/design-system/src/components/ds-split-button/index.ts index a4c0fd2af..5fb6721cf 100644 --- a/packages/design-system/src/components/ds-split-button/index.ts +++ b/packages/design-system/src/components/ds-split-button/index.ts @@ -1,2 +1,6 @@ export { default as DsSplitButton } from './ds-split-button'; -export type { DsSplitButtonProps, DsSplitButtonSlotProps } from './ds-split-button.types'; +export { + type DsSplitButtonProps, + type DsSplitButtonSlotProps, + splitButtonSizes, +} from './ds-split-button.types'; From 2e13f716dd00bd22864a8a20357ee50ef908c6a6 Mon Sep 17 00:00:00 2001 From: Ihor Romanchuk Date: Wed, 8 Apr 2026 15:53:02 +0200 Subject: [PATCH 04/10] add browser tests --- .../ds-split-button.browser.test.tsx | 191 ++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 packages/design-system/src/components/ds-split-button/__tests__/ds-split-button.browser.test.tsx 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..b79a7a788 --- /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, +} as const satisfies Partial; + +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, + } as DsSelectProps, + }} + /> + ); + } + + 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); + }); +}); From 179ff73f6ab324dacd85f5dedef2b607b843080d Mon Sep 17 00:00:00 2001 From: Ihor Romanchuk Date: Wed, 8 Apr 2026 18:22:45 +0200 Subject: [PATCH 05/10] fix styles; exclude some props --- .../ds-split-button.module.scss | 11 +++++++++-- .../ds-split-button/ds-split-button.tsx | 18 ++++-------------- .../ds-split-button/ds-split-button.types.ts | 9 +++++++-- 3 files changed, 20 insertions(+), 18 deletions(-) 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 index a5795fd7f..b60352efd 100644 --- 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 @@ -17,7 +17,7 @@ $button-transition-duration: 0.15s; } @mixin when-button-highlighted { - .root:has(.actionButton:not(:disabled):is(:hover, :focus-visible, :active)) & { + .root:has(.actionButton:not(:disabled):is(:hover, :focus-visible, :active, [data-selected='true'])) & { @content; } } @@ -44,9 +44,16 @@ $button-transition-duration: 0.15s; .actionButton { @include when-button-highlighted { z-index: $highlighted-z-index; - border-right-color: var(--border-border-secondary-hover); transition: border-color $button-transition-duration; } + + &: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 { 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 index 91cac4cfc..e68dd941b 100644 --- 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 @@ -13,26 +13,16 @@ const DsSplitButton = ({ disabled, slotProps, }: DsSplitButtonProps) => { - const { - className: buttonClassName, - size: buttonSize, - disabled: buttonDisabled, - ...buttonRest - } = slotProps.button; + const { className: buttonClassName, disabled: buttonDisabled, ...buttonRest } = slotProps.button; - const { - className: selectClassName, - size: selectSize, - disabled: selectDisabled, - ...selectRest - } = slotProps.select; + const { className: selectClassName, disabled: selectDisabled, ...selectRest } = slotProps.select; return (
@@ -45,7 +35,7 @@ const 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 index b0576bd40..ac6d04184 100644 --- 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 @@ -5,9 +5,14 @@ import type { DsSelectProps } from '../ds-select'; export const splitButtonSizes = ['medium', 'small'] as const; export type SplitButtonSize = (typeof splitButtonSizes)[number]; +type ButtonSlotProps = Omit; + +type DistributiveOmit = T extends unknown ? Omit : never; +type SelectSlotProps = DistributiveOmit; + export interface DsSplitButtonSlotProps { - button: Partial; - select: DsSelectProps; + button: ButtonSlotProps; + select: SelectSlotProps; } export interface DsSplitButtonProps { From b9883c2054fd74ca327c8266777c6cb786a4b726 Mon Sep 17 00:00:00 2001 From: Ihor Romanchuk Date: Tue, 14 Apr 2026 17:31:46 +0200 Subject: [PATCH 06/10] fox some PR comments --- .../ds-split-button/ds-split-button.tsx | 17 ++++++++++------- .../ds-split-button/ds-split-button.utils.ts | 6 ------ 2 files changed, 10 insertions(+), 13 deletions(-) delete mode 100644 packages/design-system/src/components/ds-split-button/ds-split-button.utils.ts 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 index e68dd941b..c4b4dff53 100644 --- 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 @@ -1,8 +1,7 @@ import classNames from 'classnames'; -import { DsSelect } from '../ds-select'; +import { DsSelect, type SelectSize } from '../ds-select'; import styles from './ds-split-button.module.scss'; -import type { DsSplitButtonProps } from './ds-split-button.types'; -import { getSelectSize } from './ds-split-button.utils'; +import type { DsSplitButtonProps, SplitButtonSize } from './ds-split-button.types'; import { DsButtonV3 } from '../ds-button-v3'; const DsSplitButton = ({ @@ -13,14 +12,14 @@ const DsSplitButton = ({ disabled, slotProps, }: DsSplitButtonProps) => { - const { className: buttonClassName, disabled: buttonDisabled, ...buttonRest } = slotProps.button; + const { className: buttonClassName, disabled: buttonDisabled, ...buttonProps } = slotProps.button; - const { className: selectClassName, disabled: selectDisabled, ...selectRest } = slotProps.select; + const { className: selectClassName, disabled: selectDisabled, ...selectProps } = slotProps.select; return (
{ + return size === 'medium' ? 'default' : 'small'; +}; + export default DsSplitButton; diff --git a/packages/design-system/src/components/ds-split-button/ds-split-button.utils.ts b/packages/design-system/src/components/ds-split-button/ds-split-button.utils.ts deleted file mode 100644 index 53d9c9baa..000000000 --- a/packages/design-system/src/components/ds-split-button/ds-split-button.utils.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { SelectSize } from '../ds-select'; -import type { SplitButtonSize } from './ds-split-button.types'; - -export const getSelectSize = (size: SplitButtonSize): SelectSize => { - return size === 'medium' ? 'default' : 'small'; -}; From 541034ee3acd2c93b7989c539246862612dff5b9 Mon Sep 17 00:00:00 2001 From: Ihor Romanchuk Date: Wed, 15 Apr 2026 12:32:00 +0200 Subject: [PATCH 07/10] Move DistributiveOmit into type-utils.ts --- .../src/components/ds-split-button/ds-split-button.types.ts | 2 +- packages/design-system/src/utils/type-utils.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 packages/design-system/src/utils/type-utils.ts 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 index ac6d04184..e083df832 100644 --- 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 @@ -1,13 +1,13 @@ 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 DistributiveOmit = T extends unknown ? Omit : never; type SelectSlotProps = DistributiveOmit; export interface DsSplitButtonSlotProps { 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; From 01e7d28ed93b626feb71a14c56c9068d6ee8c68b Mon Sep 17 00:00:00 2001 From: Ihor Romanchuk Date: Wed, 15 Apr 2026 13:13:52 +0200 Subject: [PATCH 08/10] cleanup tests --- .../__tests__/ds-split-button.browser.test.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) 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 index b79a7a788..e8e638d4b 100644 --- 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 @@ -15,7 +15,7 @@ const defaultSelect = { value: '30', onValueChange: vi.fn(), multiple: false, -} as const satisfies Partial; +} satisfies DsSelectProps; describe('DsSplitButton', () => { it('calls slotProps.button.onClick when primary action is clicked', async () => { @@ -29,7 +29,7 @@ describe('DsSplitButton', () => { 'aria-label': 'Refresh', onClick, }, - select: { ...defaultSelect, onValueChange: vi.fn() } as DsSelectProps, + select: defaultSelect, }} />, ); @@ -61,7 +61,7 @@ describe('DsSplitButton', () => { setValue(v); }, multiple: false, - } as DsSelectProps, + }, }} /> ); @@ -96,7 +96,7 @@ describe('DsSplitButton', () => { value: '30', onValueChange, multiple: false, - } as DsSelectProps, + }, }} />, ); @@ -126,7 +126,7 @@ describe('DsSplitButton', () => { 'aria-label': 'Refresh', onClick, }, - select: { ...defaultSelect, onValueChange: vi.fn() } as DsSelectProps, + select: defaultSelect, }} />, ); @@ -150,7 +150,7 @@ describe('DsSplitButton', () => { icon: 'refresh', 'aria-label': 'Refresh', }, - select: { ...defaultSelect, onValueChange: vi.fn() } as DsSelectProps, + select: defaultSelect, }} />, ); @@ -174,7 +174,7 @@ describe('DsSplitButton', () => { icon: 'refresh', 'aria-label': 'Refresh', }, - select: { ...defaultSelect, onValueChange: vi.fn() } as DsSelectProps, + select: defaultSelect, }} />, ); From 9846ece878b34ba9f4265896f097f35ba58187c3 Mon Sep 17 00:00:00 2001 From: Ihor Romanchuk Date: Wed, 15 Apr 2026 13:38:09 +0200 Subject: [PATCH 09/10] Reuse the same scss variable across components --- .../components/ds-button-v3/ds-button-v3.module.scss | 12 +++++------- .../ds-split-button/ds-split-button.module.scss | 5 +++-- .../design-system/src/styles/shared/_button.scss | 2 ++ 3 files changed, 10 insertions(+), 9 deletions(-) create mode 100644 packages/design-system/src/styles/shared/_button.scss 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/ds-split-button.module.scss b/packages/design-system/src/components/ds-split-button/ds-split-button.module.scss index b60352efd..ac00e378f 100644 --- 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 @@ -1,8 +1,9 @@ +@use '../../styles/shared/button' as button; + $highlighted-z-index: 1; $divider-z-index: $highlighted-z-index + 1; $divider-width: 1px; $border-width: 1px; -$button-transition-duration: 0.15s; @mixin when-button-disabled { .root:has(.actionButton:disabled) & { @@ -44,7 +45,7 @@ $button-transition-duration: 0.15s; .actionButton { @include when-button-highlighted { z-index: $highlighted-z-index; - transition: border-color $button-transition-duration; + transition: border-color button.$transition-duration-quick; } &:not(:disabled):is(:hover, :active, [data-selected='true']) { 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; From 061d83802ecc58a978dd5aa44938a481a3a76c08 Mon Sep 17 00:00:00 2001 From: Ihor Romanchuk Date: Wed, 15 Apr 2026 15:04:05 +0200 Subject: [PATCH 10/10] Make size a responsive property --- .../ds-split-button/ds-split-button.stories.tsx | 2 +- .../src/components/ds-split-button/index.ts | 16 ++++++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) 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 index 051524501..2ecdfc96c 100644 --- 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 @@ -1,7 +1,7 @@ import { useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; import { fn } from 'storybook/test'; -import DsSplitButton from './ds-split-button'; +import { DsSplitButton } from './'; import { splitButtonSizes } from './ds-split-button.types'; import type { DsSelectProps } from '../ds-select'; diff --git a/packages/design-system/src/components/ds-split-button/index.ts b/packages/design-system/src/components/ds-split-button/index.ts index 5fb6721cf..696d86f7d 100644 --- a/packages/design-system/src/components/ds-split-button/index.ts +++ b/packages/design-system/src/components/ds-split-button/index.ts @@ -1,6 +1,10 @@ -export { default as DsSplitButton } from './ds-split-button'; -export { - type DsSplitButtonProps, - type DsSplitButtonSlotProps, - splitButtonSizes, -} from './ds-split-button.types'; +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;