Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/real-maps-strive.md
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
@@ -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;
Expand All @@ -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;
Expand Down
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,
} satisfies 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,
}}
/>,
);

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,
},
}}
/>
);
}

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,
},
}}
/>,
);

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,
}}
/>,
);

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,
}}
/>,
);

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,
}}
/>,
);

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);
});
});
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like there are some padding-top issues with the small size (maybe bug in the select component itself?):
image

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is a known bug in the DsSelect component.
https://drivenets.atlassian.net/browse/AR-52305

Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading
Loading