From a2cd245fe41269481f9f9187ded2e807798b96dd Mon Sep 17 00:00:00 2001 From: Siriwat K Date: Mon, 12 Sep 2022 09:39:50 +0700 Subject: [PATCH] [Radio][Joy] Integrate with form control (#34277) --- .../radio/ExampleAlignmentButtons.js | 3 +- .../radio/ExampleSegmentedControls.js | 3 +- .../data/joy/components/radio/ExampleTiers.js | 53 ++++++++++++++ .../components/radio/RadioButtonControl.js | 17 +++++ .../joy/components/radio/RadioButtonLabel.js | 6 ++ docs/data/joy/components/radio/radio.md | 16 ++++ docs/src/modules/components/JoyUsageDemo.tsx | 2 +- packages/mui-joy/src/Checkbox/Checkbox.tsx | 2 +- .../src/FormControl/FormControl.test.tsx | 73 ++++++++++++++++++- .../mui-joy/src/FormControl/FormControl.tsx | 2 +- .../src/FormHelperText/FormHelperText.tsx | 4 + packages/mui-joy/src/Radio/Radio.tsx | 31 ++++++-- .../mui-joy/src/RadioGroup/RadioGroup.tsx | 22 +++--- 13 files changed, 212 insertions(+), 22 deletions(-) create mode 100644 docs/data/joy/components/radio/ExampleTiers.js create mode 100644 docs/data/joy/components/radio/RadioButtonControl.js create mode 100644 docs/data/joy/components/radio/RadioButtonLabel.js diff --git a/docs/data/joy/components/radio/ExampleAlignmentButtons.js b/docs/data/joy/components/radio/ExampleAlignmentButtons.js index 0131269dc28b43..718e807c1111ea 100644 --- a/docs/data/joy/components/radio/ExampleAlignmentButtons.js +++ b/docs/data/joy/components/radio/ExampleAlignmentButtons.js @@ -7,7 +7,7 @@ import FormatAlignJustifyIcon from '@mui/icons-material/FormatAlignJustify'; import FormatAlignLeftIcon from '@mui/icons-material/FormatAlignLeft'; import FormatAlignRightIcon from '@mui/icons-material/FormatAlignRight'; -export default function RadioButtonsGroup() { +export default function ExampleAlignmentButtons() { const [alignment, setAlignment] = React.useState('left'); return ( {['left', 'center', 'right', 'justify'].map((item) => ( ({ position: 'relative', display: 'flex', diff --git a/docs/data/joy/components/radio/ExampleSegmentedControls.js b/docs/data/joy/components/radio/ExampleSegmentedControls.js index b373c572f91d6b..70a316c6082d5f 100644 --- a/docs/data/joy/components/radio/ExampleSegmentedControls.js +++ b/docs/data/joy/components/radio/ExampleSegmentedControls.js @@ -4,7 +4,7 @@ import Radio from '@mui/joy/Radio'; import RadioGroup from '@mui/joy/RadioGroup'; import Typography from '@mui/joy/Typography'; -export default function RadioButtonsGroup() { +export default function ExampleSegmentedControls() { const [justify, setJustify] = React.useState('flex-start'); return ( @@ -28,6 +28,7 @@ export default function RadioButtonsGroup() { > {['flex-start', 'center', 'flex-end'].map((item) => ( + div': { p: 1, flexDirection: 'row', gap: 2 } }} + > + + +
+ Small + + For light background jobs like sending email + +
+
+ + +
+ Medium + + For tasks like image resizing, exporting PDFs, etc. + +
+
+ + +
+ Large + + For intensive tasks like video encoding, etc. + +
+
+
+ + ); +} diff --git a/docs/data/joy/components/radio/RadioButtonControl.js b/docs/data/joy/components/radio/RadioButtonControl.js new file mode 100644 index 00000000000000..9cae5737b1d8ca --- /dev/null +++ b/docs/data/joy/components/radio/RadioButtonControl.js @@ -0,0 +1,17 @@ +import * as React from 'react'; +import FormControl from '@mui/joy/FormControl'; +import FormLabel from '@mui/joy/FormLabel'; +import FormHelperText from '@mui/joy/FormHelperText'; +import Radio from '@mui/joy/Radio'; + +export default function RadioButtonControl() { + return ( + + +
+ Selection title + One line description maximum lorem ipsum +
+
+ ); +} diff --git a/docs/data/joy/components/radio/RadioButtonLabel.js b/docs/data/joy/components/radio/RadioButtonLabel.js new file mode 100644 index 00000000000000..ca18b522c9107f --- /dev/null +++ b/docs/data/joy/components/radio/RadioButtonLabel.js @@ -0,0 +1,6 @@ +import * as React from 'react'; +import Radio from '@mui/joy/Radio'; + +export default function RadioButtonLabel() { + return ; +} diff --git a/docs/data/joy/components/radio/radio.md b/docs/data/joy/components/radio/radio.md index ae2a26b1710d76..39c94a19498520 100644 --- a/docs/data/joy/components/radio/radio.md +++ b/docs/data/joy/components/radio/radio.md @@ -46,6 +46,16 @@ The `Radio` component supports every Joy UI global variant and it comes with `ou {{"demo": "RadioButtons.js"}} +### Label + +Use `label` prop to label the radio buttons. + +{{"demo": "RadioButtonLabel.js"}} + +For complex layout, compose a radio button with `FormControl`, `FormLabel`, and `FormHelperText` (optional). + +{{"demo": "RadioButtonControl.js"}} + ### Position To swap the label and radio position, use the CSS property `flex-direction: row-reverse`. @@ -125,6 +135,12 @@ Visit the [WAI-ARIA documentation](https://www.w3.org/WAI/ARIA/apg/patterns/radi {{"demo": "ExampleSegmentedControls.js"}} +### Tiers + +A clone of an [inspiration](https://dribbble.com/shots/11239824-Radio-button-groups) that demonstrate the composition of the components. + +{{"demo": "ExampleTiers.js", "bg": true}} + ### Alignment buttons Provide an icon as a label to the `Radio` to make the radio buttons concise. You need to provide `aria-label` to the input slot for users who rely on screen readers. diff --git a/docs/src/modules/components/JoyUsageDemo.tsx b/docs/src/modules/components/JoyUsageDemo.tsx index 66cbde117ad9a1..4efb26dfaed35b 100644 --- a/docs/src/modules/components/JoyUsageDemo.tsx +++ b/docs/src/modules/components/JoyUsageDemo.tsx @@ -267,13 +267,13 @@ export default function JoyUsageDemo({ if (knob === 'switch') { return ( {propName} setProps((latestProps) => ({ diff --git a/packages/mui-joy/src/Checkbox/Checkbox.tsx b/packages/mui-joy/src/Checkbox/Checkbox.tsx index 568404d8643797..dd256320f28083 100644 --- a/packages/mui-joy/src/Checkbox/Checkbox.tsx +++ b/packages/mui-joy/src/Checkbox/Checkbox.tsx @@ -226,7 +226,7 @@ const Checkbox = React.forwardRef(function Checkbox(inProps, ref) { }, [registerEffect]); } - const id = useId(idOverride); + const id = useId(idOverride ?? formControl?.htmlFor); const useCheckboxProps = { checked: checkedProp, diff --git a/packages/mui-joy/src/FormControl/FormControl.test.tsx b/packages/mui-joy/src/FormControl/FormControl.test.tsx index d128d1239514b7..86b743fe277fdd 100644 --- a/packages/mui-joy/src/FormControl/FormControl.test.tsx +++ b/packages/mui-joy/src/FormControl/FormControl.test.tsx @@ -11,6 +11,7 @@ import Input, { inputClasses } from '@mui/joy/Input'; import Select, { selectClasses } from '@mui/joy/Select'; import Textarea, { textareaClasses } from '@mui/joy/Textarea'; import RadioGroup from '@mui/joy/RadioGroup'; +import Radio, { radioClasses } from '@mui/joy/Radio'; import Switch, { switchClasses } from '@mui/joy/Switch'; describe('', () => { @@ -184,10 +185,11 @@ describe('', () => { }); describe('Checkbox', () => { - it('should linked the helper text', () => { + it('should linked the label and helper text', () => { const { getByLabelText, getByText } = render( - + label + helper text , ); @@ -246,6 +248,73 @@ describe('', () => { expect(getByRole('radiogroup')).to.have.attribute('aria-labelledby', label.id); expect(getByRole('radiogroup')).to.have.attribute('aria-describedby', helperText.id); }); + + it('works with radio buttons', () => { + const { getByLabelText, getByRole, getByText } = render( + + label + + + + helper text + , + ); + + const label = getByText('label'); + const helperText = getByText('helper text'); + + expect(getByRole('radio')).toBeVisible(); + expect(getByLabelText('label')).to.have.attribute('role', 'radiogroup'); + expect(getByRole('radiogroup')).to.have.attribute('aria-labelledby', label.id); + expect(getByRole('radiogroup')).to.have.attribute('aria-describedby', helperText.id); + }); + }); + + describe('Radio', () => { + it('should linked the label and helper text', () => { + const { getByLabelText, getByText } = render( + + label + + helper text + , + ); + + const helperText = getByText('helper text'); + + expect(getByLabelText('label')).to.have.attribute('aria-describedby', helperText.id); + }); + + it('should inherit color prop from FormControl', () => { + const { getByTestId } = render( + + + , + ); + + expect(getByTestId('radio')).to.have.class(radioClasses.colorSuccess); + }); + + it('should inherit error prop from FormControl', () => { + const { getByTestId } = render( + + + , + ); + + expect(getByTestId('radio')).to.have.class(radioClasses.colorDanger); + }); + + it('should inherit disabled from FormControl', () => { + const { getByLabelText, getByTestId } = render( + + + , + ); + + expect(getByTestId('radio')).to.have.class(radioClasses.disabled); + expect(getByLabelText('label')).to.have.attribute('disabled'); + }); }); describe('Switch', () => { diff --git a/packages/mui-joy/src/FormControl/FormControl.tsx b/packages/mui-joy/src/FormControl/FormControl.tsx index 0dd2850c92cc06..c1e797b5cbf7ff 100644 --- a/packages/mui-joy/src/FormControl/FormControl.tsx +++ b/packages/mui-joy/src/FormControl/FormControl.tsx @@ -31,7 +31,7 @@ export const FormControlRoot = styled('div', { overridesResolver: (props, styles) => styles.root, })<{ ownerState: FormControlOwnerState }>(({ theme, ownerState }) => ({ '--FormLabel-margin': - ownerState.orientation === 'horizontal' ? '0 0.375rem 0 0' : '0 0 0.375rem 0', + ownerState.orientation === 'horizontal' ? '0 0.375rem 0 0' : '0 0 0.25rem 0', '--FormHelperText-margin': '0.375rem 0 0 0', '--FormLabel-asterisk-color': theme.vars.palette.danger[500], '--FormHelperText-color': theme.vars.palette[ownerState.color!]?.[500], diff --git a/packages/mui-joy/src/FormHelperText/FormHelperText.tsx b/packages/mui-joy/src/FormHelperText/FormHelperText.tsx index 4cb6ab6fef38eb..a46f2aecb0e2ce 100644 --- a/packages/mui-joy/src/FormHelperText/FormHelperText.tsx +++ b/packages/mui-joy/src/FormHelperText/FormHelperText.tsx @@ -8,6 +8,7 @@ import { styled, useThemeProps } from '../styles'; import { FormHelperTextProps, FormHelperTextTypeMap } from './FormHelperTextProps'; import { getFormHelperTextUtilityClass } from './formHelperTextClasses'; import FormControlContext from '../FormControl/FormControlContext'; +import formLabelClasses from '../FormLabel/formLabelClasses'; const useUtilityClasses = () => { const slots = { @@ -29,6 +30,9 @@ const FormHelperTextRoot = styled('p', { lineHeight: theme.vars.lineHeight.sm, color: `var(--FormHelperText-color, ${theme.vars.palette.text.secondary})`, margin: 'var(--FormHelperText-margin, 0px)', + [`.${formLabelClasses.root} + &`]: { + '--FormHelperText-margin': '0px', // remove the margin if the helper text is next to the form label. + }, })); const FormHelperText = React.forwardRef(function FormHelperText(inProps, ref) { diff --git a/packages/mui-joy/src/Radio/Radio.tsx b/packages/mui-joy/src/Radio/Radio.tsx index 9a03c62989ee17..e64d10bfdf4e37 100644 --- a/packages/mui-joy/src/Radio/Radio.tsx +++ b/packages/mui-joy/src/Radio/Radio.tsx @@ -10,6 +10,7 @@ import radioClasses, { getRadioUtilityClass } from './radioClasses'; import { RadioOwnerState, RadioTypeMap } from './RadioProps'; import RadioGroupContext from '../RadioGroup/RadioGroupContext'; import { TypographyContext } from '../Typography/Typography'; +import FormControlContext from '../FormControl/FormControlContext'; const useUtilityClasses = (ownerState: RadioOwnerState) => { const { checked, disabled, disableIcon, focusVisible, color, variant, size } = ownerState; @@ -243,11 +244,30 @@ const Radio = React.forwardRef(function Radio(inProps, ref) { value, ...other } = props; - const id = useId(idOverride); + + const formControl = React.useContext(FormControlContext); + + if (process.env.NODE_ENV !== 'production') { + const registerEffect = formControl?.registerEffect; + // eslint-disable-next-line react-hooks/rules-of-hooks + React.useEffect(() => { + if (registerEffect) { + return registerEffect(); + } + + return undefined; + }, [registerEffect]); + } + + const id = useId(idOverride ?? formControl?.htmlFor); const radioGroup = React.useContext(RadioGroupContext); - const activeColor = color || 'primary'; - const inactiveColor = color || 'neutral'; - const size = inProps.size || radioGroup?.size || sizeProp; + const activeColor = formControl?.error + ? 'danger' + : inProps.color ?? formControl?.color ?? color ?? 'primary'; + const inactiveColor = formControl?.error + ? 'danger' + : inProps.color ?? formControl?.color ?? color ?? 'neutral'; + const size = inProps.size || formControl?.size || radioGroup?.size || sizeProp; const name = inProps.name || radioGroup?.name || nameProp; const disableIcon = inProps.disableIcon || radioGroup?.disableIcon || disableIconProp; const overlay = inProps.overlay || radioGroup?.overlay || overlayProp; @@ -259,7 +279,7 @@ const Radio = React.forwardRef(function Radio(inProps, ref) { const useRadioProps = { checked: radioChecked, defaultChecked, - disabled: disabledProp, + disabled: disabledProp ?? formControl?.disabled, onBlur, onChange, onFocus, @@ -326,6 +346,7 @@ const Radio = React.forwardRef(function Radio(inProps, ref) { id, name, value: String(value), + 'aria-describedby': formControl?.['aria-describedby'], }, ownerState, }); diff --git a/packages/mui-joy/src/RadioGroup/RadioGroup.tsx b/packages/mui-joy/src/RadioGroup/RadioGroup.tsx index 9559ed35591726..bf6cd58273f253 100644 --- a/packages/mui-joy/src/RadioGroup/RadioGroup.tsx +++ b/packages/mui-joy/src/RadioGroup/RadioGroup.tsx @@ -140,16 +140,18 @@ const RadioGroup = React.forwardRef(function RadioGroup(inProps, ref) { aria-describedby={formControl?.['aria-describedby']} {...other} > - {React.Children.map(children, (child, index) => - React.isValidElement(child) - ? React.cloneElement(child, { - // to let Radio knows when to apply margin(Inline|Block)Start - ...(index === 0 && { 'data-first-child': '' }), - ...(index === React.Children.count(children) - 1 && { 'data-last-child': '' }), - 'data-parent': 'RadioGroup', - } as Record) - : child, - )} + + {React.Children.map(children, (child, index) => + React.isValidElement(child) + ? React.cloneElement(child, { + // to let Radio knows when to apply margin(Inline|Block)Start + ...(index === 0 && { 'data-first-child': '' }), + ...(index === React.Children.count(children) - 1 && { 'data-last-child': '' }), + 'data-parent': 'RadioGroup', + } as Record) + : child, + )} + );