Skip to content

Commit

Permalink
feat(checkbox): indeterminate status
Browse files Browse the repository at this point in the history
  • Loading branch information
andresin87 committed Apr 18, 2023
1 parent f8074a2 commit 7f41e99
Show file tree
Hide file tree
Showing 5 changed files with 115 additions and 31 deletions.
10 changes: 8 additions & 2 deletions packages/components/checkbox/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,20 @@
"build": "vite build"
},
"dependencies": {
"@radix-ui/react-label": "^2.0.1",
"@radix-ui/react-checkbox": "1",
"@spark-ui/icons": "^1.6.1",
"@spark-ui/icons": "1",
"@spark-ui/internal-utils": "1",
"class-variance-authority": "0.4.0"
"@spark-ui/use-merge-refs": "0",
"class-variance-authority": "^0.4.0"
},
"peerDependencies": {
"react": "^16.8 || ^17.0 || ^18.0",
"react-dom": "^16.8 || ^17.0 || ^18.0",
"tailwindcss": "^3.0.0"
},
"devDependencies": {
"@spark-ui/button": "1",
"@spark-ui/radio": "1"
}
}
14 changes: 10 additions & 4 deletions packages/components/checkbox/src/Checkbox.doc.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import * as CheckboxStories from './Checkbox.stories'
# Checkbox

A control that allows the user to toggle between checked and not checked.
It is used in forms when a user needs to select multiple values from several options.

## Install

Expand All @@ -29,19 +30,24 @@ import { Checkbox } from "@spark-ui/checkbox"

## Usage

The default behaviour is the uncontrolled (statefull) unchecked status.
It changes its own state when clicking on the element.
It will also be focused when the user navigates using the keyboard and changes its status when pressing 'Space' key.

<Canvas of={CheckboxStories.Default} />

## Disabled
The element will not change its status when clicking on it and it will never be focused using keyboard navigation.

<Canvas of={CheckboxStories.Disabled} />

## DefaultChecked
## Uncontrolled State

<Canvas of={CheckboxStories.DefaultChecked} />
<Canvas of={CheckboxStories.UncontrolledState} />

## Controlled
## ControlledState

<Canvas of={CheckboxStories.Controlled} />
<Canvas of={CheckboxStories.ControlledState} />

## Intent

Expand Down
61 changes: 51 additions & 10 deletions packages/components/checkbox/src/Checkbox.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { Button } from '@spark-ui/button'
import { Radio, RadioGroup } from '@spark-ui/radio'
import { Meta, StoryFn } from '@storybook/react'
import { useState } from 'react'

Expand All @@ -14,21 +16,60 @@ export const Default: StoryFn = _args => <Checkbox id={'c1'}>Accept terms and co

export const Disabled: StoryFn = _args => <Checkbox disabled>Accept terms and conditions.</Checkbox>

