From 6fb355ed4b52518aa641a7c27663e5253f5068ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dudak?= Date: Tue, 23 Jul 2024 15:30:53 +0200 Subject: [PATCH 01/10] [Menu] RadioGroup --- docs/data/base/components/menu/RadioItems.js | 156 +++++++++ docs/data/base/components/menu/RadioItems.tsx | 156 +++++++++ docs/data/base/components/menu/menu.md | 4 + .../Menu/RadioGroup/MenuRadioGroup.test.tsx | 18 ++ .../src/Menu/RadioGroup/MenuRadioGroup.tsx | 90 ++++++ .../Menu/RadioGroup/MenuRadioGroupContext.ts | 21 ++ .../src/Menu/RadioItem/MenuRadioItem.test.tsx | 299 ++++++++++++++++++ .../src/Menu/RadioItem/MenuRadioItem.tsx | 213 +++++++++++++ .../src/Menu/RadioItem/useMenuRadioItem.ts | 50 +++ packages/mui-base/src/Menu/index.ts | 5 + 10 files changed, 1012 insertions(+) create mode 100644 docs/data/base/components/menu/RadioItems.js create mode 100644 docs/data/base/components/menu/RadioItems.tsx create mode 100644 packages/mui-base/src/Menu/RadioGroup/MenuRadioGroup.test.tsx create mode 100644 packages/mui-base/src/Menu/RadioGroup/MenuRadioGroup.tsx create mode 100644 packages/mui-base/src/Menu/RadioGroup/MenuRadioGroupContext.ts create mode 100644 packages/mui-base/src/Menu/RadioItem/MenuRadioItem.test.tsx create mode 100644 packages/mui-base/src/Menu/RadioItem/MenuRadioItem.tsx create mode 100644 packages/mui-base/src/Menu/RadioItem/useMenuRadioItem.ts diff --git a/docs/data/base/components/menu/RadioItems.js b/docs/data/base/components/menu/RadioItems.js new file mode 100644 index 000000000..89a7a015d --- /dev/null +++ b/docs/data/base/components/menu/RadioItems.js @@ -0,0 +1,156 @@ +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-state='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]}; + } + + &::before { + content: '○'; + display: inline-block; + margin-right: 8px; + } + + &[data-state=checked]::before { + content: '◉'; + } + `, +); + +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-state='closed'] { + pointer-events: none; + } +`; diff --git a/docs/data/base/components/menu/RadioItems.tsx b/docs/data/base/components/menu/RadioItems.tsx new file mode 100644 index 000000000..89a7a015d --- /dev/null +++ b/docs/data/base/components/menu/RadioItems.tsx @@ -0,0 +1,156 @@ +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-state='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]}; + } + + &::before { + content: '○'; + display: inline-block; + margin-right: 8px; + } + + &[data-state=checked]::before { + content: '◉'; + } + `, +); + +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-state='closed'] { + pointer-events: none; + } +`; diff --git a/docs/data/base/components/menu/menu.md b/docs/data/base/components/menu/menu.md index 43813505f..8e4defa2e 100644 --- a/docs/data/base/components/menu/menu.md +++ b/docs/data/base/components/menu/menu.md @@ -164,6 +164,10 @@ To change how long the menu waits until it opens or closes when `openOnHover` is ``` +## Radio items + +{{"demo": "RadioItems.js"}} + ## Nested menu Menu items can open submenus. 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..60a6af48a --- /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'; + +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..c47a1d13f --- /dev/null +++ b/packages/mui-base/src/Menu/RadioGroup/MenuRadioGroup.tsx @@ -0,0 +1,90 @@ +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'; + +const EMPTY_OBJECT = {}; + +const MenuRadioGroup = React.forwardRef(function MenuRadioGroup( + props: MenuRadioGroup.Props, + forwardedRef: React.ForwardedRef, +) { + const { render, className, value: valueProp, defaultValue, onValueChange, ...other } = props; + + const [value, setValueUnwrapped] = useControlled({ + controlled: valueProp, + default: defaultValue, + name: 'MenuRadioGroup', + }); + + 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; + value?: any; + defaultValue?: any; + 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]), + /** + * A function to customize rendering of the component. + */ + render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), +} as any; + +const MemoizedMenuRadioGroup = React.memo(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..8f12b10af --- /dev/null +++ b/packages/mui-base/src/Menu/RadioItem/MenuRadioItem.test.tsx @@ -0,0 +1,299 @@ +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'; +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, +}; + +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 () => { + 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 + expect(renderItem2Spy.callCount).to.equal(4); // '2' rerenders as it receives highlight + }); + + // 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-state', '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-state', '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-state', '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..a378702f1 --- /dev/null +++ b/packages/mui-base/src/Menu/RadioItem/MenuRadioItem.tsx @@ -0,0 +1,213 @@ +'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'; + +const customStyleHookMapping: CustomStyleHookMapping = { + checked: (value: boolean) => ({ 'data-state': 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, + ...other + } = props; + + const { getRootProps } = useMenuRadioItem({ + checked, + setChecked, + closeOnClick, + disabled, + highlighted, + id, + menuEvents, + ref: forwardedRef, + treatMouseupAsClick, + }); + + 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://mui.com/base-ui/react-menu/) + * + * API: + * + * - [MenuItem API](https://mui.com/base-ui/react-menu/components-api/#menu-item) + */ +const MenuRadioItem = React.forwardRef(function MenuRadioItem( + props: MenuRadioItem.Props, + forwardedRef: React.ForwardedRef, +) { + const { id: idProp, value, label, ...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 } = 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 setChecked = React.useCallback( + (event: Event) => { + setSelectedValue(value, event); + }, + [setSelectedValue, value], + ); + + return ( + + ); +}); + +interface InnerMenuRadioItemProps extends Omit { + highlighted: boolean; + propGetter: (externalProps?: GenericHTMLProps) => GenericHTMLProps; + menuEvents: FloatingEvents; + treatMouseupAsClick: boolean; + checked: boolean; + setChecked: (event: Event) => void; +} + +namespace MenuRadioItem { + export type OwnerState = { + disabled: boolean; + highlighted: boolean; + checked: boolean; + }; + + export interface Props extends BaseUIComponentProps<'div', OwnerState> { + 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 + */ + checked: PropTypes.bool, + /** + * @ignore + */ + children: PropTypes.node, + /** + * If `true`, the menu will close when the menu item is clicked. + * + * @default true + */ + closeOnClick: PropTypes.bool, + /** + * @ignore + */ + defaultChecked: 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, + /** + * @ignore + */ + onCheckedChange: PropTypes.func, + /** + * The click handler for the menu item. + */ + onClick: PropTypes.func, +} as any; + +export { MenuRadioItem }; 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/index.ts b/packages/mui-base/src/Menu/index.ts index 33250fa38..7db45a694 100644 --- a/packages/mui-base/src/Menu/index.ts +++ b/packages/mui-base/src/Menu/index.ts @@ -14,6 +14,11 @@ 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 { MenuRoot as Root } from './Root/MenuRoot'; export { useMenuRoot } from './Root/useMenuRoot'; export { MenuRootContext, useMenuRootContext } from './Root/MenuRootContext'; From 3530ae066e76405da69e82ce57903c826fb58843 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dudak?= Date: Tue, 10 Sep 2024 14:50:42 +0200 Subject: [PATCH 02/10] prettier --- packages/mui-base/src/Menu/RadioGroup/MenuRadioGroup.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mui-base/src/Menu/RadioGroup/MenuRadioGroup.tsx b/packages/mui-base/src/Menu/RadioGroup/MenuRadioGroup.tsx index fc7cda7c8..4b837d483 100644 --- a/packages/mui-base/src/Menu/RadioGroup/MenuRadioGroup.tsx +++ b/packages/mui-base/src/Menu/RadioGroup/MenuRadioGroup.tsx @@ -98,7 +98,7 @@ MenuRadioGroup.propTypes /* remove-proptypes */ = { value: PropTypes.any, } as any; -const MemoizedMenuRadioGroup = React.memo(MenuRadioGroup);/** +const MemoizedMenuRadioGroup = React.memo(MenuRadioGroup); /** * * Demos: * From 0298df300e52b2d25af3577bdf820487ea155e3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dudak?= Date: Tue, 10 Sep 2024 14:56:38 +0200 Subject: [PATCH 03/10] Fix the test --- packages/mui-base/src/Menu/Root/MenuRoot.test.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 }, + ); }); }); From 9b6703dbd5171e37ffa86202299d48f6e271e847 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dudak?= Date: Tue, 10 Sep 2024 16:45:59 +0200 Subject: [PATCH 04/10] Indicator component --- docs/data/components/menu/RadioItems.js | 68 ++++++++++---- docs/data/components/menu/RadioItems.tsx | 68 ++++++++++---- .../src/Menu/RadioItem/MenuRadioItem.tsx | 36 +++++--- .../Menu/RadioItem/MenuRadioItemContext.tsx | 20 +++++ .../MenuRadioItemIndicator.test.tsx | 24 +++++ .../MenuRadioItemIndicator.tsx | 89 +++++++++++++++++++ packages/mui-base/src/Menu/index.ts | 2 + 7 files changed, 262 insertions(+), 45 deletions(-) create mode 100644 packages/mui-base/src/Menu/RadioItem/MenuRadioItemContext.tsx create mode 100644 packages/mui-base/src/Menu/RadioItemIndicator/MenuRadioItemIndicator.test.tsx create mode 100644 packages/mui-base/src/Menu/RadioItemIndicator/MenuRadioItemIndicator.tsx diff --git a/docs/data/components/menu/RadioItems.js b/docs/data/components/menu/RadioItems.js index 702dc513f..314d6f34d 100644 --- a/docs/data/components/menu/RadioItems.js +++ b/docs/data/components/menu/RadioItems.js @@ -10,15 +10,42 @@ export default function RadioItems() { - Cascadia Code - Consolas - DejaVu Sans Mono - Fira Code - JetBrains Mono - Menlo - Monaco - Monolisa - Source Code Pro + + + Cascadia Code + + + + Consolas + + + + DejaVu Sans Mono + + + + Fira Code + + + + JetBrains Mono + + + + Menlo + + + + Monaco + + + + Monolisa + + + + Source Code Pro + @@ -101,15 +128,24 @@ const RadioItem = styled(Menu.RadioItem)( &[data-disabled] { color: ${theme.palette.mode === 'dark' ? grey[700] : grey[400]}; } + `, +); - &::before { - content: '○'; - display: inline-block; - margin-right: 8px; - } +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-state=checked]::before { - content: '◉'; + &[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; } `, ); diff --git a/docs/data/components/menu/RadioItems.tsx b/docs/data/components/menu/RadioItems.tsx index 702dc513f..314d6f34d 100644 --- a/docs/data/components/menu/RadioItems.tsx +++ b/docs/data/components/menu/RadioItems.tsx @@ -10,15 +10,42 @@ export default function RadioItems() { - Cascadia Code - Consolas - DejaVu Sans Mono - Fira Code - JetBrains Mono - Menlo - Monaco - Monolisa - Source Code Pro + + + Cascadia Code + + + + Consolas + + + + DejaVu Sans Mono + + + + Fira Code + + + + JetBrains Mono + + + + Menlo + + + + Monaco + + + + Monolisa + + + + Source Code Pro + @@ -101,15 +128,24 @@ const RadioItem = styled(Menu.RadioItem)( &[data-disabled] { color: ${theme.palette.mode === 'dark' ? grey[700] : grey[400]}; } + `, +); - &::before { - content: '○'; - display: inline-block; - margin-right: 8px; - } +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-state=checked]::before { - content: '◉'; + &[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; } `, ); diff --git a/packages/mui-base/src/Menu/RadioItem/MenuRadioItem.tsx b/packages/mui-base/src/Menu/RadioItem/MenuRadioItem.tsx index 3df189dba..8d520f5b7 100644 --- a/packages/mui-base/src/Menu/RadioItem/MenuRadioItem.tsx +++ b/packages/mui-base/src/Menu/RadioItem/MenuRadioItem.tsx @@ -10,6 +10,7 @@ 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-state': value ? 'checked' : 'unchecked' }), @@ -79,7 +80,7 @@ const MenuRadioItem = React.forwardRef(function MenuRadioItem( props: MenuRadioItem.Props, forwardedRef: React.ForwardedRef, ) { - const { id: idProp, value, label, ...other } = props; + const { id: idProp, value, label, disabled = false, ...other } = props; const itemRef = React.useRef(null); const listItem = useListItem({ label: label ?? itemRef.current?.innerText }); @@ -97,6 +98,8 @@ const MenuRadioItem = React.forwardRef(function MenuRadioItem( // 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); @@ -104,19 +107,26 @@ const MenuRadioItem = React.forwardRef(function MenuRadioItem( [setSelectedValue, value], ); + const contextValue = React.useMemo( + () => ({ checked, highlighted, disabled }), + [checked, highlighted, disabled], + ); + return ( - + + + ); }); diff --git a/packages/mui-base/src/Menu/RadioItem/MenuRadioItemContext.tsx b/packages/mui-base/src/Menu/RadioItem/MenuRadioItemContext.tsx new file mode 100644 index 000000000..760b3618d --- /dev/null +++ b/packages/mui-base/src/Menu/RadioItem/MenuRadioItemContext.tsx @@ -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/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/index.ts b/packages/mui-base/src/Menu/index.ts index 7db45a694..11e63ca14 100644 --- a/packages/mui-base/src/Menu/index.ts +++ b/packages/mui-base/src/Menu/index.ts @@ -19,6 +19,8 @@ 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'; From ebab358272c59d20f1e544dca6fa0e2696adcbc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dudak?= Date: Thu, 12 Sep 2024 09:36:16 +0200 Subject: [PATCH 05/10] Docs --- docs/data/api/menu-radio-item-indicator.json | 20 +++++++++++ docs/data/components/menu/menu.mdx | 34 ++++++++++++++++++- .../menu-radio-item-indicator.json | 13 +++++++ ...temContext.tsx => MenuRadioItemContext.ts} | 0 4 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 docs/data/api/menu-radio-item-indicator.json create mode 100644 docs/data/translations/api-docs/menu-radio-item-indicator/menu-radio-item-indicator.json rename packages/mui-base/src/Menu/RadioItem/{MenuRadioItemContext.tsx => MenuRadioItemContext.ts} (100%) 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/components/menu/menu.mdx b/docs/data/components/menu/menu.mdx index f71ab6d81..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, MenuRadioGroup, MenuRadioItem +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 @@ -143,8 +146,37 @@ 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-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/packages/mui-base/src/Menu/RadioItem/MenuRadioItemContext.tsx b/packages/mui-base/src/Menu/RadioItem/MenuRadioItemContext.ts similarity index 100% rename from packages/mui-base/src/Menu/RadioItem/MenuRadioItemContext.tsx rename to packages/mui-base/src/Menu/RadioItem/MenuRadioItemContext.ts From 182e7c1ee06928115de6526fc89aff32069a1489 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dudak?= Date: Mon, 16 Sep 2024 19:37:12 +0200 Subject: [PATCH 06/10] Fix tests --- packages/mui-base/src/Menu/Positioner/useMenuPositioner.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/mui-base/src/Menu/Positioner/useMenuPositioner.ts b/packages/mui-base/src/Menu/Positioner/useMenuPositioner.ts index a8d133770..df70d426c 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 ? '' : undefined, }); }, [positionerStyles, open, keepMounted, hidden], From 015bd77d0bd9567c702c151400ea0f06571143ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dudak?= Date: Wed, 18 Sep 2024 11:11:25 +0200 Subject: [PATCH 07/10] Feedback --- packages/mui-base/src/Menu/Positioner/useMenuPositioner.ts | 2 +- packages/mui-base/src/Menu/RadioItem/MenuRadioItem.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/mui-base/src/Menu/Positioner/useMenuPositioner.ts b/packages/mui-base/src/Menu/Positioner/useMenuPositioner.ts index df70d426c..02c1890eb 100644 --- a/packages/mui-base/src/Menu/Positioner/useMenuPositioner.ts +++ b/packages/mui-base/src/Menu/Positioner/useMenuPositioner.ts @@ -43,7 +43,7 @@ export function useMenuPositioner( zIndex: 2147483647, // max z-index }, 'aria-hidden': !open || undefined, - inert: !open || undefined ? '' : undefined, + inert: !open ? '' : undefined, }); }, [positionerStyles, open, keepMounted, hidden], diff --git a/packages/mui-base/src/Menu/RadioItem/MenuRadioItem.tsx b/packages/mui-base/src/Menu/RadioItem/MenuRadioItem.tsx index 8d520f5b7..96ca07245 100644 --- a/packages/mui-base/src/Menu/RadioItem/MenuRadioItem.tsx +++ b/packages/mui-base/src/Menu/RadioItem/MenuRadioItem.tsx @@ -13,7 +13,7 @@ import { useMenuRadioGroupContext } from '../RadioGroup/MenuRadioGroupContext'; import { MenuRadioItemContext } from './MenuRadioItemContext'; const customStyleHookMapping: CustomStyleHookMapping = { - checked: (value: boolean) => ({ 'data-state': value ? 'checked' : 'unchecked' }), + checked: (value: boolean) => ({ 'data-radioitem': value ? 'checked' : 'unchecked' }), }; const InnerMenuRadioItem = React.memo( From 6f5caac33c0ae4ac69737cbf33fb474d7deaf687 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dudak?= Date: Wed, 18 Sep 2024 11:50:45 +0200 Subject: [PATCH 08/10] Fix tests --- packages/mui-base/src/Menu/RadioItem/MenuRadioItem.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/mui-base/src/Menu/RadioItem/MenuRadioItem.test.tsx b/packages/mui-base/src/Menu/RadioItem/MenuRadioItem.test.tsx index 9da5a131b..fc08c1c87 100644 --- a/packages/mui-base/src/Menu/RadioItem/MenuRadioItem.test.tsx +++ b/packages/mui-base/src/Menu/RadioItem/MenuRadioItem.test.tsx @@ -174,7 +174,7 @@ describe('', () => { await user.click(item); expect(item).to.have.attribute('aria-checked', 'true'); - expect(item).to.have.attribute('data-state', 'checked'); + expect(item).to.have.attribute('data-radioitem', 'checked'); }); ['Space', 'Enter'].forEach((key) => { @@ -202,7 +202,7 @@ describe('', () => { }); await user.keyboard(`[${key}]`); - expect(item).to.have.attribute('data-state', 'checked'); + expect(item).to.have.attribute('data-radioitem', 'checked'); }); }); @@ -257,7 +257,7 @@ describe('', () => { const itemAfterReopen = getByRole('menuitemradio'); expect(itemAfterReopen).to.have.attribute('aria-checked', 'true'); - expect(itemAfterReopen).to.have.attribute('data-state', 'checked'); + expect(itemAfterReopen).to.have.attribute('data-radioitem', 'checked'); }); }); From 9dbc42c3da18cf62fcde78f5f788e750ede5ee5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dudak?= Date: Wed, 18 Sep 2024 14:22:09 +0200 Subject: [PATCH 09/10] useEventCallback + JSDocs --- docs/data/api/menu-radio-group.json | 5 ++- docs/data/api/menu-radio-item.json | 1 + .../menu-radio-group/menu-radio-group.json | 7 +++- .../menu-radio-item/menu-radio-item.json | 5 ++- .../src/Menu/RadioGroup/MenuRadioGroup.tsx | 38 ++++++++++++++++--- .../src/Menu/RadioItem/MenuRadioItem.tsx | 7 +++- 6 files changed, 54 insertions(+), 9 deletions(-) diff --git a/docs/data/api/menu-radio-group.json b/docs/data/api/menu-radio-group.json index 391054978..7c93dbc87 100644 --- a/docs/data/api/menu-radio-group.json +++ b/docs/data/api/menu-radio-group.json @@ -2,7 +2,10 @@ "props": { "children": { "type": { "name": "node" } }, "className": { "type": { "name": "union", "description": "func
    | string" } }, - "render": { "type": { "name": "union", "description": "element
    | func" } } + "defaultValue": { "type": { "name": "any" } }, + "onValueChange": { "type": { "name": "func" }, "default": "() => {}" }, + "render": { "type": { "name": "union", "description": "element
    | func" } }, + "value": { "type": { "name": "any" } } }, "name": "MenuRadioGroup", "imports": [ diff --git a/docs/data/api/menu-radio-item.json b/docs/data/api/menu-radio-item.json index 64915f541..fc3ac0f34 100644 --- a/docs/data/api/menu-radio-item.json +++ b/docs/data/api/menu-radio-item.json @@ -1,5 +1,6 @@ { "props": { + "value": { "type": { "name": "any" }, "required": true }, "closeOnClick": { "type": { "name": "bool" }, "default": "true" }, "disabled": { "type": { "name": "bool" }, "default": "false" }, "id": { "type": { "name": "string" } }, 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 index c0bdf41a5..150d54171 100644 --- 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 @@ -5,7 +5,12 @@ "className": { "description": "Class names applied to the element or a function that returns them based on the component's state." }, - "render": { "description": "A function to customize rendering of the component." } + "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/menu-radio-item.json b/docs/data/translations/api-docs/menu-radio-item/menu-radio-item.json index 46541ccb2..49a3980c9 100644 --- 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 @@ -9,7 +9,10 @@ "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." } + "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/RadioGroup/MenuRadioGroup.tsx b/packages/mui-base/src/Menu/RadioGroup/MenuRadioGroup.tsx index 4b837d483..53d8dedd0 100644 --- a/packages/mui-base/src/Menu/RadioGroup/MenuRadioGroup.tsx +++ b/packages/mui-base/src/Menu/RadioGroup/MenuRadioGroup.tsx @@ -4,14 +4,23 @@ 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, ...other } = props; + const { + render, + className, + value: valueProp, + defaultValue, + onValueChange: onValueChangeProp = NOOP, + ...other + } = props; const [value, setValueUnwrapped] = useControlled({ controlled: valueProp, @@ -19,6 +28,8 @@ const MenuRadioGroup = React.forwardRef(function MenuRadioGroup( name: 'MenuRadioGroup', }); + const onValueChange = useEventCallback(onValueChangeProp); + const setValue = React.useCallback( (newValue: any, event: Event) => { setValueUnwrapped(newValue); @@ -59,8 +70,20 @@ namespace MenuRadioGroup { * 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; } @@ -81,11 +104,14 @@ MenuRadioGroup.propTypes /* remove-proptypes */ = { */ className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), /** - * @ignore + * The default value of the selected radio button. + * This is the uncontrolled equivalent of `value`. */ defaultValue: PropTypes.any, /** - * @ignore + * Function called when the selected value changes. + * + * @default () => {} */ onValueChange: PropTypes.func, /** @@ -93,12 +119,14 @@ MenuRadioGroup.propTypes /* remove-proptypes */ = { */ render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), /** - * @ignore + * The value of the selected radio button. */ value: PropTypes.any, } as any; -const MemoizedMenuRadioGroup = React.memo(MenuRadioGroup); /** +const MemoizedMenuRadioGroup = React.memo(MenuRadioGroup); + +/** * * Demos: * diff --git a/packages/mui-base/src/Menu/RadioItem/MenuRadioItem.tsx b/packages/mui-base/src/Menu/RadioItem/MenuRadioItem.tsx index 96ca07245..d9fc70fa2 100644 --- a/packages/mui-base/src/Menu/RadioItem/MenuRadioItem.tsx +++ b/packages/mui-base/src/Menu/RadioItem/MenuRadioItem.tsx @@ -148,6 +148,10 @@ namespace MenuRadioItem { }; 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; /** @@ -211,7 +215,8 @@ MenuRadioItem.propTypes /* remove-proptypes */ = { */ onClick: PropTypes.func, /** - * @ignore + * 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; From 563d2b19f4f155d061f869362d8481635db7b69b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dudak?= Date: Thu, 19 Sep 2024 11:06:41 +0200 Subject: [PATCH 10/10] Make the Menu close on Enter press --- packages/mui-base/src/Menu/Item/useMenuItem.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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); }