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 ( +
+
+ +
+ + + + + {children} + +
+ ); +}); + +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 ( + <> + + + {children} + + + ); +}); + +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}> - + {children} diff --git a/packages/react/src/index.js b/packages/react/src/index.js index d54843d5f1ea..47a6afb042f1 100644 --- a/packages/react/src/index.js +++ b/packages/react/src/index.js @@ -271,6 +271,8 @@ export { MenuItemSelectable as unstable_MenuItemSelectable, } from './components/Menu'; export { OverflowMenuV2 as unstable_OverflowMenuV2 } from './components/OverflowMenuV2'; +export { ComboButton as unstable_ComboButton } from './components/ComboButton'; +export { MenuButton as unstable_MenuButton } from './components/MenuButton'; export { PageSelector as unstable_PageSelector, Pagination as unstable_Pagination, diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index b181670a014a..8a1317a9525c 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -149,6 +149,8 @@ export { MenuItemSelectable as unstable_MenuItemSelectable, } from './components/Menu'; export { OverflowMenuV2 as unstable_OverflowMenuV2 } from './components/OverflowMenuV2'; +export { ComboButton as unstable_ComboButton } from './components/ComboButton'; +export { MenuButton as unstable_MenuButton } from './components/MenuButton'; export { PageSelector as unstable_PageSelector, Pagination as unstable_Pagination, diff --git a/packages/react/src/internal/useAttachedMenu.js b/packages/react/src/internal/useAttachedMenu.js new file mode 100644 index 000000000000..d6c4becc5d69 --- /dev/null +++ b/packages/react/src/internal/useAttachedMenu.js @@ -0,0 +1,76 @@ +/** + * 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 { useState } from 'react'; + +/** + * @typedef {object} useAttachedMenuReturn + * @property {boolean} open Whether the menu is open or not + * @property {[number, number]} x The x position of the menu + * @property {[number, number]} y The y position of the menu + * @property {Function} handleClick A function to be called when the trigger element receives a click event + * @property {Function} handleMousedown A function to be called when the trigger element recives a mousedown event + * @property {Function} handleClose A function to be called when the menu emits onClose + */ + +/** + * This hook contains common code to be used when a menu should be visually attached to an anchor based on a click event. + * + * @param {Element|object} anchor The element or ref the menu should visually be attached to. + * @returns {useAttachedMenuReturn} + */ +export function useAttachedMenu(anchor) { + const [open, setOpen] = useState(false); + const [position, setPosition] = useState([ + [-1, -1], + [-1, -1], + ]); + + function openMenu() { + const anchorEl = anchor?.current || anchor; + + if (anchorEl) { + const { left, top, right, bottom } = anchorEl.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(); + } + + return { + open, + x: position[0], + y: position[1], + handleClick, + handleMousedown, + handleClose: closeMenu, + }; +} diff --git a/packages/styles/scss/components/_index.scss b/packages/styles/scss/components/_index.scss index c104962401c3..b8ebb05f418a 100644 --- a/packages/styles/scss/components/_index.scss +++ b/packages/styles/scss/components/_index.scss @@ -12,6 +12,7 @@ @use 'checkbox'; @use 'code-snippet'; @use 'combo-box'; +@use 'combo-button'; @use 'contained-list'; @use 'content-switcher'; @use 'copy-button'; @@ -40,6 +41,7 @@ @use 'list'; @use 'list-box'; @use 'loading'; +@use 'menu-button'; @use 'menu'; @use 'modal'; @use 'multiselect'; diff --git a/packages/styles/scss/components/combo-button/_combo-button.scss b/packages/styles/scss/components/combo-button/_combo-button.scss new file mode 100644 index 000000000000..a30f204a3e99 --- /dev/null +++ b/packages/styles/scss/components/combo-button/_combo-button.scss @@ -0,0 +1,47 @@ +// +// 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 '../../config' as *; +@use '../../motion' as *; +@use '../../utilities/convert' as *; + +/// Combo Button styles +/// @access public +/// @group combo-button +@mixin combo-button { + .#{$prefix}--combo-button__container { + display: inline-flex; + column-gap: rem(1px); + } + + $triggerSizes: ( + 'sm': rem(32px), + 'md': rem(40px), + 'lg': rem(48px), + ); + + @each $size, $trigger in $triggerSizes { + .#{$prefix}--combo-button__container--#{$size} + .#{$prefix}--combo-button__primary-action { + min-width: 10rem - rem(1px) - $trigger; + } + } + + .#{$prefix}--combo-button__primary-action .#{$prefix}--btn { + width: 100%; + } + + .#{$prefix}--combo-button__trigger svg { + transition: transform $duration-fast-02 motion(standard, productive); + } + + .#{$prefix}--combo-button__container--open + .#{$prefix}--combo-button__trigger + svg { + transform: rotate(180deg); + } +} diff --git a/packages/styles/scss/components/combo-button/_index.scss b/packages/styles/scss/components/combo-button/_index.scss new file mode 100644 index 000000000000..4d1a361c4a99 --- /dev/null +++ b/packages/styles/scss/components/combo-button/_index.scss @@ -0,0 +1,11 @@ +// +// 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. +// + +@forward 'combo-button'; +@use 'combo-button'; + +@include combo-button.combo-button; diff --git a/packages/styles/scss/components/menu-button/_index.scss b/packages/styles/scss/components/menu-button/_index.scss new file mode 100644 index 000000000000..9da89c0ce1c5 --- /dev/null +++ b/packages/styles/scss/components/menu-button/_index.scss @@ -0,0 +1,11 @@ +// +// 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. +// + +@forward 'menu-button'; +@use 'menu-button'; + +@include menu-button.menu-button; diff --git a/packages/styles/scss/components/menu-button/_menu-button.scss b/packages/styles/scss/components/menu-button/_menu-button.scss new file mode 100644 index 000000000000..f71e5f981253 --- /dev/null +++ b/packages/styles/scss/components/menu-button/_menu-button.scss @@ -0,0 +1,27 @@ +// +// 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 '../../config' as *; +@use '../../motion' as *; +@use '../../utilities/convert' as *; + +/// Menu Button styles +/// @access public +/// @group menu-button +@mixin menu-button { + .#{$prefix}--menu-button__trigger:not(.#{$prefix}--btn--ghost) { + min-width: 10rem; + } + + .#{$prefix}--menu-button__trigger svg { + transition: transform $duration-fast-02 motion(standard, productive); + } + + .#{$prefix}--menu-button__trigger--open svg { + transform: rotate(180deg); + } +} diff --git a/packages/styles/scss/components/menu/_menu.scss b/packages/styles/scss/components/menu/_menu.scss index bee9ff38a321..164760f44c5c 100644 --- a/packages/styles/scss/components/menu/_menu.scss +++ b/packages/styles/scss/components/menu/_menu.scss @@ -27,7 +27,7 @@ position: fixed; z-index: z('modal'); - min-width: 13rem; + min-width: 10rem; max-width: 18rem; padding: $spacing-02 0; background-color: $layer; @@ -35,6 +35,10 @@ visibility: hidden; } + .#{$prefix}--menu--with-icons { + min-width: 12rem; + } + .#{$prefix}--menu--open { visibility: visible; @@ -62,7 +66,7 @@ color: $text-primary; column-gap: $spacing-03; cursor: pointer; - grid-template-columns: 0 1fr max-content; + grid-template-columns: 1fr max-content; padding-inline: $spacing-05; transition: background-color $duration-fast-01 motion(standard, productive); @@ -89,6 +93,10 @@ } .#{$prefix}--menu-item__icon { + display: none; + } + + .#{$prefix}--menu--with-icons .#{$prefix}--menu-item__icon { display: flex; }