Skip to content

Commit

Permalink
feat(indeterminate-checkbox): add support for indeterminate checkboxes (
Browse files Browse the repository at this point in the history
#1167)

* feat(indeterminate-checkbox): add support for indeterminate checkboxes

* Create an indeterminate checkbox state
  • Loading branch information
byrekt authored Jul 6, 2022
1 parent bdf2a0c commit 71a9320
Show file tree
Hide file tree
Showing 5 changed files with 363 additions and 62 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "pcln-design-system",
"comment": "Added an indeterminate checkbox state",
"type": "minor"
}
],
"packageName": "pcln-design-system"
}
33 changes: 31 additions & 2 deletions packages/core/src/Checkbox/Checkbox.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,7 @@ describe('Checkbox', () => {

test('renders checked when defaultChecked prop is passed as true', () => {
const { getByRole } = render(<Checkbox id='check-box' defaultChecked onChange={onChange} />)
const checkbox = getByRole('checkbox')
// @ts-ignore
const checkbox = getByRole('checkbox') as HTMLInputElement
expect(checkbox.checked).toBe(true)
})

Expand All @@ -36,4 +35,34 @@ describe('Checkbox', () => {
fireEvent.click(checkbox)
expect(onChange).toHaveBeenCalled()
})

it('renders an indeterminate checkbox that can be clicked to set checked to true', () => {
const { getByRole } = render(<Checkbox id='check-box' indeterminate onChange={onChange} />)
const checkbox = getByRole('checkbox') as HTMLInputElement

expect(checkbox.checked).toBe(false)
expect(checkbox.indeterminate).toBe(true)
fireEvent.click(checkbox)
expect(checkbox.checked).toBe(true)
expect(checkbox.indeterminate).toBe(false)
})
it('renders an indeterminate checkbox that can be clicked to set checked to false', () => {
const { getByRole } = render(<Checkbox id='check-box' indeterminate defaultChecked onChange={onChange} />)
const checkbox = getByRole('checkbox') as HTMLInputElement

expect(checkbox.checked).toBe(true)
expect(checkbox.indeterminate).toBe(true)
fireEvent.click(checkbox)
expect(checkbox.checked).toBe(false)
expect(checkbox.indeterminate).toBe(false)
})
it('correctly passes in the ref so that the underlying input element can be modified by the parent component if needed', () => {
const ref = React.createRef()
const { getByRole } = render(
<Checkbox id='check-box' ref={ref} indeterminate defaultChecked onChange={onChange} />
)
const checkbox = getByRole('checkbox') as HTMLInputElement

expect(ref.current.id).toBe(checkbox.id)
})
})
194 changes: 144 additions & 50 deletions packages/core/src/Checkbox/Checkbox.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* eslint-disable react/prop-types */
import React from 'react'
import React, { useState } from 'react'
import styled from 'styled-components'
import { action } from '@storybook/addon-actions'

Expand Down Expand Up @@ -37,59 +37,153 @@ function onSubmit(e) {
return formAction(e)
}

