diff --git a/docs/data/base/components/radio-group/UnstyledRadioGroupIntroduction/system/index.js b/docs/data/base/components/radio-group/UnstyledRadioGroupIntroduction/system/index.js new file mode 100644 index 0000000000..020f334beb --- /dev/null +++ b/docs/data/base/components/radio-group/UnstyledRadioGroupIntroduction/system/index.js @@ -0,0 +1,76 @@ +import * as React from 'react'; +import * as RadioGroup from '@base_ui/react/RadioGroup'; +import * as Radio from '@base_ui/react/Radio'; +import { styled } from '@mui/system'; + +export default function UnstyledRadioGroupIntroduction() { + return ( + + + + Light + + + + Medium + + + + Heavy + + + ); +} + +const grey = { + 100: '#E5EAF2', + 200: '#D8E0E9', + 300: '#CBD4E2', +}; + +const blue = { + 400: '#3399FF', + 600: '#0072E6', + 800: '#004C99', +}; + +const RadioItem = styled(Radio.Root)` + display: flex; + align-items: center; + padding: 8px 16px; + border-radius: 4px; + border: none; + background-color: ${grey[100]}; + color: black; + outline: none; + font-size: 16px; + cursor: default; + + &:hover { + background-color: ${grey[100]}; + } + + &:focus-visible { + outline: 2px solid ${blue[400]}; + outline-offset: 2px; + } + + &[data-radio='checked'] { + background-color: ${blue[600]}; + color: white; + } +`; + +const Indicator = styled(Radio.Indicator)` + border-radius: 50%; + width: 8px; + height: 8px; + margin-right: 8px; + outline: 1px solid black; + + &[data-radio='checked'] { + background-color: white; + border: none; + outline: none; + } +`; diff --git a/docs/data/base/components/radio-group/UnstyledRadioGroupIntroduction/system/index.tsx b/docs/data/base/components/radio-group/UnstyledRadioGroupIntroduction/system/index.tsx new file mode 100644 index 0000000000..020f334beb --- /dev/null +++ b/docs/data/base/components/radio-group/UnstyledRadioGroupIntroduction/system/index.tsx @@ -0,0 +1,76 @@ +import * as React from 'react'; +import * as RadioGroup from '@base_ui/react/RadioGroup'; +import * as Radio from '@base_ui/react/Radio'; +import { styled } from '@mui/system'; + +export default function UnstyledRadioGroupIntroduction() { + return ( + + + + Light + + + + Medium + + + + Heavy + + + ); +} + +const grey = { + 100: '#E5EAF2', + 200: '#D8E0E9', + 300: '#CBD4E2', +}; + +const blue = { + 400: '#3399FF', + 600: '#0072E6', + 800: '#004C99', +}; + +const RadioItem = styled(Radio.Root)` + display: flex; + align-items: center; + padding: 8px 16px; + border-radius: 4px; + border: none; + background-color: ${grey[100]}; + color: black; + outline: none; + font-size: 16px; + cursor: default; + + &:hover { + background-color: ${grey[100]}; + } + + &:focus-visible { + outline: 2px solid ${blue[400]}; + outline-offset: 2px; + } + + &[data-radio='checked'] { + background-color: ${blue[600]}; + color: white; + } +`; + +const Indicator = styled(Radio.Indicator)` + border-radius: 50%; + width: 8px; + height: 8px; + margin-right: 8px; + outline: 1px solid black; + + &[data-radio='checked'] { + background-color: white; + border: none; + outline: none; + } +`; diff --git a/docs/data/base/components/radio-group/UnstyledRadioGroupIntroduction/system/index.tsx.preview b/docs/data/base/components/radio-group/UnstyledRadioGroupIntroduction/system/index.tsx.preview new file mode 100644 index 0000000000..0547decebc --- /dev/null +++ b/docs/data/base/components/radio-group/UnstyledRadioGroupIntroduction/system/index.tsx.preview @@ -0,0 +1,14 @@ + + + + Light + + + + Medium + + + + Heavy + + \ No newline at end of file diff --git a/docs/data/base/components/radio-group/radio-group.md b/docs/data/base/components/radio-group/radio-group.md new file mode 100644 index 0000000000..097667ba8a --- /dev/null +++ b/docs/data/base/components/radio-group/radio-group.md @@ -0,0 +1,135 @@ +--- +productId: base-ui +title: React Radio Group component +components: RadioGroupRoot, RadioRoot, RadioIndicator +githubLabel: 'component: radio' +waiAria: https://www.w3.org/WAI/ARIA/apg/patterns/radio/ +--- + +# Radio Group + +

