Skip to content

Commit

Permalink
feat(component): add Toggle component (#1274)
Browse files Browse the repository at this point in the history
  • Loading branch information
bc-apostoliuk authored Aug 2, 2023
1 parent 46dd08e commit 8200e44
Show file tree
Hide file tree
Showing 9 changed files with 554 additions and 0 deletions.
108 changes: 108 additions & 0 deletions packages/big-design/src/components/Toggle/Toggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import React, {
cloneElement,
isValidElement,
LabelHTMLAttributes,
MouseEvent,
useId,
useMemo,
} from 'react';

import { Box, FormControlLabel } from '@bigcommerce/big-design';

import { typedMemo, warning } from '../../utils';
import { InputLocalization } from '../Input/Input';

import { StyledButton } from './styled';

type RequireOneOf<T, Keys extends keyof T = keyof T> = Pick<T, Exclude<keyof T, Keys>> &
{
[K in Keys]-?: Required<Pick<T, K>> & Partial<Pick<T, Exclude<Keys, K>>>;
}[Keys];

interface ToggleItem<T> {
value: T;
label?: string;
icon?: React.ReactNode;
}

type ToggleItemType<T> = RequireOneOf<ToggleItem<T>, 'icon' | 'label'>;

export interface ToggleProps<T> {
id?: string;
value: T;
items: Array<ToggleItemType<T>>;
label?: React.ReactChild;
labelId?: string;
disabled?: boolean;
localization?: InputLocalization;
onChange(id: T): void;
}

export const Toggle = typedMemo(
<T,>({
value: activeValue,
disabled,
items,
label,
labelId,
localization,
onChange,
...props
}: ToggleProps<T>) => {
const uniqueId = useId();
const id = props.id ? props.id : uniqueId;

const handleClick = (itemId: T) => (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();

return onChange(itemId);
};

const renderedLabel = useMemo(() => {
if (!label) {
return null;
}

if (typeof label === 'string') {
return (
<FormControlLabel htmlFor={id} id={labelId} localization={localization}>
{label}
</FormControlLabel>
);
}

if (
isValidElement<LabelHTMLAttributes<HTMLLabelElement>>(label) &&
label.type === FormControlLabel
) {
return cloneElement(label, {
id: labelId,
htmlFor: id,
});
}

warning('label must be either a string or a FormControlLabel component.');
}, [id, label, labelId, localization]);

return (
<div>
{renderedLabel}
<Box aria-labelledby={labelId} display="flex" id={id} marginBottom="medium" role="group">
{items.map(({ value: itemId, label, icon }, idx) => (
<StyledButton
aria-checked={itemId === activeValue}
disabled={disabled}
isActive={itemId === activeValue}
isIconType={!!icon}
key={idx}
onClick={handleClick(itemId)}
role="switch"
>
{icon || label}
</StyledButton>
))}
</Box>
</div>
);
},
);
1 change: 1 addition & 0 deletions packages/big-design/src/components/Toggle/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './Toggle';
154 changes: 154 additions & 0 deletions packages/big-design/src/components/Toggle/spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { fireEvent, screen } from '@testing-library/react';
import React from 'react';

import { render } from '@test/utils';

import { FormControlLabel } from '../Form';

import { Toggle } from './Toggle';

describe('Toggle', () => {
it('renders three toggle buttons', () => {
render(
<Toggle
items={[
{
value: '1',
label: '1',
},
{
value: '2',
label: '2',
},
{
value: '3',
label: '3',
},
]}
onChange={() => null}
value="1"
/>,
);

expect(screen.getAllByRole('switch')).toHaveLength(3);
});

it('renders label', () => {
render(
<Toggle
id="toggle-test"
items={[
{
value: '1',
label: '1',
},
{
value: '2',
label: '2',
},
{
value: '3',
label: '3',
},
]}
label="Toggle test label"
labelId="test-id"
onChange={() => null}
value="1"
/>,
);

expect(screen.getByRole('group', { name: /toggle test label/i })).toBeInTheDocument();
});

it('renders custom label', () => {
render(
<Toggle
items={[
{
value: '1',
label: '1',
},
{
value: '2',
label: '2',
},
{
value: '3',
label: '3',
},
]}
label={<FormControlLabel>Custom label</FormControlLabel>}
labelId="test-id"
onChange={() => null}
value="1"
/>,
);

expect(screen.getByRole('group', { name: /custom label/i })).toBeInTheDocument();
});

it('disables all tabs', () => {
render(
<Toggle
disabled={true}
items={[
{
value: '1',
label: '1',
},
{
value: '2',
label: '2',
},
{
value: '3',
label: '3',
},
]}
onChange={() => null}
value="1"
/>,
);

const tabs = screen.getAllByRole('switch');

expect(tabs[0]).toBeDisabled();
expect(tabs[1]).toBeDisabled();
expect(tabs[2]).toBeDisabled();
});

it('triggers onTabClick', () => {
const onTabClickMock = jest.fn();

render(
<Toggle
items={[
{
value: '1',
label: '1',
},
{
value: '2',
label: '2',
},
{
value: '3',
label: '3',
},
]}
onChange={onTabClickMock}
value="1"
/>,
);

const tabs = screen.getAllByRole('switch');

if (tabs[1] && tabs[2]) {
fireEvent.click(tabs[1]);
fireEvent.click(tabs[2]);
}

expect(onTabClickMock).toHaveBeenCalledTimes(2);
});
});
82 changes: 82 additions & 0 deletions packages/big-design/src/components/Toggle/styled.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { theme as defaultTheme } from '@bigcommerce/big-design-theme';
import styled from 'styled-components';

export const StyledButton = styled.button<{ isActive: boolean; isIconType: boolean }>`
align-items: center;
appearance: none;
background-color: ${({ isActive, theme }) =>
isActive ? theme.colors.primary20 : theme.colors.white};
border: 1px solid
${({ theme, isActive }) => (isActive ? theme.colors.primary30 : theme.colors.secondary30)};
border-radius: 0;
color: ${({ isActive, theme }) => (isActive ? theme.colors.primary60 : theme.colors.secondary60)};
cursor: pointer;
flex: none;
font-size: ${({ theme }) => theme.typography.fontSize.medium};
font-weight: ${({ theme }) => theme.typography.fontWeight.regular};
justify-content: center;
line-height: ${({ theme }) => theme.lineHeight.xLarge};
outline: none;
padding: ${({ theme }) => `0 ${theme.spacing.medium}`};
position: relative;
text-align: center;
text-decoration: none;
user-select: none;
vertical-align: middle;
white-space: nowrap;
width: auto;
margin-right: -1px;
&:last-of-type {
margin-right: 0;
}
&:focus {
box-shadow: ${({ theme }) => `0 0 0 ${theme.spacing.xxSmall} ${theme.colors.primary20}`};
z-index: 999;
}
${({ isActive, theme }) =>
isActive
? `
z-index: 1;
`
: `
&:hover {
background-color: ${theme.colors.secondary10};
}
`}
&:first-child {
border-top-left-radius: ${({ theme }) => theme.borderRadius.normal};
border-bottom-left-radius: ${({ theme }) => theme.borderRadius.normal};
}
&:last-child {
border-top-right-radius: ${({ theme }) => theme.borderRadius.normal};
border-bottom-right-radius: ${({ theme }) => theme.borderRadius.normal};
}
&[disabled] {
border-color: ${({ theme }) => theme.colors.secondary30};
pointer-events: none;
${({ theme }) =>
`
background-color: ${theme.colors.secondary10};
color: ${theme.colors.secondary50}
`}
}
${({ isIconType }) =>
isIconType &&
`
display: flex;
width: 36px;
height: 36px;
padding: 0;
align-items: center;
`}
`;

StyledButton.defaultProps = { theme: defaultTheme };
1 change: 1 addition & 0 deletions packages/big-design/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,6 @@ export * from './Tabs';
export * from './Textarea';
export * from './Timepicker';
export * from './Tooltip';
export * from './Toggle';
export * from './Typography';
export * from './Worksheet';
Loading

0 comments on commit 8200e44

Please sign in to comment.