diff --git a/.changeset/real-maps-strive.md b/.changeset/real-maps-strive.md new file mode 100644 index 00000000..e43c98d4 --- /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 00000000..ec1da65a --- /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: 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 { + 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 00000000..fbbbef45 --- /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 00000000..53453349 --- /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 00000000..3065e6d7 --- /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 00000000..9ed52634 --- /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 00000000..53d9c9ba --- /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 00000000..a4c0fd2a --- /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 ac8f389e..d5aac1d3 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';