-
Notifications
You must be signed in to change notification settings - Fork 7
feat(design-system): add DsSplitButton component [AR-54024]
#347
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 9 commits
51977d0
6f82b6c
29a2d5a
2f53c07
cca6e3c
2e13f71
6ce88da
179ff73
696432a
6e700bb
b9883c2
541034e
01e7d28
9846ece
b3939c9
061d838
b44107b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@drivenets/design-system': minor | ||
| --- | ||
|
|
||
| Add `DsSplitButton` component |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -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<DsSelectProps>; | ||||||
|
|
||||||
| describe('DsSplitButton', () => { | ||||||
| it('calls slotProps.button.onClick when primary action is clicked', async () => { | ||||||
| const onClick = vi.fn(); | ||||||
|
|
||||||
| await page.render( | ||||||
| <DsSplitButton | ||||||
| slotProps={{ | ||||||
| button: { | ||||||
| icon: 'refresh', | ||||||
| 'aria-label': 'Refresh', | ||||||
| onClick, | ||||||
| }, | ||||||
| select: { ...defaultSelect, onValueChange: vi.fn() } as DsSelectProps, | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think you don't need any of the
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. you're overriding an existing prop in all tests for no reason. This can be simplified in all cases:
Suggested change
|
||||||
| }} | ||||||
| />, | ||||||
| ); | ||||||
|
|
||||||
| 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 ( | ||||||
| <DsSplitButton | ||||||
| slotProps={{ | ||||||
| button: { | ||||||
| icon: 'refresh', | ||||||
| 'aria-label': 'Refresh', | ||||||
| onClick: vi.fn(), | ||||||
| }, | ||||||
| select: { | ||||||
| options: refreshOptions, | ||||||
| value, | ||||||
| onValueChange: (v) => { | ||||||
| onValueChange(v); | ||||||
| setValue(v); | ||||||
| }, | ||||||
| multiple: false, | ||||||
| } as DsSelectProps, | ||||||
| }} | ||||||
| /> | ||||||
| ); | ||||||
| } | ||||||
|
|
||||||
| await page.render(<Controlled />); | ||||||
|
|
||||||
| 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( | ||||||
| <DsSplitButton | ||||||
| disabled | ||||||
| slotProps={{ | ||||||
| button: { | ||||||
| icon: 'refresh', | ||||||
| 'aria-label': 'Refresh', | ||||||
| onClick, | ||||||
| }, | ||||||
| select: { | ||||||
| options: refreshOptions, | ||||||
| value: '30', | ||||||
| onValueChange, | ||||||
| multiple: false, | ||||||
| } as DsSelectProps, | ||||||
| }} | ||||||
| />, | ||||||
| ); | ||||||
|
|
||||||
| 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( | ||||||
| <DsSplitButton | ||||||
| slotProps={{ | ||||||
| button: { | ||||||
| loading: true, | ||||||
| icon: 'refresh', | ||||||
| 'aria-label': 'Refresh', | ||||||
| onClick, | ||||||
| }, | ||||||
| select: { ...defaultSelect, onValueChange: vi.fn() } as DsSelectProps, | ||||||
| }} | ||||||
| />, | ||||||
| ); | ||||||
|
|
||||||
| 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( | ||||||
| <DsSplitButton | ||||||
| size="medium" | ||||||
| slotProps={{ | ||||||
| button: { | ||||||
| icon: 'refresh', | ||||||
| 'aria-label': 'Refresh', | ||||||
| }, | ||||||
| select: { ...defaultSelect, onValueChange: vi.fn() } as DsSelectProps, | ||||||
| }} | ||||||
| />, | ||||||
| ); | ||||||
|
|
||||||
| 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( | ||||||
| <DsSplitButton | ||||||
| size="small" | ||||||
| slotProps={{ | ||||||
| button: { | ||||||
| icon: 'refresh', | ||||||
| 'aria-label': 'Refresh', | ||||||
| }, | ||||||
| select: { ...defaultSelect, onValueChange: vi.fn() } as DsSelectProps, | ||||||
| }} | ||||||
| />, | ||||||
| ); | ||||||
|
|
||||||
| 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); | ||||||
| }); | ||||||
| }); | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It is a known bug in the DsSelect component. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,98 @@ | ||
| $highlighted-z-index: 1; | ||
| $divider-z-index: $highlighted-z-index + 1; | ||
| $divider-width: 1px; | ||
| $border-width: 1px; | ||
| $button-transition-duration: 0.15s; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. transition standard is
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I made it aligned with DsButtonV3, it's important to have the same duration, because $button-transition-duration affects button right border.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So maybe add a comment about this? |
||
|
|
||
| @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 { | ||
|
vpolessky-dn marked this conversation as resolved.
|
||
| @include when-button-highlighted { | ||
| z-index: $highlighted-z-index; | ||
| 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 { | ||
| 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); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,97 @@ | ||
| 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 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<typeof DsSplitButton> = { | ||
| 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<typeof DsSplitButton>; | ||
|
|
||
| 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 ( | ||
| <DsSplitButton | ||
| {...args} | ||
| slotProps={{ | ||
| button: { | ||
| ...args.slotProps.button, | ||
| loading, | ||
| icon: 'refresh', | ||
| onClick: handleAction, | ||
| }, | ||
| select: { | ||
| ...args.slotProps.select, | ||
| value, | ||
| onValueChange: setValue, | ||
| } as DsSelectProps, | ||
| }} | ||
| /> | ||
| ); | ||
| }, | ||
| }; | ||
|
|
||
| export const Loading: Story = { | ||
| args: { | ||
| slotProps: { | ||
| button: { | ||
| loading: true, | ||
| }, | ||
| select: { | ||
| options: refreshOptions, | ||
| value: '30', | ||
| onValueChange: fn(), | ||
| }, | ||
| }, | ||
| }, | ||
| }; | ||
|
|
||
| export const Disabled: Story = { | ||
| args: { | ||
| disabled: true, | ||
| }, | ||
| }; |

There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this could be simplified to: