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:
parent
4c350a74d0
commit
f0e4aded3a
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
|
1
libs/ui-toolkit/src/components/toggle/index.ts
Normal file
1
libs/ui-toolkit/src/components/toggle/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './toggle';
|
201
libs/ui-toolkit/src/components/toggle/toggle.spec.tsx
Normal file
201
libs/ui-toolkit/src/components/toggle/toggle.spec.tsx
Normal 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);
|
||||
});
|
||||
});
|
74
libs/ui-toolkit/src/components/toggle/toggle.stories.tsx
Normal file
74
libs/ui-toolkit/src/components/toggle/toggle.stories.tsx
Normal 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]',
|
||||
};
|
71
libs/ui-toolkit/src/components/toggle/toggle.tsx
Normal file
71
libs/ui-toolkit/src/components/toggle/toggle.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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';
|
||||
|
Loading…
Reference in New Issue
Block a user