Merge pull request #17 from acid-info/topic-implement-checkbox

feat: implement Checkbox component
This commit is contained in:
jeangovil 2023-03-09 18:39:27 +03:30 committed by GitHub
commit 8835bbbc7c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 625 additions and 10 deletions

View File

@ -7,6 +7,7 @@ import { ButtonStyles } from '../Button/Button.styles'
import { CardStyles } from '../Card/Card.styles'
import { CardBodyStyles } from '../CardBody/CardBody.styles'
import { CardHeaderStyles } from '../CardHeader/CardHeader.styles'
import { CheckboxStyles } from '../Checkbox/Checkbox.styles'
import { CollapseStyles } from '../Collapse/Collapse.styles'
import { CollapseHeaderStyles } from '../CollapseHeader/CollapseHeader.styles'
import { DropdownStyles } from '../Dropdown/Dropdown.styles'
@ -15,6 +16,8 @@ import { IconButtonStyles } from '../IconButton/IconButton.styles'
import { LsdIconStyles } from '../Icons/LsdIcon/LsdIcon.styles'
import { ListBoxStyles } from '../ListBox/ListBox.styles'
import { QuoteStyles } from '../Quote/Quote.styles'
import { RadioButtonStyles } from '../RadioButton/RadioButton.styles'
import { RadioButtonGroupStyles } from '../RadioButtonGroup/RadioButtonGroup.styles'
import { TabItemStyles } from '../TabItem/TabItem.styles'
import { TabsStyles } from '../Tabs/Tabs.styles'
import { TagStyles } from '../Tag/Tag.styles'
@ -40,10 +43,13 @@ const componentStyles: Array<ReturnType<typeof withTheme> | SerializedStyles> =
CardBodyStyles,
TagStyles,
TextFieldStyles,
CheckboxStyles,
AutocompleteStyles,
QuoteStyles,
CollapseStyles,
CollapseHeaderStyles,
RadioButtonStyles,
RadioButtonGroupStyles,
]
export const CSSBaseline: React.FC<{ theme?: Theme }> = ({

View File

@ -0,0 +1,15 @@
export const checkboxClasses = {
root: `lsd-checkbox`,
input: `lsd-checkbox__input`,
icon: `lsd-checkbox__icon`,
label: `lsd-checkbox__label`,
focused: `lsd-checkbox--focused`,
disabled: `lsd-checkbox--disabled`,
indeterminate: 'lsd-checkbox--indeterminate',
large: `lsd-checkbox--large`,
medium: `lsd-checkbox--medium`,
small: 'lsd-checkbox--small',
}

View File

@ -0,0 +1,28 @@
import { Meta, Story } from '@storybook/react'
import { Checkbox, CheckboxProps } from './Checkbox'
export default {
title: 'Checkbox',
component: Checkbox,
argTypes: {
size: {
type: {
name: 'enum',
value: ['small', 'medium', 'large'],
},
defaultValue: 'large',
},
},
} as Meta
export const Root: Story<CheckboxProps> = (args) => (
<Checkbox {...args}>Checkbox</Checkbox>
)
Root.args = {
size: 'large',
disabled: false,
indeterminate: false,
checked: undefined,
onChange: undefined,
}

View File

@ -0,0 +1,59 @@
import { css } from '@emotion/react'
import { checkboxClasses } from './Checkbox.classes'
export const CheckboxStyles = css`
.${checkboxClasses.root} {
position: relative;
display: flex;
flex-direction: row;
align-items: center;
}
.${checkboxClasses.input} {
opacity: 0;
position: absolute;
left: 0;
top: 0;
padding: 0;
margin: 0;
width: 100%;
height: 100%;
}
.${checkboxClasses.root}:not(.${checkboxClasses.disabled}) {
&:hover,
&.${checkboxClasses.focused} {
text-decoration: underline;
}
.${checkboxClasses.input} {
cursor: pointer;
}
}
.${checkboxClasses.disabled} {
opacity: 0.34;
}
.${checkboxClasses.label} {
margin-left: 18px;
}
.${checkboxClasses.large} {
.${checkboxClasses.label} {
margin-left: 18px;
}
}
.${checkboxClasses.medium} {
.${checkboxClasses.label} {
margin-left: 14px;
}
}
.${checkboxClasses.small} {
.${checkboxClasses.label} {
margin-left: 12px;
}
}
`

View File

@ -0,0 +1,100 @@
import clsx from 'clsx'
import React, { useEffect, useRef, useState } from 'react'
import { useInput } from '../../utils/useInput'
import { CheckboxFilledIcon, CheckboxIcon } from '../Icons'
import { CheckboxIndeterminateIcon } from '../Icons/CheckboxIndeterminate'
import { Typography } from '../Typography'
import { checkboxClasses } from './Checkbox.classes'
export type CheckboxProps = Omit<
React.LabelHTMLAttributes<HTMLLabelElement>,
'onChange' | 'value' | 'color'
> &
Pick<
React.InputHTMLAttributes<HTMLInputElement>,
'name' | 'onChange' | 'checked' | 'defaultChecked'
> & {
disabled?: boolean
indeterminate?: boolean
size?: 'small' | 'medium' | 'large'
inputProps?: React.InputHTMLAttributes<HTMLInputElement>
}
export const Checkbox: React.FC<CheckboxProps> & {
classes: typeof checkboxClasses
} = ({
name,
size = 'large',
onChange,
checked,
defaultChecked,
disabled = false,
indeterminate = false,
inputProps = {},
children,
...props
}) => {
const ref = useRef<HTMLInputElement>(null)
const [focused, setFocused] = useState(false)
const input = useInput({
value: checked,
defaultValue: defaultChecked ?? false,
onChange,
ref,
})
useEffect(() => {
if (!ref.current) return
const onFocus = () => setFocused(true)
const onBlur = () => setFocused(false)
ref.current.addEventListener('focus', onFocus)
ref.current.addEventListener('blur', onBlur)
return () => {
ref.current?.removeEventListener('focus', onFocus)
ref.current?.removeEventListener('blur', onBlur)
}
}, [ref.current])
return (
<Typography
color="primary"
variant={size === 'large' ? 'label1' : 'label2'}
component="label"
aria-disabled={disabled ? 'true' : 'false'}
{...props}
className={clsx(
props.className,
checkboxClasses.root,
checkboxClasses[size],
focused && checkboxClasses.focused,
disabled && checkboxClasses.disabled,
indeterminate && checkboxClasses.indeterminate,
)}
>
<input
ref={ref}
name={name}
type="checkbox"
disabled={disabled}
checked={input.value}
onChange={input.onChange}
defaultChecked={defaultChecked}
className={clsx(inputProps.className, checkboxClasses.input)}
{...inputProps}
/>
{indeterminate ? (
<CheckboxIndeterminateIcon color="primary" focusable={false} />
) : input.value ? (
<CheckboxFilledIcon color="primary" focusable={false} />
) : (
<CheckboxIcon color="primary" focusable={false} />
)}
<span className={checkboxClasses.label}>{children}</span>
</Typography>
)
}
Checkbox.classes = checkboxClasses

View File

@ -0,0 +1 @@
export * from './Checkbox'

View File

@ -0,0 +1,24 @@
import { LsdIcon } from '../LsdIcon'
export const CheckboxIndeterminateIcon = LsdIcon(
(props) => (
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2.91667 1.75C2.27233 1.75 1.75 2.27233 1.75 2.91667V11.0833C1.75 11.7277 2.27233 12.25 2.91667 12.25H11.0833C11.7277 12.25 12.25 11.7277 12.25 11.0833V2.91667C12.25 2.27233 11.7277 1.75 11.0833 1.75H2.91667ZM9.91667 6.41667H4.08333V7.58333H9.91667V6.41667Z"
fill="black"
/>
</svg>
),
{
filled: true,
},
)

View File

@ -0,0 +1 @@
export * from './CheckboxIndeterminateIcon'

View File

@ -0,0 +1,26 @@
import { LsdIcon } from '../LsdIcon'
export const RadioButtonFilledIcon = LsdIcon(
(props) => (
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M7.0013 1.16669C3.7813 1.16669 1.16797 3.78002 1.16797 7.00002C1.16797 10.22 3.7813 12.8334 7.0013 12.8334C10.2213 12.8334 12.8346 10.22 12.8346 7.00002C12.8346 3.78002 10.2213 1.16669 7.0013 1.16669ZM7.0013 11.6667C4.42297 11.6667 2.33464 9.57835 2.33464 7.00002C2.33464 4.42169 4.42297 2.33335 7.0013 2.33335C9.57964 2.33335 11.668 4.42169 11.668 7.00002C11.668 9.57835 9.57964 11.6667 7.0013 11.6667Z"
fill="black"
/>
<path
d="M7.0013 9.91669C8.61213 9.91669 9.91797 8.61085 9.91797 7.00002C9.91797 5.38919 8.61213 4.08335 7.0013 4.08335C5.39047 4.08335 4.08464 5.38919 4.08464 7.00002C4.08464 8.61085 5.39047 9.91669 7.0013 9.91669Z"
fill="black"
/>
</svg>
),
{
filled: true,
},
)

View File

@ -0,0 +1 @@
export * from './RadioButtonFilledIcon'

View File

@ -0,0 +1,22 @@
import { LsdIcon } from '../LsdIcon'
export const RadioButtonIcon = LsdIcon(
(props) => (
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M7.0013 1.16669C3.7813 1.16669 1.16797 3.78002 1.16797 7.00002C1.16797 10.22 3.7813 12.8334 7.0013 12.8334C10.2213 12.8334 12.8346 10.22 12.8346 7.00002C12.8346 3.78002 10.2213 1.16669 7.0013 1.16669ZM7.0013 11.6667C4.42297 11.6667 2.33464 9.57835 2.33464 7.00002C2.33464 4.42169 4.42297 2.33335 7.0013 2.33335C9.57964 2.33335 11.668 4.42169 11.668 7.00002C11.668 9.57835 9.57964 11.6667 7.0013 11.6667Z"
fill="black"
/>
</svg>
),
{
filled: true,
},
)

View File

@ -0,0 +1 @@
export * from './RadioButtonIcon'

View File

@ -17,3 +17,5 @@ export * from './NavigateNextIcon'
export * from './NewPageIcon'
export * from './SearchIcon'
export * from './PickIcon'
export * from './RadioButtonIcon'
export * from './RadioButtonFilledIcon'

View File

@ -0,0 +1,12 @@
export const radioButtonClasses = {
root: `lsd-radio-button`,
input: `lsd-radio-button__input`,
label: `lsd-radio-button__label`,
disabled: `lsd-radio-button--disabled`,
large: `lsd-radio-button--large`,
medium: `lsd-radio-button--medium`,
small: 'lsd-radio-button--small',
}

View File

@ -0,0 +1,28 @@
import { Meta, Story } from '@storybook/react'
import { RadioButton, RadioButtonProps } from './RadioButton'
export default {
title: 'RadioButton',
component: RadioButton,
argTypes: {
size: {
type: {
name: 'enum',
value: ['small', 'medium', 'large'],
},
defaultValue: 'large',
},
},
} as Meta
export const Root: Story<RadioButtonProps> = (args) => (
<RadioButton {...args}>RadioButton label</RadioButton>
)
Root.args = {
size: 'large',
disabled: false,
checked: undefined,
onChange: undefined,
value: '1',
}

View File

@ -0,0 +1,58 @@
import { css } from '@emotion/react'
import { radioButtonClasses } from './RadioButton.classes'
export const RadioButtonStyles = css`
.${radioButtonClasses.root} {
position: relative;
display: flex;
flex-direction: row;
align-items: center;
}
.${radioButtonClasses.input} {
opacity: 0;
position: absolute;
left: 0;
top: 0;
padding: 0;
margin: 0;
width: 100%;
height: 100%;
}
.${radioButtonClasses.root}:not(.${radioButtonClasses.disabled}) {
&:hover {
text-decoration: underline;
}
.${radioButtonClasses.input} {
cursor: pointer;
}
}
.${radioButtonClasses.disabled} {
opacity: 0.34;
}
.${radioButtonClasses.label} {
margin-left: 18px;
}
.${radioButtonClasses.large} {
.${radioButtonClasses.label} {
margin-left: 18px;
}
}
.${radioButtonClasses.medium} {
.${radioButtonClasses.label} {
margin-left: 14px;
}
}
.${radioButtonClasses.small} {
.${radioButtonClasses.label} {
margin-left: 12px;
}
}
`

View File

@ -0,0 +1,95 @@
import clsx from 'clsx'
import React, { useRef } from 'react'
import { useInput } from '../../utils/useInput'
import { RadioButtonFilledIcon, RadioButtonIcon } from '../Icons'
import { useRadioButtonGroupContext } from '../RadioButtonGroup/RadioButtonGroup.context'
import { Typography } from '../Typography'
import { radioButtonClasses } from './RadioButton.classes'
export type RadioButtonProps = Omit<
React.LabelHTMLAttributes<HTMLLabelElement>,
'onChange' | 'value' | 'color'
> &
Pick<
React.InputHTMLAttributes<HTMLInputElement>,
'onChange' | 'checked' | 'defaultChecked'
> & {
disabled?: boolean
size?: 'small' | 'medium' | 'large'
name?: string
value: string
inputProps?: React.InputHTMLAttributes<HTMLInputElement>
}
export const RadioButton: React.FC<RadioButtonProps> & {
classes: typeof radioButtonClasses
} = ({
size: _size = 'large',
onChange,
checked: _checked,
defaultChecked,
disabled = false,
value,
name: _name,
inputProps = {},
children,
...props
}) => {
const ref = useRef<HTMLInputElement>(null)
const radioButtonGroup = useRadioButtonGroupContext()
const size = radioButtonGroup?.size ?? _size
const name = radioButtonGroup?.name ?? _name ?? ''
const selected = radioButtonGroup
? radioButtonGroup.value === value
: _checked
const input = useInput({
value: selected,
defaultValue: defaultChecked ?? false,
onChange,
ref,
})
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
radioButtonGroup
? radioButtonGroup.setActiveRadioButton(event.target.value)
: input.onChange(event)
}
return (
<Typography
color="primary"
variant={size === 'large' ? 'label1' : 'label2'}
component="label"
aria-disabled={disabled ? 'true' : 'false'}
{...props}
className={clsx(
props.className,
radioButtonClasses.root,
radioButtonClasses[size],
disabled && radioButtonClasses.disabled,
)}
>
<input
ref={ref}
name={name}
value={value}
type="radio"
checked={input.value}
onChange={handleChange}
defaultChecked={defaultChecked}
className={clsx(inputProps.className, radioButtonClasses.input)}
{...inputProps}
/>
{input.value ? (
<RadioButtonFilledIcon color="primary" focusable={false} />
) : (
<RadioButtonIcon color="primary" focusable={false} />
)}
<span className={radioButtonClasses.label}>{children}</span>
</Typography>
)
}
RadioButton.classes = radioButtonClasses

View File

@ -0,0 +1 @@
export * from './RadioButton'

View File

@ -0,0 +1,4 @@
export const radioButtonGroupClasses = {
root: `lsd-radio-button-group`,
label: `lsd-radio-button-group__label`,
}

View File

@ -0,0 +1,19 @@
import React from 'react'
import {
ActiveRadioButtonType,
RadioButtonGroupProps,
} from './RadioButtonGroup'
export type RadioButtonGroupContextType = {
value?: ActiveRadioButtonType | null
name?: string | null
setActiveRadioButton: (value: ActiveRadioButtonType) => void
size?: RadioButtonGroupProps['size']
}
export const RadioButtonGroupContext =
React.createContext<RadioButtonGroupContextType>(null as any)
export const useRadioButtonGroupContext = () =>
React.useContext(RadioButtonGroupContext)

View File

@ -0,0 +1,35 @@
import { Meta, Story } from '@storybook/react'
import { RadioButton } from '../RadioButton'
import { RadioButtonGroup, RadioButtonGroupProps } from './RadioButtonGroup'
export default {
title: 'RadioButtonGroup',
component: RadioButtonGroup,
argTypes: {
size: {
type: {
name: 'enum',
value: ['small', 'medium', 'large'],
},
defaultValue: 'large',
},
},
} as Meta
export const Root: Story<RadioButtonGroupProps> = (args) => (
<RadioButtonGroup name="name" {...args}>
<RadioButton value="1">RadioButton label</RadioButton>
<RadioButton value="2">RadioButton label</RadioButton>
<RadioButton value="3">RadioButton label</RadioButton>
<RadioButton value="4">RadioButton label</RadioButton>
<RadioButton value="5">RadioButton label</RadioButton>
<RadioButton value="6">RadioButton label</RadioButton>
<RadioButton value="7">RadioButton label</RadioButton>
<RadioButton value="8">RadioButton label</RadioButton>
</RadioButtonGroup>
)
Root.args = {
size: 'large',
label: 'Label',
}

View File

@ -0,0 +1,15 @@
import { css } from '@emotion/react'
import { radioButtonGroupClasses } from './RadioButtonGroup.classes'
export const RadioButtonGroupStyles = css`
.${radioButtonGroupClasses.root} {
display: flex;
flex-direction: column;
gap: 6px;
width: fit-content;
}
.${radioButtonGroupClasses.label} {
margin-bottom: 6px;
}
`

View File

@ -0,0 +1,52 @@
import clsx from 'clsx'
import React, { useEffect, useRef, useState } from 'react'
import { RadioButtonGroupContext } from './RadioButtonGroup.context'
import { radioButtonGroupClasses } from './RadioButtonGroup.classes'
import { Typography } from '../Typography'
export type ActiveRadioButtonType = string | readonly string[]
export type RadioButtonGroupProps = React.HTMLAttributes<HTMLDivElement> & {
value: ActiveRadioButtonType | null
name?: string | null
onChange?: (value: ActiveRadioButtonType) => void
size?: 'small' | 'medium' | 'large'
label?: string
}
export const RadioButtonGroup: React.FC<RadioButtonGroupProps> & {
classes: typeof radioButtonGroupClasses
} = ({ size = 'large', label, value, name, onChange, children, ...props }) => {
const ref = useRef<HTMLDivElement>(null)
const [activeValue, setActiveValue] = useState(value)
const setActiveRadioButton = (value: ActiveRadioButtonType) => {
if (onChange) onChange(value)
else setActiveValue(value)
}
useEffect(() => setActiveValue(value), [value])
return (
<RadioButtonGroupContext.Provider
value={{ value: activeValue, setActiveRadioButton, name, size }}
>
<div
ref={ref}
{...props}
className={clsx(props.className, radioButtonGroupClasses.root)}
>
<Typography
component="span"
variant={size === 'small' ? 'label2' : 'label1'}
className={radioButtonGroupClasses.label}
>
{label && label}
</Typography>
{children}
</div>
</RadioButtonGroupContext.Provider>
)
}
RadioButtonGroup.classes = radioButtonGroupClasses

View File

@ -0,0 +1 @@
export * from './RadioButtonGroup'

View File

@ -5,6 +5,7 @@ export * from './components/Button'
export * from './components/Card'
export * from './components/CardBody'
export * from './components/CardHeader'
export * from './components/Checkbox'
export * from './components/Collapse'
export * from './components/CollapseHeader'
export * from './components/Dropdown'
@ -17,3 +18,5 @@ export * from './components/TabItem'
export * from './components/Tabs'
export * from './components/Tag'
export * from './components/Theme'
export * from './components/RadioButton'
export * from './components/RadioButtonGroup'

View File

@ -1,22 +1,23 @@
import React, { useEffect, useState } from 'react'
export type InputValueType =
React.InputHTMLAttributes<HTMLInputElement>['value']
| React.InputHTMLAttributes<HTMLInputElement>['value']
| boolean
export type InputOnChangeType =
React.InputHTMLAttributes<HTMLInputElement>['onChange']
export type InputProps = {
value?: InputValueType
defaultValue?: InputValueType
export type InputProps<T extends InputValueType = InputValueType> = {
value?: T
defaultValue: T
onChange?: InputOnChangeType
ref?: React.RefObject<HTMLInputElement>
}
export const useInput = (props: InputProps) => {
const [value, setValue] = useState<InputValueType>(
props.value ?? props.defaultValue ?? '',
)
export const useInput = <T extends InputValueType = InputValueType>(
props: InputProps<T>,
) => {
const [value, setValue] = useState<T>(props.value ?? props.defaultValue)
const uncontrolled = typeof props.value === 'undefined'
const filled =
@ -27,7 +28,12 @@ export const useInput = (props: InputProps) => {
: value.toString().length > 0
const onChange: InputOnChangeType = (event) => {
if (uncontrolled) return setValue(event.target.value)
const type = event.target.type
const value =
event.target[
type === 'checkbox' || type === 'radio' ? 'checked' : 'value'
]
if (uncontrolled) return setValue(value as T)
props.onChange && props.onChange(event)
}
@ -46,7 +52,7 @@ export const useInput = (props: InputProps) => {
}
useEffect(() => {
!uncontrolled && setValue(props.value)
!uncontrolled && setValue(props.value as T)
}, [uncontrolled, props.value])
return {