diff --git a/docs/data/api/menu-checkbox-item-indicator.json b/docs/data/api/menu-checkbox-item-indicator.json new file mode 100644 index 0000000000..aaf1adec7d --- /dev/null +++ b/docs/data/api/menu-checkbox-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": "MenuCheckboxItemIndicator", + "imports": [ + "import * as Menu from '@base_ui/react/Menu';\nconst MenuCheckboxItemIndicator = Menu.CheckboxItemIndicator;" + ], + "classes": [], + "spread": true, + "themeDefaultProps": true, + "muiName": "MenuCheckboxItemIndicator", + "forwardsRefTo": "HTMLSpanElement", + "filename": "/packages/mui-base/src/Menu/CheckboxItemIndicator/MenuCheckboxItemIndicator.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/data/api/menu-checkbox-item.json b/docs/data/api/menu-checkbox-item.json new file mode 100644 index 0000000000..c622f69014 --- /dev/null +++ b/docs/data/api/menu-checkbox-item.json @@ -0,0 +1,22 @@ +{ + "props": { + "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": "MenuCheckboxItem", + "imports": [ + "import * as Menu from '@base_ui/react/Menu';\nconst MenuCheckboxItem = Menu.CheckboxItem;" + ], + "classes": [], + "spread": true, + "themeDefaultProps": true, + "muiName": "MenuCheckboxItem", + "forwardsRefTo": "HTMLDivElement", + "filename": "/packages/mui-base/src/Menu/CheckboxItem/MenuCheckboxItem.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/data/components/menu/CheckboxItems.js b/docs/data/components/menu/CheckboxItems.js new file mode 100644 index 0000000000..8b316c8b4d --- /dev/null +++ b/docs/data/components/menu/CheckboxItems.js @@ -0,0 +1,200 @@ +'use client'; + +import * as React from 'react'; +import * as Menu from '@base_ui/react/Menu'; +import { styled } from '@mui/system'; + +export default function CheckboxItems() { + const createHandleMenuClick = (menuItem) => { + return () => { + console.log(`Clicked on ${menuItem}`); + }; + }; + + return ( + + My account + + + Profile + + Language settings + + + + Mute notifications + + + + Enable preview features + + Log out + + + + ); +} + +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 MenuItem = styled(Menu.Item)( + ({ 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 CheckboxItem = styled(Menu.CheckboxItem)( + ({ 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.CheckboxItemIndicator)( + ({ 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: 2px; + + + &[data-checkboxitem=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/CheckboxItems.tsx b/docs/data/components/menu/CheckboxItems.tsx new file mode 100644 index 0000000000..930e8c7093 --- /dev/null +++ b/docs/data/components/menu/CheckboxItems.tsx @@ -0,0 +1,200 @@ +'use client'; + +import * as React from 'react'; +import * as Menu from '@base_ui/react/Menu'; +import { styled } from '@mui/system'; + +export default function CheckboxItems() { + const createHandleMenuClick = (menuItem: string) => { + return () => { + console.log(`Clicked on ${menuItem}`); + }; + }; + + return ( + + My account + + + Profile + + Language settings + + + + Mute notifications + + + + Enable preview features + + Log out + + + + ); +} + +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 MenuItem = styled(Menu.Item)( + ({ 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 CheckboxItem = styled(Menu.CheckboxItem)( + ({ 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.CheckboxItemIndicator)( + ({ 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: 2px; + + + &[data-checkboxitem=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 b3417f5dfb..de773cb1ef 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, MenuRadioItemIndicator +components: MenuItem, MenuPositioner, MenuPopup, MenuRoot, MenuTrigger, SubmenuTrigger, MenuArrow, MenuRadioGroup, MenuRadioItem, MenuRadioItemIndicator, MenuCheckboxItem, MenuCheckboxItemIndicator githubLabel: 'component: menu' waiAria: https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/ --- @@ -33,6 +33,8 @@ Menus are implemented using a collection of related components: - `` 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. +- `` is a menu item that acts as a checkbox. See [Checkbox items](#checkbox-items) for more details. +- `` is a visual for the selected CheckboxItem. ```tsx @@ -177,6 +179,34 @@ If you keep the state externally (and use the `checked` prop), this isn't requir +## Checkbox items + +Menu items can act as checkboxes. + + + + + If you rely on the CheckboxItem 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 + + + + + Item 1 + + + + +``` + +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-checkbox-item-indicator/menu-checkbox-item-indicator.json b/docs/data/translations/api-docs/menu-checkbox-item-indicator/menu-checkbox-item-indicator.json new file mode 100644 index 0000000000..b30e00bb16 --- /dev/null +++ b/docs/data/translations/api-docs/menu-checkbox-item-indicator/menu-checkbox-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 checkbox is not checked." + }, + "render": { "description": "A function to customize rendering of the component." } + }, + "classDescriptions": {} +} diff --git a/docs/data/translations/api-docs/menu-checkbox-item/menu-checkbox-item.json b/docs/data/translations/api-docs/menu-checkbox-item/menu-checkbox-item.json new file mode 100644 index 0000000000..a13fca2fee --- /dev/null +++ b/docs/data/translations/api-docs/menu-checkbox-item/menu-checkbox-item.json @@ -0,0 +1,15 @@ +{ + "componentDescription": "", + "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." } + }, + "classDescriptions": {} +} diff --git a/docs/src/components/demo/DemoLoader.tsx b/docs/src/components/demo/DemoLoader.tsx index d376808f9f..7bdad55fdc 100644 --- a/docs/src/components/demo/DemoLoader.tsx +++ b/docs/src/components/demo/DemoLoader.tsx @@ -24,6 +24,7 @@ export async function DemoLoader(props: DemoLoaderProps) { return ; } catch (error) { if (process.env.NODE_ENV !== 'production') { + console.error(error); return (
Unable to render the {demo} demo. diff --git a/docs/translations/api-docs/menu-checkbox-item/menu-checkbox-item.json b/docs/translations/api-docs/menu-checkbox-item/menu-checkbox-item.json new file mode 100644 index 0000000000..46541ccb2f --- /dev/null +++ b/docs/translations/api-docs/menu-checkbox-item/menu-checkbox-item.json @@ -0,0 +1,15 @@ +{ + "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." } + }, + "classDescriptions": {} +} diff --git a/docs/translations/api-docs/use-menu-checkbox-item/use-menu-checkbox-item.json b/docs/translations/api-docs/use-menu-checkbox-item/use-menu-checkbox-item.json new file mode 100644 index 0000000000..e3eb65c6e4 --- /dev/null +++ b/docs/translations/api-docs/use-menu-checkbox-item/use-menu-checkbox-item.json @@ -0,0 +1 @@ +{ "hookDescription": "", "parametersDescriptions": {}, "returnValueDescriptions": {} } diff --git a/packages/mui-base/src/Menu/CheckboxItem/MenuCheckboxItem.test.tsx b/packages/mui-base/src/Menu/CheckboxItem/MenuCheckboxItem.test.tsx new file mode 100644 index 0000000000..fc48ebc875 --- /dev/null +++ b/packages/mui-base/src/Menu/CheckboxItem/MenuCheckboxItem.test.tsx @@ -0,0 +1,342 @@ +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'; + +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 }, +}; + +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('menuitemcheckbox'); + 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', () => { + ( + [ + [true, 'true', 'checked'], + [false, 'false', 'unchecked'], + ] as const + ).forEach(([checked, ariaChecked, dataState]) => + it('adds the state and ARIA attributes when checked', async () => { + const { getByRole } = await render( + + Open + + + Item + + + , + ); + + const trigger = getByRole('button', { name: 'Open' }); + await user.click(trigger); + + const item = getByRole('menuitemcheckbox'); + expect(item).to.have.attribute('aria-checked', ariaChecked); + expect(item).to.have.attribute('data-checkboxitem', dataState); + }), + ); + + it('toggles the checked state when clicked', async () => { + const { getByRole } = await render( + + Open + + + Item + + + , + ); + + const trigger = getByRole('button', { name: 'Open' }); + await user.click(trigger); + + const item = getByRole('menuitemcheckbox'); + await user.click(item); + + expect(item).to.have.attribute('aria-checked', 'true'); + expect(item).to.have.attribute('data-checkboxitem', 'checked'); + + await user.click(item); + + expect(item).to.have.attribute('aria-checked', 'false'); + expect(item).to.have.attribute('data-checkboxitem', 'unchecked'); + }); + + it(`toggles the checked state when Space 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('menuitemcheckbox'); + + await waitFor(() => { + expect(item).toHaveFocus(); + }); + + await user.keyboard(`[Space]`); + expect(item).to.have.attribute('data-checkboxitem', 'checked'); + + await user.keyboard(`[Space]`); + expect(item).to.have.attribute('data-checkboxitem', 'unchecked'); + }); + + it(`toggles the checked state and closes the menu when Enter is pressed`, async () => { + const { getByRole, queryByRole } = await render( + + Open + + + Item + + + , + ); + + const trigger = getByRole('button', { name: 'Open' }); + await act(() => { + trigger.focus(); + }); + + await user.keyboard('[ArrowDown]'); + const item = getByRole('menuitemcheckbox'); + + await waitFor(() => { + expect(item).toHaveFocus(); + }); + + await user.keyboard(`[Enter]`); + + expect(queryByRole('menu', { hidden: false })).to.equal(null); + await user.click(trigger); + expect(item).to.have.attribute('data-checkboxitem', 'checked'); + }); + + it('calls `onCheckedChange` when the item is clicked', async () => { + const onCheckedChange = spy(); + const { getByRole } = await render( + + Open + + + Item + + + , + ); + + const trigger = getByRole('button', { name: 'Open' }); + await user.click(trigger); + + const item = getByRole('menuitemcheckbox'); + await user.click(item); + + expect(onCheckedChange.callCount).to.equal(1); + expect(onCheckedChange.lastCall.args[0]).to.equal(true); + + await user.click(item); + + expect(onCheckedChange.callCount).to.equal(2); + expect(onCheckedChange.lastCall.args[0]).to.equal(false); + }); + + 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('menuitemcheckbox'); + await user.click(item); + + await user.click(trigger); + + await user.click(trigger); + + const itemAfterReopen = getByRole('menuitemcheckbox'); + expect(itemAfterReopen).to.have.attribute('aria-checked', 'true'); + expect(itemAfterReopen).to.have.attribute('data-checkboxitem', '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('menuitemcheckbox'); + 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('menuitemcheckbox'); + await user.click(item); + + expect(queryByRole('menu')).not.to.equal(null); + }); + }); +}); diff --git a/packages/mui-base/src/Menu/CheckboxItem/MenuCheckboxItem.tsx b/packages/mui-base/src/Menu/CheckboxItem/MenuCheckboxItem.tsx new file mode 100644 index 0000000000..6fa0f04570 --- /dev/null +++ b/packages/mui-base/src/Menu/CheckboxItem/MenuCheckboxItem.tsx @@ -0,0 +1,219 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { FloatingEvents, useFloatingTree, useListItem } from '@floating-ui/react'; +import { useMenuCheckboxItem } from './useMenuCheckboxItem'; +import { MenuCheckboxItemContext } from './MenuCheckboxItemContext'; +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'; + +const customStyleHookMapping: CustomStyleHookMapping = { + checked: (value: boolean) => ({ 'data-checkboxitem': value ? 'checked' : 'unchecked' }), +}; + +const InnerMenuCheckboxItem = React.memo( + React.forwardRef(function InnerMenuItem( + props: InnerMenuCheckboxItemProps, + forwardedRef: React.ForwardedRef, + ) { + const { + checked: checkedProp, + defaultChecked, + onCheckedChange, + className, + closeOnClick = false, + disabled = false, + highlighted, + id, + menuEvents, + propGetter, + render, + treatMouseupAsClick, + typingRef, + ...other + } = props; + + const { getRootProps, checked } = useMenuCheckboxItem({ + closeOnClick, + disabled, + highlighted, + id, + menuEvents, + ref: forwardedRef, + treatMouseupAsClick, + checked: checkedProp, + defaultChecked, + onCheckedChange, + typingRef, + }); + + const ownerState: MenuCheckboxItem.OwnerState = React.useMemo( + () => ({ disabled, highlighted, checked }), + [disabled, highlighted, checked], + ); + + const { renderElement } = useComponentRenderer({ + render: render || 'div', + className, + ownerState, + propGetter: (externalProps) => propGetter(getRootProps(externalProps)), + customStyleHookMapping, + extraProps: other, + }); + + return ( + + {renderElement()} + + ); + }), +); +/** + * + * Demos: + * + * - [Menu](https://base-ui.netlify.app/components/react-menu/) + * + * API: + * + * - [MenuCheckboxItem API](https://base-ui.netlify.app/components/react-menu/#api-reference-MenuCheckboxItem) + */ +const MenuCheckboxItem = React.forwardRef(function MenuCheckboxItem( + props: MenuCheckboxItem.Props, + forwardedRef: React.ForwardedRef, +) { + const { id: idProp, 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, typingRef } = useMenuRootContext(); + const id = useId(idProp); + + const highlighted = listItem.index === activeIndex; + const { events: menuEvents } = useFloatingTree()!; + + // This wrapper component is used as a performance optimization. + // MenuCheckboxItem reads the context and re-renders the actual MenuCheckboxItem + // only when it needs to. + + return ( + + ); +}); + +// ESLint reporting false positives here +interface InnerMenuCheckboxItemProps extends MenuCheckboxItem.Props { + // eslint-disable-next-line react/no-unused-prop-types + highlighted: boolean; + // eslint-disable-next-line react/no-unused-prop-types + propGetter: (externalProps?: GenericHTMLProps) => GenericHTMLProps; + // eslint-disable-next-line react/no-unused-prop-types + menuEvents: FloatingEvents; + // eslint-disable-next-line react/no-unused-prop-types + treatMouseupAsClick: boolean; + // eslint-disable-next-line react/no-unused-prop-types + typingRef: React.RefObject; +} + +namespace MenuCheckboxItem { + export type OwnerState = { + disabled: boolean; + highlighted: boolean; + checked: boolean; + }; + + export interface Props extends BaseUIComponentProps<'div', OwnerState> { + checked?: boolean; + defaultChecked?: boolean; + onCheckedChange?: (checked: boolean, event: Event) => void; + 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; + } +} + +MenuCheckboxItem.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 { MenuCheckboxItem }; diff --git a/packages/mui-base/src/Menu/CheckboxItem/MenuCheckboxItemContext.ts b/packages/mui-base/src/Menu/CheckboxItem/MenuCheckboxItemContext.ts new file mode 100644 index 0000000000..beab8db3f2 --- /dev/null +++ b/packages/mui-base/src/Menu/CheckboxItem/MenuCheckboxItemContext.ts @@ -0,0 +1,20 @@ +import * as React from 'react'; + +export interface MenuCheckboxItemContext { + checked: boolean; + highlighted: boolean; + disabled: boolean; +} + +export const MenuCheckboxItemContext = React.createContext( + undefined, +); + +export function useMenuCheckboxItemContext() { + const context = React.useContext(MenuCheckboxItemContext); + if (context === undefined) { + throw new Error('useMenuCheckboxItemContext must be used within a MenuCheckboxItemProvider'); + } + + return context; +} diff --git a/packages/mui-base/src/Menu/CheckboxItem/useMenuCheckboxItem.ts b/packages/mui-base/src/Menu/CheckboxItem/useMenuCheckboxItem.ts new file mode 100644 index 0000000000..6b2dc30624 --- /dev/null +++ b/packages/mui-base/src/Menu/CheckboxItem/useMenuCheckboxItem.ts @@ -0,0 +1,63 @@ +import * as React from 'react'; +import { useMenuItem } from '../Item/useMenuItem'; +import { useControlled } from '../../utils/useControlled'; +import { GenericHTMLProps } from '../../utils/types'; +import { mergeReactProps } from '../../utils/mergeReactProps'; + +/** + * + * API: + * + * - [useMenuCheckboxItem API](https://mui.com/base-ui/api/use-menu-checkbox-item/) + */ +export function useMenuCheckboxItem( + params: useMenuCheckboxItem.Parameters, +): useMenuCheckboxItem.ReturnValue { + const { checked: checkedProp, defaultChecked, onCheckedChange, ...other } = params; + + const [checked, setChecked] = useControlled({ + controlled: checkedProp, + default: defaultChecked ?? false, + name: 'MenuCheckboxItem', + state: 'checked', + }); + + const { getRootProps: getMenuItemRootProps, ...menuItem } = useMenuItem(other); + + const getRootProps = React.useCallback( + (externalProps?: GenericHTMLProps): GenericHTMLProps => { + return getMenuItemRootProps( + mergeReactProps(externalProps, { + role: 'menuitemcheckbox', + 'aria-checked': checked, + onClick: (event: React.MouseEvent) => { + setChecked((currentlyChecked) => !currentlyChecked); + onCheckedChange?.(!checked, event.nativeEvent); + }, + }), + ); + }, + [checked, getMenuItemRootProps, onCheckedChange, setChecked], + ); + + return React.useMemo( + () => ({ + ...menuItem, + getRootProps, + checked, + }), + [checked, getRootProps, menuItem], + ); +} + +export namespace useMenuCheckboxItem { + export interface Parameters extends useMenuItem.Parameters { + checked?: boolean; + defaultChecked?: boolean; + onCheckedChange?: (checked: boolean, event: Event) => void; + } + + export interface ReturnValue extends useMenuItem.ReturnValue { + checked: boolean; + } +} diff --git a/packages/mui-base/src/Menu/CheckboxItemIndicator/MenuCheckboxItemIndicator.test.tsx b/packages/mui-base/src/Menu/CheckboxItemIndicator/MenuCheckboxItemIndicator.test.tsx new file mode 100644 index 0000000000..dd0cfefae8 --- /dev/null +++ b/packages/mui-base/src/Menu/CheckboxItemIndicator/MenuCheckboxItemIndicator.test.tsx @@ -0,0 +1,22 @@ +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/CheckboxItemIndicator/MenuCheckboxItemIndicator.tsx b/packages/mui-base/src/Menu/CheckboxItemIndicator/MenuCheckboxItemIndicator.tsx new file mode 100644 index 0000000000..5f72304f55 --- /dev/null +++ b/packages/mui-base/src/Menu/CheckboxItemIndicator/MenuCheckboxItemIndicator.tsx @@ -0,0 +1,89 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { MenuCheckboxItem } from '../CheckboxItem/MenuCheckboxItem'; +import { useMenuCheckboxItemContext } from '../CheckboxItem/MenuCheckboxItemContext'; +import { CustomStyleHookMapping } from '../../utils/getStyleHookProps'; +import { useComponentRenderer } from '../../utils/useComponentRenderer'; +import { BaseUIComponentProps } from '../../utils/types'; + +const customStyleHookMapping: CustomStyleHookMapping = { + checked: (value: boolean) => ({ 'data-checkboxitem': value ? 'checked' : 'unchecked' }), +}; + +/** + * + * Demos: + * + * - [Menu](https://base-ui.netlify.app/components/react-menu/) + * + * API: + * + * - [MenuCheckboxItemIndicator API](https://base-ui.netlify.app/components/react-menu/#api-reference-MenuCheckboxItemIndicator) + */ +const MenuCheckboxItemIndicator = React.forwardRef(function MenuCheckboxItemIndicatorComponent( + props: MenuCheckboxItemIndicator.Props, + forwardedRef: React.ForwardedRef, +) { + const { render, className, keepMounted = true, ...other } = props; + + const ownerState = useMenuCheckboxItemContext(); + + const { renderElement } = useComponentRenderer({ + render: render || 'span', + className, + ownerState, + customStyleHookMapping, + extraProps: other, + ref: forwardedRef, + }); + + if (!keepMounted && !ownerState.checked) { + return null; + } + + return renderElement(); +}); + +MenuCheckboxItemIndicator.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 checkbox 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 MenuCheckboxItemIndicator { + export interface Props extends BaseUIComponentProps<'span', OwnerState> { + /** + * If `true`, the component is mounted even if the checkbox is not checked. + * + * @default true + */ + keepMounted?: boolean; + } + + export interface OwnerState { + checked: boolean; + disabled: boolean; + highlighted: boolean; + } +} + +export { MenuCheckboxItemIndicator }; diff --git a/packages/mui-base/src/Menu/index.ts b/packages/mui-base/src/Menu/index.ts index 11e63ca146..8d6fd338f0 100644 --- a/packages/mui-base/src/Menu/index.ts +++ b/packages/mui-base/src/Menu/index.ts @@ -1,6 +1,11 @@ export { MenuArrow as Arrow } from './Arrow/MenuArrow'; export { useMenuArrow } from './Arrow/useMenuArrow'; +export { MenuCheckboxItem as CheckboxItem } from './CheckboxItem/MenuCheckboxItem'; +export { useMenuCheckboxItem } from './CheckboxItem/useMenuCheckboxItem'; + +export { MenuCheckboxItemIndicator as CheckboxItemIndicator } from './CheckboxItemIndicator/MenuCheckboxItemIndicator'; + export { MenuItem as Item } from './Item/MenuItem'; export { useMenuItem } from './Item/useMenuItem';