export const CheckboxStates = () => (
<div>
<Wrapper>
<StyledLabel htmlFor='unchecked_box'>
<Checkbox id='unchecked_box' onChange={checkAction} />
Unchecked by default
</StyledLabel>
</Wrapper>
const FilterExample: React.FC = () => {
const [filterState, setFilterState] = useState({
all: false,
indeterminate: false,
small: false,
medium: false,
large: false,
})

const handleFilterSelection = (event: React.SyntheticEvent<HTMLInputElement>) => {
const target = event.target
if (target.id === 'all') {
if (filterState.all || filterState.indeterminate) {
setFilterState({
all: false,
indeterminate: false,
small: false,
medium: false,
large: false,
})
} else {
setFilterState({
all: true,
indeterminate: false,
small: true,
medium: true,
large: true,
})
}
} else {
const newFilterState = { ...filterState, [target.id]: !filterState[target.id] }
const indeterminate = !(
newFilterState.small === newFilterState.medium && newFilterState.small === newFilterState.large
)
const all = newFilterState.small && newFilterState.medium && newFilterState.large
setFilterState({
...filterState,
[target.id]: !filterState[target.id],
indeterminate,
all,
})
}
}
return (
<>
<Wrapper>
<StyledLabel htmlFor='all'>
<Checkbox
id='all'
onChange={handleFilterSelection}
indeterminate={filterState.indeterminate}
checked={filterState.all}
/>
Cars
</StyledLabel>
<Box ml={2}>
<StyledLabel htmlFor='small'>
<Checkbox id='small' onChange={handleFilterSelection} checked={filterState.small} />
Small
</StyledLabel>
<StyledLabel htmlFor='medium'>
<Checkbox id='medium' onChange={handleFilterSelection} checked={filterState.medium} />
Medium
</StyledLabel>
<StyledLabel htmlFor='large'>
<Checkbox id='large' onChange={handleFilterSelection} checked={filterState.large} />
Large
</StyledLabel>
</Box>
</Wrapper>
</>
)
}

<Wrapper>
<StyledLabel htmlFor='checked_box'>
<Checkbox id='checked_box' defaultChecked onChange={checkAction} />
Checked by default
</StyledLabel>
</Wrapper>
export const CheckboxStates = () => {
return (
<div>
<Wrapper>
<StyledLabel htmlFor='unchecked_box'>
<Checkbox id='unchecked_box' onChange={checkAction} />
Unchecked by default
</StyledLabel>
</Wrapper>

<Wrapper>
<StyledLabel htmlFor='disabled_box'>
<Checkbox id='disabled_box' disabled onChange={checkAction} />
<Text.span color='border.base'>Disabled</Text.span>
</StyledLabel>
</Wrapper>
<Wrapper>
<StyledLabel htmlFor='checked_box'>
<Checkbox id='checked_box' defaultChecked onChange={checkAction} />
Checked by default
</StyledLabel>
</Wrapper>

<Wrapper>
<StyledLabel htmlFor='disabled_checked_box'>
<Checkbox id='disabled_checked_box' disabled defaultChecked onChange={checkAction} />
<Text.span color='border.base'>Disabled &amp; Checked</Text.span>
</StyledLabel>
</Wrapper>
<Wrapper>
<StyledLabel htmlFor='disabled_box'>
<Checkbox id='disabled_box' disabled onChange={checkAction} />
<Text.span color='border.base'>Disabled</Text.span>
</StyledLabel>
</Wrapper>

<Wrapper title='In A Form'>
<form onSubmit={onSubmit}>
<fieldset style={{ display: 'inline-block', padding: '16px' }}>
<legend>Fancy Form</legend>

<Wrapper>
<StyledLabel fontSize='14px' htmlFor='form_checkbox'>
<Checkbox id='form_checkbox' size={30} onChange={checkAction} />
&nbsp;In This Form
</StyledLabel>
</Wrapper>

<Button type='submit'>Submit Me</Button>
<br />
<br />
<Button variation='outline' color='border.base' type='reset'>
Reset Me
</Button>
</fieldset>
</form>
</Wrapper>
</div>
)
<Wrapper>
<StyledLabel htmlFor='disabled_checked_box'>
<Checkbox id='disabled_checked_box' disabled defaultChecked onChange={checkAction} />
<Text.span color='border.base'>Disabled &amp; Checked</Text.span>
</StyledLabel>
</Wrapper>

<Wrapper>
<StyledLabel htmlFor='indeterminate_box'>
<Checkbox id='indeterminate_box' indeterminate onChange={checkAction} />
Initially indeterminate check box (clicking will checkmark it)
</StyledLabel>
</Wrapper>

<Wrapper>
<StyledLabel htmlFor='indeterminate_checked_box'>
<Checkbox id='indeterminate_checked_box' defaultChecked indeterminate onChange={checkAction} />
Initially indeterminate check box (clicking will uncheck it)
</StyledLabel>
</Wrapper>

<Wrapper title='Indeterminate Checkboxes'>
<FilterExample />
</Wrapper>

<Wrapper title='In A Form'>
<form onSubmit={onSubmit}>
<fieldset style={{ display: 'inline-block', padding: '16px' }}>
<legend>Fancy Form</legend>

<Wrapper>
<StyledLabel fontSize='14px' htmlFor='form_checkbox'>
<Checkbox id='form_checkbox' size={30} onChange={checkAction} />
&nbsp;In This Form
</StyledLabel>
</Wrapper>

<Button type='submit'>Submit Me</Button>
<br />
<br />
<Button variation='outline' color='border.base' type='reset'>
Reset Me
</Button>
</fieldset>
</form>
</Wrapper>
</div>
)
}

