diff --git a/packages/big-design/src/components/Toggle/Toggle.tsx b/packages/big-design/src/components/Toggle/Toggle.tsx new file mode 100644 index 000000000..7919897f3 --- /dev/null +++ b/packages/big-design/src/components/Toggle/Toggle.tsx @@ -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 = Pick> & + { + [K in Keys]-?: Required> & Partial>>; + }[Keys]; + +interface ToggleItem { + value: string; + label?: string; + icon?: React.ReactNode; +} + +type ToggleItemType = RequireOneOf; + +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 = 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) => { + e.preventDefault(); + + return onChange(itemId); + }; + + const renderedLabel = useMemo(() => { + if (!label) { + return null; + } + + if (typeof label === 'string') { + return ( + + {label} + + ); + } + + if ( + isValidElement>(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 ( +
+ {renderedLabel} + + {items.map(({ value: itemId, label, icon }) => ( + + {icon || label} + + ))} + +
+ ); + }, +); + +Toggle.displayName = 'Tabs'; diff --git a/packages/big-design/src/components/Toggle/index.ts b/packages/big-design/src/components/Toggle/index.ts new file mode 100644 index 000000000..7aaab1d76 --- /dev/null +++ b/packages/big-design/src/components/Toggle/index.ts @@ -0,0 +1 @@ +export * from './Toggle'; diff --git a/packages/big-design/src/components/Toggle/spec.tsx b/packages/big-design/src/components/Toggle/spec.tsx new file mode 100644 index 000000000..5e086fbfa --- /dev/null +++ b/packages/big-design/src/components/Toggle/spec.tsx @@ -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( + null} + value="1" + />, + ); + + expect(screen.getAllByRole('switch')).toHaveLength(3); + }); + + it('disables all tabs', () => { + render( + 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( + , + ); + + const tabs = screen.getAllByRole('switch'); + + if (tabs[1] && tabs[2]) { + fireEvent.click(tabs[1]); + fireEvent.click(tabs[2]); + } + + expect(onTabClickMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/big-design/src/components/Toggle/styled.ts b/packages/big-design/src/components/Toggle/styled.ts new file mode 100644 index 000000000..7f9853c4a --- /dev/null +++ b/packages/big-design/src/components/Toggle/styled.ts @@ -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} + `} + } +`; diff --git a/packages/big-design/src/components/index.ts b/packages/big-design/src/components/index.ts index 3ec38e0f9..6877509b9 100644 --- a/packages/big-design/src/components/index.ts +++ b/packages/big-design/src/components/index.ts @@ -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'; diff --git a/packages/docs/PropTables/TogglePropsTable.tsx b/packages/docs/PropTables/TogglePropsTable.tsx new file mode 100644 index 000000000..00d9889e0 --- /dev/null +++ b/packages/docs/PropTables/TogglePropsTable.tsx @@ -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 Toggle component. + + ), + }, + { + name: 'items', + types: 'ToggleItem[]', + description: ( + <> + See{' '} + + ToggleItem + {' '} + 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 = (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 = (props) => ( + +); diff --git a/packages/docs/PropTables/index.ts b/packages/docs/PropTables/index.ts index 6e35af9d7..5d05bf5e7 100644 --- a/packages/docs/PropTables/index.ts +++ b/packages/docs/PropTables/index.ts @@ -41,4 +41,5 @@ export * from './TextareaPropTable'; export * from './TimepickerPropTable'; export * from './TooltipPropTable'; export * from './TypographyPropTable'; +export * from './TogglePropsTable'; export * from './WorksheetPropTable'; diff --git a/packages/docs/components/SideNav/SideNav.tsx b/packages/docs/components/SideNav/SideNav.tsx index 775b3f7e5..b5f8b87c2 100644 --- a/packages/docs/components/SideNav/SideNav.tsx +++ b/packages/docs/components/SideNav/SideNav.tsx @@ -69,6 +69,7 @@ export const SideNav: React.FC = () => { Search Select Switch + Toggle Textarea Timepicker diff --git a/packages/docs/pages/toggle.tsx b/packages/docs/pages/toggle.tsx new file mode 100644 index 000000000..c4162827f --- /dev/null +++ b/packages/docs/pages/toggle.tsx @@ -0,0 +1,131 @@ +import { H1, Panel, Text, Toggle } from '@bigcommerce/big-design'; +import { VisibilityIcon, VisibilityOffIcon } from '@bigcommerce/big-design-icons'; +import React, { useState } from 'react'; + +import { Code, CodePreview, ContentRoutingTabs, GuidelinesTable, List } from '../components'; +import { ToggleItemPropTable, TogglePropTable } from '../PropTables'; + +const TabsPage = () => { + return ( + <> +

Toggle

+ + + + Toggle buttons allow users to switch between alternative states or + modes of the same entity. Only one state is shown at a time. + + When to use: + + + In forms to let users switch between different options of the setting with dependent + subsettings. + + + + + + ( + + {/* jsx-to-string:start */} + {function Example() { + const [activeTab, setActiveTab] = useState('product'); + + const items = [ + { value: 'none', label: 'None' }, + { value: 'product', label: 'Product' }, + { value: 'variant', label: 'Variant' }, + ]; + + return ( + + ); + }} + {/* jsx-to-string:end */} + + ), + }, + { + id: 'icons', + title: 'Icons', + render: () => ( + + {/* jsx-to-string:start */} + {function Example() { + const [activeTab, setActiveTab] = useState('invisible'); + + const items = [ + { + icon: , + value: 'visible', + }, + { + icon: , + value: 'invisible', + }, + ]; + + return ( + + ); + }} + {/* jsx-to-string:end */} + + ), + }, + ]} + /> + + + + , + }, + { + id: 'toggle-item', + title: 'ToggleItem', + render: () => , + }, + ]} + /> + + + + + + + ); +}; + +export default TabsPage;