diff --git a/docs/data/api/menu-radio-group.json b/docs/data/api/menu-radio-group.json new file mode 100644 index 000000000..7c93dbc87 --- /dev/null +++ b/docs/data/api/menu-radio-group.json @@ -0,0 +1,23 @@ +{ + "props": { + "children": { "type": { "name": "node" } }, + "className": { "type": { "name": "union", "description": "func
| string" } }, + "defaultValue": { "type": { "name": "any" } }, + "onValueChange": { "type": { "name": "func" }, "default": "() => {}" }, + "render": { "type": { "name": "union", "description": "element
| func" } }, + "value": { "type": { "name": "any" } } + }, + "name": "MenuRadioGroup", + "imports": [ + "import * as Menu from '@base_ui/react/Menu';\nconst MenuRadioGroup = Menu.RadioGroup;" + ], + "classes": [], + "spread": true, + "themeDefaultProps": true, + "muiName": "MenuRadioGroup", + "forwardsRefTo": "HTMLDivElement", + "filename": "/packages/mui-base/src/Menu/RadioGroup/MenuRadioGroup.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/data/api/menu-radio-item-indicator.json b/docs/data/api/menu-radio-item-indicator.json new file mode 100644 index 000000000..9cd21ccb5 --- /dev/null +++ b/docs/data/api/menu-radio-item-indicator.json @@ -0,0 +1,20 @@ +{ + "props": { + "className": { "type": { "name": "union", "description": "func
| string" } }, + "keepMounted": { "type": { "name": "bool" }, "default": "true" }, + "render": { "type": { "name": "union", "description": "element
| func" } } + }, + "name": "MenuRadioItemIndicator", + "imports": [ + "import * as Menu from '@base_ui/react/Menu';\nconst MenuRadioItemIndicator = Menu.RadioItemIndicator;" + ], + "classes": [], + "spread": true, + "themeDefaultProps": true, + "muiName": "MenuRadioItemIndicator", + "forwardsRefTo": "HTMLSpanElement", + "filename": "/packages/mui-base/src/Menu/RadioItemIndicator/MenuRadioItemIndicator.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/data/api/menu-radio-item.json b/docs/data/api/menu-radio-item.json new file mode 100644 index 000000000..fc3ac0f34 --- /dev/null +++ b/docs/data/api/menu-radio-item.json @@ -0,0 +1,23 @@ +{ + "props": { + "value": { "type": { "name": "any" }, "required": true }, + "closeOnClick": { "type": { "name": "bool" }, "default": "true" }, + "disabled": { "type": { "name": "bool" }, "default": "false" }, + "id": { "type": { "name": "string" } }, + "label": { "type": { "name": "string" } }, + "onClick": { "type": { "name": "func" } } + }, + "name": "MenuRadioItem", + "imports": [ + "import * as Menu from '@base_ui/react/Menu';\nconst MenuRadioItem = Menu.RadioItem;" + ], + "classes": [], + "spread": true, + "themeDefaultProps": true, + "muiName": "MenuRadioItem", + "forwardsRefTo": "HTMLDivElement", + "filename": "/packages/mui-base/src/Menu/RadioItem/MenuRadioItem.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/data/components/menu/RadioItems.js b/docs/data/components/menu/RadioItems.js new file mode 100644 index 000000000..314d6f34d --- /dev/null +++ b/docs/data/components/menu/RadioItems.js @@ -0,0 +1,193 @@ +'use client'; +import * as React from 'react'; +import * as Menu from '@base_ui/react/Menu'; +import { styled } from '@mui/system'; + +export default function RadioItems() { + return ( + + Font + + + + + + Cascadia Code + + + + Consolas + + + + DejaVu Sans Mono + + + + Fira Code + + + + JetBrains Mono + + + + Menlo + + + + Monaco + + + + Monolisa + + + + Source Code Pro + + + + + + ); +} + +const blue = { + 50: '#F0F7FF', + 100: '#C2E0FF', + 200: '#99CCF3', + 300: '#66B2FF', + 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 MenuPopup = styled(Menu.Popup)( + ({ theme }) => ` + font-family: 'IBM Plex Sans', sans-serif; + font-size: 0.875rem; + box-sizing: border-box; + padding: 6px; + margin: 12px 0; + min-width: 200px; + border-radius: 12px; + overflow: auto; + outline: 0; + background: ${theme.palette.mode === 'dark' ? grey[900] : '#fff'}; + border: 1px solid ${theme.palette.mode === 'dark' ? grey[700] : grey[200]}; + color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]}; + box-shadow: 0px 4px 30px ${theme.palette.mode === 'dark' ? grey[900] : grey[200]}; + z-index: 1; + transform-origin: var(--transform-origin); + opacity: 0; + transform: scale(0.8); + transition: opacity 100ms ease-in, transform 100ms ease-in; + + &[data-menu='open'] { + opacity: 1; + transform: scale(1); + } + `, +); + +const RadioItem = styled(Menu.RadioItem)( + ({ theme }) => ` + list-style: none; + padding: 8px; + border-radius: 8px; + cursor: default; + user-select: none; + + &:last-of-type { + border-bottom: none; + } + + &:focus { + outline: 3px solid ${theme.palette.mode === 'dark' ? blue[600] : blue[200]}; + background-color: ${theme.palette.mode === 'dark' ? grey[800] : grey[100]}; + color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]}; + } + + &[data-disabled] { + color: ${theme.palette.mode === 'dark' ? grey[700] : grey[400]}; + } + `, +); + +const Indicator = styled(Menu.RadioItemIndicator)( + ({ theme }) => ` + display: inline-block; + width: 0.75rem; + height: 0.75rem; + border: 1px solid; + vertical-align: baseline; + margin-right: 8px; + border-color: ${theme.palette.mode === 'dark' ? grey[800] : grey[700]}; + box-sizing: border-box; + border-radius: 50%; + + &[data-radioitem=checked] { + background: ${theme.palette.mode === 'dark' ? grey[800] : grey[700]}; + box-shadow: 0 0 0 2px ${theme.palette.mode === 'dark' ? grey[900] : '#fff'} inset; + } + `, +); + +const MenuButton = styled(Menu.Trigger)( + ({ theme }) => ` + font-family: 'IBM Plex Sans', sans-serif; + font-weight: 600; + font-size: 0.875rem; + line-height: 1.5; + padding: 8px 16px; + border-radius: 8px; + color: white; + transition: all 150ms ease; + cursor: pointer; + background: ${theme.palette.mode === 'dark' ? grey[900] : '#fff'}; + border: 1px solid ${theme.palette.mode === 'dark' ? grey[700] : grey[200]}; + color: ${theme.palette.mode === 'dark' ? grey[200] : grey[900]}; + box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); + + &:hover { + background: ${theme.palette.mode === 'dark' ? grey[800] : grey[50]}; + border-color: ${theme.palette.mode === 'dark' ? grey[600] : grey[300]}; + } + + &:active { + background: ${theme.palette.mode === 'dark' ? grey[700] : grey[100]}; + } + + &:focus-visible { + box-shadow: 0 0 0 4px ${theme.palette.mode === 'dark' ? blue[300] : blue[200]}; + outline: none; + } + `, +); + +const MenuPositioner = styled(Menu.Positioner)` + &:focus-visible { + outline: 0; + } + + &[data-menu='closed'] { + pointer-events: none; + } +`; diff --git a/docs/data/components/menu/RadioItems.tsx b/docs/data/components/menu/RadioItems.tsx new file mode 100644 index 000000000..314d6f34d --- /dev/null +++ b/docs/data/components/menu/RadioItems.tsx @@ -0,0 +1,193 @@ +'use client'; +import * as React from 'react'; +import * as Menu from '@base_ui/react/Menu'; +import { styled } from '@mui/system'; + +export default function RadioItems() { + return ( + + Font + + + + + + Cascadia Code + + + + Consolas + + + + DejaVu Sans Mono + + + + Fira Code + + + + JetBrains Mono + + + + Menlo + + + + Monaco + + + + Monolisa + + + + Source Code Pro + + + + + + ); +} + +const blue = { + 50: '#F0F7FF', + 100: '#C2E0FF', + 200: '#99CCF3', + 300: '#66B2FF', + 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 MenuPopup = styled(Menu.Popup)( + ({ theme }) => ` + font-family: 'IBM Plex Sans', sans-serif; + font-size: 0.875rem; + box-sizing: border-box; + padding: 6px; + margin: 12px 0; + min-width: 200px; + border-radius: 12px; + overflow: auto; + outline: 0; + background: ${theme.palette.mode === 'dark' ? grey[900] : '#fff'}; + border: 1px solid ${theme.palette.mode === 'dark' ? grey[700] : grey[200]}; + color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]}; + box-shadow: 0px 4px 30px ${theme.palette.mode === 'dark' ? grey[900] : grey[200]}; + z-index: 1; + transform-origin: var(--transform-origin); + opacity: 0; + transform: scale(0.8); + transition: opacity 100ms ease-in, transform 100ms ease-in; + + &[data-menu='open'] { + opacity: 1; + transform: scale(1); + } + `, +); + +const RadioItem = styled(Menu.RadioItem)( + ({ theme }) => ` + list-style: none; + padding: 8px; + border-radius: 8px; + cursor: default; + user-select: none; + + &:last-of-type { + border-bottom: none; + } + + &:focus { + outline: 3px solid ${theme.palette.mode === 'dark' ? blue[600] : blue[200]}; + background-color: ${theme.palette.mode === 'dark' ? grey[800] : grey[100]}; + color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]}; + } + + &[data-disabled] { + color: ${theme.palette.mode === 'dark' ? grey[700] : grey[400]}; + } + `, +); + +const Indicator = styled(Menu.RadioItemIndicator)( + ({ theme }) => ` + display: inline-block; + width: 0.75rem; + height: 0.75rem; + border: 1px solid; + vertical-align: baseline; + margin-right: 8px; + border-color: ${theme.palette.mode === 'dark' ? grey[800] : grey[700]}; + box-sizing: border-box; + border-radius: 50%; + + &[data-radioitem=checked] { + background: ${theme.palette.mode === 'dark' ? grey[800] : grey[700]}; + box-shadow: 0 0 0 2px ${theme.palette.mode === 'dark' ? grey[900] : '#fff'} inset; + } + `, +); + +const MenuButton = styled(Menu.Trigger)( + ({ theme }) => ` + font-family: 'IBM Plex Sans', sans-serif; + font-weight: 600; + font-size: 0.875rem; + line-height: 1.5; + padding: 8px 16px; + border-radius: 8px; + color: white; + transition: all 150ms ease; + cursor: pointer; + background: ${theme.palette.mode === 'dark' ? grey[900] : '#fff'}; + border: 1px solid ${theme.palette.mode === 'dark' ? grey[700] : grey[200]}; + color: ${theme.palette.mode === 'dark' ? grey[200] : grey[900]}; + box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); + + &:hover { + background: ${theme.palette.mode === 'dark' ? grey[800] : grey[50]}; + border-color: ${theme.palette.mode === 'dark' ? grey[600] : grey[300]}; + } + + &:active { + background: ${theme.palette.mode === 'dark' ? grey[700] : grey[100]}; + } + + &:focus-visible { + box-shadow: 0 0 0 4px ${theme.palette.mode === 'dark' ? blue[300] : blue[200]}; + outline: none; + } + `, +); + +const MenuPositioner = styled(Menu.Positioner)` + &:focus-visible { + outline: 0; + } + + &[data-menu='closed'] { + pointer-events: none; + } +`; diff --git a/docs/data/components/menu/menu.mdx b/docs/data/components/menu/menu.mdx index 4ee2dbb69..b3417f5df 100644 --- a/docs/data/components/menu/menu.mdx +++ b/docs/data/components/menu/menu.mdx @@ -2,7 +2,7 @@ productId: base-ui title: React Menu component description: The Menu component provide end users with a list of options on temporary surfaces. -components: MenuItem, MenuPositioner, MenuPopup, MenuRoot, MenuTrigger, SubmenuTrigger, MenuArrow +components: MenuItem, MenuPositioner, MenuPopup, MenuRoot, MenuTrigger, SubmenuTrigger, MenuArrow, MenuRadioGroup, MenuRadioItem, MenuRadioItemIndicator githubLabel: 'component: menu' waiAria: https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/ --- @@ -30,6 +30,9 @@ Menus are implemented using a collection of related components: - `` is the menu item. - `` renders an optional pointing arrow, placed inside the popup. - `` is a menu item that opens a submenu. See [Nested menu](#nested-menu) for more details. +- `` is a menu item that acts as a radio button. See [Radio items](#radio-items) for more details. +- `` groups RadioItems together. Only one RadioItem in a group can be selected at a time. +- `` is a visual indicator for the selected RadioItem. ```tsx @@ -141,6 +144,39 @@ To change how long the menu waits until it opens or closes when `openOnHover` is ``` +## Radio items + +Menu items can be used as radio buttons. To group them together, use the `Menu.RadioGroup` component: + + + + + If you rely on the RadioItem to manage its state (e.g., you use the `defaultChecked` and `onCheckedChange` props), ensure that the item is not unmounted when its parent menu is closed. + Unmounting the component resets its state. + +To do this, add the `keepMounted` prop to the `Menu.Positioner` the checkbox item is in (and all parent positioners, in the case of a nested menu): + +```jsx + + + + + + Light + + + Dark + + + + + +``` + +If you keep the state externally (and use the `checked` prop), this isn't required. + + + ## Nested menu Menu items can open submenus. diff --git a/docs/data/translations/api-docs/menu-radio-group/menu-radio-group.json b/docs/data/translations/api-docs/menu-radio-group/menu-radio-group.json new file mode 100644 index 000000000..150d54171 --- /dev/null +++ b/docs/data/translations/api-docs/menu-radio-group/menu-radio-group.json @@ -0,0 +1,16 @@ +{ + "componentDescription": "", + "propDescriptions": { + "children": { "description": "The content of the component." }, + "className": { + "description": "Class names applied to the element or a function that returns them based on the component's state." + }, + "defaultValue": { + "description": "The default value of the selected radio button. This is the uncontrolled equivalent of value." + }, + "onValueChange": { "description": "Function called when the selected value changes." }, + "render": { "description": "A function to customize rendering of the component." }, + "value": { "description": "The value of the selected radio button." } + }, + "classDescriptions": {} +} diff --git a/docs/data/translations/api-docs/menu-radio-item-indicator/menu-radio-item-indicator.json b/docs/data/translations/api-docs/menu-radio-item-indicator/menu-radio-item-indicator.json new file mode 100644 index 000000000..35acde828 --- /dev/null +++ b/docs/data/translations/api-docs/menu-radio-item-indicator/menu-radio-item-indicator.json @@ -0,0 +1,13 @@ +{ + "componentDescription": "", + "propDescriptions": { + "className": { + "description": "Class names applied to the element or a function that returns them based on the component's state." + }, + "keepMounted": { + "description": "If true, the component is mounted even if the Radio is not checked." + }, + "render": { "description": "A function to customize rendering of the component." } + }, + "classDescriptions": {} +} diff --git a/docs/data/translations/api-docs/menu-radio-item/menu-radio-item.json b/docs/data/translations/api-docs/menu-radio-item/menu-radio-item.json new file mode 100644 index 000000000..49a3980c9 --- /dev/null +++ b/docs/data/translations/api-docs/menu-radio-item/menu-radio-item.json @@ -0,0 +1,18 @@ +{ + "componentDescription": "An unstyled menu item to be used within a Menu.", + "propDescriptions": { + "closeOnClick": { + "description": "If true, the menu will close when the menu item is clicked." + }, + "disabled": { "description": "If true, the menu item will be disabled." }, + "id": { "description": "The id of the menu item." }, + "label": { + "description": "A text representation of the menu item's content. Used for keyboard text navigation matching." + }, + "onClick": { "description": "The click handler for the menu item." }, + "value": { + "description": "Value of the radio item. This is the value that will be set in the MenuRadioGroup when the item is selected." + } + }, + "classDescriptions": {} +} diff --git a/packages/mui-base/src/Menu/Item/useMenuItem.ts b/packages/mui-base/src/Menu/Item/useMenuItem.ts index 42f77ebdb..afc2e29f2 100644 --- a/packages/mui-base/src/Menu/Item/useMenuItem.ts +++ b/packages/mui-base/src/Menu/Item/useMenuItem.ts @@ -37,7 +37,14 @@ export function useMenuItem(params: useMenuItem.Parameters): useMenuItem.ReturnV event.defaultMuiPrevented = true; } }, - onClick: (event: React.MouseEvent) => { + onClick: (event: React.MouseEvent | React.KeyboardEvent) => { + if (event.type === 'keydown') { + if ((event as React.KeyboardEvent).key === 'Enter') { + menuEvents.emit('close', event); + return; + } + } + if (closeOnClick) { menuEvents.emit('close', event); } diff --git a/packages/mui-base/src/Menu/Positioner/MenuPositioner.tsx b/packages/mui-base/src/Menu/Positioner/MenuPositioner.tsx index 4d6a1a5f7..5dd0f6c96 100644 --- a/packages/mui-base/src/Menu/Positioner/MenuPositioner.tsx +++ b/packages/mui-base/src/Menu/Positioner/MenuPositioner.tsx @@ -141,7 +141,7 @@ const MenuPositioner = React.forwardRef(function MenuPositioner( modal={false} initialFocus={nested ? -1 : 0} returnFocus - key={mounted.toString()} + disabled={!mounted} > {renderElement()} diff --git a/packages/mui-base/src/Menu/Positioner/useMenuPositioner.ts b/packages/mui-base/src/Menu/Positioner/useMenuPositioner.ts index a8d133770..02c1890eb 100644 --- a/packages/mui-base/src/Menu/Positioner/useMenuPositioner.ts +++ b/packages/mui-base/src/Menu/Positioner/useMenuPositioner.ts @@ -43,6 +43,7 @@ export function useMenuPositioner( zIndex: 2147483647, // max z-index }, 'aria-hidden': !open || undefined, + inert: !open ? '' : undefined, }); }, [positionerStyles, open, keepMounted, hidden], diff --git a/packages/mui-base/src/Menu/RadioGroup/MenuRadioGroup.test.tsx b/packages/mui-base/src/Menu/RadioGroup/MenuRadioGroup.test.tsx new file mode 100644 index 000000000..0116f9c17 --- /dev/null +++ b/packages/mui-base/src/Menu/RadioGroup/MenuRadioGroup.test.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; +import { expect } from 'chai'; +import * as Menu from '@base_ui/react/Menu'; +import { createRenderer, describeConformance } from '#test-utils'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + render, + refInstanceof: window.HTMLDivElement, + })); + + it('renders a div with the `group` role', async () => { + const { getByRole } = await render(); + expect(getByRole('group')).toBeVisible(); + }); +}); diff --git a/packages/mui-base/src/Menu/RadioGroup/MenuRadioGroup.tsx b/packages/mui-base/src/Menu/RadioGroup/MenuRadioGroup.tsx new file mode 100644 index 000000000..53d8dedd0 --- /dev/null +++ b/packages/mui-base/src/Menu/RadioGroup/MenuRadioGroup.tsx @@ -0,0 +1,139 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { MenuRadioGroupContext } from './MenuRadioGroupContext'; +import { BaseUIComponentProps } from '../../utils/types'; +import { useComponentRenderer } from '../../utils/useComponentRenderer'; +import { useControlled } from '../../utils/useControlled'; +import { useEventCallback } from '../../utils/useEventCallback'; + +const EMPTY_OBJECT = {}; +const NOOP = () => {}; + +const MenuRadioGroup = React.forwardRef(function MenuRadioGroup( + props: MenuRadioGroup.Props, + forwardedRef: React.ForwardedRef, +) { + const { + render, + className, + value: valueProp, + defaultValue, + onValueChange: onValueChangeProp = NOOP, + ...other + } = props; + + const [value, setValueUnwrapped] = useControlled({ + controlled: valueProp, + default: defaultValue, + name: 'MenuRadioGroup', + }); + + const onValueChange = useEventCallback(onValueChangeProp); + + const setValue = React.useCallback( + (newValue: any, event: Event) => { + setValueUnwrapped(newValue); + onValueChange?.(newValue, event); + }, + [onValueChange, setValueUnwrapped], + ); + + const { renderElement } = useComponentRenderer({ + render: render || 'div', + className, + ownerState: EMPTY_OBJECT, + extraProps: { + role: 'group', + ...other, + }, + ref: forwardedRef, + }); + + const context = React.useMemo( + () => ({ + value, + setValue, + }), + [value, setValue], + ); + + return ( + + {renderElement()} + + ); +}); + +namespace MenuRadioGroup { + export interface Props extends BaseUIComponentProps<'div', OwnerState> { + /** + * The content of the component. + */ + children?: React.ReactNode; + /** + * The value of the selected radio button. + */ + value?: any; + /** + * The default value of the selected radio button. + * This is the uncontrolled equivalent of `value`. + */ + defaultValue?: any; + /** + * Function called when the selected value changes. + * + * @default () => {} + */ + onValueChange?: (newValue: any, event: Event) => void; + } + + export type OwnerState = {}; +} + +MenuRadioGroup.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * The content of the component. + */ + 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. + * This is the uncontrolled equivalent of `value`. + */ + defaultValue: PropTypes.any, + /** + * Function called when the selected value changes. + * + * @default () => {} + */ + onValueChange: PropTypes.func, + /** + * A function to customize rendering of the component. + */ + render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), + /** + * The value of the selected radio button. + */ + value: PropTypes.any, +} as any; + +const MemoizedMenuRadioGroup = React.memo(MenuRadioGroup); + +/** + * + * Demos: + * + * - [Menu](https://base-ui.netlify.app/components/react-menu/) + * + * API: + * + * - [MenuRadioGroup API](https://base-ui.netlify.app/components/react-menu/#api-reference-MenuRadioGroup) + */ +export { MemoizedMenuRadioGroup as MenuRadioGroup }; diff --git a/packages/mui-base/src/Menu/RadioGroup/MenuRadioGroupContext.ts b/packages/mui-base/src/Menu/RadioGroup/MenuRadioGroupContext.ts new file mode 100644 index 000000000..10ac8dc81 --- /dev/null +++ b/packages/mui-base/src/Menu/RadioGroup/MenuRadioGroupContext.ts @@ -0,0 +1,21 @@ +import * as React from 'react'; + +export interface MenuRadioGroupContext { + value: any; + setValue: (newValue: any, event: Event) => void; +} + +export const MenuRadioGroupContext = React.createContext(null); + +if (process.env.NODE_ENV !== 'production') { + MenuRadioGroupContext.displayName = 'MenuRadioGroupContext'; +} + +export function useMenuRadioGroupContext() { + const context = React.useContext(MenuRadioGroupContext); + if (context === null) { + throw new Error('useMenuRadioGroupContext must be used within a MenuRadioGroup'); + } + + return context; +} diff --git a/packages/mui-base/src/Menu/RadioItem/MenuRadioItem.test.tsx b/packages/mui-base/src/Menu/RadioItem/MenuRadioItem.test.tsx new file mode 100644 index 000000000..fc08c1c87 --- /dev/null +++ b/packages/mui-base/src/Menu/RadioItem/MenuRadioItem.test.tsx @@ -0,0 +1,313 @@ +import * as React from 'react'; +import { expect } from 'chai'; +import { spy } from 'sinon'; +import userEvent from '@testing-library/user-event'; +import { fireEvent, act, waitFor } from '@mui/internal-test-utils'; +import { FloatingRootContext, FloatingTree } from '@floating-ui/react'; +import * as Menu from '@base_ui/react/Menu'; +import { MenuRootContext } from '@base_ui/react/Menu'; +import { describeConformance, createRenderer } from '#test-utils'; +import { MenuRadioGroupContext } from '../RadioGroup/MenuRadioGroupContext'; + +const testRootContext: MenuRootContext = { + floatingRootContext: {} as FloatingRootContext, + getPositionerProps: (p) => ({ ...p }), + getTriggerProps: (p) => ({ ...p }), + getItemProps: (p) => ({ ...p }), + parentContext: null, + nested: false, + triggerElement: null, + setTriggerElement: () => {}, + setPositionerElement: () => {}, + activeIndex: null, + disabled: false, + itemDomElements: { current: [] }, + itemLabels: { current: [] }, + open: true, + setOpen: () => {}, + clickAndDragEnabled: false, + setClickAndDragEnabled: () => {}, + popupRef: { current: null }, + mounted: true, + transitionStatus: undefined, + typingRef: { current: false }, +}; + +const testRadioGroupContext = { + value: '0', + setValue: () => {}, +}; + +describe('', () => { + const { render } = createRenderer(); + const user = userEvent.setup(); + + describeConformance(, () => ({ + render: (node) => { + return render( + + + + {node} + + + , + ); + }, + refInstanceof: window.HTMLDivElement, + })); + + it('perf: does not rerender menu items unnecessarily', async function test() { + if (/jsdom/.test(window.navigator.userAgent)) { + this.skip(); + } + + const renderItem1Spy = spy(); + const renderItem2Spy = spy(); + const renderItem3Spy = spy(); + const renderItem4Spy = spy(); + + const LoggingRoot = React.forwardRef(function LoggingRoot( + props: any & { renderSpy: () => void }, + ref: React.ForwardedRef, + ) { + const { renderSpy, ownerState, ...other } = props; + renderSpy(); + return
  • ; + }); + + const { getAllByRole } = await render( + + + + + } + id="item-1" + > + 1 + + } + id="item-2" + > + 2 + + } + id="item-3" + > + 3 + + } + id="item-4" + > + 4 + + + + + , + ); + + const menuItems = getAllByRole('menuitemradio'); + await act(() => { + menuItems[0].focus(); + }); + + renderItem1Spy.resetHistory(); + renderItem2Spy.resetHistory(); + renderItem3Spy.resetHistory(); + renderItem4Spy.resetHistory(); + + expect(renderItem1Spy.callCount).to.equal(0); + + fireEvent.keyDown(menuItems[0], { key: 'ArrowDown' }); // highlights '2' + + // React renders twice in strict mode, so we expect twice the number of spy calls + // Also, useButton's focusVisible polyfill causes an extra render when focus is gained/lost. + + await waitFor( + () => { + expect(renderItem1Spy.callCount).to.equal(4); // '1' rerenders as it loses highlight + }, + { timeout: 1000 }, + ); + + await waitFor( + () => { + expect(renderItem2Spy.callCount).to.equal(4); // '2' rerenders as it receives highlight + }, + { timeout: 1000 }, + ); + + // neither the highlighted nor the selected state of these options changed, + // so they don't need to rerender: + expect(renderItem3Spy.callCount).to.equal(0); + expect(renderItem4Spy.callCount).to.equal(0); + }); + + describe('state management', () => { + it('adds the state and ARIA attributes when selected', async () => { + const { getByRole } = await render( + + Open + + + + Item + + + + , + ); + + const trigger = getByRole('button', { name: 'Open' }); + await user.click(trigger); + + const item = getByRole('menuitemradio'); + await user.click(item); + + expect(item).to.have.attribute('aria-checked', 'true'); + expect(item).to.have.attribute('data-radioitem', 'checked'); + }); + + ['Space', 'Enter'].forEach((key) => { + it(`selects the item when ${key} is pressed`, async () => { + const { getByRole } = await render( + + Open + + + Item + + + , + ); + + const trigger = getByRole('button', { name: 'Open' }); + await act(() => { + trigger.focus(); + }); + await user.keyboard('[ArrowDown]'); + const item = getByRole('menuitemradio'); + + await waitFor(() => { + expect(item).toHaveFocus(); + }); + + await user.keyboard(`[${key}]`); + expect(item).to.have.attribute('data-radioitem', 'checked'); + }); + }); + + it('calls `onValueChange` when the item is clicked', async () => { + const onValueChange = spy(); + const { getByRole } = await render( + + Open + + + + Item + + + + , + ); + + const trigger = getByRole('button', { name: 'Open' }); + await user.click(trigger); + + const item = getByRole('menuitemradio'); + await user.click(item); + + expect(onValueChange.callCount).to.equal(1); + expect(onValueChange.lastCall.args[0]).to.equal(1); + }); + + it('keeps the state when closed and reopened', async () => { + const { getByRole } = await render( + + Open + + + + Item + + + + , + ); + + const trigger = getByRole('button', { name: 'Open' }); + await user.click(trigger); + + const item = getByRole('menuitemradio'); + await user.click(item); + + await user.click(trigger); + + await user.click(trigger); + + const itemAfterReopen = getByRole('menuitemradio'); + expect(itemAfterReopen).to.have.attribute('aria-checked', 'true'); + expect(itemAfterReopen).to.have.attribute('data-radioitem', 'checked'); + }); + }); + + describe('prop: closeOnClick', () => { + it('when `closeOnClick=true`, closes the menu when the item is clicked', async () => { + const { getByRole, queryByRole } = await render( + + Open + + + + + Item + + + + + , + ); + + const trigger = getByRole('button', { name: 'Open' }); + await user.click(trigger); + + const item = getByRole('menuitemradio'); + await user.click(item); + + expect(queryByRole('menu')).to.equal(null); + }); + + it('does not close the menu when the item is clicked by default', async () => { + const { getByRole, queryByRole } = await render( + + Open + + + + Item + + + + , + ); + + const trigger = getByRole('button', { name: 'Open' }); + await user.click(trigger); + + const item = getByRole('menuitemradio'); + await user.click(item); + + expect(queryByRole('menu')).not.to.equal(null); + }); + }); +}); diff --git a/packages/mui-base/src/Menu/RadioItem/MenuRadioItem.tsx b/packages/mui-base/src/Menu/RadioItem/MenuRadioItem.tsx new file mode 100644 index 000000000..d9fc70fa2 --- /dev/null +++ b/packages/mui-base/src/Menu/RadioItem/MenuRadioItem.tsx @@ -0,0 +1,224 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { FloatingEvents, useFloatingTree, useListItem } from '@floating-ui/react'; +import { useMenuRadioItem } from './useMenuRadioItem'; +import { useMenuRootContext } from '../Root/MenuRootContext'; +import { useComponentRenderer } from '../../utils/useComponentRenderer'; +import { CustomStyleHookMapping } from '../../utils/getStyleHookProps'; +import { useId } from '../../utils/useId'; +import type { BaseUIComponentProps, GenericHTMLProps } from '../../utils/types'; +import { useForkRef } from '../../utils/useForkRef'; +import { useMenuRadioGroupContext } from '../RadioGroup/MenuRadioGroupContext'; +import { MenuRadioItemContext } from './MenuRadioItemContext'; + +const customStyleHookMapping: CustomStyleHookMapping = { + checked: (value: boolean) => ({ 'data-radioitem': value ? 'checked' : 'unchecked' }), +}; + +const InnerMenuRadioItem = React.memo( + React.forwardRef(function InnerMenuItem( + props: InnerMenuRadioItemProps, + forwardedRef: React.ForwardedRef, + ) { + const { + checked, + setChecked, + className, + closeOnClick = false, + disabled = false, + highlighted, + id, + menuEvents, + propGetter, + render, + treatMouseupAsClick, + typingRef, + ...other + } = props; + + const { getRootProps } = useMenuRadioItem({ + checked, + setChecked, + closeOnClick, + disabled, + highlighted, + id, + menuEvents, + ref: forwardedRef, + treatMouseupAsClick, + typingRef, + }); + + const ownerState: MenuRadioItem.OwnerState = { disabled, highlighted, checked }; + + const { renderElement } = useComponentRenderer({ + render: render || 'div', + className, + ownerState, + propGetter: (externalProps) => propGetter(getRootProps(externalProps)), + customStyleHookMapping, + extraProps: other, + }); + + return renderElement(); + }), +); + +/** + * An unstyled menu item to be used within a Menu. + * + * Demos: + * + * - [Menu](https://base-ui.netlify.app/components/react-menu/) + * + * API: + * + * - [MenuRadioItem API](https://base-ui.netlify.app/components/react-menu/#api-reference-MenuRadioItem) + */ +const MenuRadioItem = React.forwardRef(function MenuRadioItem( + props: MenuRadioItem.Props, + forwardedRef: React.ForwardedRef, +) { + const { id: idProp, value, label, disabled = false, ...other } = props; + + const itemRef = React.useRef(null); + const listItem = useListItem({ label: label ?? itemRef.current?.innerText }); + const mergedRef = useForkRef(forwardedRef, listItem.ref, itemRef); + + const { getItemProps, activeIndex, clickAndDragEnabled, typingRef } = useMenuRootContext(); + const id = useId(idProp); + + const highlighted = listItem.index === activeIndex; + const { events: menuEvents } = useFloatingTree()!; + + const { value: selectedValue, setValue: setSelectedValue } = useMenuRadioGroupContext(); + + // This wrapper component is used as a performance optimization. + // MenuRadioItem reads the context and re-renders the actual MenuRadioItem + // only when it needs to. + + const checked = selectedValue === value; + + const setChecked = React.useCallback( + (event: Event) => { + setSelectedValue(value, event); + }, + [setSelectedValue, value], + ); + + const contextValue = React.useMemo( + () => ({ checked, highlighted, disabled }), + [checked, highlighted, disabled], + ); + + return ( + + + + ); +}); + +interface InnerMenuRadioItemProps extends Omit { + highlighted: boolean; + propGetter: (externalProps?: GenericHTMLProps) => GenericHTMLProps; + menuEvents: FloatingEvents; + treatMouseupAsClick: boolean; + checked: boolean; + setChecked: (event: Event) => void; + typingRef: React.RefObject; +} + +namespace MenuRadioItem { + export type OwnerState = { + disabled: boolean; + highlighted: boolean; + checked: boolean; + }; + + export interface Props extends BaseUIComponentProps<'div', OwnerState> { + /** + * Value of the radio item. + * This is the value that will be set in the MenuRadioGroup when the item is selected. + */ + value: any; + children?: React.ReactNode; + /** + * The click handler for the menu item. + */ + onClick?: React.MouseEventHandler; + /** + * If `true`, the menu item will be disabled. + * @default false + */ + disabled?: boolean; + /** + * A text representation of the menu item's content. + * Used for keyboard text navigation matching. + */ + label?: string; + /** + * The id of the menu item. + */ + id?: string; + /** + * If `true`, the menu will close when the menu item is clicked. + * + * @default true + */ + closeOnClick?: boolean; + } +} + +MenuRadioItem.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, + /** + * If `true`, the menu will close when the menu item is clicked. + * + * @default true + */ + closeOnClick: PropTypes.bool, + /** + * If `true`, the menu item will be disabled. + * @default false + */ + disabled: PropTypes.bool, + /** + * The id of the menu item. + */ + id: PropTypes.string, + /** + * A text representation of the menu item's content. + * Used for keyboard text navigation matching. + */ + label: PropTypes.string, + /** + * The click handler for the menu item. + */ + onClick: PropTypes.func, + /** + * Value of the radio item. + * This is the value that will be set in the MenuRadioGroup when the item is selected. + */ + value: PropTypes.any.isRequired, +} as any; + +export { MenuRadioItem }; diff --git a/packages/mui-base/src/Menu/RadioItem/MenuRadioItemContext.ts b/packages/mui-base/src/Menu/RadioItem/MenuRadioItemContext.ts new file mode 100644 index 000000000..760b3618d --- /dev/null +++ b/packages/mui-base/src/Menu/RadioItem/MenuRadioItemContext.ts @@ -0,0 +1,20 @@ +import * as React from 'react'; + +export interface MenuRadioItemContext { + checked: boolean; + highlighted: boolean; + disabled: boolean; +} + +export const MenuRadioItemContext = React.createContext( + undefined, +); + +export function useMenuRadioItemContext() { + const context = React.useContext(MenuRadioItemContext); + if (context === undefined) { + throw new Error('useMenuRadioItemContext must be used within a MenuRadioItemProvider'); + } + + return context; +} diff --git a/packages/mui-base/src/Menu/RadioItem/useMenuRadioItem.ts b/packages/mui-base/src/Menu/RadioItem/useMenuRadioItem.ts new file mode 100644 index 000000000..c897c7440 --- /dev/null +++ b/packages/mui-base/src/Menu/RadioItem/useMenuRadioItem.ts @@ -0,0 +1,50 @@ +import * as React from 'react'; +import { useMenuItem } from '../Item/useMenuItem'; +import { GenericHTMLProps } from '../../utils/types'; +import { mergeReactProps } from '../../utils/mergeReactProps'; + +/** + * + * API: + * + * - [useMenuRadioItem API](https://mui.com/base-ui/api/use-menu-radio-item/) + */ +export function useMenuRadioItem( + params: useMenuRadioItem.Parameters, +): useMenuRadioItem.ReturnValue { + const { checked, setChecked, ...other } = params; + + const { getRootProps: getMenuItemRootProps, ...menuItem } = useMenuItem(other); + + const getRootProps = React.useCallback( + (externalProps?: GenericHTMLProps): GenericHTMLProps => { + return getMenuItemRootProps( + mergeReactProps(externalProps, { + role: 'menuitemradio', + 'aria-checked': checked, + onClick: (event: React.MouseEvent) => { + setChecked(event.nativeEvent); + }, + }), + ); + }, + [checked, getMenuItemRootProps, setChecked], + ); + + return { + ...menuItem, + getRootProps, + checked, + }; +} + +export namespace useMenuRadioItem { + export interface Parameters extends useMenuItem.Parameters { + checked: boolean; + setChecked: (event: Event) => void; + } + + export interface ReturnValue extends useMenuItem.ReturnValue { + checked: boolean; + } +} diff --git a/packages/mui-base/src/Menu/RadioItemIndicator/MenuRadioItemIndicator.test.tsx b/packages/mui-base/src/Menu/RadioItemIndicator/MenuRadioItemIndicator.test.tsx new file mode 100644 index 000000000..023aa4437 --- /dev/null +++ b/packages/mui-base/src/Menu/RadioItemIndicator/MenuRadioItemIndicator.test.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; +import * as Menu from '@base_ui/react/Menu'; +import { createRenderer, describeConformance } from '#test-utils'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + refInstanceof: window.HTMLSpanElement, + render(node) { + return render( + + + + + {node} + + + + , + ); + }, + })); +}); diff --git a/packages/mui-base/src/Menu/RadioItemIndicator/MenuRadioItemIndicator.tsx b/packages/mui-base/src/Menu/RadioItemIndicator/MenuRadioItemIndicator.tsx new file mode 100644 index 000000000..2afe03d28 --- /dev/null +++ b/packages/mui-base/src/Menu/RadioItemIndicator/MenuRadioItemIndicator.tsx @@ -0,0 +1,89 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { MenuRadioItem } from '../RadioItem/MenuRadioItem'; +import { useMenuRadioItemContext } from '../RadioItem/MenuRadioItemContext'; +import { CustomStyleHookMapping } from '../../utils/getStyleHookProps'; +import { useComponentRenderer } from '../../utils/useComponentRenderer'; +import { BaseUIComponentProps } from '../../utils/types'; + +const customStyleHookMapping: CustomStyleHookMapping = { + checked: (value: boolean) => ({ 'data-radioitem': value ? 'checked' : 'unchecked' }), +}; + +/** + * + * Demos: + * + * - [Menu](https://base-ui.netlify.app/components/react-menu/) + * + * API: + * + * - [MenuRadioItemIndicator API](https://base-ui.netlify.app/components/react-menu/#api-reference-MenuRadioItemIndicator) + */ +const MenuRadioItemIndicator = React.forwardRef(function MenuRadioItemIndicatorComponent( + props: MenuRadioItemIndicator.Props, + forwardedRef: React.ForwardedRef, +) { + const { render, className, keepMounted = true, ...other } = props; + + const ownerState = useMenuRadioItemContext(); + + const { renderElement } = useComponentRenderer({ + render: render || 'span', + className, + ownerState, + customStyleHookMapping, + extraProps: other, + ref: forwardedRef, + }); + + if (!keepMounted && !ownerState.checked) { + return null; + } + + return renderElement(); +}); + +MenuRadioItemIndicator.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]), + /** + * If `true`, the component is mounted even if the Radio is not checked. + * + * @default true + */ + keepMounted: PropTypes.bool, + /** + * A function to customize rendering of the component. + */ + render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), +} as any; + +namespace MenuRadioItemIndicator { + export interface Props extends BaseUIComponentProps<'span', OwnerState> { + /** + * If `true`, the component is mounted even if the Radio is not checked. + * + * @default true + */ + keepMounted?: boolean; + } + + export interface OwnerState { + checked: boolean; + disabled: boolean; + highlighted: boolean; + } +} + +export { MenuRadioItemIndicator }; diff --git a/packages/mui-base/src/Menu/Root/MenuRoot.test.tsx b/packages/mui-base/src/Menu/Root/MenuRoot.test.tsx index ef290cc0b..b97d0dbdd 100644 --- a/packages/mui-base/src/Menu/Root/MenuRoot.test.tsx +++ b/packages/mui-base/src/Menu/Root/MenuRoot.test.tsx @@ -561,7 +561,12 @@ describe('', () => { const menuItem = getByRole('menuitem'); await user.click(menuItem); - expect(button).toHaveFocus(); + await waitFor( + () => { + expect(button).toHaveFocus(); + }, + { timeout: 1000 }, + ); }); }); diff --git a/packages/mui-base/src/Menu/index.ts b/packages/mui-base/src/Menu/index.ts index 33250fa38..11e63ca14 100644 --- a/packages/mui-base/src/Menu/index.ts +++ b/packages/mui-base/src/Menu/index.ts @@ -14,6 +14,13 @@ export { useMenuPositionerContext, } from './Positioner/MenuPositionerContext'; +export { MenuRadioGroup as RadioGroup } from './RadioGroup/MenuRadioGroup'; + +export { MenuRadioItem as RadioItem } from './RadioItem/MenuRadioItem'; +export { useMenuRadioItem } from './RadioItem/useMenuRadioItem'; + +export { MenuRadioItemIndicator as RadioItemIndicator } from './RadioItemIndicator/MenuRadioItemIndicator'; + export { MenuRoot as Root } from './Root/MenuRoot'; export { useMenuRoot } from './Root/useMenuRoot'; export { MenuRootContext, useMenuRootContext } from './Root/MenuRootContext';