CheckboxStates.story = {
name: 'Checkbox states',
Expand Down
62 changes: 52 additions & 10 deletions packages/core/src/Checkbox/Checkbox.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import React from 'react'
import React, { useRef, useEffect } from 'react'
import styled, { withTheme } from 'styled-components'
import { BoxChecked, BoxEmpty } from 'pcln-icons'
import { BoxChecked, BoxEmpty, BoxMinus } from 'pcln-icons'
import PropTypes, { InferProps } from 'prop-types'
import { applyVariations, getPaletteColor, deprecatedColorValue } from '../utils'

const propTypes = {
id: PropTypes.string.isRequired,
indeterminate: PropTypes.bool,
size: PropTypes.number,
onChange: PropTypes.func,
color: deprecatedColorValue(),
Expand All @@ -31,12 +32,32 @@ const CheckBoxWrapper = styled.div`
svg[data-name='checked'] {
display: none;
}
svg[data-name='indeterminate'] {
display: none;
}
> input:indeterminate {
& ~ svg[data-name='indeterminate'] {
display: inline-block;
color: ${(props) =>
props.disabled ? getPaletteColor('border.base')(props) : getPaletteColor('base')(props)};
}
&:hover ~ svg[data-name='indeterminate'] {
color: ${(props) =>
props.disabled ? getPaletteColor('border.base')(props) : getPaletteColor('dark')(props)};
}
& ~ svg[data-name='empty'] {
display: none;
}
& ~ svg[data-name='checked'] {
display: none;
}
}
> input:hover ~ svg[data-name='empty'] {
color: ${(props) =>
props.disabled ? getPaletteColor('border.base')(props) : getPaletteColor('base')(props)};
}
> input {
&:focus ~ svg {
border: 1px solid ${getPaletteColor('border.base')};
Expand All @@ -54,15 +75,16 @@ const CheckBoxWrapper = styled.div`
& ~ svg[data-name='empty'] {
display: none;
}
&:focus ~ svg {
border: 1px solid ${getPaletteColor('base')};
background-color: ${getPaletteColor('light')};
}
&:hover ~ svg[data-name='checked'] {
color: ${(props) =>
props.disabled ? getPaletteColor('border.base')(props) : getPaletteColor('dark')(props)}
props.disabled ? getPaletteColor('border.base')(props) : getPaletteColor('dark')(props)};
}
}
${applyVariations('Checkbox')}
Expand All @@ -76,21 +98,40 @@ const StyledInput = styled.input`
`

const Checkbox: React.FC<InferProps<typeof propTypes>> = React.forwardRef((props, ref) => {
// eslint-disable-next-line react/prop-types
const { disabled, size } = props
const inputRef = useRef()

useEffect(() => {
inputRef.current.indeterminate = props.indeterminate
}, [props.indeterminate])

// eslint-disable-next-line react/prop-types
const { disabled, size, indeterminate } = props
// Add 4px to Icon's height and width to account for size reduction caused by adding padding to SVG element
const borderAdjustedSize = size + 4

return (
<CheckBoxWrapper
// eslint-disable-next-line react/prop-types
theme={props.theme}
color={props.color}
disabled={disabled}
>
<StyledInput type='checkbox' {...props} role='checkbox' ref={ref} />
<StyledInput
type='checkbox'
{...props}
role='checkbox'
aria-checked={props.indeterminate ? 'mixed' : props.checked}
ref={(element: HTMLInputElement) => {
if (indeterminate && element) {
element.indeterminate = true
}
if (ref) {
ref.current = element
}
inputRef.current = element
}}
/>
<BoxChecked size={borderAdjustedSize} data-name='checked' />
<BoxMinus size={borderAdjustedSize} data-name='indeterminate' />
<BoxEmpty size={borderAdjustedSize} data-name='empty' />
</CheckBoxWrapper>
)
Expand All @@ -101,6 +142,7 @@ Checkbox.displayName = 'Checkbox'
Checkbox.propTypes = propTypes
Checkbox.defaultProps = {
size: 20,
indeterminate: false,
color: 'primary',
}

Expand Down
Loading

0 comments on commit 71a9320

Please sign in to comment.