Skip to content

Commit

Permalink
feat: new switch
Browse files Browse the repository at this point in the history
  • Loading branch information
kostasdano committed Dec 4, 2023
1 parent 2a616ad commit abd3b02
Show file tree
Hide file tree
Showing 8 changed files with 378 additions and 2 deletions.
71 changes: 71 additions & 0 deletions src/components/Controls/Switch/Switch.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { Meta, Canvas, ArgTypes, Story } from '@storybook/blocks';
import Switch from './Switch';
import * as SwitchStories from './Switch.stories';

<Meta of={SwitchStories} />

<SectionHeader title={'Switch'} />

- [Overview](#overview)
- [Props](#props)
- [Usage](#usage)
- [Variants](#variants)

## Overview

Enables the user to set the state of a single setting to 'on' or 'off'.

## Props

<ArgTypes of={Switch} />

## Usage

<UsageGuidelines
guidelines={[
'Use switches for binary choices only (e.g. “enable/disable”)',
'When the item that is being toggled has a default state (e.g. deactivated by default)',
]}
policies={[
'If you have more than two choices, use a checkbox or a radio button instead',
'If you can select 1+ items, use checkboxes',
'If you can select exactly 1 item, use radio buttons',
]}
/>

<SubsectionHeader title="Variants" />

### Simple Switch

<Canvas of={SwitchStories.SimpleSwitch} />

### Switch label placement

Label can be placed either `left` or `right` of the switch.

<Tip>
The labelConfig prop of each Switch includes an sx prop in order to add custom styles on the
container that wraps the switch input and the label
</Tip>

<Canvas of={SwitchStories.SwitchLabelPlacement} />

### Switch label sizes

Label can be either a string or a custom component. In the case of the string, there are 2 sizes (declared inside the labelConfig), `normal` and `large`

<Canvas of={SwitchStories.SwitchLabelSizes} />

### Switch with helptext

Switch can include a helptext under the label.

<Canvas of={SwitchStories.SwitchWithHelptext} />

### Disabled Switch

<Canvas of={SwitchStories.DisabledSwitch} />

### Playground

<Canvas of={SwitchStories.Playground} />
108 changes: 108 additions & 0 deletions src/components/Controls/Switch/Switch.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { FIGMA_URL } from 'utils/common';
import Switch from './Switch';
import { useState } from 'react';
import Stack from 'components/storyUtils/Stack';
import { boolean, select, text } from '@storybook/addon-knobs';

export default {
title: 'Updated Components/Controls/Switch',
component: Switch,
parameters: {
design: [
{
type: 'figma',
name: 'Switch',
url: `${FIGMA_URL}?node-id=10283%3A104364`,
},
],
},
};

export const SimpleSwitch = {
render: () => {
const [selected, setSelected] = useState(false);
return (
<Switch isSelected={selected} onChange={setSelected}>
Option
</Switch>
);
},
name: 'Simple Switch',
};

export const SwitchLabelPlacement = {
render: () => {
return (
<>
<Stack height={50}>
<Switch>Option</Switch>
</Stack>
<Stack>
<Switch labelConfig={{ placement: 'left' }}>Option</Switch>
</Stack>
</>
);
},
name: 'Switch label placement',
};

export const SwitchLabelSizes = {
render: () => {
return (
<>
<Stack height={50}>
<Switch>Normal Option</Switch>
</Stack>
<Stack>
<Switch labelConfig={{ size: 'large' }}>Large Option</Switch>
</Stack>
</>
);
},
name: 'Switch label sizes',
};

export const SwitchWithHelptext = {
render: () => {
return <Switch labelConfig={{ helpText: 'This is the helptext of the option' }}>Option</Switch>;
},
name: 'Switch with helptext',
};

export const DisabledSwitch = {
render: () => {
return (
<>
<Stack height={50}>
<Switch isDisabled>Option</Switch>
</Stack>
<Stack>
<Switch labelConfig={{ helpText: 'This option is disabled' }} isDisabled>
Option
</Switch>
</Stack>
</>
);
},
name: 'Disabled Switch',
};

export const Playground = {
render: () => {
return (
<Stack>
<Switch
labelConfig={{
placement: select('Label placement', ['left', 'right'], 'right'),
size: select('Label size', ['normal', 'large'], 'normal'),
helpText: text('Help text', ''),
}}
isDisabled={boolean('isDisabled', false)}
>
Option
</Switch>
</Stack>
);
},
name: 'Playground',
};
77 changes: 77 additions & 0 deletions src/components/Controls/Switch/Switch.style.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import type { SerializedStyles } from '@emotion/react';
import { css } from '@emotion/react';
import type { Theme } from 'theme';

import { getControlsTokens } from 'components/Controls/Controls.tokens';
import type { LabelConfig } from 'components/Controls/Controls.types';

export const switchStyles =
({ placement = 'right', sx }: Pick<LabelConfig, 'placement' | 'sx'>) =>
(theme: Theme): SerializedStyles => {
const tokens = getControlsTokens(theme);

return css`
display: flex;
flex-direction: ${placement === 'right' ? 'row' : 'row-reverse'};
align-items: center;
gap: ${tokens('switch.padding')};
position: relative;
cursor: pointer;
.bar {
width: ${tokens('switch.width.track')};
height: ${tokens('switch.height.track')};
background: ${tokens('switch.backgroundColor.track')};
position: absolute;
border-radius: ${tokens('switch.borderRadius')};
}
.indicator {
width: ${tokens('switch.width.track')};
height: ${tokens('switch.size.thumb')};
box-sizing: border-box;
position: relative;
&:before {
content: '';
box-sizing: border-box;
display: block;
width: ${tokens('switch.size.thumb')};
height: ${tokens('switch.size.thumb')};
background: ${tokens('switch.backgroundColor.thumb.default')};
border: ${tokens('switch.borderWidth')} solid
${tokens('switch.borderColor.thumb.default')};
border-radius: 100%;
transition: all 200ms;
}
}
&[data-hovered],
&[data-focus-visible='true'] {
.indicator {
&:before {
transition: all 0.2s;
box-shadow: 0px 0px 0px 8px ${theme.tokens.state.get('backgroundColor.hover')};
border-radius: 100%;
}
}
}
&[data-selected] {
.indicator {
&:before {
transform: translateX(80%);
background: ${tokens('switch.backgroundColor.thumb.active')};
border-color: ${tokens('switch.borderColor.thumb.active')};
}
}
}
&[data-disabled] {
opacity: ${theme.tokens.disabledState.get('default')};
cursor: not-allowed;
}
${sx};
`;
};
54 changes: 54 additions & 0 deletions src/components/Controls/Switch/Switch.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import userEvent from '@testing-library/user-event';
import React from 'react';

import { render, screen } from '../../../test';
import Switch from './Switch';
import { Mock } from 'vitest';

describe('Switch', () => {
let mockOnClick: Mock<any, any>;

beforeEach(() => {
mockOnClick = vi.fn();
});

afterEach(() => {
vi.clearAllMocks();
});

it('it renders the Switch correctly', () => {
const { container } = render(<Switch>Label</Switch>);

expect(container).toMatchSnapshot();
});

it('should be able to change its check condition', async () => {
render(<Switch />);

const switchComponent = screen.getByTestId('undefined_undefined_switch');

expect(switchComponent.getAttribute('data-selected')).toEqual(null);

await userEvent.click(switchComponent);

expect(switchComponent.getAttribute('data-selected')).toEqual('true');
});

it('should invoke the onChange function', async () => {
render(<Switch isSelected={false} onChange={mockOnClick} />);
const switchComponent = screen.getByTestId('undefined_undefined_switch');

await userEvent.click(switchComponent);

expect(mockOnClick).toHaveBeenCalledTimes(1);
});

it('should not invoke the onChange function if the switch is disabled', async () => {
render(<Switch isSelected={false} onChange={mockOnClick} isDisabled />);
const switchComponent = screen.getByTestId('undefined_undefined_switch');

await userEvent.click(switchComponent);

expect(mockOnClick).toHaveBeenCalledTimes(0);
});
});
63 changes: 63 additions & 0 deletions src/components/Controls/Switch/Switch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import * as React from 'react';
import type { SwitchAria } from 'react-aria';
import { Switch as ReactAriaSwitch } from 'react-aria-components';
import type { TestProps } from 'utils/types';

import { switchStyles } from './Switch.style';
import ControlLabel from 'components/Controls/ControlLabel';
import type { LabelConfig } from 'components/Controls/Controls.types';

export type SwitchProps = Partial<SwitchAria> & {
/** Id property of the radio input */
id?: string;
/** The value of the radio input */
value?: string;
/** Callback for when the element's selection state changes. */
onChange?: (isSelected: boolean) => void;
/** Label configuration; includes placement, size, helpText and sx */
labelConfig?: LabelConfig;
children?: React.ReactNode;
} & TestProps;

const Switch = React.forwardRef<HTMLInputElement, SwitchProps>((props, ref) => {
const {
id,
value,
isSelected,
isDisabled,
onChange,
labelConfig = {},
dataTestPrefixId,
children,
} = props;
const { placement = 'right', size = 'normal', helpText, sx } = labelConfig;

return (
<ReactAriaSwitch
id={id}
value={value}
isSelected={isSelected}
isDisabled={isDisabled}
onChange={onChange}
css={switchStyles({ placement, sx })}
data-testid={`${dataTestPrefixId}_${value}_switch`}
ref={ref}
>
<div className="bar" />
<div className="indicator" />
{children && (
<ControlLabel
size={size}
helpText={helpText}
dataTestPrefixId={`${dataTestPrefixId}_radio_${value?.split(' ').join('_')}`}
>
{children}
</ControlLabel>
)}
</ReactAriaSwitch>
);
});

Switch.displayName = 'Switch';

export default Switch;
2 changes: 2 additions & 0 deletions src/components/Controls/Switch/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default } from './Switch';
export * from './Switch';
1 change: 1 addition & 0 deletions src/components/Controls/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { default as Radio } from './Radio';
export { default as RadioGroup } from './Radio/components/RadioGroup';
export { default as Switch } from './Switch';
4 changes: 2 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,8 @@ export { default as Select, StatefulSelect } from './components/Select';
export * from './components/Select';
export { default as Slider } from './components/Slider';
export * from './components/Slider';
export { default as Switch } from './components/Switch';
export * from './components/Switch';
export { Switch } from './components/Controls';
export * from './components/Controls/Switch';
export { default as Table } from './components/Table';
export * from './components/Table';
export { default as Tag } from './components/Tag';
Expand Down

0 comments on commit abd3b02

Please sign in to comment.