Radio Groups contain a set of checkable buttons where only one of the buttons can be checked at a time.

+ +{{"component": "@mui/docs/ComponentLinkHeader", "design": false}} + +{{"component": "modules/components/ComponentPageTabs.js"}} + +## Introduction + +{{"demo": "UnstyledRadioGroupIntroduction", "defaultCodeOpen": false, "bg": "gradient"}} + +## Installation + +Base UI components are all available as a single package. + + + +```bash npm +npm install @base_ui/react +``` + +```bash yarn +yarn add @base_ui/react +``` + +```bash pnpm +pnpm add @base_ui/react +``` + + + +Once you have the package installed, import the components. + +```ts +import * as RadioGroup from '@base_ui/react/RadioGroup'; +import * as Radio from '@base_ui/react/Radio'; +``` + +## Anatomy + +Radio Group is composed of a `Root` and `Radio` components: + +- `` is a top-level element that wraps the other components. +- `` renders an individual ` + , + ); + + const [radioA] = screen.getAllByRole('radio'); + const submitButton = screen.getByRole('button'); + + submitButton.click(); + + expect(stringifiedFormData).to.equal('group='); + + await act(() => { + radioA.click(); + }); + + submitButton.click(); + + expect(stringifiedFormData).to.equal('group=a'); + }); + + it('should automatically select radio upon navigation', async () => { + render( + + + + , + ); + + const a = screen.getByTestId('a'); + const b = screen.getByTestId('b'); + + act(() => { + a.focus(); + }); + + expect(a).to.have.attribute('aria-checked', 'false'); + + await user.keyboard('{ArrowDown}'); + + expect(a).to.have.attribute('aria-checked', 'false'); + + expect(b).toHaveFocus(); + expect(b).to.have.attribute('aria-checked', 'true'); + }); + + it('should manage arrow key navigation', async () => { + render( +
+
, + ); + + const a = screen.getByTestId('a'); + const b = screen.getByTestId('b'); + const c = screen.getByTestId('c'); + const after = screen.getByTestId('after'); + + act(() => { + a.focus(); + }); + + expect(a).toHaveFocus(); + + await user.keyboard('{ArrowDown}'); + + expect(b).toHaveFocus(); + + await user.keyboard('{ArrowDown}'); + + expect(c).toHaveFocus(); + + await user.keyboard('{ArrowDown}'); + + expect(a).toHaveFocus(); + + await user.keyboard('{ArrowUp}'); + + expect(c).toHaveFocus(); + + await user.keyboard('{ArrowUp}'); + + expect(b).toHaveFocus(); + + await user.keyboard('{ArrowUp}'); + + expect(a).toHaveFocus(); + + await user.keyboard('{ArrowLeft}'); + + expect(c).toHaveFocus(); + + await user.keyboard('{ArrowRight}'); + + expect(a).toHaveFocus(); + + await user.tab(); + + expect(after).toHaveFocus(); + + await user.tab({ shift: true }); + + expect(a).toHaveFocus(); + + await user.keyboard('{ArrowLeft}'); + + expect(c).toHaveFocus(); + + await user.tab({ shift: true }); + await user.tab(); + + expect(c).toHaveFocus(); + }); +}); diff --git a/packages/mui-base/src/RadioGroup/Root/RadioGroupRoot.tsx b/packages/mui-base/src/RadioGroup/Root/RadioGroupRoot.tsx new file mode 100644 index 0000000000..2fc5100a3d --- /dev/null +++ b/packages/mui-base/src/RadioGroup/Root/RadioGroupRoot.tsx @@ -0,0 +1,178 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import type { BaseUIComponentProps } from '../../utils/types'; +import { CompositeRoot } from '../../Composite/Root/CompositeRoot'; +import { useComponentRenderer } from '../../utils/useComponentRenderer'; +import { useEventCallback } from '../../utils/useEventCallback'; +import { useRadioGroupRoot } from './useRadioGroupRoot'; +import { RadioGroupRootContext } from './RadioGroupRootContext'; +import { useFieldRootContext } from '../../Field/Root/FieldRootContext'; + +const RadioGroupRoot = React.forwardRef(function RadioGroupRoot( + props: RadioGroupRoot.Props, + forwardedRef: React.ForwardedRef, +) { + const { + render, + className, + disabled: disabledProp, + readOnly, + required, + onValueChange: onValueChangeProp, + name, + ...otherProps + } = props; + + const { getRootProps, getInputProps, checkedValue, setCheckedValue, touched, setTouched } = + useRadioGroupRoot(props); + + const { ownerState: fieldOwnerState, disabled: fieldDisabled } = useFieldRootContext(); + + const disabled = fieldDisabled || disabledProp; + + const onValueChange = useEventCallback(onValueChangeProp ?? (() => {})); + + const ownerState: RadioGroupRoot.OwnerState = React.useMemo( + () => ({ + ...fieldOwnerState, + disabled: disabled ?? false, + required: required ?? false, + readOnly: readOnly ?? false, + }), + [fieldOwnerState, disabled, readOnly, required], + ); + + const contextValue: RadioGroupRootContext = React.useMemo( + () => ({ + checkedValue, + setCheckedValue, + onValueChange, + disabled, + readOnly, + required, + touched, + setTouched, + }), + [ + checkedValue, + setCheckedValue, + onValueChange, + disabled, + readOnly, + required, + touched, + setTouched, + ], + ); + + const { renderElement } = useComponentRenderer({ + propGetter: getRootProps, + render: render ?? 'div', + ref: forwardedRef, + className, + ownerState, + extraProps: otherProps, + }); + + return ( + + + + + ); +}); + +namespace RadioGroupRoot { + export interface OwnerState { + disabled: boolean | undefined; + readOnly: boolean | undefined; + } + + export interface Props + extends Omit, 'value' | 'defaultValue'> { + /** + * Determines if the radio group is disabled. + * @default false + */ + disabled?: boolean; + /** + * Determines if the radio group is readonly. + * @default false + */ + readOnly?: boolean; + /** + * Determines if the radio group is required. + * @default false + */ + required?: boolean; + /** + * The name of the radio group submitted with the form data. + */ + name?: string; + /** + * The value of the selected radio button. Use when controlled. + */ + value?: unknown; + /** + * The default value of the selected radio button. Use when uncontrolled. + */ + defaultValue?: unknown; + /** + * Callback fired when the value changes. + */ + onValueChange?: (value: unknown, event: React.ChangeEvent) => void; + } +} + +RadioGroupRoot.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * @ignore + */ + children: PropTypes.node, + /** + * 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]), + /** + * The default value of the selected radio button. Use when uncontrolled. + */ + defaultValue: PropTypes.any, + /** + * Determines if the radio group is disabled. + * @default false + */ + disabled: PropTypes.bool, + /** + * The name of the radio group submitted with the form data. + */ + name: PropTypes.string, + /** + * Callback fired when the value changes. + */ + onValueChange: PropTypes.func, + /** + * Determines if the radio group is readonly. + * @default false + */ + readOnly: PropTypes.bool, + /** + * A function to customize rendering of the component. + */ + render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), + /** + * Determines if the radio group is required. + * @default false + */ + required: PropTypes.bool, + /** + * The value of the selected radio button. Use when controlled. + */ + value: PropTypes.any, +} as any; + +export { RadioGroupRoot }; diff --git a/packages/mui-base/src/RadioGroup/Root/RadioGroupRootContext.ts b/packages/mui-base/src/RadioGroup/Root/RadioGroupRootContext.ts new file mode 100644 index 0000000000..2aef2556ed --- /dev/null +++ b/packages/mui-base/src/RadioGroup/Root/RadioGroupRootContext.ts @@ -0,0 +1,29 @@ +'use client'; +import * as React from 'react'; +import { NOOP } from '../../utils/noop'; + +export interface RadioGroupRootContext { + disabled: boolean | undefined; + readOnly: boolean | undefined; + required: boolean | undefined; + checkedValue: unknown; + setCheckedValue: React.Dispatch>; + onValueChange: (value: unknown, event: React.ChangeEvent) => void; + touched: boolean; + setTouched: React.Dispatch>; +} + +export const RadioGroupRootContext = React.createContext({ + disabled: undefined, + readOnly: undefined, + required: undefined, + checkedValue: '', + setCheckedValue: NOOP, + onValueChange: NOOP, + touched: false, + setTouched: NOOP, +}); + +export function useRadioGroupRootContext() { + return React.useContext(RadioGroupRootContext); +} diff --git a/packages/mui-base/src/RadioGroup/Root/useRadioGroupRoot.ts b/packages/mui-base/src/RadioGroup/Root/useRadioGroupRoot.ts new file mode 100644 index 0000000000..667da5542c --- /dev/null +++ b/packages/mui-base/src/RadioGroup/Root/useRadioGroupRoot.ts @@ -0,0 +1,147 @@ +import * as React from 'react'; +import { contains } from '@floating-ui/react/utils'; +import { mergeReactProps } from '../../utils/mergeReactProps'; +import { useControlled } from '../../utils/useControlled'; +import { useFieldRootContext } from '../../Field/Root/FieldRootContext'; +import { useEnhancedEffect } from '../../utils/useEnhancedEffect'; +import { useId } from '../../utils/useId'; +import { useFieldControlValidation } from '../../Field/Control/useFieldControlValidation'; + +/** + * + * API: + * + * - [useRadioGroupRoot API](https://mui.com/base-ui/api/use-radio-group-root/) + */ +export function useRadioGroupRoot(params: useRadioGroupRoot.Parameters) { + const { disabled = false, name, defaultValue, readOnly, value: externalValue } = params; + + const { + labelId, + setDisabled, + setControlId, + setTouched: setFieldTouched, + validityData, + setValidityData, + } = useFieldRootContext(); + + const { + getValidationProps, + getInputValidationProps, + inputRef: inputValidationRef, + commitValidation, + } = useFieldControlValidation(); + + useEnhancedEffect(() => { + setDisabled(disabled); + }, [disabled, setDisabled]); + + const id = useId(); + + useEnhancedEffect(() => { + setControlId(id); + return () => { + setControlId(undefined); + }; + }, [id, setControlId]); + + const [checkedValue, setCheckedValue] = useControlled({ + controlled: externalValue, + default: defaultValue, + name: 'RadioGroup', + state: 'value', + }); + + useEnhancedEffect(() => { + if (validityData.initialValue === null && checkedValue !== validityData.initialValue) { + setValidityData((prev) => ({ ...prev, initialValue: checkedValue })); + } + }, [checkedValue, setValidityData, validityData.initialValue]); + + const [touched, setTouched] = React.useState(false); + + const getRootProps = React.useCallback( + (externalProps = {}) => + mergeReactProps<'div'>(getValidationProps(externalProps), { + role: 'radiogroup', + 'aria-disabled': disabled || undefined, + 'aria-readonly': readOnly || undefined, + 'aria-labelledby': labelId, + onBlur(event) { + if (!contains(event.currentTarget, event.relatedTarget)) { + setFieldTouched(true); + commitValidation(checkedValue); + } + }, + onKeyDownCapture(event) { + if (event.key.startsWith('Arrow')) { + setFieldTouched(true); + setTouched(true); + } + }, + }), + [ + checkedValue, + commitValidation, + disabled, + getValidationProps, + labelId, + readOnly, + setFieldTouched, + ], + ); + + const serializedCheckedValue = React.useMemo(() => { + if (checkedValue == null) { + return ''; // avoid uncontrolled -> controlled error + } + if (typeof checkedValue === 'string') { + return checkedValue; + } + return JSON.stringify(checkedValue); + }, [checkedValue]); + + const getInputProps = React.useCallback( + (externalProps = {}) => + mergeReactProps(getInputValidationProps(externalProps), { + type: 'hidden', + value: serializedCheckedValue, + ref: inputValidationRef, + id, + name, + disabled, + readOnly, + }), + [ + getInputValidationProps, + serializedCheckedValue, + inputValidationRef, + id, + name, + disabled, + readOnly, + ], + ); + + return React.useMemo( + () => ({ + getRootProps, + getInputProps, + checkedValue, + setCheckedValue, + touched, + setTouched, + }), + [getRootProps, getInputProps, checkedValue, setCheckedValue, touched], + ); +} + +namespace useRadioGroupRoot { + export interface Parameters { + name?: string; + disabled?: boolean; + readOnly?: boolean; + defaultValue?: unknown; + value?: unknown; + } +} diff --git a/packages/mui-base/src/RadioGroup/index.barrel.ts b/packages/mui-base/src/RadioGroup/index.barrel.ts new file mode 100644 index 0000000000..3d7b2c9369 --- /dev/null +++ b/packages/mui-base/src/RadioGroup/index.barrel.ts @@ -0,0 +1 @@ +export * from './Root/RadioGroupRoot'; diff --git a/packages/mui-base/src/RadioGroup/index.ts b/packages/mui-base/src/RadioGroup/index.ts new file mode 100644 index 0000000000..f78d654570 --- /dev/null +++ b/packages/mui-base/src/RadioGroup/index.ts @@ -0,0 +1 @@ +export { RadioGroupRoot as Root } from './Root/RadioGroupRoot'; diff --git a/packages/mui-base/src/utils/noop.ts b/packages/mui-base/src/utils/noop.ts new file mode 100644 index 0000000000..7da8f11910 --- /dev/null +++ b/packages/mui-base/src/utils/noop.ts @@ -0,0 +1 @@ +export const NOOP = () => {};