Feat / 94 toggle button (#223)

* Toggle button built ui-toolkit

* Rewrote controlled toggle button story without args

* Fixed ts args issue using ComponentStory element

* Wrapped controlled version of buttons in a form for Storybook to stop light and dark theme scope collisions

* More toggle tests

* Update libs/ui-toolkit/src/components/toggle/toggle.stories.tsx

Co-authored-by: Dexter Edwards <dexter.edwards93@gmail.com>

* Displays checked state as text for controlled toggles in storybook

* Used classnames helper

* Added toggle to deal ticket

* Simplified the toggles array type to allow any number of toggles, removing the need for a cast

Co-authored-by: Dexter Edwards <dexter.edwards93@gmail.com>
This commit is contained in:
Sam Keen 2022-04-20 12:58:50 +01:00 committed by GitHub
parent 4c350a74d0
commit f0e4aded3a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 368 additions and 54 deletions

View File

@ -1,38 +0,0 @@
import { Button } from '@vegaprotocol/ui-toolkit';
interface ButtonRadioProps {
name: string;
options: Array<{ value: string; text: string }>;
currentOption: string | null;
onSelect: (option: string) => void;
}
export const ButtonRadio = ({
name,
options,
currentOption,
onSelect,
}: ButtonRadioProps) => {
return (
<div className="flex gap-8">
{options.map((option) => {
const isSelected = option.value === currentOption;
return (
<Button
onClick={() => onSelect(option.value)}
className="flex-1"
variant={isSelected ? 'accent' : undefined}
data-testid={
isSelected
? `${name}-${option.value}-selected`
: `${name}-${option.value}`
}
key={option.value}
>
{option.text}
</Button>
);
})}
</div>
);
};

View File

@ -1,6 +1,6 @@
import { FormGroup } from '@vegaprotocol/ui-toolkit';
import { OrderSide } from '@vegaprotocol/wallet';
import { ButtonRadio } from './button-radio';
import { Toggle } from '@vegaprotocol/ui-toolkit';
import type { Order } from './use-order-state';
interface SideSelectorProps {
@ -9,16 +9,18 @@ interface SideSelectorProps {
}
export const SideSelector = ({ order, onSelect }: SideSelectorProps) => {
const toggles = Object.entries(OrderSide).map(([label, value]) => ({
label,
value,
}));
return (
<FormGroup label="Direction">
<ButtonRadio
<Toggle
name="order-side"
options={Object.entries(OrderSide).map(([text, value]) => ({
text,
value,
}))}
currentOption={order.side}
onSelect={(value) => onSelect(value as OrderSide)}
toggles={toggles}
checkedValue={order.side}
onChange={(e) => onSelect(e.target.value as OrderSide)}
/>
</FormGroup>
);

View File

@ -1,6 +1,6 @@
import { FormGroup } from '@vegaprotocol/ui-toolkit';
import { OrderType } from '@vegaprotocol/wallet';
import { ButtonRadio } from './button-radio';
import { Toggle } from '@vegaprotocol/ui-toolkit';
import type { Order } from './use-order-state';
interface TypeSelectorProps {
@ -9,16 +9,18 @@ interface TypeSelectorProps {
}
export const TypeSelector = ({ order, onSelect }: TypeSelectorProps) => {
const toggles = Object.entries(OrderType).map(([label, value]) => ({
label,
value,
}));
return (
<FormGroup label="Order type">
<ButtonRadio
<Toggle
name="order-type"
options={Object.entries(OrderType).map(([text, value]) => ({
text,
value,
}))}
currentOption={order.type}
onSelect={(value) => onSelect(value as OrderType)}
toggles={toggles}
checkedValue={order.type}
onChange={(e) => onSelect(e.target.value as OrderType)}
/>
</FormGroup>
);

View File

@ -0,0 +1 @@
export * from './toggle';

View File

@ -0,0 +1,201 @@
import { useState } from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { Toggle } from './toggle';
describe('Toggle', () => {
it('should render buttons successfully', () => {
render(
<Toggle
name="test"
toggles={[
{
label: 'Option 1',
value: 'test-1',
},
{
label: 'Option 2',
value: 'test-2',
},
]}
/>
);
expect(screen.getByText('Option 1')).toBeInTheDocument();
expect(screen.getByText('Option 2')).toBeInTheDocument();
});
it('supports more than 2 inputs', () => {
render(
<Toggle
name="test"
toggles={[
{
label: 'Option 1',
value: 'test-1',
},
{
label: 'Option 2',
value: 'test-2',
},
{
label: 'Option 3',
value: 'test-3',
},
]}
/>
);
expect(screen.getByText('Option 3')).toBeInTheDocument();
});
it('allow less than 2 inputs', () => {
render(
<Toggle
name="test"
toggles={[
{
label: 'Option 1',
value: 'test-1',
},
]}
/>
);
expect(screen.getByText('Option 1')).toBeInTheDocument();
});
it('uncontrolled toggle initially has no checked item', () => {
render(
<Toggle
name="test"
toggles={[
{
label: 'Option 1',
value: 'test-1',
},
{
label: 'Option 2',
value: 'test-2',
},
]}
/>
);
expect(screen.getByDisplayValue('test-1').matches(':checked')).toBeFalsy();
expect(screen.getByDisplayValue('test-2').matches(':checked')).toBeFalsy();
});
it('uncontrolled toggle displays correct checked item after click', () => {
render(
<Toggle
name="test"
toggles={[
{
label: 'Option 1',
value: 'test-1',
},
{
label: 'Option 2',
value: 'test-2',
},
]}
/>
);
const button = screen.getByText('Option 1');
fireEvent.click(button);
expect(screen.getByDisplayValue('test-1').matches(':checked')).toBeTruthy();
expect(screen.getByDisplayValue('test-2').matches(':checked')).toBeFalsy();
});
it('controlled toggle displays correct checked value, first option selected', () => {
render(
<Toggle
name="test"
toggles={[
{
label: 'Option 1',
value: 'test-1',
},
{
label: 'Option 2',
value: 'test-2',
},
]}
checkedValue={'test-1'}
onChange={() => null}
/>
);
expect(screen.getByDisplayValue('test-1')).toHaveProperty('checked', true);
});
it('controlled toggle displays correct checked value, second option selected', () => {
render(
<Toggle
name="test"
toggles={[
{
label: 'Option 1',
value: 'test-1',
},
{
label: 'Option 2',
value: 'test-2',
},
]}
checkedValue={'test-2'}
onChange={() => null}
/>
);
expect(screen.getByDisplayValue('test-2')).toHaveProperty('checked', true);
});
it('onchange handler returning null results in nothing happening', () => {
render(
<Toggle
name="test"
toggles={[
{
label: 'Option 1',
value: 'test-1',
},
{
label: 'Option 2',
value: 'test-2',
},
]}
checkedValue={'test-2'}
onChange={() => null}
/>
);
expect(screen.getByDisplayValue('test-2')).toHaveProperty('checked', true);
const button = screen.getByText('Option 1');
fireEvent.click(button);
expect(screen.getByDisplayValue('test-2')).toHaveProperty('checked', true);
});
it('onchange handler controlling state sets new value', () => {
const ComponentWrapper = () => {
const [value, setValue] = useState('test-2');
return (
<Toggle
name="test"
toggles={[
{
label: 'Option 1',
value: 'test-1',
},
{
label: 'Option 2',
value: 'test-2',
},
]}
checkedValue={value}
onChange={(e) => setValue(e.target.value)}
/>
);
};
render(<ComponentWrapper />);
expect(screen.getByDisplayValue('test-2')).toHaveProperty('checked', true);
const button = screen.getByText('Option 1');
fireEvent.click(button);
expect(screen.getByDisplayValue('test-1')).toHaveProperty('checked', true);
});
});

View File

@ -0,0 +1,74 @@
import type { ComponentStory, ComponentMeta } from '@storybook/react';
import { Toggle } from './toggle';
import { useState } from 'react';
export default {
component: Toggle,
title: 'Toggle',
} as ComponentMeta<typeof Toggle>;
export const Controlled: ComponentStory<typeof Toggle> = () => {
const [checked, setChecked] = useState('test-1');
return (
<form>
<div className="mb-12">Current checked state: {checked}</div>
<Toggle
name="controlled"
toggles={[
{
label: 'Option 1',
value: 'test-1',
},
{
label: 'Option 2',
value: 'test-2',
},
]}
checkedValue={checked}
onChange={(e) => setChecked(e.target.value)}
className="max-w-[400px]"
/>
</form>
);
};
const UncontrolledTemplate: ComponentStory<typeof Toggle> = (args) => (
<Toggle {...args} />
);
export const Uncontrolled = UncontrolledTemplate.bind({});
Uncontrolled.args = {
name: 'uncontrolled',
toggles: [
{
label: 'Option 1',
value: 'test-1',
},
{
label: 'Option 2',
value: 'test-2',
},
],
className: 'max-w-[400px]',
};
export const MoreButtons = UncontrolledTemplate.bind({});
MoreButtons.args = {
name: 'more',
toggles: [
{
label: 'Option 1',
value: 'test-1',
},
{
label: 'Option 2',
value: 'test-2',
},
{
label: 'Option 3',
value: 'test-3',
},
],
className: 'max-w-[600px]',
};

View File

@ -0,0 +1,71 @@
import classnames from 'classnames';
import type { ChangeEvent } from 'react';
// Supports controlled and uncontrolled setups.
interface ToggleProps {
label: string;
value: string;
}
export interface ToggleInputProps {
name: string;
toggles: ToggleProps[];
className?: string;
onChange?: (e: ChangeEvent<HTMLInputElement>) => void;
checkedValue?: string | undefined | null;
}
export const Toggle = ({
name,
toggles,
className,
onChange,
checkedValue,
...props
}: ToggleInputProps) => {
const fieldsetClasses = classnames(className, 'flex');
const labelClasses = classnames(
'group flex-1',
'-ml-[1px] first-of-type:ml-0'
);
const radioClasses = classnames('sr-only', 'peer');
const buttonClasses = classnames(
'relative peer-checked:z-10 inline-block w-full',
'border border-black-60 active:border-black dark:border-white-60 dark:active:border-white peer-checked:border-black dark:peer-checked:border-vega-yellow',
'group-first-of-type:rounded-tl group-first-of-type:rounded-bl group-last-of-type:rounded-tr group-last-of-type:rounded-br',
'px-28 py-4',
'peer-checked:bg-vega-yellow hover:bg-black-25 dark:hover:bg-white-25 hover:peer-checked:bg-vega-yellow',
'text-ui text-black-60 dark:text-white-60 peer-checked:text-black active:text-black dark:active:text-white peer-checked:font-bold text-center',
'cursor-pointer peer-checked:cursor-auto select-none transition-all'
);
return (
<fieldset className={fieldsetClasses} {...props}>
{toggles.map(({ label, value }, key) => {
const isSelected = value === checkedValue;
return (
<label key={key} className={labelClasses}>
<input
type="radio"
name={name}
value={value}
onChange={onChange}
checked={
checkedValue === undefined ? undefined : value === checkedValue
}
className={radioClasses}
/>
<span
className={buttonClasses}
data-testid={
isSelected ? `${name}-${value}-selected` : `${name}-${value}`
}
>
{label}
</span>
</label>
);
})}
</fieldset>
);
};

View File

@ -15,6 +15,7 @@ export { Select } from './components/select';
export { Splash } from './components/splash';
export { TextArea } from './components/text-area';
export { ThemeSwitcher } from './components/theme-switcher';
export { Toggle } from './components/toggle';
export { Dialog } from './components/dialog/dialog';
export { VegaLogo } from './components/vega-logo';
export { Tooltip } from './components/tooltip';