Skip to content

Commit

Permalink
feat(component): add Toggle component
Browse files Browse the repository at this point in the history
  • Loading branch information
bc-apostoliuk committed Jul 31, 2023
1 parent 0595d64 commit 1ceac59
Show file tree
Hide file tree
Showing 9 changed files with 465 additions and 0 deletions.
101 changes: 101 additions & 0 deletions packages/big-design/src/components/Toggle/Toggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import React, {
cloneElement,
isValidElement,
LabelHTMLAttributes,
memo,
MouseEvent,
useId,
useMemo,
} from 'react';

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

import { 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 {
value: string;
label?: string;
icon?: React.ReactNode;
}

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

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

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

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

return onChange(itemId);
};

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

if (typeof label === 'string') {
return (
<FormControlLabel htmlFor={id} 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 display="flex" id={id} marginBottom="medium" role="group">
{items.map(({ value: itemId, label, icon }) => (
<StyledButton
aria-checked={itemId === activeValue}
disabled={disabled}
isActive={itemId === activeValue}
key={itemId}
onClick={handleClick(itemId)}
role="switch"
>
{icon || label}
</StyledButton>
))}
</Box>
</div>
);
},
);

Toggle.displayName = 'Tabs';
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';
97 changes: 97 additions & 0 deletions packages/big-design/src/components/Toggle/spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { fireEvent, screen } from '@testing-library/react';
import React from 'react';

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

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('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);
});
});
57 changes: 57 additions & 0 deletions packages/big-design/src/components/Toggle/styled.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import styled from 'styled-components';

export const StyledButton = styled.button<{ isActive: 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-left-width: 0.5px;
border-right-width: 0.5px;
border-radius: 0;
color: ${({ isActive, theme }) => (isActive ? theme.colors.primary60 : theme.colors.secondary60)};
cursor: pointer;
display: inline-flex;
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;
&:focus {
box-shadow: ${({ theme }) => `0 0 0 ${theme.spacing.xxSmall} ${theme.colors.primary20}`};
z-index: 999;
}
&:first-child {
border-top-left-radius: ${({ theme }) => theme.borderRadius.normal};
border-bottom-left-radius: ${({ theme }) => theme.borderRadius.normal};
border-left-width: 1px;
}
&:last-child {
border-top-right-radius: ${({ theme }) => theme.borderRadius.normal};
border-bottom-right-radius: ${({ theme }) => theme.borderRadius.normal};
border-right-width: 1px;
}
&[disabled] {
border-color: ${({ theme }) => theme.colors.secondary30};
pointer-events: none;
${({ theme }) =>
`
background-color: ${theme.colors.secondary10};
color: ${theme.colors.secondary50}
`}
}
`;
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';
75 changes: 75 additions & 0 deletions packages/docs/PropTables/TogglePropsTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import React from 'react';

import { Code, NextLink, Prop, PropTable, PropTableWrapper } from '../components';

const toggleProps: Prop[] = [
{
name: 'id',
types: 'string',
description: 'Defines an HTML id attribute to the parent wrapper.',
},
{
name: 'label',
types: ['string', 'FormControlLabel'],
description: 'Adds a label to the toggle.',
},
{
name: 'value',
types: 'string',
description: 'Determines the active button by value.',
},
{
name: 'disabled',
types: 'boolean',
description: (
<>
Disables the <Code>Toggle</Code> component.
</>
),
},
{
name: 'items',
types: 'ToggleItem[]',
description: (
<>
See{' '}
<NextLink href={{ hash: 'toggle-item-prop-table', query: { props: 'toggle-item' } }}>
ToggleItem
</NextLink>{' '}
for usage.
</>
),
},
{
name: 'onChange',
types: '(value: string) => void',
description: 'Function that will get called when a toggle button is clicked.',
},
];

export const TogglePropTable: React.FC<PropTableWrapper> = (props) => (
<PropTable propList={toggleProps} title="Toggle" {...props} />
);

const toggleItemProps: Prop[] = [
{
name: 'value',
types: 'string',
description: 'Toggle button value, must be unique.',
required: true,
},
{
name: 'label',
types: 'string',
description: "Toggle button label, can't be used with icon.",
},
{
name: 'icon',
types: 'React.ReactNode',
description: "Toggle button icon, can't be used with title.",
},
];

export const ToggleItemPropTable: React.FC<PropTableWrapper> = (props) => (
<PropTable propList={toggleItemProps} title="Toggle[ToggleItem]" {...props} />
);
1 change: 1 addition & 0 deletions packages/docs/PropTables/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,5 @@ export * from './TextareaPropTable';
export * from './TimepickerPropTable';
export * from './TooltipPropTable';
export * from './TypographyPropTable';
export * from './TogglePropsTable';
export * from './WorksheetPropTable';
1 change: 1 addition & 0 deletions packages/docs/components/SideNav/SideNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export const SideNav: React.FC = () => {
<SideNavLink href="/search">Search</SideNavLink>
<SideNavLink href="/select">Select</SideNavLink>
<SideNavLink href="/switch">Switch</SideNavLink>
<SideNavLink href="/toggle">Toggle</SideNavLink>
<SideNavLink href="/textarea">Textarea</SideNavLink>
<SideNavLink href="/timepicker">Timepicker</SideNavLink>
</SideNavGroup>
Expand Down
Loading

0 comments on commit 1ceac59

Please sign in to comment.