From b9874d1fbf712a5fb3d375a82ce8fb4d8fda3e6f Mon Sep 17 00:00:00 2001 From: Adam Alston Date: Tue, 25 Feb 2025 00:02:31 -0500 Subject: [PATCH] refactor: convert OverflowMenu to functional component and improve types --- .../components/OverflowMenu/OverflowMenu.tsx | 896 +++++++++--------- .../src/components/OverflowMenu/index.tsx | 10 +- .../components/OverflowMenu/next/index.tsx | 11 +- .../react/src/internal/createClassWrapper.tsx | 23 +- 4 files changed, 456 insertions(+), 484 deletions(-) diff --git a/packages/react/src/components/OverflowMenu/OverflowMenu.tsx b/packages/react/src/components/OverflowMenu/OverflowMenu.tsx index 005bfeea1952..3288872dbeb8 100644 --- a/packages/react/src/components/OverflowMenu/OverflowMenu.tsx +++ b/packages/react/src/components/OverflowMenu/OverflowMenu.tsx @@ -1,36 +1,58 @@ /** - * Copyright IBM Corp. 2016, 2023 + * Copyright IBM Corp. 2016, 2025 * * 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, { + Children, + cloneElement, + forwardRef, + isValidElement, + KeyboardEvent, + MouseEvent, + useCallback, + useContext, + useEffect, + useRef, + useState, + type ElementType, + type ReactElement, + type ReactNode, + type Ref, +} from 'react'; +import { OverflowMenuVertical } from '@carbon/icons-react'; +import classNames from 'classnames'; +import invariant from 'invariant'; +import PropTypes from 'prop-types'; +import ClickListener from '../../internal/ClickListener'; import FloatingMenu, { DIRECTION_BOTTOM, DIRECTION_TOP, } from '../../internal/FloatingMenu'; -import React, { ComponentType } from 'react'; import { matches as keyCodeMatches, keys } from '../../internal/keyboard'; - -import ClickListener from '../../internal/ClickListener'; -import { IconButton } from '../IconButton'; -import { OverflowMenuVertical } from '@carbon/icons-react'; +import { noopFn } from '../../internal/noopFn'; import { PrefixContext } from '../../internal/usePrefix'; -import PropTypes from 'prop-types'; -import classNames from 'classnames'; import deprecate from '../../prop-types/deprecate'; -import invariant from 'invariant'; import mergeRefs from '../../tools/mergeRefs'; -import { noopFn } from '../../internal/noopFn'; import setupGetInstanceId from '../../tools/setupGetInstanceId'; +import { IconButton } from '../IconButton'; const getInstanceId = setupGetInstanceId(); -const on = (element, ...args) => { - element.addEventListener(...args); +const on = ( + target: EventTarget, + ...args: [ + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions, + ] +) => { + target.addEventListener(...args); return { release() { - element.removeEventListener(...args); + target.removeEventListener(...args); return null; }, }; @@ -38,7 +60,6 @@ const on = (element, ...args) => { /** * The CSS property names of the arrow keyed by the floating menu direction. - * @type {[key: string]: string} */ const triggerButtonPositionProps = { [DIRECTION_TOP]: 'bottom', @@ -46,8 +67,8 @@ const triggerButtonPositionProps = { }; /** - * Determines how the position of arrow should affect the floating menu position. - * @type {[key: string]: number} + * Determines how the position of the arrow should affect the floating menu + * position. */ const triggerButtonPositionFactors = { [DIRECTION_TOP]: -2, @@ -55,12 +76,19 @@ const triggerButtonPositionFactors = { }; /** - * @param {Element} menuBody The menu body with the menu arrow. - * @param {string} direction The floating menu direction. - * @returns {FloatingMenu~offset} The adjustment of the floating menu position, upon the position of the menu arrow. - * @private + * Calculates the offset for the floating menu. + * + * @param menuBody - The menu body with the menu arrow. + * @param direction - The floating menu direction. + * @returns The adjustment of the floating menu position, upon the position of + * the menu arrow. */ -export const getMenuOffset = (menuBody, direction, trigger, flip) => { +export const getMenuOffset = ( + menuBody: HTMLElement, + direction: string, + trigger: HTMLElement | null, + flip: boolean +) => { const triggerButtonPositionProp = triggerButtonPositionProps[direction]; const triggerButtonPositionFactor = triggerButtonPositionFactors[direction]; if (__DEV__) { @@ -82,22 +110,16 @@ export const getMenuOffset = (menuBody, direction, trigger, flip) => { top: 0, }; } - default: break; } }; interface Offset { - top?: number | null | undefined; - left?: number | null | undefined; + top?: number | null; + left?: number | null; } -type IconProps = { - className?: string; - 'aria-label'?: string; -}; - export interface OverflowMenuProps { /** * Specify a label to be read by screen readers on the container node @@ -105,20 +127,20 @@ export interface OverflowMenuProps { ['aria-label']?: string; /** - * Deprecated, please use `aria-label` instead. * Specify a label to be read by screen readers on the container note. - * @deprecated - * */ - ariaLabel: string; + * + * @deprecated - Use `aria-label` instead. + */ + ariaLabel?: string; /** * The child nodes. - * */ - children: React.ReactNode; + */ + children: ReactNode; /** - * The CSS class names. - * */ + * The CSS class names. + */ className?: string; /** @@ -195,7 +217,7 @@ export interface OverflowMenuProps { /** * Function called to override icon rendering. */ - renderIcon?: ComponentType; + renderIcon?: ElementType; /** * Specify a CSS selector that matches the DOM element that should @@ -211,428 +233,250 @@ export interface OverflowMenuProps { /** * The ref to the HTML element that should receive focus when the OverflowMenu opens */ - innerRef?: React.Ref; -} - -export interface OverflowMenuState { - open: boolean; - prevOpen?: boolean; - hasMountedTrigger: boolean; - click: boolean; -} - -interface ReleaseHandle { - release: () => null; + innerRef?: Ref; } -class OverflowMenu extends React.Component< - OverflowMenuProps, - OverflowMenuState -> { - state: OverflowMenuState = { - open: false, // Set a default value for 'open' - hasMountedTrigger: false, // Set a default value for 'hasMountedTrigger' - click: false, // Set a default value for 'click' - }; - instanceId = getInstanceId(); - - static propTypes = { - /** - * Specify a label to be read by screen readers on the container node - */ - ['aria-label']: PropTypes.string, - - /** - * Deprecated, please use `aria-label` instead. - * Specify a label to be read by screen readers on the container note. - */ - ariaLabel: deprecate( - PropTypes.string, - 'This prop syntax has been deprecated. Please use the new `aria-label`.' - ), - - /** - * The child nodes. - */ - children: PropTypes.node, - - /** - * The CSS class names. - */ - className: PropTypes.string, - - /** - * The menu direction. - */ - direction: PropTypes.oneOf([DIRECTION_TOP, DIRECTION_BOTTOM]), - - /** - * `true` if the menu alignment should be flipped. - */ - flipped: PropTypes.bool, - - /** - * Enable or disable focus trap behavior - */ - focusTrap: PropTypes.bool, - - /** - * The CSS class for the icon. - */ - iconClass: PropTypes.string, - - /** - * The icon description. - */ - iconDescription: PropTypes.string, - - /** - * The element ID. - */ - id: PropTypes.string, - - /** - * `true` to use the light version. For use on $ui-01 backgrounds only. - * Don't use this to make OverflowMenu background color same as container background color. - */ - light: deprecate( - PropTypes.bool, - 'The `light` prop for `OverflowMenu` is no longer needed and has been deprecated. It will be removed in the next major release. Use the Layer component instead.' - ), - - /** - * The adjustment in position applied to the floating menu. - */ - menuOffset: PropTypes.oneOfType([ - PropTypes.shape({ - top: PropTypes.number, - left: PropTypes.number, - }), - PropTypes.func, - ]), - - /** - * The adjustment in position applied to the floating menu. - */ - menuOffsetFlip: PropTypes.oneOfType([ - PropTypes.shape({ - top: PropTypes.number, - left: PropTypes.number, - }), - PropTypes.func, - ]), - - /** - * The class to apply to the menu options - */ - menuOptionsClass: PropTypes.string, - - /** - * The event handler for the `click` event. - */ - onClick: PropTypes.func, - - /** - * Function called when menu is closed - */ - onClose: PropTypes.func, - - /** - * The event handler for the `focus` event. - */ - onFocus: PropTypes.func, - - /** - * The event handler for the `keydown` event. - */ - onKeyDown: PropTypes.func, - - /** - * Function called when menu is opened - */ - onOpen: PropTypes.func, - - /** - * `true` if the menu should be open. - */ - open: PropTypes.bool, - - /** - * Function called to override icon rendering. - */ - renderIcon: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), - - /** - * Specify a CSS selector that matches the DOM element that should - * be focused when the OverflowMenu opens - */ - selectorPrimaryFocus: PropTypes.string, - - /** - * Specify the size of the OverflowMenu. Currently supports either `sm`, 'md' (default) or 'lg` as an option. - */ - size: PropTypes.oneOf(['sm', 'md', 'lg']), - }; - - static contextType = PrefixContext; - - /** - * The handle of `onfocusin` or `focus` event handler. - * @private - */ - - _hFocusIn: ReleaseHandle | null = null; - - /** - * The timeout handle for handling `blur` event. - * @private - */ - _hBlurTimeout; - - /** - * The element ref of the tooltip's trigger button. - * @type {React.RefObject} - * @private - */ - _triggerRef = React.createRef(); - - componentDidUpdate(_, prevState) { - const { onClose = noopFn } = this.props; - if (!this.state.open && prevState.open) { - onClose(); - } - } +export const OverflowMenu = forwardRef( + ( + { + ['aria-label']: ariaLabel = null, + ariaLabel: deprecatedAriaLabel, + children, + className, + direction = DIRECTION_BOTTOM, + flipped = false, + focusTrap = true, + iconClass, + iconDescription = 'Options', + id, + light, + menuOffset = getMenuOffset, + menuOffsetFlip = getMenuOffset, + menuOptionsClass, + onClick = noopFn, + onClose = noopFn, + onOpen = noopFn, + open: openProp, + renderIcon: IconElement = OverflowMenuVertical, + selectorPrimaryFocus = '[data-floating-menu-primary-focus]', + size = 'md', + ...other + }, + ref + ) => { + const prefix = useContext(PrefixContext); + const [open, setOpen] = useState(openProp ?? false); + const [click, setClick] = useState(false); + const [hasMountedTrigger, setHasMountedTrigger] = useState(false); + /** The handle of `onfocusin` or `focus` event handler. */ + const hFocusIn = useRef<{ release: () => null } | null>(null); + const instanceId = useRef(getInstanceId()); + const menuBodyRef = useRef(null); + const menuItemRefs = useRef>({}); + const prevOpenProp = useRef(openProp); + const prevOpenState = useRef(open); + /** The element ref of the tooltip's trigger button. */ + const triggerRef = useRef(null); + + // Sync open prop changes. + useEffect(() => { + if (prevOpenProp.current !== openProp) { + setOpen(!!openProp); + prevOpenProp.current = openProp; + } + }, [openProp]); - componentDidMount() { - // ensure that if open=true on first render, we wait - // to render the floating menu until the trigger ref is not null - if (this._triggerRef.current) { - this.setState({ hasMountedTrigger: true }); - } - } + // Mark trigger as mounted. + useEffect(() => { + if (triggerRef.current) { + setHasMountedTrigger(true); + } + }, []); - static getDerivedStateFromProps({ open }, state) { - const { prevOpen } = state; - return prevOpen === open - ? null - : { - open, - prevOpen: open, - }; - } + // Call `onClose` when menu closes. + useEffect(() => { + if (!open && prevOpenState.current) { + onClose(); + } + prevOpenState.current = open; + }, [open, onClose]); - componentWillUnmount() { - if (typeof this._hBlurTimeout === 'number') { - clearTimeout(this._hBlurTimeout); - this._hBlurTimeout = undefined; - } - } + const focusMenuEl = useCallback(() => { + if (triggerRef.current) { + triggerRef.current.focus(); + } + }, []); - handleClick = (evt) => { - const { onClick = noopFn } = this.props; - this.setState({ click: true }); - if (!this._menuBody || !this._menuBody.contains(evt.target)) { - this.setState({ open: !this.state.open }); - onClick(evt); - } - }; + const closeMenu = useCallback( + (onCloseMenu?: () => void) => { + setOpen(false); + // Optional callback to be executed after the state as been set to close + if (onCloseMenu) { + onCloseMenu(); + } + onClose(); + }, + [onClose] + ); - closeMenuAndFocus = () => { - const wasClicked = this.state.click; - const wasOpen = this.state.open; - this.closeMenu(() => { - if (wasOpen && !wasClicked) { - this.focusMenuEl(); + const closeMenuAndFocus = useCallback(() => { + const wasClicked = click; + const wasOpen = open; + closeMenu(() => { + if (wasOpen && !wasClicked) { + focusMenuEl(); + } + }); + }, [click, open, closeMenu, focusMenuEl]); + + const closeMenuOnEscape = useCallback(() => { + const wasOpen = open; + closeMenu(() => { + if (wasOpen) { + focusMenuEl(); + } + }); + }, [open, closeMenu, focusMenuEl]); + + const handleClick = (evt: MouseEvent) => { + setClick(true); + if ( + !menuBodyRef.current || + !menuBodyRef.current.contains(evt.target as Node) + ) { + setOpen((prev) => !prev); + onClick(evt); } - }); - }; + }; - closeMenuOnEscape = () => { - const wasOpen = this.state.open; - this.closeMenu(() => { - if (wasOpen) { - this.focusMenuEl(); + const handleKeyPress = (evt: KeyboardEvent) => { + if ( + open && + keyCodeMatches(evt, [ + keys.ArrowUp, + keys.ArrowRight, + keys.ArrowDown, + keys.ArrowLeft, + ]) + ) { + evt.preventDefault(); } - }); - }; - - handleKeyPress = (evt) => { - if ( - this.state.open && - keyCodeMatches(evt, [ - keys.ArrowUp, - keys.ArrowRight, - keys.ArrowDown, - keys.ArrowLeft, - ]) - ) { - evt.preventDefault(); - } - // Close the overflow menu on escape - if (keyCodeMatches(evt, [keys.Escape])) { - this.closeMenuOnEscape(); + // Close the overflow menu on escape + if (keyCodeMatches(evt, [keys.Escape])) { + closeMenuOnEscape(); - // Stop the esc keypress from bubbling out and closing something it shouldn't - evt.stopPropagation(); - } - }; - - handleClickOutside = (evt) => { - if ( - this.state.open && - (!this._menuBody || !this._menuBody.contains(evt.target)) - ) { - this.closeMenu(); - } - }; + // Stop the esc keypress from bubbling out and closing something it shouldn't + evt.stopPropagation(); + } + }; - closeMenu = (onCloseMenu?) => { - const { onClose = noopFn } = this.props; - this.setState({ open: false }, () => { - // Optional callback to be executed after the state as been set to close - if (onCloseMenu) { - onCloseMenu(); + const handleClickOutside = (evt: MouseEvent) => { + if ( + open && + (!menuBodyRef.current || + !menuBodyRef.current.contains(evt.target as Node)) + ) { + closeMenu(); } - onClose(); - }); - }; + }; - focusMenuEl = () => { - const { current: triggerEl } = this._triggerRef; - if (triggerEl) { - (triggerEl as HTMLElement).focus(); - } - }; + /** + * Focuses the next enabled overflow menu item given the currently focused + * item index and direction to move. + */ + const handleOverflowMenuItemFocus = ({ + currentIndex, + direction, + }: { + /** + * The index of the currently focused overflow menu item in the list of + * overflow menu items + */ + currentIndex: number; + /** + * Number denoting the direction to move focus (1 for forwards, -1 for + * backwards). + */ + direction: number; + }) => { + const enabledIndices = Children.toArray(children).reduce( + (acc, curr, i) => { + if (isValidElement(curr) && !curr.props.disabled) { + acc.push(i); + } + return acc; + }, + [] + ); + const nextValidIndex = (() => { + const nextIndex = enabledIndices.indexOf(currentIndex) + direction; + switch (nextIndex) { + case -1: + return enabledIndices.length - 1; + case enabledIndices.length: + return 0; + default: + return nextIndex; + } + })(); + const overflowMenuItem = + menuItemRefs.current[enabledIndices[nextValidIndex]]; + overflowMenuItem?.focus(); + }; - /** - * Focuses the next enabled overflow menu item given the currently focused - * item index and direction to move - * @param {object} params - * @param {number} params.currentIndex - the index of the currently focused - * overflow menu item in the list of overflow menu items - * @param {number} params.direction - number denoting the direction to move - * focus (1 for forwards, -1 for backwards) - */ - handleOverflowMenuItemFocus = ({ currentIndex, direction }) => { - const enabledIndices: number[] = React.Children.toArray( - this.props.children - ).reduce((acc: number[], curr, i) => { - if (React.isValidElement(curr) && !curr.props.disabled) { - acc.push(i); + const bindMenuBody = (menuBody: HTMLElement | null) => { + if (!menuBody) { + menuBodyRef.current = menuBody; } - return acc; - }, []); - const nextValidIndex = (() => { - const nextIndex = enabledIndices.indexOf(currentIndex) + direction; - switch (nextIndex) { - case -1: - return enabledIndices.length - 1; - case enabledIndices.length: - return 0; - default: - return nextIndex; + if (!menuBody && hFocusIn.current) { + hFocusIn.current = hFocusIn.current.release(); } - })(); - const overflowMenuItem = - this[`overflowMenuItem${enabledIndices[nextValidIndex]}`]; - overflowMenuItem?.focus(); - }; - - /** - * Handles the floating menu being unmounted or non-floating menu being - * mounted or unmounted. - * @param {Element} menuBody The DOM element of the menu body. - * @private - */ - _menuBody: HTMLElement | null = null; + }; - _bindMenuBody = (menuBody: HTMLElement | null) => { - if (!menuBody) { - this._menuBody = menuBody; - } - if (!menuBody && this._hFocusIn) { - this._hFocusIn = this._hFocusIn.release(); - } - }; + const handlePlace = (menuBody: HTMLElement) => { + if (!menuBody) return; - /** - * Handles the floating menu being placed. - * @param {Element} menuBody The DOM element of the menu body. - * @private - */ - _handlePlace = (menuBody) => { - const { onOpen = noopFn } = this.props; - if (menuBody) { - this._menuBody = menuBody; + menuBodyRef.current = menuBody; const hasFocusin = 'onfocusin' in window; const focusinEventName = hasFocusin ? 'focusin' : 'focus'; - this._hFocusIn = on( + hFocusIn.current = on( menuBody.ownerDocument, focusinEventName, - (event) => { - const target = ClickListener.getEventTarget(event); - const { current: triggerEl } = this._triggerRef; + (event: Event) => { + const target = event.target as HTMLElement; + const triggerEl = triggerRef.current; if (typeof target.matches === 'function') { if ( !menuBody.contains(target) && triggerEl && !target.matches( - `.${this.context}--overflow-menu:first-child,.${this.context}--overflow-menu-options:first-child` + `.${prefix}--overflow-menu:first-child, .${prefix}--overflow-menu-options:first-child` ) ) { - this.closeMenuAndFocus(); + closeMenuAndFocus(); } } }, !hasFocusin ); onOpen(); - } - }; - - /** - * @returns {Element} The DOM element where the floating menu is placed in. - */ - _getTarget = () => { - const { current: triggerEl } = this._triggerRef; - return ( - (triggerEl instanceof Element && - triggerEl.closest('[data-floating-menu-container]')) || - document.body - ); - }; + }; - render() { - const prefix = this.context; - const { - id, - ['aria-label']: ariaLabel = null, - ariaLabel: deprecatedAriaLabel, - children, - iconDescription = 'Options', - direction = DIRECTION_BOTTOM, - flipped = false, - focusTrap = true, - menuOffset = getMenuOffset, - menuOffsetFlip = getMenuOffset, - iconClass, - onClick = noopFn, // eslint-disable-line - onOpen = noopFn, // eslint-disable-line - selectorPrimaryFocus = '[data-floating-menu-primary-focus]', // eslint-disable-line - renderIcon: IconElement = OverflowMenuVertical, - // eslint-disable-next-line react/prop-types - innerRef: ref, - menuOptionsClass, - light, - size = 'md', - ...other - } = this.props; + const getTarget = () => { + const triggerEl = triggerRef.current; + if (triggerEl instanceof Element) { + return ( + triggerEl.closest('[data-floating-menu-container]') || document.body + ); + } + return document.body; + }; - const { open = false } = this.state; + const menuBodyId = `overflow-menu-${instanceId.current}__menu-body`; const overflowMenuClasses = classNames( - this.props.className, + className, `${prefix}--overflow-menu`, { [`${prefix}--overflow-menu--open`]: open, @@ -645,7 +489,7 @@ class OverflowMenu extends React.Component< menuOptionsClass, `${prefix}--overflow-menu-options`, { - [`${prefix}--overflow-menu--flip`]: this.props.flipped, + [`${prefix}--overflow-menu--flip`]: flipped, [`${prefix}--overflow-menu-options--open`]: open, [`${prefix}--overflow-menu-options--light`]: light, [`${prefix}--overflow-menu-options--${size}`]: size, @@ -657,22 +501,20 @@ class OverflowMenu extends React.Component< iconClass ); - const childrenWithProps = React.Children.toArray(children).map( - (child, index) => - React.isValidElement(child) - ? React.cloneElement(child, { - // @ts-expect-error: PropTypes are not expressive enough to cover this case - closeMenu: child.props.closeMenu || this.closeMenuAndFocus, - handleOverflowMenuItemFocus: this.handleOverflowMenuItemFocus, - ref: (e) => { - this[`overflowMenuItem${index}`] = e; - }, - index, - }) - : null - ); - - const menuBodyId = `overflow-menu-${this.instanceId}__menu-body`; + const childrenWithProps = Children.toArray(children).map((child, index) => { + if (isValidElement(child)) { + const childElement = child as ReactElement; + return cloneElement(childElement, { + closeMenu: childElement.props.closeMenu || closeMenuAndFocus, + handleOverflowMenuItemFocus, + ref: (el: HTMLElement) => { + menuItemRefs.current[index] = el; + }, + index, + }); + } + return null; + }); const menuBody = ( @@ -689,27 +531,22 @@ class OverflowMenu extends React.Component< const wrappedMenuBody = ( - {React.cloneElement(menuBody, { + menuRef={bindMenuBody} + flipped={flipped} + target={getTarget} + onPlace={handlePlace} + selectorPrimaryFocus={selectorPrimaryFocus}> + {cloneElement(menuBody, { 'data-floating-menu-direction': direction, })} ); - const iconProps = { - className: overflowMenuIconClasses, - 'aria-label': iconDescription, - }; - return ( - + @@ -720,24 +557,161 @@ class OverflowMenu extends React.Component< aria-expanded={open} aria-controls={open ? menuBodyId : undefined} className={overflowMenuClasses} - onClick={this.handleClick} + onClick={handleClick} id={id} - ref={mergeRefs(this._triggerRef, ref)} + ref={mergeRefs(triggerRef, ref)} size={size} label={iconDescription} kind="ghost"> - + - {open && this.state.hasMountedTrigger && wrappedMenuBody} + {open && hasMountedTrigger && wrappedMenuBody} ); } -} +); + +OverflowMenu.propTypes = { + /** + * Specify a label to be read by screen readers on the container node + */ + ['aria-label']: PropTypes.string, + + /** + * Deprecated, please use `aria-label` instead. + * Specify a label to be read by screen readers on the container note. + */ + ariaLabel: deprecate( + PropTypes.string, + 'This prop syntax has been deprecated. Please use the new `aria-label`.' + ), + + /** + * The child nodes. + */ + children: PropTypes.node, + + /** + * The CSS class names. + */ + className: PropTypes.string, + + /** + * The menu direction. + */ + direction: PropTypes.oneOf([DIRECTION_TOP, DIRECTION_BOTTOM]), + + /** + * `true` if the menu alignment should be flipped. + */ + flipped: PropTypes.bool, + + /** + * Enable or disable focus trap behavior + */ + focusTrap: PropTypes.bool, + + /** + * The CSS class for the icon. + */ + iconClass: PropTypes.string, + + /** + * The icon description. + */ + iconDescription: PropTypes.string, + + /** + * The element ID. + */ + id: PropTypes.string, + + /** + * `true` to use the light version. For use on $ui-01 backgrounds only. + * Don't use this to make OverflowMenu background color same as container background color. + */ + light: deprecate( + PropTypes.bool, + 'The `light` prop for `OverflowMenu` is no longer needed and has been deprecated. It will be removed in the next major release. Use the Layer component instead.' + ), + + /** + * The adjustment in position applied to the floating menu. + */ + menuOffset: PropTypes.oneOfType([ + PropTypes.shape({ + top: PropTypes.number, + left: PropTypes.number, + }), + PropTypes.func, + ]), + + /** + * The adjustment in position applied to the floating menu. + */ + menuOffsetFlip: PropTypes.oneOfType([ + PropTypes.shape({ + top: PropTypes.number, + left: PropTypes.number, + }), + PropTypes.func, + ]), + + /** + * The class to apply to the menu options + */ + menuOptionsClass: PropTypes.string, + + /** + * The event handler for the `click` event. + */ + onClick: PropTypes.func, + + /** + * Function called when menu is closed + */ + onClose: PropTypes.func, + + /** + * The event handler for the `focus` event. + */ + onFocus: PropTypes.func, + + /** + * The event handler for the `keydown` event. + */ + onKeyDown: PropTypes.func, + + /** + * Function called when menu is opened + */ + onOpen: PropTypes.func, + + /** + * `true` if the menu should be open. + */ + open: PropTypes.bool, + + /** + * Function called to override icon rendering. + */ + // @ts-expect-error: PropTypes are not expressive enough to cover this case + renderIcon: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + + /** + * Specify a CSS selector that matches the DOM element that should + * be focused when the OverflowMenu opens + */ + selectorPrimaryFocus: PropTypes.string, + + /** + * Specify the size of the OverflowMenu. Currently supports either `sm`, 'md' (default) or 'lg` as an option. + */ + size: PropTypes.oneOf(['sm', 'md', 'lg']), +}; -export { OverflowMenu }; -export default (() => { - const forwardRef = (props, ref) => ; - forwardRef.displayName = 'OverflowMenu'; - return React.forwardRef(forwardRef); -})(); +export default OverflowMenu; diff --git a/packages/react/src/components/OverflowMenu/index.tsx b/packages/react/src/components/OverflowMenu/index.tsx index 4d0185f8d877..e79f596ac3fd 100644 --- a/packages/react/src/components/OverflowMenu/index.tsx +++ b/packages/react/src/components/OverflowMenu/index.tsx @@ -1,5 +1,5 @@ /** - * Copyright IBM Corp. 2016, 2023 + * Copyright IBM Corp. 2016, 2025 * * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. @@ -14,11 +14,9 @@ import { OverflowMenu as OverflowMenuV12 } from './next'; import { OverflowMenu as OverflowMenuComponent } from './OverflowMenu'; import { createClassWrapper } from '../../internal/createClassWrapper'; -const OverflowMenuV11 = createClassWrapper( - OverflowMenuComponent as typeof React.Component -); +const OverflowMenuV11 = createClassWrapper(OverflowMenuComponent); -function OverflowMenu(props) { +const OverflowMenu = (props: OverflowMenuProps) => { const enableV12OverflowMenu = useFeatureFlag('enable-v12-overflowmenu'); return enableV12OverflowMenu ? ( @@ -26,7 +24,7 @@ function OverflowMenu(props) { ) : ( ); -} +}; OverflowMenu.displayName = 'OverflowMenu'; diff --git a/packages/react/src/components/OverflowMenu/next/index.tsx b/packages/react/src/components/OverflowMenu/next/index.tsx index 2f044e0f1ce0..9e7bab20ece4 100644 --- a/packages/react/src/components/OverflowMenu/next/index.tsx +++ b/packages/react/src/components/OverflowMenu/next/index.tsx @@ -1,16 +1,11 @@ /** - * Copyright IBM Corp. 2020, 2023 + * Copyright IBM Corp. 2020, 2025 * * 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, { - type ComponentType, - type FunctionComponent, - useRef, - useEffect, -} from 'react'; +import React, { useEffect, useRef, type ElementType } from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; import { OverflowMenuVertical } from '@carbon/icons-react'; @@ -71,7 +66,7 @@ interface OverflowMenuProps { /** * Optionally provide a custom icon to be rendered on the trigger button. */ - renderIcon?: ComponentType | FunctionComponent; + renderIcon?: ElementType; /** * Specify the size of the menu, from a list of available sizes. diff --git a/packages/react/src/internal/createClassWrapper.tsx b/packages/react/src/internal/createClassWrapper.tsx index 5d6610b56081..239d281d2477 100644 --- a/packages/react/src/internal/createClassWrapper.tsx +++ b/packages/react/src/internal/createClassWrapper.tsx @@ -1,25 +1,30 @@ /** - * Copyright IBM Corp. 2016, 2023 + * Copyright IBM Corp. 2016, 2025 * * 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, { ComponentClass, FunctionComponent } from 'react'; +import React, { + type ComponentClass, + type ForwardRefExoticComponent, + type FunctionComponent, + type PropsWithChildren, +} from 'react'; /** * Wrap a class component with a functional component. This prevents an end-user * from being able to pass `ref` and access the underlying class instance. */ -export function createClassWrapper( - Component: ComponentClass -): FunctionComponent { - function ClassWrapper(props) { +export const createClassWrapper = ( + Component: ComponentClass | ForwardRefExoticComponent +): FunctionComponent => { + const ClassWrapper = (props: Props) => { return ; - } + }; const name = Component.displayName || Component.name; ClassWrapper.displayName = `ClassWrapper(${name})`; - return ClassWrapper as FunctionComponent; -} + return ClassWrapper; +};