From 6f74390e899796f9e42ae1d85e85ba49c64ec951 Mon Sep 17 00:00:00 2001 From: atomiks Date: Tue, 2 Apr 2024 19:24:10 +1100 Subject: [PATCH] [Checkbox] Component and Hook (#159) --- .../checkbox/UnstyledCheckboxIndeterminate.js | 78 +++++ .../UnstyledCheckboxIndeterminate.tsx | 78 +++++ .../UnstyledCheckboxIndeterminate.tsx.preview | 10 + .../UnstyledCheckboxIndeterminateGroup.js | 157 +++++++++ .../UnstyledCheckboxIndeterminateGroup.tsx | 157 +++++++++ .../UnstyledCheckboxIntroduction/css/index.js | 134 ++++++++ .../css/index.tsx | 134 ++++++++ .../system/index.js | 91 ++++++ .../system/index.tsx | 91 ++++++ .../tailwind/index.js | 96 ++++++ .../tailwind/index.tsx | 89 +++++ .../tailwind/index.tsx.preview | 16 + .../data/base/components/checkbox/checkbox.md | 116 ++++++- docs/data/base/pages.ts | 2 +- docs/data/base/pagesApi.js | 12 + .../pages/base-ui/api/checkbox-indicator.json | 21 ++ docs/pages/base-ui/api/checkbox.json | 34 ++ docs/pages/base-ui/api/use-checkbox.json | 54 +++ .../base-ui/react-checkbox/[docsTab]/index.js | 62 ++++ .../checkbox-indicator.json | 13 + .../api-docs-base/checkbox/checkbox.json | 27 ++ .../api-docs/use-checkbox/use-checkbox.json | 24 ++ docs/translations/translations.json | 3 + .../mui-base/src/Checkbox/Checkbox.test.tsx | 307 ++++++++++++++++++ packages/mui-base/src/Checkbox/Checkbox.tsx | 143 ++++++++ .../mui-base/src/Checkbox/Checkbox.types.ts | 22 ++ .../mui-base/src/Checkbox/CheckboxContext.ts | 6 + .../src/Checkbox/CheckboxIndicator.test.tsx | 91 ++++++ .../src/Checkbox/CheckboxIndicator.tsx | 75 +++++ packages/mui-base/src/Checkbox/index.ts | 10 + packages/mui-base/src/Checkbox/utils.ts | 22 ++ packages/mui-base/src/index.ts | 2 + packages/mui-base/src/useCheckbox/index.ts | 3 + .../mui-base/src/useCheckbox/useCheckbox.ts | 104 ++++++ .../src/useCheckbox/useCheckbox.types.ts | 87 +++++ .../mui-base/src/utils/mergeReactProps.ts | 2 +- 36 files changed, 2367 insertions(+), 6 deletions(-) create mode 100644 docs/data/base/components/checkbox/UnstyledCheckboxIndeterminate.js create mode 100644 docs/data/base/components/checkbox/UnstyledCheckboxIndeterminate.tsx create mode 100644 docs/data/base/components/checkbox/UnstyledCheckboxIndeterminate.tsx.preview create mode 100644 docs/data/base/components/checkbox/UnstyledCheckboxIndeterminateGroup.js create mode 100644 docs/data/base/components/checkbox/UnstyledCheckboxIndeterminateGroup.tsx create mode 100644 docs/data/base/components/checkbox/UnstyledCheckboxIntroduction/css/index.js create mode 100644 docs/data/base/components/checkbox/UnstyledCheckboxIntroduction/css/index.tsx create mode 100644 docs/data/base/components/checkbox/UnstyledCheckboxIntroduction/system/index.js create mode 100644 docs/data/base/components/checkbox/UnstyledCheckboxIntroduction/system/index.tsx create mode 100644 docs/data/base/components/checkbox/UnstyledCheckboxIntroduction/tailwind/index.js create mode 100644 docs/data/base/components/checkbox/UnstyledCheckboxIntroduction/tailwind/index.tsx create mode 100644 docs/data/base/components/checkbox/UnstyledCheckboxIntroduction/tailwind/index.tsx.preview create mode 100644 docs/pages/base-ui/api/checkbox-indicator.json create mode 100644 docs/pages/base-ui/api/checkbox.json create mode 100644 docs/pages/base-ui/api/use-checkbox.json create mode 100644 docs/pages/base-ui/react-checkbox/[docsTab]/index.js create mode 100644 docs/translations/api-docs-base/checkbox-indicator/checkbox-indicator.json create mode 100644 docs/translations/api-docs-base/checkbox/checkbox.json create mode 100644 docs/translations/api-docs/use-checkbox/use-checkbox.json create mode 100644 packages/mui-base/src/Checkbox/Checkbox.test.tsx create mode 100644 packages/mui-base/src/Checkbox/Checkbox.tsx create mode 100644 packages/mui-base/src/Checkbox/Checkbox.types.ts create mode 100644 packages/mui-base/src/Checkbox/CheckboxContext.ts create mode 100644 packages/mui-base/src/Checkbox/CheckboxIndicator.test.tsx create mode 100644 packages/mui-base/src/Checkbox/CheckboxIndicator.tsx create mode 100644 packages/mui-base/src/Checkbox/index.ts create mode 100644 packages/mui-base/src/Checkbox/utils.ts create mode 100644 packages/mui-base/src/useCheckbox/index.ts create mode 100644 packages/mui-base/src/useCheckbox/useCheckbox.ts create mode 100644 packages/mui-base/src/useCheckbox/useCheckbox.types.ts diff --git a/docs/data/base/components/checkbox/UnstyledCheckboxIndeterminate.js b/docs/data/base/components/checkbox/UnstyledCheckboxIndeterminate.js new file mode 100644 index 0000000000..423a49a9ce --- /dev/null +++ b/docs/data/base/components/checkbox/UnstyledCheckboxIndeterminate.js @@ -0,0 +1,78 @@ +import * as React from 'react'; +import { styled } from '@mui/system'; +import { Checkbox as BaseCheckbox } from '@mui/base/Checkbox'; +import HorizontalRule from '@mui/icons-material/HorizontalRule'; + +export default function UnstyledCheckboxIndeterminate() { + return ( +
+ + + + + + + + + + +
+ ); +} + +const blue = { + 400: '#3399FF', + 600: '#0072E6', + 800: '#004C99', +}; + +const grey = { + 100: '#E5EAF2', +}; + +const Checkbox = styled(BaseCheckbox)( + ({ theme }) => ` + width: 24px; + height: 24px; + padding: 0; + border-radius: 4px; + border: 2px solid ${blue[600]}; + background: none; + transition-property: background, border-color; + transition-duration: 0.15s; + outline: none; + + &[data-disabled] { + opacity: 0.4; + cursor: not-allowed; + } + + &:focus-visible { + outline: 2px solid ${theme.palette.mode === 'dark' ? blue[800] : blue[400]}; + outline-offset: 2px; + } + + &[data-state='checked'], + &[data-state='mixed'] { + border-color: transparent; + background: ${blue[600]}; + } + `, +); + +const HorizontalRuleIcon = styled(HorizontalRule)` + height: 100%; + width: 100%; +`; + +const Indicator = styled(BaseCheckbox.Indicator)` + color: ${grey[100]}; + height: 100%; + display: inline-block; + visibility: hidden; + + &[data-state='checked'], + &[data-state='mixed'] { + visibility: visible; + } +`; diff --git a/docs/data/base/components/checkbox/UnstyledCheckboxIndeterminate.tsx b/docs/data/base/components/checkbox/UnstyledCheckboxIndeterminate.tsx new file mode 100644 index 0000000000..423a49a9ce --- /dev/null +++ b/docs/data/base/components/checkbox/UnstyledCheckboxIndeterminate.tsx @@ -0,0 +1,78 @@ +import * as React from 'react'; +import { styled } from '@mui/system'; +import { Checkbox as BaseCheckbox } from '@mui/base/Checkbox'; +import HorizontalRule from '@mui/icons-material/HorizontalRule'; + +export default function UnstyledCheckboxIndeterminate() { + return ( +
+ + + + + + + + + + +
+ ); +} + +const blue = { + 400: '#3399FF', + 600: '#0072E6', + 800: '#004C99', +}; + +const grey = { + 100: '#E5EAF2', +}; + +const Checkbox = styled(BaseCheckbox)( + ({ theme }) => ` + width: 24px; + height: 24px; + padding: 0; + border-radius: 4px; + border: 2px solid ${blue[600]}; + background: none; + transition-property: background, border-color; + transition-duration: 0.15s; + outline: none; + + &[data-disabled] { + opacity: 0.4; + cursor: not-allowed; + } + + &:focus-visible { + outline: 2px solid ${theme.palette.mode === 'dark' ? blue[800] : blue[400]}; + outline-offset: 2px; + } + + &[data-state='checked'], + &[data-state='mixed'] { + border-color: transparent; + background: ${blue[600]}; + } + `, +); + +const HorizontalRuleIcon = styled(HorizontalRule)` + height: 100%; + width: 100%; +`; + +const Indicator = styled(BaseCheckbox.Indicator)` + color: ${grey[100]}; + height: 100%; + display: inline-block; + visibility: hidden; + + &[data-state='checked'], + &[data-state='mixed'] { + visibility: visible; + } +`; diff --git a/docs/data/base/components/checkbox/UnstyledCheckboxIndeterminate.tsx.preview b/docs/data/base/components/checkbox/UnstyledCheckboxIndeterminate.tsx.preview new file mode 100644 index 0000000000..c2de858786 --- /dev/null +++ b/docs/data/base/components/checkbox/UnstyledCheckboxIndeterminate.tsx.preview @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/docs/data/base/components/checkbox/UnstyledCheckboxIndeterminateGroup.js b/docs/data/base/components/checkbox/UnstyledCheckboxIndeterminateGroup.js new file mode 100644 index 0000000000..12091ea170 --- /dev/null +++ b/docs/data/base/components/checkbox/UnstyledCheckboxIndeterminateGroup.js @@ -0,0 +1,157 @@ +import * as React from 'react'; +import { styled } from '@mui/system'; +import { Checkbox as BaseCheckbox } from '@mui/base/Checkbox'; +import HorizontalRule from '@mui/icons-material/HorizontalRule'; +import Check from '@mui/icons-material/Check'; + +const colors = ['Red', 'Green', 'Blue']; + +export default function UnstyledCheckboxIndeterminateGroup() { + const [checkedValues, setCheckedValues] = React.useState([false, true, false]); + + const isChecked = checkedValues.every((value) => value); + const isIndeterminate = checkedValues.some((value) => value) && !isChecked; + + const id = React.useId(); + + return ( +
+ + `${id}-${color}`).join(' ')} + indeterminate={isIndeterminate} + checked={isChecked} + onChange={(event) => { + const checked = event.target.checked; + setCheckedValues([checked, checked, checked]); + }} + > + + {isIndeterminate ? : } + + + + + + {colors.map((color, index) => ( + + { + const newCheckedValues = [...checkedValues]; + newCheckedValues[index] = event.target.checked; + setCheckedValues(newCheckedValues); + }} + > + + + + + + + ))} + +
+ ); +} + +const blue = { + 400: '#3399FF', + 600: '#0072E6', + 800: '#004C99', +}; + +const grey = { + 100: '#E5EAF2', + 400: '#B0B8C4', + 800: '#303740', +}; + +const Checkbox = styled(BaseCheckbox)( + ({ theme }) => ` + width: 24px; + height: 24px; + padding: 0; + border-radius: 4px; + border: 2px solid ${blue[600]}; + background: none; + transition-property: background, border-color; + transition-duration: 0.15s; + outline: none; + + &[data-disabled] { + opacity: 0.4; + cursor: not-allowed; + } + + &:focus-visible { + outline: 2px solid ${theme.palette.mode === 'dark' ? blue[800] : blue[400]}; + outline-offset: 2px; + } + + &[data-state="checked"], &[data-state="mixed"] { + border-color: transparent; + background: ${blue[600]}; + } + `, +); + +const HorizontalRuleIcon = styled(HorizontalRule)` + height: 100%; + width: 100%; +`; + +const CheckIcon = styled(Check)` + height: 100%; + width: 100%; +`; + +const Indicator = styled(BaseCheckbox.Indicator)` + height: 100%; + display: inline-block; + visibility: hidden; + color: ${grey[100]}; + + &[data-state='checked'], + &[data-state='mixed'] { + visibility: visible; + } +`; + +const ListRoot = styled('div')` + display: flex; + align-items: center; + margin-bottom: 8px; +`; + +const List = styled('ul')` + list-style: none; + padding: 0; + margin: 0; + margin-left: 32px; +`; + +const ListItem = styled('li')` + display: flex; + align-items: center; + + &:not(:last-child) { + margin-bottom: 8px; + } +`; + +const Label = styled('label')( + ({ theme }) => ` + padding-left: 8px; + color: ${theme.palette.mode === 'dark' ? grey[400] : grey[800]}; + `, +); diff --git a/docs/data/base/components/checkbox/UnstyledCheckboxIndeterminateGroup.tsx b/docs/data/base/components/checkbox/UnstyledCheckboxIndeterminateGroup.tsx new file mode 100644 index 0000000000..12091ea170 --- /dev/null +++ b/docs/data/base/components/checkbox/UnstyledCheckboxIndeterminateGroup.tsx @@ -0,0 +1,157 @@ +import * as React from 'react'; +import { styled } from '@mui/system'; +import { Checkbox as BaseCheckbox } from '@mui/base/Checkbox'; +import HorizontalRule from '@mui/icons-material/HorizontalRule'; +import Check from '@mui/icons-material/Check'; + +const colors = ['Red', 'Green', 'Blue']; + +export default function UnstyledCheckboxIndeterminateGroup() { + const [checkedValues, setCheckedValues] = React.useState([false, true, false]); + + const isChecked = checkedValues.every((value) => value); + const isIndeterminate = checkedValues.some((value) => value) && !isChecked; + + const id = React.useId(); + + return ( +
+ + `${id}-${color}`).join(' ')} + indeterminate={isIndeterminate} + checked={isChecked} + onChange={(event) => { + const checked = event.target.checked; + setCheckedValues([checked, checked, checked]); + }} + > + + {isIndeterminate ? : } + + + + + + {colors.map((color, index) => ( + + { + const newCheckedValues = [...checkedValues]; + newCheckedValues[index] = event.target.checked; + setCheckedValues(newCheckedValues); + }} + > + + + + + + + ))} + +
+ ); +} + +const blue = { + 400: '#3399FF', + 600: '#0072E6', + 800: '#004C99', +}; + +const grey = { + 100: '#E5EAF2', + 400: '#B0B8C4', + 800: '#303740', +}; + +const Checkbox = styled(BaseCheckbox)( + ({ theme }) => ` + width: 24px; + height: 24px; + padding: 0; + border-radius: 4px; + border: 2px solid ${blue[600]}; + background: none; + transition-property: background, border-color; + transition-duration: 0.15s; + outline: none; + + &[data-disabled] { + opacity: 0.4; + cursor: not-allowed; + } + + &:focus-visible { + outline: 2px solid ${theme.palette.mode === 'dark' ? blue[800] : blue[400]}; + outline-offset: 2px; + } + + &[data-state="checked"], &[data-state="mixed"] { + border-color: transparent; + background: ${blue[600]}; + } + `, +); + +const HorizontalRuleIcon = styled(HorizontalRule)` + height: 100%; + width: 100%; +`; + +const CheckIcon = styled(Check)` + height: 100%; + width: 100%; +`; + +const Indicator = styled(BaseCheckbox.Indicator)` + height: 100%; + display: inline-block; + visibility: hidden; + color: ${grey[100]}; + + &[data-state='checked'], + &[data-state='mixed'] { + visibility: visible; + } +`; + +const ListRoot = styled('div')` + display: flex; + align-items: center; + margin-bottom: 8px; +`; + +const List = styled('ul')` + list-style: none; + padding: 0; + margin: 0; + margin-left: 32px; +`; + +const ListItem = styled('li')` + display: flex; + align-items: center; + + &:not(:last-child) { + margin-bottom: 8px; + } +`; + +const Label = styled('label')( + ({ theme }) => ` + padding-left: 8px; + color: ${theme.palette.mode === 'dark' ? grey[400] : grey[800]}; + `, +); diff --git a/docs/data/base/components/checkbox/UnstyledCheckboxIntroduction/css/index.js b/docs/data/base/components/checkbox/UnstyledCheckboxIntroduction/css/index.js new file mode 100644 index 0000000000..188c6cecb4 --- /dev/null +++ b/docs/data/base/components/checkbox/UnstyledCheckboxIntroduction/css/index.js @@ -0,0 +1,134 @@ +import * as React from 'react'; +import { Checkbox } from '@mui/base/Checkbox'; +import { useTheme } from '@mui/system'; +import Check from '@mui/icons-material/Check'; + +export default function UnstyledCheckboxIntroduction() { + return ( +
+ + + + + + + + + + + + + + + + + + + + + +
+ ); +} + +const grey = { + 100: '#E5EAF2', + 300: '#C7D0DD', + 500: '#9DA8B7', + 600: '#6B7A90', + 800: '#303740', + 900: '#1C2025', +}; + +function useIsDarkMode() { + const theme = useTheme(); + return theme.palette.mode === 'dark'; +} + +function Styles() { + // Replace this with your app logic for determining dark mode + const isDarkMode = useIsDarkMode(); + + return ( + + ); +} diff --git a/docs/data/base/components/checkbox/UnstyledCheckboxIntroduction/css/index.tsx b/docs/data/base/components/checkbox/UnstyledCheckboxIntroduction/css/index.tsx new file mode 100644 index 0000000000..188c6cecb4 --- /dev/null +++ b/docs/data/base/components/checkbox/UnstyledCheckboxIntroduction/css/index.tsx @@ -0,0 +1,134 @@ +import * as React from 'react'; +import { Checkbox } from '@mui/base/Checkbox'; +import { useTheme } from '@mui/system'; +import Check from '@mui/icons-material/Check'; + +export default function UnstyledCheckboxIntroduction() { + return ( +
+ + + + + + + + + + + + + + + + + + + + + +
+ ); +} + +const grey = { + 100: '#E5EAF2', + 300: '#C7D0DD', + 500: '#9DA8B7', + 600: '#6B7A90', + 800: '#303740', + 900: '#1C2025', +}; + +function useIsDarkMode() { + const theme = useTheme(); + return theme.palette.mode === 'dark'; +} + +function Styles() { + // Replace this with your app logic for determining dark mode + const isDarkMode = useIsDarkMode(); + + return ( + + ); +} diff --git a/docs/data/base/components/checkbox/UnstyledCheckboxIntroduction/system/index.js b/docs/data/base/components/checkbox/UnstyledCheckboxIntroduction/system/index.js new file mode 100644 index 0000000000..e6ec77f797 --- /dev/null +++ b/docs/data/base/components/checkbox/UnstyledCheckboxIntroduction/system/index.js @@ -0,0 +1,91 @@ +import * as React from 'react'; +import { styled } from '@mui/system'; +import { Checkbox as BaseCheckbox } from '@mui/base/Checkbox'; +import Check from '@mui/icons-material/Check'; + +export default function UnstyledSwitchIntroduction() { + return ( +
+ + + + + + + + + + + + + + + + + + + + +
+ ); +} + +const grey = { + 100: '#E5EAF2', +}; + +const blue = { + 400: '#3399FF', + 600: '#0072E6', + 800: '#004C99', +}; + +const Checkbox = styled(BaseCheckbox)( + ({ theme }) => ` + width: 24px; + height: 24px; + padding: 0; + border-radius: 4px; + border: 2px solid ${blue[600]}; + background: none; + transition-property: background, border-color; + transition-duration: 0.15s; + outline: none; + + &[data-disabled] { + opacity: 0.4; + cursor: not-allowed; + } + + &:focus-visible { + outline: 2px solid ${theme.palette.mode === 'dark' ? blue[800] : blue[400]}; + outline-offset: 2px; + } + + &[data-state="checked"], &[data-state="mixed"] { + border-color: transparent; + background: ${blue[600]}; + } + `, +); + +const CheckIcon = styled(Check)` + width: 100%; + height: 100%; +`; + +const Indicator = styled(BaseCheckbox.Indicator)` + color: ${grey[100]}; + height: 100%; + display: inline-block; + visibility: hidden; + + &[data-state='checked'], + &[data-state='mixed'] { + visibility: visible; + } +`; diff --git a/docs/data/base/components/checkbox/UnstyledCheckboxIntroduction/system/index.tsx b/docs/data/base/components/checkbox/UnstyledCheckboxIntroduction/system/index.tsx new file mode 100644 index 0000000000..e6ec77f797 --- /dev/null +++ b/docs/data/base/components/checkbox/UnstyledCheckboxIntroduction/system/index.tsx @@ -0,0 +1,91 @@ +import * as React from 'react'; +import { styled } from '@mui/system'; +import { Checkbox as BaseCheckbox } from '@mui/base/Checkbox'; +import Check from '@mui/icons-material/Check'; + +export default function UnstyledSwitchIntroduction() { + return ( +
+ + + + + + + + + + + + + + + + + + + + +
+ ); +} + +const grey = { + 100: '#E5EAF2', +}; + +const blue = { + 400: '#3399FF', + 600: '#0072E6', + 800: '#004C99', +}; + +const Checkbox = styled(BaseCheckbox)( + ({ theme }) => ` + width: 24px; + height: 24px; + padding: 0; + border-radius: 4px; + border: 2px solid ${blue[600]}; + background: none; + transition-property: background, border-color; + transition-duration: 0.15s; + outline: none; + + &[data-disabled] { + opacity: 0.4; + cursor: not-allowed; + } + + &:focus-visible { + outline: 2px solid ${theme.palette.mode === 'dark' ? blue[800] : blue[400]}; + outline-offset: 2px; + } + + &[data-state="checked"], &[data-state="mixed"] { + border-color: transparent; + background: ${blue[600]}; + } + `, +); + +const CheckIcon = styled(Check)` + width: 100%; + height: 100%; +`; + +const Indicator = styled(BaseCheckbox.Indicator)` + color: ${grey[100]}; + height: 100%; + display: inline-block; + visibility: hidden; + + &[data-state='checked'], + &[data-state='mixed'] { + visibility: visible; + } +`; diff --git a/docs/data/base/components/checkbox/UnstyledCheckboxIntroduction/tailwind/index.js b/docs/data/base/components/checkbox/UnstyledCheckboxIntroduction/tailwind/index.js new file mode 100644 index 0000000000..3e180a6936 --- /dev/null +++ b/docs/data/base/components/checkbox/UnstyledCheckboxIntroduction/tailwind/index.js @@ -0,0 +1,96 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { Checkbox as BaseCheckbox } from '@mui/base/Checkbox'; +import { useTheme } from '@mui/system'; +import Check from '@mui/icons-material/Check'; + +function classNames(...classes) { + return classes.filter(Boolean).join(' '); +} + +function useIsDarkMode() { + const theme = useTheme(); + return theme.palette.mode === 'dark'; +} + +export default function UnstyledCheckboxIntroduction() { + // Replace this with your app logic for determining dark mode + const isDarkMode = useIsDarkMode(); + + return ( +
+ + + + + + + + + + + + +
+ ); +} + +const Checkbox = React.forwardRef(function Checkbox(props, ref) { + return ( + + classNames( + 'w-6 h-6 p-0 rounded-md', + 'border-2 border-solid border-purple-500', + 'outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-purple-500 focus-visible:ring-opacity-60', + 'transition-colors duration-150', + state.disabled && 'opacity-40 cursor-not-allowed', + state.checked && 'bg-purple-500', + !state.checked && 'bg-transparent', + typeof props.className === 'function' + ? props.className(state) + : props.className, + ) + } + /> + ); +}); + +Checkbox.propTypes = { + /** + * Class names applied to the element or a function that returns them based on the component's state. + */ + className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), +}; + +const Indicator = React.forwardRef(function Indicator(props, ref) { + return ( + + classNames( + 'h-full inline-block invisible data-[state=checked]:visible text-gray-100', + typeof props.className === 'function' + ? props.className(state) + : props.className, + ) + } + > + + + ); +}); + +Indicator.propTypes = { + /** + * Class names applied to the element or a function that returns them based on the component's state. + */ + className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), +}; diff --git a/docs/data/base/components/checkbox/UnstyledCheckboxIntroduction/tailwind/index.tsx b/docs/data/base/components/checkbox/UnstyledCheckboxIntroduction/tailwind/index.tsx new file mode 100644 index 0000000000..922dee5864 --- /dev/null +++ b/docs/data/base/components/checkbox/UnstyledCheckboxIntroduction/tailwind/index.tsx @@ -0,0 +1,89 @@ +import * as React from 'react'; +import { + Checkbox as BaseCheckbox, + type CheckboxIndicatorProps, + type CheckboxProps, +} from '@mui/base/Checkbox'; +import { useTheme } from '@mui/system'; +import Check from '@mui/icons-material/Check'; + +function classNames(...classes: Array) { + return classes.filter(Boolean).join(' '); +} + +function useIsDarkMode() { + const theme = useTheme(); + return theme.palette.mode === 'dark'; +} + +export default function UnstyledCheckboxIntroduction() { + // Replace this with your app logic for determining dark mode + const isDarkMode = useIsDarkMode(); + + return ( +
+ + + + + + + + + + + + +
+ ); +} + +const Checkbox = React.forwardRef( + function Checkbox(props, ref) { + return ( + + classNames( + 'w-6 h-6 p-0 rounded-md', + 'border-2 border-solid border-purple-500', + 'outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-purple-500 focus-visible:ring-opacity-60', + 'transition-colors duration-150', + state.disabled && 'opacity-40 cursor-not-allowed', + state.checked && 'bg-purple-500', + !state.checked && 'bg-transparent', + typeof props.className === 'function' + ? props.className(state) + : props.className, + ) + } + /> + ); + }, +); + +const Indicator = React.forwardRef( + function Indicator(props, ref) { + return ( + + classNames( + 'h-full inline-block invisible data-[state=checked]:visible text-gray-100', + typeof props.className === 'function' + ? props.className(state) + : props.className, + ) + } + > + + + ); + }, +); diff --git a/docs/data/base/components/checkbox/UnstyledCheckboxIntroduction/tailwind/index.tsx.preview b/docs/data/base/components/checkbox/UnstyledCheckboxIntroduction/tailwind/index.tsx.preview new file mode 100644 index 0000000000..8a53b1cf9f --- /dev/null +++ b/docs/data/base/components/checkbox/UnstyledCheckboxIntroduction/tailwind/index.tsx.preview @@ -0,0 +1,16 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/data/base/components/checkbox/checkbox.md b/docs/data/base/components/checkbox/checkbox.md index 56ce9d252a..7c0870bd31 100644 --- a/docs/data/base/components/checkbox/checkbox.md +++ b/docs/data/base/components/checkbox/checkbox.md @@ -1,14 +1,122 @@ --- productId: base-ui -title: React Checkbox component +title: React Checkbox component and hook +components: Checkbox, CheckboxIndicator +hooks: useCheckbox githubLabel: 'component: checkbox' waiAria: https://www.w3.org/WAI/ARIA/apg/patterns/checkbox/ --- -# Checkbox 🚧 +# Checkbox

