From d9ee56fb661acfc339361075ced0c3aecd23c7b5 Mon Sep 17 00:00:00 2001 From: atomiks Date: Wed, 6 Mar 2024 19:26:07 +1100 Subject: [PATCH 01/10] [Checkbox] Component and Hook Fix test Fix doc wording Add tests for indeterminate prop Fix prop diff Update based on discussions Update demos Add conditional unmounting to CheckboxIndicator Avoid passing keepMounted prop to element Update docs/data/base/components/checkbox/UnstyledCheckboxIndeterminate.js Co-authored-by: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Signed-off-by: atomiks Update docs/data/base/components/checkbox/UnstyledCheckboxIndeterminateGroup.js Co-authored-by: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Signed-off-by: atomiks Update docs/data/base/components/checkbox/UnstyledCheckboxIndeterminateGroup.js Co-authored-by: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Signed-off-by: atomiks Update docs/data/base/components/checkbox/UnstyledCheckboxIntroduction/tailwind/index.tsx Co-authored-by: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Signed-off-by: atomiks Update docs/data/base/components/checkbox/UnstyledCheckboxIndeterminateGroup.js Co-authored-by: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Signed-off-by: atomiks Update docs/data/base/components/checkbox/checkbox.md Co-authored-by: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Signed-off-by: atomiks Use 24px size Fix formatting Remove planned from Checkbox Generate API docs and sync with Switch Simplify types Use undefined fallback due to defaults Remove unused file Remove unused colors Simplify API Resolve styling inconsistencies Remove unused inputRef prop Revert otherProps spread location Remove unused colors i18n Try deleting tests Add back CheckboxIndicator tests Bifurcate Checkbox tests Bifurcate Checkbox tests Bifurcate Checkbox tests Bifurcate Checkbox tests Bifurcate Checkbox tests Bifurcate Checkbox tests Bifurcate Checkbox tests Bifurcate Checkbox tests Try deleting Button test Skip tests rerun Reuse variable Update Rerun docs generation Use describeConformance Update tests Update docs ci --- .../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 | 107 +++++- 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 | 35 ++ docs/pages/base-ui/api/use-checkbox.json | 11 + .../base-ui/react-checkbox/[docsTab]/index.js | 62 +++ .../checkbox-indicator.json | 13 + .../api-docs-base/checkbox/checkbox.json | 28 ++ .../api-docs/use-checkbox/use-checkbox.json | 5 + docs/translations/translations.json | 3 + .../mui-base/src/Checkbox/Checkbox.test.tsx | 352 ++++++++++++++++++ packages/mui-base/src/Checkbox/Checkbox.tsx | 148 ++++++++ .../mui-base/src/Checkbox/Checkbox.types.ts | 80 ++++ .../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/useCheckbox/index.ts | 3 + .../mui-base/src/useCheckbox/useCheckbox.ts | 101 +++++ .../src/useCheckbox/useCheckbox.types.ts | 29 ++ .../src/utils/BaseUiComponentCommonProps.ts | 15 + 35 files changed, 2357 insertions(+), 5 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 create mode 100644 packages/mui-base/src/utils/BaseUiComponentCommonProps.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..53091f6a11 100644 --- a/docs/data/base/components/checkbox/checkbox.md +++ b/docs/data/base/components/checkbox/checkbox.md @@ -1,14 +1,113 @@ --- 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(''); + + 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..c2505b6204 --- /dev/null +++ b/packages/mui-base/src/Checkbox/Checkbox.tsx @@ -0,0 +1,148 @@ +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