From b0498013388491e150973ee01da81c2abc9a79a6 Mon Sep 17 00:00:00 2001 From: atomiks Date: Wed, 6 Mar 2024 19:26:07 +1100 Subject: [PATCH] [Checkbox] Component and Hook --- .../checkbox/UnstyledCheckboxIndeterminate.js | 102 ++++++ .../UnstyledCheckboxIndeterminate.tsx | 102 ++++++ .../UnstyledCheckboxIndeterminate.tsx.preview | 10 + .../UnstyledCheckboxIntroduction/css/index.js | 150 +++++++++ .../css/index.tsx | 150 +++++++++ .../system/index.js | 114 +++++++ .../system/index.tsx | 114 +++++++ .../tailwind/index.js | 98 ++++++ .../tailwind/index.tsx | 91 ++++++ .../tailwind/index.tsx.preview | 16 + .../data/base/components/checkbox/checkbox.md | 97 +++++- docs/pages/base-ui/api/use-checkbox.json | 11 + .../api-docs/use-checkbox/use-checkbox.json | 5 + .../mui-base/src/Checkbox/Checkbox.test.tsx | 298 ++++++++++++++++++ packages/mui-base/src/Checkbox/Checkbox.tsx | 139 ++++++++ .../mui-base/src/Checkbox/Checkbox.types.ts | 79 +++++ .../mui-base/src/Checkbox/CheckboxContext.ts | 6 + .../src/Checkbox/CheckboxIndicator.tsx | 35 ++ packages/mui-base/src/Checkbox/index.ts | 10 + packages/mui-base/src/Checkbox/utils.ts | 17 + .../mui-base/src/useCheckbox/useCheckbox.ts | 96 ++++++ .../src/useCheckbox/useCheckbox.types.ts | 111 +++++++ .../src/utils/combineComponentExports.ts | 11 + packages/mui-base/src/utils/constants.ts | 13 + .../mui-base/src/utils/getStyleHookProps.ts | 27 ++ .../mui-base/src/utils/resolveClassName.ts | 13 + packages/mui-base/src/utils/useControlled.ts | 1 + packages/mui-base/src/utils/useForkRef.ts | 1 + 28 files changed, 1913 insertions(+), 4 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/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/use-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.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/useCheckbox.ts create mode 100644 packages/mui-base/src/useCheckbox/useCheckbox.types.ts create mode 100644 packages/mui-base/src/utils/combineComponentExports.ts create mode 100644 packages/mui-base/src/utils/constants.ts create mode 100644 packages/mui-base/src/utils/getStyleHookProps.ts create mode 100644 packages/mui-base/src/utils/resolveClassName.ts create mode 100644 packages/mui-base/src/utils/useControlled.ts create mode 100644 packages/mui-base/src/utils/useForkRef.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..e202368841 --- /dev/null +++ b/docs/data/base/components/checkbox/UnstyledCheckboxIndeterminate.js @@ -0,0 +1,102 @@ +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 = { + 50: '#e3f2fd', + 100: '#bbdefb', + 200: '#90caf9', + 300: '#64b5f6', + 400: '#42a5f5', + 500: '#2196f3', + 600: '#1e88e5', + 700: '#1976d2', + 800: '#1565c0', + 900: '#0d47a1', +}; + +const grey = { + 50: '#F3F6F9', + 100: '#E5EAF2', + 200: '#DAE2ED', + 300: '#C7D0DD', + 400: '#B0B8C4', + 500: '#9DA8B7', + 600: '#6B7A90', + 700: '#434D5B', + 800: '#303740', + 900: '#1C2025', +}; + +const Checkbox = styled(BaseCheckbox)( + ({ theme }) => ` + width: 32px; + height: 32px; + padding: 0; + border-radius: 4px; + border: 2px solid ${blue[500]}; + background: none; + transition-property: background, border-color; + transition-duration: 0.15s; + outline: none; + + &:not([data-disabled]):hover { + border-color: ${blue[700]}; + } + + &[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]}; + + &:not([data-disabled]):hover { + background: ${blue[700]}; + } + } + `, +); + +const HorizontalRuleIcon = styled(HorizontalRule)` + height: 100%; + color: ${grey[100]}; +`; + +const Indicator = styled(BaseCheckbox.Indicator)( + ({ theme }) => ` + color: ${theme.palette.mode === 'dark' ? grey[100] : 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..e202368841 --- /dev/null +++ b/docs/data/base/components/checkbox/UnstyledCheckboxIndeterminate.tsx @@ -0,0 +1,102 @@ +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 = { + 50: '#e3f2fd', + 100: '#bbdefb', + 200: '#90caf9', + 300: '#64b5f6', + 400: '#42a5f5', + 500: '#2196f3', + 600: '#1e88e5', + 700: '#1976d2', + 800: '#1565c0', + 900: '#0d47a1', +}; + +const grey = { + 50: '#F3F6F9', + 100: '#E5EAF2', + 200: '#DAE2ED', + 300: '#C7D0DD', + 400: '#B0B8C4', + 500: '#9DA8B7', + 600: '#6B7A90', + 700: '#434D5B', + 800: '#303740', + 900: '#1C2025', +}; + +const Checkbox = styled(BaseCheckbox)( + ({ theme }) => ` + width: 32px; + height: 32px; + padding: 0; + border-radius: 4px; + border: 2px solid ${blue[500]}; + background: none; + transition-property: background, border-color; + transition-duration: 0.15s; + outline: none; + + &:not([data-disabled]):hover { + border-color: ${blue[700]}; + } + + &[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]}; + + &:not([data-disabled]):hover { + background: ${blue[700]}; + } + } + `, +); + +const HorizontalRuleIcon = styled(HorizontalRule)` + height: 100%; + color: ${grey[100]}; +`; + +const Indicator = styled(BaseCheckbox.Indicator)( + ({ theme }) => ` + color: ${theme.palette.mode === 'dark' ? grey[100] : 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..391b3614c7 --- /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/UnstyledCheckboxIntroduction/css/index.js b/docs/data/base/components/checkbox/UnstyledCheckboxIntroduction/css/index.js new file mode 100644 index 0000000000..db1b854ede --- /dev/null +++ b/docs/data/base/components/checkbox/UnstyledCheckboxIntroduction/css/index.js @@ -0,0 +1,150 @@ +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 = { + 50: '#F3F6F9', + 100: '#E5EAF2', + 200: '#DAE2ED', + 300: '#C7D0DD', + 400: '#B0B8C4', + 500: '#9DA8B7', + 600: '#6B7A90', + 700: '#434D5B', + 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 modes + 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..db1b854ede --- /dev/null +++ b/docs/data/base/components/checkbox/UnstyledCheckboxIntroduction/css/index.tsx @@ -0,0 +1,150 @@ +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 = { + 50: '#F3F6F9', + 100: '#E5EAF2', + 200: '#DAE2ED', + 300: '#C7D0DD', + 400: '#B0B8C4', + 500: '#9DA8B7', + 600: '#6B7A90', + 700: '#434D5B', + 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 modes + 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..27f7474704 --- /dev/null +++ b/docs/data/base/components/checkbox/UnstyledCheckboxIntroduction/system/index.js @@ -0,0 +1,114 @@ +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 blue = { + 50: '#F0F7FF', + 100: '#C2E0FF', + 200: '#99CCF3', + 400: '#3399FF', + 500: '#007FFF', + 600: '#0072E6', + 700: '#0059B3', + 800: '#004C99', + 900: '#003A75', +}; + +const grey = { + 50: '#F3F6F9', + 100: '#E5EAF2', + 200: '#DAE2ED', + 300: '#C7D0DD', + 400: '#B0B8C4', + 500: '#9DA8B7', + 600: '#6B7A90', + 700: '#434D5B', + 800: '#303740', + 900: '#1C2025', +}; + +const Checkbox = styled(BaseCheckbox)( + ({ theme }) => ` + width: 32px; + height: 32px; + padding: 0; + border-radius: 4px; + border: 2px solid ${blue[500]}; + background: none; + transition-property: background, border-color; + transition-duration: 0.15s; + outline: none; + + &:not([data-disabled]):hover { + border-color: ${blue[700]}; + } + + &[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]}; + + &:not([data-disabled]):hover { + background: ${blue[700]}; + } + } + `, +); + +const CheckIcon = styled(Check)` + height: 100%; +`; + +const Indicator = styled(BaseCheckbox.Indicator)( + ({ theme }) => ` + color: ${theme.palette.mode === 'dark' ? grey[100] : 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..27f7474704 --- /dev/null +++ b/docs/data/base/components/checkbox/UnstyledCheckboxIntroduction/system/index.tsx @@ -0,0 +1,114 @@ +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 blue = { + 50: '#F0F7FF', + 100: '#C2E0FF', + 200: '#99CCF3', + 400: '#3399FF', + 500: '#007FFF', + 600: '#0072E6', + 700: '#0059B3', + 800: '#004C99', + 900: '#003A75', +}; + +const grey = { + 50: '#F3F6F9', + 100: '#E5EAF2', + 200: '#DAE2ED', + 300: '#C7D0DD', + 400: '#B0B8C4', + 500: '#9DA8B7', + 600: '#6B7A90', + 700: '#434D5B', + 800: '#303740', + 900: '#1C2025', +}; + +const Checkbox = styled(BaseCheckbox)( + ({ theme }) => ` + width: 32px; + height: 32px; + padding: 0; + border-radius: 4px; + border: 2px solid ${blue[500]}; + background: none; + transition-property: background, border-color; + transition-duration: 0.15s; + outline: none; + + &:not([data-disabled]):hover { + border-color: ${blue[700]}; + } + + &[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]}; + + &:not([data-disabled]):hover { + background: ${blue[700]}; + } + } + `, +); + +const CheckIcon = styled(Check)` + height: 100%; +`; + +const Indicator = styled(BaseCheckbox.Indicator)( + ({ theme }) => ` + color: ${theme.palette.mode === 'dark' ? grey[100] : 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..778a479a9b --- /dev/null +++ b/docs/data/base/components/checkbox/UnstyledCheckboxIntroduction/tailwind/index.js @@ -0,0 +1,98 @@ +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 modes + const isDarkMode = useIsDarkMode(); + + return ( +
+ + + + + + + + + + + + +
+ ); +} + +const Checkbox = React.forwardRef(function Checkbox(props, ref) { + return ( + + classNames( + 'w-[32px] h-[32px] 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 hover:border-transparent', + !state.checked && 'bg-transparent', + state.checked && !state.disabled && 'hover:bg-purple-700', + !state.checked && !state.disabled && 'hover:border-purple-700', + 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..c6ba34682b --- /dev/null +++ b/docs/data/base/components/checkbox/UnstyledCheckboxIntroduction/tailwind/index.tsx @@ -0,0 +1,91 @@ +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 modes + const isDarkMode = useIsDarkMode(); + + return ( +
+ + + + + + + + + + + + +
+ ); +} + +const Checkbox = React.forwardRef( + function Checkbox(props, ref) { + return ( + + classNames( + 'w-[32px] h-[32px] 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 hover:border-transparent', + !state.checked && 'bg-transparent', + state.checked && !state.disabled && 'hover:bg-purple-700', + !state.checked && !state.disabled && 'hover:border-purple-700', + 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 58e682082f..96ca2dd577 100644 --- a/docs/data/base/components/checkbox/checkbox.md +++ b/docs/data/base/components/checkbox/checkbox.md @@ -1,14 +1,103 @@ --- 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/material-ui/issues/38036) 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 that houses one interior slotβ€”an indicator: + +```tsx + + + +``` + +The indicator can contain children, such as an icon: + +```tsx + + + + + +``` + +### Custom structure + +Use the `render` prop to override the rendered checkbox or indicator element with your own components: + +```jsx + }> + } /> + +``` + +To ensure all 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'); + }); + }); + + 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', () => { + 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', () => { + 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 (/jsdom/.test(window.navigator.userAgent)) { + // 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..79a94b8264 --- /dev/null +++ b/packages/mui-base/src/Checkbox/Checkbox.tsx @@ -0,0 +1,139 @@ +'use client'; +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/useCheckbox'; +import { createStyleHooks } from './utils'; + +function defaultRender(props: React.ComponentPropsWithRef<'button'>) { + return