diff --git a/e2e/components/ComboButton/ComboButton-test.e2e.js b/e2e/components/ComboButton/ComboButton-test.e2e.js
new file mode 100644
index 000000000000..56dc75346093
--- /dev/null
+++ b/e2e/components/ComboButton/ComboButton-test.e2e.js
@@ -0,0 +1,37 @@
+/**
+ * Copyright IBM Corp. 2023
+ *
+ * This source code is licensed under the Apache-2.0 license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+'use strict';
+
+const { expect, test } = require('@playwright/test');
+const { themes } = require('../../test-utils/env');
+const { snapshotStory, visitStory } = require('../../test-utils/storybook');
+
+test.describe('ComboButton', () => {
+ themes.forEach((theme) => {
+ test.describe(theme, () => {
+ test('combo-button @vrt', async ({ page }) => {
+ await snapshotStory(page, {
+ component: 'ComboButton',
+ id: 'experimental-unstable-combobutton--default',
+ theme,
+ });
+ });
+ });
+ });
+
+ test('accessibility-checker @avt', async ({ page }) => {
+ await visitStory(page, {
+ component: 'ComboButton',
+ id: 'experimental-unstable-combobutton--default',
+ globals: {
+ theme: 'white',
+ },
+ });
+ await expect(page).toHaveNoACViolations('ComboButton');
+ });
+});
diff --git a/e2e/components/MenuButton/MenuButton-test.e2e.js b/e2e/components/MenuButton/MenuButton-test.e2e.js
new file mode 100644
index 000000000000..b11456cb0ff6
--- /dev/null
+++ b/e2e/components/MenuButton/MenuButton-test.e2e.js
@@ -0,0 +1,37 @@
+/**
+ * Copyright IBM Corp. 2023
+ *
+ * This source code is licensed under the Apache-2.0 license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+'use strict';
+
+const { expect, test } = require('@playwright/test');
+const { themes } = require('../../test-utils/env');
+const { snapshotStory, visitStory } = require('../../test-utils/storybook');
+
+test.describe('MenuButton', () => {
+ themes.forEach((theme) => {
+ test.describe(theme, () => {
+ test('menu-button @vrt', async ({ page }) => {
+ await snapshotStory(page, {
+ component: 'MenuButton',
+ id: 'experimental-unstable-menubutton--default',
+ theme,
+ });
+ });
+ });
+ });
+
+ test('accessibility-checker @avt', async ({ page }) => {
+ await visitStory(page, {
+ component: 'MenuButton',
+ id: 'experimental-unstable-menubutton--default',
+ globals: {
+ theme: 'white',
+ },
+ });
+ await expect(page).toHaveNoACViolations('MenuButton');
+ });
+});
diff --git a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap
index 66fd4f99be9a..5f25e5517da8 100644
--- a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap
+++ b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap
@@ -9038,6 +9038,57 @@ Map {
"8": "cool-gray",
"9": "warm-gray",
},
+ "unstable_ComboButton" => Object {
+ "$$typeof": Symbol(react.forward_ref),
+ "propTypes": Object {
+ "children": Object {
+ "isRequired": true,
+ "type": "node",
+ },
+ "className": Object {
+ "type": "string",
+ },
+ "disabled": Object {
+ "type": "bool",
+ },
+ "label": Object {
+ "isRequired": true,
+ "type": "string",
+ },
+ "onClick": Object {
+ "type": "func",
+ },
+ "size": Object {
+ "args": Array [
+ Array [
+ "sm",
+ "md",
+ "lg",
+ ],
+ ],
+ "type": "oneOf",
+ },
+ "tooltipAlign": Object {
+ "args": Array [
+ Array [
+ "top",
+ "top-left",
+ "top-right",
+ "bottom",
+ "bottom-left",
+ "bottom-right",
+ "left",
+ "right",
+ ],
+ ],
+ "type": "oneOf",
+ },
+ "translateWithId": Object {
+ "type": "func",
+ },
+ },
+ "render": [Function],
+ },
"unstable_FeatureFlags" => Object {
"propTypes": Object {
"children": Object {
@@ -9101,6 +9152,9 @@ Map {
"onClose": Object {
"type": "func",
},
+ "onOpen": Object {
+ "type": "func",
+ },
"open": Object {
"type": "bool",
},
@@ -9157,6 +9211,46 @@ Map {
},
"render": [Function],
},
+ "unstable_MenuButton" => Object {
+ "$$typeof": Symbol(react.forward_ref),
+ "propTypes": Object {
+ "children": Object {
+ "isRequired": true,
+ "type": "node",
+ },
+ "className": Object {
+ "type": "string",
+ },
+ "disabled": Object {
+ "type": "bool",
+ },
+ "kind": Object {
+ "args": Array [
+ Array [
+ "primary",
+ "tertiary",
+ "ghost",
+ ],
+ ],
+ "type": "oneOf",
+ },
+ "label": Object {
+ "isRequired": true,
+ "type": "string",
+ },
+ "size": Object {
+ "args": Array [
+ Array [
+ "sm",
+ "md",
+ "lg",
+ ],
+ ],
+ "type": "oneOf",
+ },
+ },
+ "render": [Function],
+ },
"unstable_MenuItem" => Object {
"$$typeof": Symbol(react.forward_ref),
"propTypes": Object {
diff --git a/packages/react/src/__tests__/index-test.js b/packages/react/src/__tests__/index-test.js
index 8cf6b6628a68..475bd9305453 100644
--- a/packages/react/src/__tests__/index-test.js
+++ b/packages/react/src/__tests__/index-test.js
@@ -221,9 +221,11 @@ describe('Carbon Components React', () => {
"UnorderedList",
"VStack",
"types",
+ "unstable_ComboButton",
"unstable_FeatureFlags",
"unstable_LayoutDirection",
"unstable_Menu",
+ "unstable_MenuButton",
"unstable_MenuItem",
"unstable_MenuItemDivider",
"unstable_MenuItemGroup",
diff --git a/packages/react/src/components/ComboButton/ComboButton-test.js b/packages/react/src/components/ComboButton/ComboButton-test.js
new file mode 100644
index 000000000000..8ff643d1c320
--- /dev/null
+++ b/packages/react/src/components/ComboButton/ComboButton-test.js
@@ -0,0 +1,162 @@
+/**
+ * Copyright IBM Corp. 2016, 2023
+ *
+ * This source code is licensed under the Apache-2.0 license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import React from 'react';
+
+import { MenuItem } from '../Menu';
+
+import { ComboButton } from './';
+
+const prefix = 'cds';
+
+describe('ComboButton', () => {
+ describe('renders as expected - Component API', () => {
+ it('supports a ref on the outermost element', () => {
+ const ref = jest.fn();
+ const { container } = render(
+
+
+
+ );
+ expect(ref).toHaveBeenCalledWith(container.firstChild);
+ });
+
+ it('supports a custom class name on the outermost element', () => {
+ const { container } = render(
+
+
+
+ );
+ expect(container.firstChild).toHaveClass('test');
+ });
+
+ it('forwards additional props on the outermost element', () => {
+ const { container } = render(
+
+
+
+ );
+ expect(container.firstChild).toHaveAttribute('data-testid', 'test');
+ });
+
+ it('renders props.label on the trigger button', () => {
+ render(
+
+
+
+ );
+ expect(screen.getAllByRole('button')[0]).toHaveTextContent(/^Test$/);
+ });
+
+ it('supports props.disabled', () => {
+ render(
+
+
+
+ );
+
+ // primary action button
+ expect(screen.getAllByRole('button')[0]).toBeDisabled();
+
+ // trigger button
+ expect(screen.getAllByRole('button')[1]).toBeDisabled();
+ });
+
+ describe('supports props.size', () => {
+ const sizes = ['sm', 'md', 'lg'];
+
+ sizes.forEach((size) => {
+ it(`size="${size}"`, () => {
+ const { container } = render(
+
+
+
+ );
+
+ expect(container.firstChild).toHaveClass(
+ `${prefix}--combo-button__container--${size}`
+ );
+ });
+ });
+ });
+
+ describe('supports props.tooltipAlign', () => {
+ const alignments = [
+ 'top',
+ 'top-left',
+ 'top-right',
+ 'bottom',
+ 'bottom-left',
+ 'bottom-right',
+ 'left',
+ 'right',
+ ];
+
+ alignments.forEach((alignment) => {
+ it(`tooltipAlign="${alignment}"`, () => {
+ const { container } = render(
+
+
+
+ );
+
+ expect(container.firstChild.lastChild).toHaveClass(
+ `${prefix}--popover--${alignment}`
+ );
+ });
+ });
+ });
+
+ it('supports props.translateWithId', () => {
+ const t = () => 'test';
+
+ render(
+
+
+
+ );
+
+ const triggerButton = screen.getAllByRole('button')[1];
+ const tooltipId = triggerButton.getAttribute('aria-labelledby');
+ const tooltip = document.getElementById(tooltipId);
+
+ expect(tooltip).toHaveTextContent(t());
+ });
+ });
+
+ describe('behaves as expected', () => {
+ it('emits props.onClick on primary action click', async () => {
+ const onClick = jest.fn();
+ render(
+
+
+
+ );
+
+ expect(onClick).toHaveBeenCalledTimes(0);
+ await userEvent.click(screen.getAllByRole('button')[0]);
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+
+ it('opens a menu on click on the trigger button', async () => {
+ render(
+
+
+
+ );
+
+ await userEvent.click(screen.getAllByRole('button')[1]);
+
+ expect(screen.getByRole('menu')).toBeTruthy();
+ expect(screen.getByRole('menuitem')).toHaveTextContent(
+ /^Additional action$/
+ );
+ });
+ });
+});
diff --git a/packages/react/src/components/ComboButton/ComboButton.mdx b/packages/react/src/components/ComboButton/ComboButton.mdx
new file mode 100644
index 000000000000..13c7627d82af
--- /dev/null
+++ b/packages/react/src/components/ComboButton/ComboButton.mdx
@@ -0,0 +1,36 @@
+import { ArgsTable, Canvas, Story } from '@storybook/addon-docs';
+
+# ComboButton
+
+[Source code](https://github.com/carbon-design-system/carbon/tree/main/packages/react/src/components/ComboButton)
+
+
+
+
+- [Overview](#overview)
+- [Component API](#component-api)
+- [Feedback](#feedback)
+
+
+
+## Overview
+
+A `ComboButton` can be used to offer additional, secondary actions in a disclosed list next to the primary action. These additional actions must be `MenuItem`s passed as `children`. The primary action's label is passed as `props.label`.
+
+```jsx
+
+
+
+
+
+```
+
+## Component API
+
+
+
+## Feedback
+
+Help us improve this component by providing feedback, asking questions on Slack,
+or updating this file on
+[GitHub](https://github.com/carbon-design-system/carbon/edit/main/packages/react/src/components/ComboButton/ComboButton.mdx).
diff --git a/packages/react/src/components/ComboButton/ComboButton.stories.js b/packages/react/src/components/ComboButton/ComboButton.stories.js
new file mode 100644
index 000000000000..ca696bfa0319
--- /dev/null
+++ b/packages/react/src/components/ComboButton/ComboButton.stories.js
@@ -0,0 +1,85 @@
+/**
+ * Copyright IBM Corp. 2023
+ *
+ * This source code is licensed under the Apache-2.0 license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React from 'react';
+import { action } from '@storybook/addon-actions';
+
+import { MenuItem, MenuItemDivider } from '../Menu';
+
+import { ComboButton } from './';
+import mdx from './ComboButton.mdx';
+
+export default {
+ title: 'Experimental/unstable__ComboButton',
+ component: ComboButton,
+ subcomponents: {
+ MenuItem,
+ MenuItemDivider,
+ },
+ parameters: {
+ docs: {
+ page: mdx,
+ },
+ },
+};
+
+export const Default = () => (
+
+
+
+
+
+);
+
+export const WithDanger = () => (
+
+
+
+
+
+
+
+);
+
+export const Playground = (args) => {
+ const onClick = action('onClick (MenuItem)');
+
+ return (
+
+
+
+
+
+
+
+ );
+};
+
+Playground.argTypes = {
+ children: {
+ table: {
+ disable: true,
+ },
+ },
+ className: {
+ table: {
+ disable: true,
+ },
+ },
+ translateWithId: {
+ table: {
+ disable: true,
+ },
+ },
+ label: {
+ defaultValue: 'Primary action',
+ },
+};
+
+Playground.args = {
+ onClick: action('onClick'),
+};
diff --git a/packages/react/src/components/ComboButton/docs/overview.mdx b/packages/react/src/components/ComboButton/docs/overview.mdx
new file mode 100644
index 000000000000..fa1ff29e53f9
--- /dev/null
+++ b/packages/react/src/components/ComboButton/docs/overview.mdx
@@ -0,0 +1,13 @@
+## Live demo
+
+
diff --git a/packages/react/src/components/ComboButton/index.js b/packages/react/src/components/ComboButton/index.js
new file mode 100644
index 000000000000..85ed51b55c6c
--- /dev/null
+++ b/packages/react/src/components/ComboButton/index.js
@@ -0,0 +1,184 @@
+/**
+ * Copyright IBM Corp. 2023
+ *
+ * This source code is licensed under the Apache-2.0 license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React, { useRef, useState } from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+
+import { ChevronDown } from '@carbon/icons-react';
+import { Button } from '../Button';
+import { IconButton } from '../IconButton';
+import { Menu } from '../Menu';
+
+import { useAttachedMenu } from '../../internal/useAttachedMenu';
+import { useId } from '../../internal/useId';
+import { useMergedRefs } from '../../internal/useMergedRefs';
+import { usePrefix } from '../../internal/usePrefix';
+
+const spacing = 4; // top and bottom spacing between the button and the menu. in px
+const defaultTranslations = {
+ 'carbon.combo-button.additional-actions': 'Additional actions',
+};
+
+function defaultTranslateWithId(messageId) {
+ return defaultTranslations[messageId];
+}
+
+const ComboButton = React.forwardRef(function ComboButton(
+ {
+ children,
+ className,
+ disabled,
+ label,
+ onClick,
+ size = 'lg',
+ tooltipAlign,
+ translateWithId: t = defaultTranslateWithId,
+ ...rest
+ },
+ forwardRef
+) {
+ const id = useId('combobutton');
+ const prefix = usePrefix();
+
+ const containerRef = useRef(null);
+ const menuRef = useRef(null);
+ const ref = useMergedRefs([forwardRef, containerRef]);
+ const [width, setWidth] = useState(0);
+ const {
+ open,
+ x,
+ y,
+ handleClick: hookOnClick,
+ handleMousedown: handleTriggerMousedown,
+ handleClose,
+ } = useAttachedMenu(containerRef);
+
+ function handleTriggerClick() {
+ if (containerRef.current) {
+ const { width: w } = containerRef.current.getBoundingClientRect();
+ setWidth(w);
+ hookOnClick();
+ }
+ }
+
+ function handlePrimaryActionClick(e) {
+ if (onClick) {
+ onClick(e);
+ }
+ }
+
+ function handleOpen() {
+ menuRef.current.style.width = `${width}px`;
+ }
+
+ const containerClasses = classNames(
+ `${prefix}--combo-button__container`,
+ `${prefix}--combo-button__container--${size}`,
+ {
+ [`${prefix}--combo-button__container--open`]: open,
+ },
+ className
+ );
+
+ const primaryActionClasses = classNames(
+ `${prefix}--combo-button__primary-action`
+ );
+ const triggerClasses = classNames(`${prefix}--combo-button__trigger`);
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+});
+
+ComboButton.propTypes = {
+ /**
+ * A collection of MenuItems to be rendered as additional actions for this ComboButton.
+ */
+ children: PropTypes.node.isRequired,
+
+ /**
+ * Additional CSS class names.
+ */
+ className: PropTypes.string,
+
+ /**
+ * Specify whether the ComboButton should be disabled, or not.
+ */
+ disabled: PropTypes.bool,
+
+ /**
+ * Provide the label to be renderd on the primary action button.
+ */
+ label: PropTypes.string.isRequired,
+
+ /**
+ * Provide an optional function to be called when the primary action element is clicked.
+ */
+ onClick: PropTypes.func,
+
+ /**
+ * Specify the size of the buttons and menu.
+ */
+ size: PropTypes.oneOf(['sm', 'md', 'lg']),
+
+ /**
+ * Specify how the trigger tooltip should be aligned.
+ */
+ tooltipAlign: PropTypes.oneOf([
+ 'top',
+ 'top-left',
+ 'top-right',
+ 'bottom',
+ 'bottom-left',
+ 'bottom-right',
+ 'left',
+ 'right',
+ ]),
+
+ /**
+ * Optional method that takes in a message id and returns an
+ * internationalized string.
+ */
+ translateWithId: PropTypes.func,
+};
+
+export { ComboButton };
diff --git a/packages/react/src/components/ContextMenu/useContextMenu.mdx b/packages/react/src/components/ContextMenu/useContextMenu.mdx
index ecf4656f41ff..9b272b9db43c 100644
--- a/packages/react/src/components/ContextMenu/useContextMenu.mdx
+++ b/packages/react/src/components/ContextMenu/useContextMenu.mdx
@@ -2,7 +2,7 @@ import { ArgsTable, Canvas, Story } from '@storybook/addon-docs';
# useContextMenu
-[Source code](https://github.com/carbon-design-system/carbon/tree/main/packages/react/src/components/Menu)
+[Source code](https://github.com/carbon-design-system/carbon/tree/main/packages/react/src/components/ContextMenu)
diff --git a/packages/react/src/components/Menu/Menu.js b/packages/react/src/components/Menu/Menu.js
index 6ffccc888a59..05fecfb64f00 100644
--- a/packages/react/src/components/Menu/Menu.js
+++ b/packages/react/src/components/Menu/Menu.js
@@ -31,6 +31,7 @@ const Menu = React.forwardRef(function Menu(
className,
label,
onClose,
+ onOpen,
open,
size = 'sm',
target = document.body,
@@ -79,7 +80,17 @@ const Menu = React.forwardRef(function Menu(
function handleOpen() {
if (menu.current) {
focusReturn.current = document.activeElement;
- setPosition(calculatePosition());
+
+ const pos = calculatePosition();
+ menu.current.style.left = `${pos[0]}px`;
+ menu.current.style.top = `${pos[1]}px`;
+ setPosition(pos);
+
+ menu.current.focus();
+
+ if (onOpen) {
+ onOpen();
+ }
}
}
@@ -246,12 +257,7 @@ const Menu = React.forwardRef(function Menu(
aria-label={label}
tabIndex={-1}
onKeyDown={handleKeyDown}
- onBlur={handleBlur}
- // eslint-disable-next-line react/forbid-dom-props
- style={{
- left: `${position[0]}px`,
- top: `${position[1]}px`,
- }}>
+ onBlur={handleBlur}>
{children}
@@ -281,6 +287,11 @@ Menu.propTypes = {
*/
onClose: PropTypes.func,
+ /**
+ * Provide an optional function to be called when the Menu is opened.
+ */
+ onOpen: PropTypes.func,
+
/**
* Whether the Menu is open or not.
*/
diff --git a/packages/react/src/components/MenuButton/MenuButton-test.js b/packages/react/src/components/MenuButton/MenuButton-test.js
new file mode 100644
index 000000000000..f95541549ab1
--- /dev/null
+++ b/packages/react/src/components/MenuButton/MenuButton-test.js
@@ -0,0 +1,115 @@
+/**
+ * Copyright IBM Corp. 2016, 2023
+ *
+ * This source code is licensed under the Apache-2.0 license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import React from 'react';
+
+import { MenuItem } from '../Menu';
+
+import { MenuButton } from './';
+
+const prefix = 'cds';
+
+describe('MenuButton', () => {
+ describe('renders as expected - Component API', () => {
+ it('supports a ref on the outermost element', () => {
+ const ref = jest.fn();
+ const { container } = render(
+
+
+
+ );
+ expect(ref).toHaveBeenCalledWith(container.firstChild);
+ });
+
+ it('supports a custom class name on the outermost element', () => {
+ const { container } = render(
+
+
+
+ );
+ expect(container.firstChild).toHaveClass('test');
+ });
+
+ it('forwards additional props on the outermost element', () => {
+ const { container } = render(
+
+
+
+ );
+ expect(container.firstChild).toHaveAttribute('data-testid', 'test');
+ });
+
+ it('renders props.label on the trigger button', () => {
+ render(
+
+
+
+ );
+ expect(screen.getByRole('button')).toHaveTextContent(/^Test$/);
+ });
+
+ it('supports props.disabled', () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByRole('button')).toBeDisabled();
+ });
+
+ describe('supports props.size', () => {
+ // Button component doesn't apply any size class for `lg`
+ const sizes = ['sm', 'md'];
+
+ sizes.forEach((size) => {
+ it(`size="${size}"`, () => {
+ const { container } = render(
+
+
+
+ );
+
+ expect(container.firstChild).toHaveClass(`${prefix}--btn--${size}`);
+ });
+ });
+ });
+
+ describe('supports props.kind', () => {
+ const kinds = ['primary', 'tertiary', 'ghost'];
+
+ kinds.forEach((kind) => {
+ it(`kind="${kind}"`, () => {
+ const { container } = render(
+
+
+
+ );
+
+ expect(container.firstChild).toHaveClass(`${prefix}--btn--${kind}`);
+ });
+ });
+ });
+ });
+
+ describe('behaves as expected', () => {
+ it('opens a menu on click', async () => {
+ render(
+
+
+
+ );
+
+ await userEvent.click(screen.getByRole('button'));
+
+ expect(screen.getByRole('menu')).toBeTruthy();
+ expect(screen.getByRole('menuitem')).toHaveTextContent(/^Action$/);
+ });
+ });
+});
diff --git a/packages/react/src/components/MenuButton/MenuButton.mdx b/packages/react/src/components/MenuButton/MenuButton.mdx
new file mode 100644
index 000000000000..e63b4bbd1371
--- /dev/null
+++ b/packages/react/src/components/MenuButton/MenuButton.mdx
@@ -0,0 +1,36 @@
+import { ArgsTable, Canvas, Story } from '@storybook/addon-docs';
+
+# MenuButton
+
+[Source code](https://github.com/carbon-design-system/carbon/tree/main/packages/react/src/components/MenuButton)
+
+
+
+
+- [Overview](#overview)
+- [Component API](#component-api)
+- [Feedback](#feedback)
+
+
+
+## Overview
+
+A `MenuButton` can be used to group a set of actions that are related. These actions must be `MenuItem`s passed as `children`. The trigger buttons's label is passed as `props.label`.
+
+```jsx
+
+
+
+
+
+```
+
+## Component API
+
+
+
+## Feedback
+
+Help us improve this component by providing feedback, asking questions on Slack,
+or updating this file on
+[GitHub](https://github.com/carbon-design-system/carbon/edit/main/packages/react/src/components/MenuButton/MenuButton.mdx).
diff --git a/packages/react/src/components/MenuButton/MenuButton.stories.js b/packages/react/src/components/MenuButton/MenuButton.stories.js
new file mode 100644
index 000000000000..67ff145c577a
--- /dev/null
+++ b/packages/react/src/components/MenuButton/MenuButton.stories.js
@@ -0,0 +1,87 @@
+/**
+ * Copyright IBM Corp. 2023
+ *
+ * This source code is licensed under the Apache-2.0 license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React from 'react';
+import { action } from '@storybook/addon-actions';
+
+import { MenuItem, MenuItemDivider } from '../Menu';
+
+import { MenuButton } from './';
+import mdx from './MenuButton.mdx';
+
+export default {
+ title: 'Experimental/unstable__MenuButton',
+ component: MenuButton,
+ subcomponents: {
+ MenuItem,
+ MenuItemDivider,
+ },
+ parameters: {
+ docs: {
+ page: mdx,
+ },
+ },
+};
+
+export const Default = () => (
+
+
+
+
+
+);
+
+export const WithDanger = () => (
+
+
+
+
+
+
+
+);
+
+export const WithDividers = () => (
+
+
+
+
+
+
+
+
+
+
+);
+
+export const Playground = (args) => {
+ const onClick = action('onClick (MenuItem)');
+
+ return (
+
+
+
+
+
+ );
+};
+
+Playground.argTypes = {
+ children: {
+ table: {
+ disable: true,
+ },
+ },
+ className: {
+ table: {
+ disable: true,
+ },
+ },
+ label: {
+ defaultValue: 'Actions',
+ },
+};
diff --git a/packages/react/src/components/MenuButton/docs/overview.mdx b/packages/react/src/components/MenuButton/docs/overview.mdx
new file mode 100644
index 000000000000..57746b222302
--- /dev/null
+++ b/packages/react/src/components/MenuButton/docs/overview.mdx
@@ -0,0 +1,13 @@
+## Live demo
+
+
diff --git a/packages/react/src/components/MenuButton/index.js b/packages/react/src/components/MenuButton/index.js
new file mode 100644
index 000000000000..b15b959c356f
--- /dev/null
+++ b/packages/react/src/components/MenuButton/index.js
@@ -0,0 +1,140 @@
+/**
+ * Copyright IBM Corp. 2023
+ *
+ * This source code is licensed under the Apache-2.0 license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React, { useRef, useState } from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+
+import { ChevronDown } from '@carbon/icons-react';
+import { Button } from '../Button';
+import { Menu } from '../Menu';
+
+import { useAttachedMenu } from '../../internal/useAttachedMenu';
+import { useId } from '../../internal/useId';
+import { useMergedRefs } from '../../internal/useMergedRefs';
+import { usePrefix } from '../../internal/usePrefix';
+
+const spacing = 4; // top and bottom spacing between the button and the menu. in px
+const validButtonKinds = ['primary', 'tertiary', 'ghost'];
+const defaultButtonKind = 'primary';
+
+const MenuButton = React.forwardRef(function MenuButton(
+ {
+ children,
+ className,
+ disabled,
+ kind = defaultButtonKind,
+ label,
+ size = 'lg',
+ ...rest
+ },
+ forwardRef
+) {
+ const id = useId('MenuButton');
+ const prefix = usePrefix();
+
+ const triggerRef = useRef(null);
+ const menuRef = useRef(null);
+ const ref = useMergedRefs([forwardRef, triggerRef]);
+ const [width, setWidth] = useState(0);
+ const {
+ open,
+ x,
+ y,
+ handleClick: hookOnClick,
+ handleMousedown,
+ handleClose,
+ } = useAttachedMenu(triggerRef);
+
+ function handleClick() {
+ if (triggerRef.current) {
+ const { width: w } = triggerRef.current.getBoundingClientRect();
+ setWidth(w);
+ hookOnClick();
+ }
+ }
+
+ function handleOpen() {
+ menuRef.current.style.width = `${width}px`;
+ }
+
+ const triggerClasses = classNames(
+ `${prefix}--menu-button__trigger`,
+ {
+ [`${prefix}--menu-button__trigger--open`]: open,
+ },
+ className
+ );
+
+ const buttonKind = validButtonKinds.includes(kind) ? kind : defaultButtonKind;
+
+ return (
+ <>
+
+
+ >
+ );
+});
+
+MenuButton.propTypes = {
+ /**
+ * A collection of MenuItems to be rendered as actions for this MenuButton.
+ */
+ children: PropTypes.node.isRequired,
+
+ /**
+ * Additional CSS class names.
+ */
+ className: PropTypes.string,
+
+ /**
+ * Specify whether the MenuButton should be disabled, or not.
+ */
+ disabled: PropTypes.bool,
+
+ /**
+ * Specify the type of button to be used as the base for the trigger button.
+ */
+ kind: PropTypes.oneOf(validButtonKinds),
+
+ /**
+ * Provide the label to be renderd on the trigger button.
+ */
+ label: PropTypes.string.isRequired,
+
+ /**
+ * Specify the size of the button and menu.
+ */
+ size: PropTypes.oneOf(['sm', 'md', 'lg']),
+};
+
+export { MenuButton };
diff --git a/packages/react/src/components/OverflowMenuV2/OverflowMenuv2.stories.js b/packages/react/src/components/OverflowMenuV2/OverflowMenuv2.stories.js
index 2c74202ecfeb..16f1c7351cdf 100644
--- a/packages/react/src/components/OverflowMenuV2/OverflowMenuv2.stories.js
+++ b/packages/react/src/components/OverflowMenuV2/OverflowMenuv2.stories.js
@@ -10,13 +10,26 @@ import { action } from '@storybook/addon-actions';
import { ArrowsVertical } from '@carbon/icons-react';
-import { MenuItem, MenuItemRadioGroup, MenuItemDivider } from '../Menu';
+import {
+ MenuItem,
+ MenuItemDivider,
+ MenuItemGroup,
+ MenuItemRadioGroup,
+ MenuItemSelectable,
+} from '../Menu';
import { OverflowMenuV2 } from './';
export default {
title: 'Experimental/unstable__OverflowMenuV2',
component: OverflowMenuV2,
+ subcomponents: {
+ MenuItem,
+ MenuItemSelectable,
+ MenuItemGroup,
+ MenuItemRadioGroup,
+ MenuItemDivider,
+ },
};
export const _OverflowMenuV2 = () => {
diff --git a/packages/react/src/components/OverflowMenuV2/index.js b/packages/react/src/components/OverflowMenuV2/index.js
index 9c8885b188e7..13674400e65a 100644
--- a/packages/react/src/components/OverflowMenuV2/index.js
+++ b/packages/react/src/components/OverflowMenuV2/index.js
@@ -5,13 +5,16 @@
* LICENSE file in the root directory of this source tree.
*/
-import React, { useState, useRef } from 'react';
+import React, { useRef } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { OverflowMenuVertical } from '@carbon/icons-react';
-import { useId } from '../../internal/useId';
+
import { Menu } from '../Menu';
+
+import { useId } from '../../internal/useId';
import { usePrefix } from '../../internal/usePrefix';
+import { useAttachedMenu } from '../../internal/useAttachedMenu';
const defaultSize = 'md';
@@ -23,46 +26,11 @@ function OverflowMenuV2({
...rest
}) {
const id = useId('overflowmenu');
- const [open, setOpen] = useState(false);
- const [position, setPosition] = useState([
- [0, 0],
- [0, 0],
- ]);
- const triggerRef = useRef(null);
const prefix = usePrefix();
- function openMenu() {
- if (triggerRef.current) {
- const { left, top, right, bottom } =
- triggerRef.current.getBoundingClientRect();
- setPosition([
- [left, right],
- [top, bottom],
- ]);
- }
-
- setOpen(true);
- }
-
- function closeMenu() {
- setOpen(false);
- }
-
- function handleClick() {
- if (open) {
- closeMenu();
- } else {
- openMenu();
- }
- }
-
- function handleMousedown(e) {
- // prevent default for mousedown on trigger element to avoid
- // the "blur" event from firing on the menu as this would close
- // it and immediately re-open since "click" event is fired after
- // "blur" event.
- e.preventDefault();
- }
+ const triggerRef = useRef(null);
+ const { open, x, y, handleClick, handleMousedown, handleClose } =
+ useAttachedMenu(triggerRef);
const containerClasses = classNames(`${prefix}--overflow-menu__container`);
@@ -88,13 +56,7 @@ function OverflowMenuV2({
ref={triggerRef}>
-