From 775ca0dc313bc0590db3a497f9003848f09b2092 Mon Sep 17 00:00:00 2001 From: Garrett Bear Date: Fri, 14 Jul 2023 11:50:47 -0700 Subject: [PATCH] Feat/15438/create ds checkbox component (#19808) * add ds checkbox --------- Co-authored-by: Garrett Bear --------- Co-authored-by: georgewrmarshall --- app/images/icons/check-bold.svg | 1 + app/images/icons/minus-bold.svg | 1 + .../component-library/checkbox/README.mdx | 218 ++++++++++++++++++ .../__snapshots__/checkbox.test.tsx.snap | 18 ++ .../component-library/checkbox/checkbox.scss | 53 +++++ .../checkbox/checkbox.stories.tsx | 177 ++++++++++++++ .../checkbox/checkbox.test.tsx | 182 +++++++++++++++ .../component-library/checkbox/checkbox.tsx | 124 ++++++++++ .../checkbox/checkbox.types.ts | 74 ++++++ .../component-library/checkbox/index.ts | 2 + .../component-library-components.scss | 1 + .../component-library/icon/icon.types.ts | 2 + ui/components/component-library/index.js | 1 + 13 files changed, 854 insertions(+) create mode 100644 app/images/icons/check-bold.svg create mode 100644 app/images/icons/minus-bold.svg create mode 100644 ui/components/component-library/checkbox/README.mdx create mode 100644 ui/components/component-library/checkbox/__snapshots__/checkbox.test.tsx.snap create mode 100644 ui/components/component-library/checkbox/checkbox.scss create mode 100644 ui/components/component-library/checkbox/checkbox.stories.tsx create mode 100644 ui/components/component-library/checkbox/checkbox.test.tsx create mode 100644 ui/components/component-library/checkbox/checkbox.tsx create mode 100644 ui/components/component-library/checkbox/checkbox.types.ts create mode 100644 ui/components/component-library/checkbox/index.ts diff --git a/app/images/icons/check-bold.svg b/app/images/icons/check-bold.svg new file mode 100644 index 000000000..a011e3386 --- /dev/null +++ b/app/images/icons/check-bold.svg @@ -0,0 +1 @@ + diff --git a/app/images/icons/minus-bold.svg b/app/images/icons/minus-bold.svg new file mode 100644 index 000000000..16026831c --- /dev/null +++ b/app/images/icons/minus-bold.svg @@ -0,0 +1 @@ + diff --git a/ui/components/component-library/checkbox/README.mdx b/ui/components/component-library/checkbox/README.mdx new file mode 100644 index 000000000..dc2f04e8a --- /dev/null +++ b/ui/components/component-library/checkbox/README.mdx @@ -0,0 +1,218 @@ +import { Story, Canvas, ArgsTable } from '@storybook/addon-docs'; + +import { Checkbox } from './checkbox'; + +# Checkbox + +Checkbox is a graphical element that allows users to select one or more options from a set of choices. + + + + + +## Props + +The `Checkbox` accepts all props below as well as all [Box](/docs/components-componentlibrary-box--docs#props) component props + + + +### Label + +Use the `label` string prop to set the label of the `Checkbox` + + + + + +```jsx +import { Checkbox } from '../../component-library'; + +; +``` + +### IsChecked + +Use the `isChecked` boolean prop to set the checked state of the `Checkbox` + + + + + +```jsx +import { Checkbox } from '../../component-library'; + +; +``` + +### IsIndeterminate + +Use the `isIndeterminate` boolean prop to set the indeterminate state of the `Checkbox` + + + + + +```jsx +import React from 'react'; +import { Checkbox, Box } from '../../component-library'; + +const [checkedItems, setCheckedItems] = React.useState([false, true, false]); + +const allChecked = checkedItems.every(Boolean); +const isIndeterminate = checkedItems.some(Boolean) && !allChecked; + +const handleIndeterminateChange = () => { + if (allChecked || isIndeterminate) { + setCheckedItems([false, false, false]); + } else { + setCheckedItems([true, true, true]); + } +}; + +const handleCheckboxChange = (index, value) => { + const newCheckedItems = [...checkedItems]; + newCheckedItems[index] = value; + setCheckedItems(newCheckedItems); +}; + + + + handleCheckboxChange(0, e.target.checked)} + label="Checkbox 1" + /> + handleCheckboxChange(1, e.target.checked)} + label="Checkbox 2" + /> + handleCheckboxChange(2, e.target.checked)} + label="Checkbox 3" + /> + +``` + +### IsDisabled + +Use the `isDisabled` boolean prop to set the disabled state of the `Checkbox` + + + + + +```jsx +import { Checkbox } from '../../component-library'; + +; +``` + +### IsReadOnly + +Use the `isReadOnly` boolean prop to set the readOnly attribute of the `Checkbox` + + + + + +```jsx +import { Checkbox } from '../../component-library'; + +; +``` + +### OnChange + +Use the `onChange` function prop to set the onChange handler of the `Checkbox` + + + + + +```jsx +import React from 'react'; +import { Checkbox } from '../../component-library'; + +const [isChecked, setIsChecked] = React.useState(false); + + setIsChecked(!isChecked)} + isChecked={isChecked} + label="isReadOnly demo" +/>; +``` + +### IsRequired + +Use the `isRequired` boolean prop to set the required attribute of the `Checkbox` + + + + + +```jsx +import { Checkbox } from '../../component-library'; + +; +``` + +### Title + +Use the `title` string prop to set the title attribute of the `Checkbox` +The title attribute is used to provide additional context or information about the checkbox. It is primarily used for browser native tooltip functionality. + + + + + +```jsx +import { Checkbox } from '../../component-library'; + +; +``` + +### Name + +Use the `name` string prop to set the name attribute of the `Checkbox` +This is best used when working with a form and submitting the value of the `Checkbox` + + + + + +```jsx +import { Checkbox } from '../../component-library'; + +; +``` + +### InputProps + +Use the `inputProps` object prop to add additonal props or attributes to the hidden input element +If needing to pass a ref to the input element, use the `inputRef` prop + + + + + +```jsx +import { Checkbox } from '../../component-library'; + +; +``` diff --git a/ui/components/component-library/checkbox/__snapshots__/checkbox.test.tsx.snap b/ui/components/component-library/checkbox/__snapshots__/checkbox.test.tsx.snap new file mode 100644 index 000000000..5df6da8de --- /dev/null +++ b/ui/components/component-library/checkbox/__snapshots__/checkbox.test.tsx.snap @@ -0,0 +1,18 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Checkbox should render the Checkbox without crashing 1`] = ` +
+ +
+`; diff --git a/ui/components/component-library/checkbox/checkbox.scss b/ui/components/component-library/checkbox/checkbox.scss new file mode 100644 index 000000000..2fe95e07a --- /dev/null +++ b/ui/components/component-library/checkbox/checkbox.scss @@ -0,0 +1,53 @@ +.mm-checkbox { + cursor: pointer; + + &__input-wrapper { + position: relative; + } + + &__input { + -webkit-appearance: none; + appearance: none; + width: 20px; + height: 20px; + + &:hover:not(:disabled) { + background-color: var(--color-background-default-hover); + } + + &:focus { + border-color: var(--color-primary-default); + } + + &:disabled { + color: var(--color-icon-muted); + cursor: not-allowed; + } + } + + &__input--checked:hover:not(:disabled), + &__input--indeterminate:hover:not(:disabled) { + border-color: var(--color-primary-alternative); + background-color: var(--color-primary-alternative); + } + + &__input--checked#{&}__input--readonly, + &__input--checked#{&}__input--readonly:hover, + &__input--indeterminate#{&}__input--readonly, + &__input--indeterminate#{&}__input--readonly:hover { + border-color: var(--color-icon-alternative); + background-color: var(--color-icon-alternative); + cursor: not-allowed; + } + + &--disabled { + opacity: var(--opacity-disabled); + } + + &__icon { + position: absolute; + top: 0; + left: 0; + pointer-events: none; + } +} diff --git a/ui/components/component-library/checkbox/checkbox.stories.tsx b/ui/components/component-library/checkbox/checkbox.stories.tsx new file mode 100644 index 000000000..6ee20f4f2 --- /dev/null +++ b/ui/components/component-library/checkbox/checkbox.stories.tsx @@ -0,0 +1,177 @@ +import { StoryFn, Meta } from '@storybook/react'; +import { useArgs } from '@storybook/client-api'; +import React from 'react'; + +import { Box } from '..'; +import { + BorderColor, + Display, + FlexDirection, +} from '../../../helpers/constants/design-system'; +import README from './README.mdx'; + +import { Checkbox } from '.'; + +export default { + title: 'Components/ComponentLibrary/Checkbox', + component: Checkbox, + parameters: { + docs: { + page: README, + }, + }, + argTypes: { + label: { + control: 'text', + }, + name: { + control: 'text', + }, + id: { + control: 'text', + }, + }, +} as Meta; + +const Template: StoryFn = (args) => { + const [{ isChecked }, updateArgs] = useArgs(); + return ( + + updateArgs({ + isChecked: !isChecked, + }) + } + isChecked={isChecked} + /> + ); +}; + +export const DefaultStory = Template.bind({}); +DefaultStory.storyName = 'Default'; + +export const Label = Template.bind({}); +Label.args = { + label: 'Checkbox label', +}; + +export const Id = Template.bind({}); +Id.args = { + label: 'Id demo', + id: 'id-demo', +}; + +export const IsChecked = Template.bind({}); +IsChecked.args = { + isChecked: true, + label: 'isChecked demo', +}; + +export const IsIndeterminate = (args) => { + const [checkedItems, setCheckedItems] = React.useState([false, true, false]); + + const allChecked = checkedItems.every(Boolean); + const isIndeterminate = checkedItems.some(Boolean) && !allChecked; + + const handleIndeterminateChange = () => { + if (allChecked || isIndeterminate) { + setCheckedItems([false, false, false]); + } else { + setCheckedItems([true, true, true]); + } + }; + + const handleCheckboxChange = (index, value) => { + const newCheckedItems = [...checkedItems]; + newCheckedItems[index] = value; + setCheckedItems(newCheckedItems); + }; + + return ( +
+ + + handleCheckboxChange(0, e.target.checked)} + label="Checkbox 1" + /> + handleCheckboxChange(1, e.target.checked)} + label="Checkbox 2" + /> + handleCheckboxChange(2, e.target.checked)} + label="Checkbox 3" + /> + +
+ ); +}; + +IsIndeterminate.args = { + label: 'isIndeterminate demo', + isIndeterminate: true, +}; + +export const IsDisabled = Template.bind({}); + +IsDisabled.args = { + isDisabled: true, + label: 'isDisabled demo', +}; + +export const IsReadOnly = Template.bind({}); + +IsReadOnly.args = { + isReadOnly: true, + isChecked: true, + label: 'isReadOnly demo', +}; + +export const OnChange = Template.bind({}); +OnChange.args = { + label: 'onChange demo', +}; + +export const IsRequired = Template.bind({}); + +IsRequired.args = { + isRequired: true, + isChecked: true, + label: 'isRequired demo', +}; + +export const Title = Template.bind({}); + +Title.args = { + title: 'Apples', + label: 'Inspect to see title attribute', +}; + +export const Name = Template.bind({}); + +Name.args = { + name: 'pineapple', + label: 'Inspect to see name attribute', +}; + +export const InputProps = Template.bind({}); +InputProps.args = { + inputProps: { borderColor: BorderColor.errorDefault }, + label: 'inputProps demo', +}; diff --git a/ui/components/component-library/checkbox/checkbox.test.tsx b/ui/components/component-library/checkbox/checkbox.test.tsx new file mode 100644 index 000000000..fb7983842 --- /dev/null +++ b/ui/components/component-library/checkbox/checkbox.test.tsx @@ -0,0 +1,182 @@ +import * as React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import { BorderColor } from '../../../helpers/constants/design-system'; +import { Checkbox } from '.'; + +describe('Checkbox', () => { + it('should render the Checkbox without crashing', () => { + const { getByRole, container } = render(); + expect(getByRole('checkbox')).toBeInTheDocument(); + expect(container).toMatchSnapshot(); + }); + + it('should render the Checkbox with additional className', () => { + const { getByTestId } = render( + , + ); + expect(getByTestId('classname')).toHaveClass('mm-checkbox mm-test'); + }); + + it('should render the Checkbox with additional className on the input', () => { + const { getByRole } = render( + , + ); + expect(getByRole('checkbox')).toHaveClass('mm-checkbox__input mm-test'); + }); + + it('should render the Checkbox with border color changed from inputProps', () => { + const { getByRole } = render( + , + ); + expect(getByRole('checkbox')).toHaveClass( + 'mm-box--border-color-error-default', + ); + }); + + it('should render isChecked', () => { + const { getByRole, getByTestId } = render( + , + ); + expect(getByRole('checkbox')).toBeChecked(); + expect(window.getComputedStyle(getByTestId('check-bold')).maskImage).toBe( + `url('./images/icons/check-bold.svg')`, + ); + }); + + it('should render isIndeterminate', () => { + const { getByRole, getByTestId } = render( + , + ); + expect(getByRole('checkbox').getAttribute('data-indeterminate')).toBe( + 'true', + ); + expect(window.getComputedStyle(getByTestId('minus-bold')).maskImage).toBe( + `url('./images/icons/minus-bold.svg')`, + ); + }); + + it('should render checkbox with label', () => { + const { getByText } = render(); + expect(getByText('Option 1')).toBeDefined(); + }); + + it('should render checkbox with id and label has matching htmlfor', () => { + const { getByTestId, getByRole } = render( + , + ); + const checkbox = getByRole('checkbox'); + + expect(checkbox).toHaveAttribute('id', 'option-1'); + expect(getByTestId('label')).toHaveAttribute('for', 'option-1'); + }); + + test('Checkbox component is disabled when isDisabled is true', () => { + const { getByRole, getByTestId } = render( + , + ); + + const checkbox = getByRole('checkbox'); + + expect(checkbox).toBeDisabled(); + expect(getByTestId('option-disabled')).toHaveClass('mm-checkbox--disabled'); + }); + + test('Checkbox component is readOnly when isReadOnly is true', () => { + const { getByLabelText } = render( + , + ); + + const checkbox = getByLabelText('Option 1'); + + expect(checkbox).toHaveAttribute('readonly'); + expect(checkbox).toHaveClass('mm-checkbox__input--readonly'); + }); + + it('Checkbox component fires onChange function when clicked', () => { + const onChange = jest.fn(); + + const { getByTestId } = render( + , + ); + + const checkbox = getByTestId('checkbox'); + + fireEvent.click(checkbox); + + expect(onChange).toHaveBeenCalled(); + }); + + it('Checkbox component fires onChange function label clicked', () => { + const onChange = jest.fn(); + + const { getByText } = render( + , + ); + + const label = getByText('Click label'); + + fireEvent.click(label); + + expect(onChange).toHaveBeenCalled(); + }); + + test('Checkbox component is required when isRequired is true', () => { + const { getByLabelText } = render( + , + ); + + const checkbox = getByLabelText('Option 1'); + + expect(checkbox).toHaveAttribute('required'); + }); + + test('Checkbox component renders with the correct title attribute', () => { + const { getByLabelText } = render( + , + ); + + const checkbox = getByLabelText('Option 1'); + + expect(checkbox).toHaveAttribute('title', 'pineapple'); + }); + + test('Checkbox component renders with the correct title attribute used from the label', () => { + const { getByLabelText } = render( + , + ); + + const checkbox = getByLabelText('Option 1'); + + expect(checkbox).toHaveAttribute('title', 'Option 1'); + }); + + test('Checkbox component renders with the correct title attribute used from the id', () => { + const { getByRole } = render(); + + const checkbox = getByRole('checkbox'); + + expect(checkbox).toHaveAttribute('title', 'option-1'); + }); + + test('Checkbox component renders with the correct name attribute', () => { + const { getByRole } = render(); + + const checkbox = getByRole('checkbox'); + + expect(checkbox).toHaveAttribute('name', 'option-1'); + }); +}); diff --git a/ui/components/component-library/checkbox/checkbox.tsx b/ui/components/component-library/checkbox/checkbox.tsx new file mode 100644 index 000000000..83d0a8113 --- /dev/null +++ b/ui/components/component-library/checkbox/checkbox.tsx @@ -0,0 +1,124 @@ +import React, { ChangeEvent, KeyboardEvent } from 'react'; +import classnames from 'classnames'; + +import { + BackgroundColor, + BorderColor, + BorderRadius, + IconColor, + Display, + AlignItems, +} from '../../../helpers/constants/design-system'; +import type { PolymorphicRef } from '../box'; + +import { Box, Icon, IconName, Text } from '..'; + +import { CheckboxProps, CheckboxComponent } from './checkbox.types'; + +export const Checkbox: CheckboxComponent = React.forwardRef( + ( + { + id, + isChecked, + isIndeterminate, + isDisabled, + isReadOnly, + isRequired, + onChange, + className = '', + iconProps, + inputProps, + inputRef, + title, + name, + label, + ...props + }: CheckboxProps, + ref?: PolymorphicRef, + ) => { + const handleCheckboxKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter') { + onChange?.(event as unknown as ChangeEvent); + } + }; + + // If no title is provided, use the label as the title only if the label is a string + const sanitizedTitle = + !title && typeof label === 'string' ? label : title || id; + + return ( + + + ) => { + if (isReadOnly) { + event.preventDefault(); + } else { + onChange?.(event); + } + }} + onKeyDown={handleCheckboxKeyDown} + margin={0} + marginRight={label ? 2 : 0} + backgroundColor={ + isChecked || isIndeterminate + ? BackgroundColor.primaryDefault + : BackgroundColor.transparent + } + borderColor={ + isChecked || isIndeterminate + ? BorderColor.primaryDefault + : BorderColor.borderDefault + } + borderRadius={BorderRadius.SM} + borderWidth={2} + display={Display.Flex} + ref={inputRef} + {...inputProps} + className={classnames( + 'mm-checkbox__input', + inputProps?.className ?? '', + { + 'mm-checkbox__input--checked': Boolean(isChecked), + 'mm-checkbox__input--indeterminate': Boolean(isIndeterminate), + 'mm-checkbox__input--readonly': Boolean(isReadOnly), + }, + )} + /> + {(isChecked || isIndeterminate) && ( + + )} + + {label ? {label} : null} + + ); + }, +); diff --git a/ui/components/component-library/checkbox/checkbox.types.ts b/ui/components/component-library/checkbox/checkbox.types.ts new file mode 100644 index 000000000..cc7871b8c --- /dev/null +++ b/ui/components/component-library/checkbox/checkbox.types.ts @@ -0,0 +1,74 @@ +import { IconProps } from '../icon'; +import type { + StyleUtilityProps, + PolymorphicComponentPropWithRef, +} from '../box'; + +export interface CheckboxStyleUtilityProps extends StyleUtilityProps { + /* + * Additional classNames to be added to the Checkbox component + */ + className?: string; + /* + * id - the id for the Checkbox and used for the htmlFor attribute of the label + */ + id?: string; + /* + * isDisabled - if true, the Checkbox will be disabled + */ + isDisabled?: boolean; + /* + * isChecked - if true, the Checkbox will be checked + */ + isChecked?: boolean; + /* + * isIndeterminate - if true, the Checkbox will be indeterminate + */ + isIndeterminate?: boolean; + /* + * isReadOnly - if true, the Checkbox will be read only + */ + isReadOnly?: boolean; + /* + * isRequired - if true, the Checkbox will be required + */ + isRequired?: boolean; + /* + * title can help add additional context to the Checkbox for screen readers and will work for native tooltip elements + * if no title is passed, then it will try to use the label prop if it is a string + */ + title?: string; + /* + * name - to identify the checkbox and associate it with its value during form submission + */ + name?: string; + /* + * onChange - the function to call when the Checkbox is changed + */ + onChange?: (event: React.ChangeEvent) => void; + /* + * label is the string or ReactNode to be rendered next to the Checkbox + */ + label?: any; + /* + * Use inputProps for additional props to be spread to the checkbox input element + */ + inputProps?: any; // TODO: Replace with Box types when the syntax and typing is properly figured out. Needs to accept everything Box accepts + /* + * Use inputRef to pass a ref to the html input element + */ + inputRef?: + | React.RefObject + | ((instance: HTMLInputElement | null) => void); + /* + * iconProps - additional props to be spread to the Icon component used for the Checkbox + */ + iconProps?: IconProps; +} + +export type CheckboxProps = + PolymorphicComponentPropWithRef; + +export type CheckboxComponent = ( + props: CheckboxProps, +) => React.ReactElement | null; diff --git a/ui/components/component-library/checkbox/index.ts b/ui/components/component-library/checkbox/index.ts new file mode 100644 index 000000000..cf2067124 --- /dev/null +++ b/ui/components/component-library/checkbox/index.ts @@ -0,0 +1,2 @@ +export { Checkbox } from './checkbox'; +export type { CheckboxProps } from './checkbox.types'; diff --git a/ui/components/component-library/component-library-components.scss b/ui/components/component-library/component-library-components.scss index d03fb28f1..f6bbd81e4 100644 --- a/ui/components/component-library/component-library-components.scss +++ b/ui/components/component-library/component-library-components.scss @@ -27,6 +27,7 @@ @import 'button-link/button-link'; @import 'button-primary/button-primary'; @import 'button-secondary/button-secondary'; +@import 'checkbox/checkbox'; @import 'input/input'; // Molecules @import 'picker-network/picker-network'; diff --git a/ui/components/component-library/icon/icon.types.ts b/ui/components/component-library/icon/icon.types.ts index 011ca4700..1ad735719 100644 --- a/ui/components/component-library/icon/icon.types.ts +++ b/ui/components/component-library/icon/icon.types.ts @@ -44,6 +44,7 @@ export enum IconName { Card = 'card', Category = 'category', Chart = 'chart', + CheckBold = 'check-bold', Check = 'check', Clock = 'clock', Close = 'close', @@ -93,6 +94,7 @@ export enum IconName { Menu = 'menu', MessageQuestion = 'message-question', Messages = 'messages', + MinusBold = 'minus-bold', MinusSquare = 'minus-square', Minus = 'minus', Mobile = 'mobile', diff --git a/ui/components/component-library/index.js b/ui/components/component-library/index.js index 4da644ec7..564643063 100644 --- a/ui/components/component-library/index.js +++ b/ui/components/component-library/index.js @@ -21,6 +21,7 @@ export { ButtonIcon, ButtonIconSize } from './button-icon'; export { ButtonLink, BUTTON_LINK_SIZES } from './button-link'; export { ButtonPrimary, BUTTON_PRIMARY_SIZES } from './button-primary'; export { ButtonSecondary, BUTTON_SECONDARY_SIZES } from './button-secondary'; +export { Checkbox } from './checkbox'; export { FormTextField } from './form-text-field'; export { HeaderBase } from './header-base'; export { HelpText } from './help-text';