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;