Checkboxes give users binary choices when presented with multiple options in a series.

-:::warning -The BaseΒ UI Checkbox component isn't available yet, but you can upvote [this GitHub issue](https://github.com/mui/base-ui/issues/24) to see it arrive sooner. +{{"component": "modules/components/ComponentLinkHeader.js", "design": false}} + +{{"component": "modules/components/ComponentPageTabs.js"}} + +## Introduction + +The Checkbox component provides users with a checkbox for toggling a checked state. + +{{"demo": "UnstyledCheckboxIntroduction", "defaultCodeOpen": false, "bg": "gradient"}} + +## Component + +```jsx +import { Checkbox } from '@mui/base/Checkbox'; +``` + +### Anatomy + +The `Checkbox` component is composed of a root component and an indicator child component: + +```tsx + + + +``` + +The indicator can contain children, such as an icon: + +```tsx + + + + + +``` + +The indicator conditionally unmounts its children when the checkbox is unchecked. For CSS animations, you can use the `keepMounted` prop to transition `visibility` and `opacity` for example: + +```tsx + + + + + +``` + +### Custom structure + +Use the `render` prop to override the rendered checkbox or indicator element with your own components: + +```jsx + }> + } /> + +``` + +To ensure behavior works as expected: + +- **Forward all props**: Your component should spread all props to the underlying element. +- **Forward the `ref`**: Your component should use [`forwardRef`](https://react.dev/reference/react/forwardRef) to ensure the Checkbox components can access the element via a ref. + +A custom component that adheres to these two principles looks like this: + +```jsx +const MyCheckbox = React.forwardRef(function MyCheckbox(props, ref) { + return + ; + + ); + } + + const { getAllByRole, getByText } = render(); + const [checkbox] = getAllByRole('checkbox'); + const button = getByText('Toggle'); + + expect(checkbox).to.have.attribute('aria-checked', 'false'); + act(() => { + button.click(); + }); + + expect(checkbox).to.have.attribute('aria-checked', 'true'); + + act(() => { + button.click(); + }); + + expect(checkbox).to.have.attribute('aria-checked', 'false'); + }); + + it('should call onChange when clicked', () => { + const handleChange = spy(); + const { getAllByRole, container } = render(); + const [checkbox] = getAllByRole('checkbox'); + const input = container.querySelector('input[type=checkbox]') as HTMLInputElement; + + act(() => { + checkbox.click(); + }); + + expect(handleChange.callCount).to.equal(1); + expect(handleChange.firstCall.args[0].target).to.equal(input); + }); + + describe('prop: disabled', () => { + it('should have the `aria-disabled` attribute', () => { + const { getAllByRole } = render(); + expect(getAllByRole('checkbox')[0]).to.have.attribute('aria-disabled', 'true'); + }); + + it('should not have the aria attribute when `disabled` is not set', () => { + const { getAllByRole } = render(); + expect(getAllByRole('checkbox')[0]).not.to.have.attribute('aria-disabled'); + }); + + it('should not change its state when clicked', () => { + const { getAllByRole } = render(); + const [checkbox] = getAllByRole('checkbox'); + + expect(checkbox).to.have.attribute('aria-checked', 'false'); + + act(() => { + checkbox.click(); + }); + + expect(checkbox).to.have.attribute('aria-checked', 'false'); + }); + }); + + describe('prop: readOnly', () => { + it('should have the `aria-readonly` attribute', () => { + const { getAllByRole } = render(); + expect(getAllByRole('checkbox')[0]).to.have.attribute('aria-readonly', 'true'); + }); + + it('should not have the aria attribute when `readOnly` is not set', () => { + const { getAllByRole } = render(); + expect(getAllByRole('checkbox')[0]).not.to.have.attribute('aria-readonly'); + }); + + it('should not change its state when clicked', () => { + const { getAllByRole } = render(); + const [checkbox] = getAllByRole('checkbox'); + + expect(checkbox).to.have.attribute('aria-checked', 'false'); + + act(() => { + checkbox.click(); + }); + + expect(checkbox).to.have.attribute('aria-checked', 'false'); + }); + }); + + describe('prop: indeterminate', () => { + it('should set the `aria-checked` attribute as "mixed"', () => { + const { getAllByRole } = render(); + expect(getAllByRole('checkbox')[0]).to.have.attribute('aria-checked', 'mixed'); + }); + + it('should not change its state when clicked', () => { + const { getAllByRole } = render(); + const [checkbox] = getAllByRole('checkbox'); + + expect(checkbox).to.have.attribute('aria-checked', 'mixed'); + + act(() => { + checkbox.click(); + }); + + expect(checkbox).to.have.attribute('aria-checked', 'mixed'); + }); + + it('should not set the `data-indeterminate` attribute', () => { + const { getAllByRole } = render(); + expect(getAllByRole('checkbox')[0]).to.not.have.attribute('data-indeterminate', 'true'); + }); + + it('should not have the aria attribute when `indeterminate` is not set', () => { + const { getAllByRole } = render(); + expect(getAllByRole('checkbox')[0]).not.to.have.attribute('aria-checked', 'mixed'); + }); + + it('should not be overridden by `checked` prop', () => { + const { getAllByRole } = render(); + expect(getAllByRole('checkbox')[0]).to.have.attribute('aria-checked', 'mixed'); + }); + }); + + it('should update its state if the underlying input is toggled', () => { + const { getAllByRole, container } = render(); + const [checkbox] = getAllByRole('checkbox'); + const input = container.querySelector('input[type=checkbox]') as HTMLInputElement; + + act(() => { + input.click(); + }); + + expect(checkbox).to.have.attribute('aria-checked', 'true'); + }); + + it('should place the style hooks on the root and the indicator', () => { + const { getAllByRole } = render( + + + , + ); + + const [checkbox] = getAllByRole('checkbox'); + const indicator = checkbox.querySelector('span'); + + expect(checkbox).to.have.attribute('data-state', 'checked'); + expect(checkbox).to.have.attribute('data-disabled', 'true'); + expect(checkbox).to.have.attribute('data-readonly', 'true'); + expect(checkbox).to.have.attribute('data-required', 'true'); + + expect(indicator).to.have.attribute('data-state', 'checked'); + expect(indicator).to.have.attribute('data-disabled', 'true'); + expect(indicator).to.have.attribute('data-readonly', 'true'); + expect(indicator).to.have.attribute('data-required', 'true'); + }); + + it('should set the name attribute on the input', () => { + const { container } = render(); + const input = container.querySelector('input[type="checkbox"]')! as HTMLInputElement; + + expect(input).to.have.attribute('name', 'checkbox-name'); + }); + + describe('form handling', () => { + it('should toggle the checkbox when a parent label is clicked', function test() { + // Clicking the label causes unrelated browser tests to fail. + if (!isJSDOM) { + this.skip(); + } + + const { getByTestId, getAllByRole } = render( + , + ); + + const [checkbox] = getAllByRole('checkbox'); + const label = getByTestId('label'); + + expect(checkbox).to.have.attribute('aria-checked', 'false'); + + act(() => { + label.click(); + }); + + expect(checkbox).to.have.attribute('aria-checked', 'true'); + }); + + it('should toggle the checkbox when a linked label is clicked', function test() { + // Clicking the label causes unrelated browser tests to fail. + if (!isJSDOM) { + this.skip(); + } + + const { getByTestId, getAllByRole } = render( +
+ + +
, + ); + + const [checkbox] = getAllByRole('checkbox'); + const label = getByTestId('label'); + + expect(checkbox).to.have.attribute('aria-checked', 'false'); + + act(() => { + label.click(); + }); + + expect(checkbox).to.have.attribute('aria-checked', 'true'); + }); + }); + + it('should include the checkbox value in the form submission', function test() { + if (isJSDOM) { + // FormData is not available in JSDOM + this.skip(); + } + + let stringifiedFormData = ''; + + const { getAllByRole, getByRole } = render( +
{ + event.preventDefault(); + const formData = new FormData(event.currentTarget); + stringifiedFormData = new URLSearchParams(formData as any).toString(); + }} + > + + + , + ); + + const [checkbox] = getAllByRole('checkbox'); + const submitButton = getByRole('button')!; + + submitButton.click(); + + expect(stringifiedFormData).to.equal('test-checkbox=off'); + + act(() => { + checkbox.click(); + }); + + submitButton.click(); + + expect(stringifiedFormData).to.equal('test-checkbox=on'); + }); +}); diff --git a/packages/mui-base/src/Checkbox/Checkbox.tsx b/packages/mui-base/src/Checkbox/Checkbox.tsx new file mode 100644 index 0000000000..0cbf27e590 --- /dev/null +++ b/packages/mui-base/src/Checkbox/Checkbox.tsx @@ -0,0 +1,143 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import type { CheckboxOwnerState, CheckboxProps } from './Checkbox.types'; +import { resolveClassName } from '../utils/resolveClassName'; +import { CheckboxContext } from './CheckboxContext'; +import { useCheckbox } from '../useCheckbox'; +import { useCheckboxStyleHooks } from './utils'; + +function defaultRender(props: React.ComponentPropsWithRef<'button'>) { + return