export const DefaultChecked: StoryFn = _args => (
<Checkbox defaultChecked>Accept terms and conditions.</Checkbox>
)
export const UncontrolledState: StoryFn = _args => {
const [index, setIndex] = useState<number>(0)
const handleReset = () => {
setIndex(index + 1)
}

export const Controlled: StoryFn = _args => {
const [value, setValue] = useState(false)
return (
<div className="gap-lg flex flex-col" key={index}>
<div className="gap-lg flex flex-row">
<Button onClick={handleReset}>reset</Button>
</div>
<Checkbox defaultChecked>Accept terms and conditions.</Checkbox>
<Checkbox defaultChecked="indeterminate">Accept terms and conditions.</Checkbox>
<Checkbox defaultChecked={false}>Accept terms and conditions.</Checkbox>
</div>
)
}

const handleChange = (checked: boolean) => {
setValue(!!checked)
export const ControlledState: StoryFn = _args => {
const [index, setIndex] = useState<number>(0)
const [checked, setChecked] = useState<boolean | 'indeterminate' | undefined>()
const handleReset = () => {
setIndex(index + 1)
setChecked(undefined)
}
const handleChange = (value: string) => {
if (value === 'undefined') {
setChecked(undefined)
} else if (value === 'true') {
setChecked(true)
} else if (value === 'indeterminate') {
setChecked('indeterminate')
} else {
setChecked(false)
}
}

return (
<Checkbox checked={value} onCheckedChange={handleChange}>
Accept terms and conditions.
</Checkbox>
<div className="gap-lg flex flex-col">
<div className="gap-lg flex flex-row">
<Button onClick={handleReset} disabled={checked === undefined}>
reset
</Button>
<RadioGroup onValueChange={handleChange} value={`${checked}`}>
<Radio value="undefined">undefined (uncontrolled – state-full)</Radio>
<Radio value="true">checked</Radio>
<Radio value="indeterminate">indeterminate</Radio>
<Radio value="false">unchecked</Radio>
</RadioGroup>
</div>
<Checkbox key={index} checked={checked} onCheckedChange={checked => setChecked(checked)}>
Accept terms and conditions.
</Checkbox>
</div>
)
}

Expand Down
4 changes: 4 additions & 0 deletions packages/components/checkbox/src/CheckboxInput.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,22 @@ export const inputStyles = cva(
intent: makeVariants<'intent', ['primary', 'success', 'alert', 'error']>({
primary: [
'spark-state-unchecked:border-outline',
'spark-state-indeterminate:border-primary spark-state-indeterminate:bg-primary',
'spark-state-checked:border-primary spark-state-checked:bg-primary',
],
success: [
'spark-state-unchecked:border-success',
'spark-state-indeterminate:border-success spark-state-indeterminate:bg-success',
'spark-state-checked:border-success spark-state-checked:bg-success',
],
alert: [
'spark-state-unchecked:border-alert',
'spark-state-indeterminate:border-alert spark-state-indeterminate:bg-alert',
'spark-state-checked:border-alert spark-state-checked:bg-alert',
],
error: [
'spark-state-unchecked:border-error',
'spark-state-indeterminate:border-error spark-state-indeterminate:bg-error',
'spark-state-checked:border-error spark-state-checked:bg-error',
],
}),
Expand Down
57 changes: 42 additions & 15 deletions packages/components/checkbox/src/CheckboxInput.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
import { Check } from '@spark-ui/icons'
import React, { ReactNode } from 'react'
import { Check, Minus } from '@spark-ui/icons'
import { useMergeRefs } from '@spark-ui/use-merge-refs'
import { ButtonHTMLAttributes, ElementRef, forwardRef, ReactNode, useState } from 'react'

import { inputStyles, type InputStylesProps } from './CheckboxInput.styles'

type CheckedStatus = boolean | 'indeterminate'

type AriaCheckedStatus = 'true' | 'false' | 'mixed' | undefined

interface RadixProps {
/**
* The checked icon to use
Expand All @@ -12,15 +17,15 @@ interface RadixProps {
/**
* The checked state of the checkbox when it is initially rendered. Use when you do not need to control its checked state.
*/
defaultChecked?: boolean
defaultChecked?: CheckedStatus
/**
* The controlled checked state of the checkbox. Must be used in conjunction with onCheckedChange.
*/
checked?: boolean
checked?: CheckedStatus
/**
* Event handler called when the checked state of the checkbox changes.
*/
onCheckedChange?: (checked: boolean) => void
onCheckedChange?: (checked: CheckedStatus) => void
/**
* When true, prevents the user from interacting with the checkbox.
*/
Expand All @@ -35,17 +40,39 @@ interface RadixProps {
name?: string
}

const useIcon = ({ icon, checked }: { icon: ReactNode; checked: AriaCheckedStatus }) => {
if (icon) {
return icon
}
switch (checked) {
case 'true':
return <Check />
case 'mixed':
return <Minus />
default:
return null
}
}

export interface InputProps
extends RadixProps, // Radix props
InputStylesProps, // CVA props (variants)
Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'value'> {} // Native HTML props

export const Input = React.forwardRef<React.ElementRef<typeof CheckboxPrimitive.Root>, InputProps>(
({ intent, icon = <Check />, ...props }, ref) => (
<CheckboxPrimitive.Root ref={ref} className={inputStyles({ intent })} {...props}>
<CheckboxPrimitive.Indicator className="text-surface flex items-center justify-center">
{icon}
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'value' | 'checked' | 'defaultChecked'> {} // Native HTML props

export const Input = forwardRef<ElementRef<typeof CheckboxPrimitive.Root>, InputProps>(
({ intent, icon, ...props }, forwardedRef) => {
const [innerChecked, setInnerChecked] = useState<'true' | 'false' | 'mixed'>()
const ref = useMergeRefs(forwardedRef, node => {
setInnerChecked(node?.getAttribute('aria-checked') as AriaCheckedStatus)
})
const innerIcon = useIcon({ icon, checked: innerChecked })

return (
<CheckboxPrimitive.Root ref={ref} className={inputStyles({ intent })} {...props}>
<CheckboxPrimitive.Indicator className="text-surface flex items-center justify-center">
{innerIcon}
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
)

0 comments on commit 7f41e99

Please sign in to comment.