From 136bbf26cf2119d3835c147a519f40f74ef5a60a Mon Sep 17 00:00:00 2001 From: Mahmoud Date: Thu, 19 Feb 2026 13:37:36 -0500 Subject: [PATCH 1/3] feat(overflow-menu): new menuOffset property --- .../components/OverflowMenu/OverflowMenu.tsx | 8 +- .../components/breadcrumb/breadcrumb-item.ts | 35 +-- .../src/components/floating-menu/defs.ts | 12 +- .../components/floating-menu/floating-menu.ts | 207 ++++++++++++------ .../overflow-menu/overflow-menu-body.ts | 34 ++- .../overflow-menu/overflow-menu.scss | 8 +- 6 files changed, 218 insertions(+), 86 deletions(-) diff --git a/packages/react/src/components/OverflowMenu/OverflowMenu.tsx b/packages/react/src/components/OverflowMenu/OverflowMenu.tsx index 2806409335e0..e2b6b4b2edd7 100644 --- a/packages/react/src/components/OverflowMenu/OverflowMenu.tsx +++ b/packages/react/src/components/OverflowMenu/OverflowMenu.tsx @@ -105,8 +105,8 @@ export const getMenuOffset: MenuOffset = ( direction ); } - // eslint-disable-next-line @typescript-eslint/no-unused-vars -- https://github.com/carbon-design-system/carbon/issues/20452 - const { offsetWidth: menuWidth, offsetHeight: menuHeight } = menuBody; + + const { offsetWidth: menuWidth } = menuBody; switch (triggerButtonPositionProp) { case 'top': @@ -202,7 +202,7 @@ export interface OverflowMenuProps menuOffset?: MenuOffset; /** - * The adjustment in position applied to the floating menu. + * The adjustment in position applied to the floating menu when flipped. */ menuOffsetFlip?: MenuOffset; @@ -731,7 +731,7 @@ OverflowMenu.propTypes = { ]), /** - * The adjustment in position applied to the floating menu. + * The adjustment in position applied to the floating menu when flipped. */ menuOffsetFlip: PropTypes.oneOfType([ PropTypes.shape({ diff --git a/packages/web-components/src/components/breadcrumb/breadcrumb-item.ts b/packages/web-components/src/components/breadcrumb/breadcrumb-item.ts index b172d9341290..8e997e9acdb7 100644 --- a/packages/web-components/src/components/breadcrumb/breadcrumb-item.ts +++ b/packages/web-components/src/components/breadcrumb/breadcrumb-item.ts @@ -1,5 +1,5 @@ /** - * Copyright IBM Corp. 2019, 2023 + * Copyright IBM Corp. 2019, 2026 * * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. @@ -9,7 +9,7 @@ import { LitElement, html } from 'lit'; import { prefix } from '../../globals/settings'; import styles from './breadcrumb.scss?lit'; import { carbonElement as customElement } from '../../globals/decorators/carbon-element'; - +import type CDSOverflowMenuBody from '../overflow-menu/overflow-menu-body'; /** * Breadcrumb item. * @@ -21,24 +21,31 @@ class CDSBreadcrumbItem extends LitElement { * Handles `slotchange` event. */ private _handleSlotChange({ target }: Event) { - if (this.getAttribute('size')) { - const items = (target as HTMLSlotElement) - .assignedNodes() - .filter( - (node) => - node.nodeType === Node.ELEMENT_NODE && - (node as Element).tagName.toLowerCase() === - `${prefix}-overflow-menu` - ); + const items = (target as HTMLSlotElement) + .assignedNodes() + .filter( + (node) => + node.nodeType === Node.ELEMENT_NODE && + (node as Element).tagName.toLowerCase() === `${prefix}-overflow-menu` + ); - items.forEach((item) => { + items.forEach((item) => { + if (this.getAttribute('size')) { (item as HTMLElement).setAttribute( 'breadcrumb-size', // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- https://github.com/carbon-design-system/carbon/issues/20452 this.getAttribute('size')! ); - }); - } + } + + const overflowMenuBody = (item as HTMLElement).querySelector( + `${prefix}-overflow-menu-body` + ) as CDSOverflowMenuBody; + + if (overflowMenuBody) { + overflowMenuBody.menuOffset = { top: 10, left: 59 }; + } + }); } connectedCallback() { diff --git a/packages/web-components/src/components/floating-menu/defs.ts b/packages/web-components/src/components/floating-menu/defs.ts index 21d29e8d0f5b..97be01be0755 100644 --- a/packages/web-components/src/components/floating-menu/defs.ts +++ b/packages/web-components/src/components/floating-menu/defs.ts @@ -1,5 +1,5 @@ /** - * Copyright IBM Corp. 2020, 2022, 2023 + * Copyright IBM Corp. 2020, 2022, 2026 * * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. @@ -33,4 +33,14 @@ export enum FLOATING_MENU_DIRECTION { * Put menu body at the bottom of its trigger button. */ BOTTOM = 'bottom', + + /** + * Put menu body to the left of its trigger button. + */ + LEFT = 'left', + + /** + * Put menu body to the right of its trigger button. + */ + RIGHT = 'right', } diff --git a/packages/web-components/src/components/floating-menu/floating-menu.ts b/packages/web-components/src/components/floating-menu/floating-menu.ts index bfb662612e1b..919553355c08 100644 --- a/packages/web-components/src/components/floating-menu/floating-menu.ts +++ b/packages/web-components/src/components/floating-menu/floating-menu.ts @@ -1,5 +1,5 @@ /** - * Copyright IBM Corp. 2019, 2024 + * Copyright IBM Corp. 2019, 2026 * * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. @@ -19,6 +19,53 @@ import { prefix } from '../../globals/settings'; export { FLOATING_MENU_DIRECTION, FLOATING_MENU_POSITION_DIRECTION }; +export interface Offset { + top: number; + left: number; +} + +export type MenuDirection = FLOATING_MENU_DIRECTION; + +export type MenuOffset = + | Offset + | (( + menuBody: HTMLElement, + menuDirection: MenuDirection, + trigger?: HTMLElement | null, + flipped?: boolean + ) => Offset); + +/** + * Calculates the offset for the floating menu. + * + * @param menuBody - The menu body element. + * @param menuDirection - The floating menu direction. + * @param trigger - The trigger element. + * @param flipped - Whether the menu is flipped. + * @returns The adjustment of the floating menu position. + */ +export const getMenuOffset = ( + menuBody: HTMLElement, + menuDirection: MenuDirection, + trigger?: HTMLElement, + flipped?: boolean +): Offset => { + const { offsetWidth: menuWidth } = menuBody; + + switch (menuDirection) { + case FLOATING_MENU_DIRECTION.TOP: + case FLOATING_MENU_DIRECTION.BOTTOM: { + const triggerWidth = !trigger ? 0 : trigger.offsetWidth; + return { + left: (!flipped ? 1 : -1) * (menuWidth / 2 - triggerWidth / 2), + top: 0, + }; + } + default: + return { left: 0, top: 0 }; + } +}; + /** * Position of floating menu, or trigger button of floating menu. */ @@ -89,23 +136,6 @@ abstract class CDSFloatingMenu extends HostListenerMixin( */ private _hObserveResizeContainer: Handle | null = null; - /** - * The `ResizeObserver` instance for observing element resizes for re-positioning floating menu position. - */ - // TODO: Wait for `.d.ts` update to support `ResizeObserver` - // eslint-disable-next-line @typescript-eslint/ban-ts-comment -- https://github.com/carbon-design-system/carbon/issues/20452 - // @ts-ignore - private _resizeObserver = new ResizeObserver(() => { - const { container, open, parent, position } = this; - if (container && open && parent) { - const { direction, start, top } = position; - this.style[ - direction !== FLOATING_MENU_POSITION_DIRECTION.RTL ? 'left' : 'right' - ] = `${start}px`; - this.style.top = `${top}px`; - } - }); - @HostListener('focusout') // eslint-disable-next-line @typescript-eslint/ban-ts-comment -- https://github.com/carbon-design-system/carbon/issues/20452 // @ts-ignore: The decorator refers to this method but TS thinks this method is not referred to @@ -163,6 +193,37 @@ abstract class CDSFloatingMenu extends HostListenerMixin( */ abstract flipped: boolean; + /** + * Gets the menu offset configuration (object or function). + * Subclasses override this to specify custom offset configs. + * + * @returns The menu offset configuration, or undefined for no offset. + */ + protected getOffsetConfig(): MenuOffset | undefined { + return undefined; + } + + /** + * Resolves the final menu offset by evaluating the offset configuration. + * Handles both static offset objects and dynamic offset functions. + * + * @returns The resolved offset with left and top values. + */ + protected resolveOffset(): Offset { + const config = this.getOffsetConfig(); + + if (!config) { + return { left: 0, top: 0 }; + } + + if (typeof config === 'function') { + const trigger = this.parent as HTMLElement; + return config(this, this.direction, trigger, this.flipped); + } + + return config; + } + /** * The DOM element to put this menu into. */ @@ -191,71 +252,88 @@ abstract class CDSFloatingMenu extends HostListenerMixin( left: refLeft = 0, top: refTop = 0, right: refRight = 0, + bottom: refBottom = 0, } = triggerPosition; - let { bottom: refBottom = 0 } = triggerPosition; const { width, height } = this.getBoundingClientRect(); - const { - left: containerLeft = 0, - right: containerRight = 0, - top: containerTop = 0, - } = container.getBoundingClientRect(); - refBottom = refBottom - containerTop; + const containerRect = container.getBoundingClientRect(); const containerComputedStyle = // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- https://github.com/carbon-design-system/carbon/issues/20452 container.ownerDocument!.defaultView!.getComputedStyle(container); + const containerPosition = + containerComputedStyle.getPropertyValue('position'); const positionDirection = containerComputedStyle.getPropertyValue( 'direction' ) as FLOATING_MENU_POSITION_DIRECTION; - const isRtl = positionDirection === FLOATING_MENU_POSITION_DIRECTION.RTL; - const containerStartFromViewport = !isRtl - ? containerLeft - : // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- https://github.com/carbon-design-system/carbon/issues/20452 - container.ownerDocument!.defaultView!.innerWidth - containerRight; - const refStartFromContainer = !isRtl - ? refLeft - containerLeft - : containerRight - refRight; - const refEndFromContainer = !isRtl - ? refRight - containerLeft - : containerRight - refLeft; - const refTopFromContainer = refTop - containerTop; - if ( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- https://github.com/carbon-design-system/carbon/issues/20452 - (container !== this.ownerDocument!.body || - containerStartFromViewport !== 0 || - containerTop !== 0) && - containerComputedStyle.getPropertyValue('position') === 'static' - ) { - throw new Error( - 'Floating menu container must not have `position:static`.' - ); - } + const scrollX = globalThis.scrollX ?? 0; + const scrollY = globalThis.scrollY ?? 0; + const effectiveScrollX = containerPosition !== 'static' ? 0 : scrollX; + const effectiveScrollY = containerPosition !== 'static' ? 0 : scrollY; + + const relativeDiff = { + top: containerPosition !== 'static' ? containerRect.top : 0, + left: containerPosition !== 'static' ? containerRect.left : 0, + }; + + const refCenterHorizontal = (refLeft + refRight) / 2; + const refCenterVertical = (refTop + refBottom) / 2; + + const offset = this.resolveOffset(); + const { top = 0, left = 0 } = offset; - const { flipped, direction } = this; + const { direction } = this; if (Object.values(FLOATING_MENU_DIRECTION).indexOf(direction) < 0) { throw new Error(`Wrong menu position direction: ${direction}`); } - const alignmentStart = flipped - ? refEndFromContainer - width - : refStartFromContainer; - - const { start, top } = { + const positions: Record Offset> = { + [FLOATING_MENU_DIRECTION.LEFT]: () => ({ + left: refLeft - width + effectiveScrollX - left - relativeDiff.left, + top: + refCenterVertical - + height / 2 + + effectiveScrollY + + top - + 9 - + relativeDiff.top, + }), [FLOATING_MENU_DIRECTION.TOP]: () => ({ - start: alignmentStart, - top: refTopFromContainer - height, + left: + refCenterHorizontal - + width / 2 + + effectiveScrollX + + left - + relativeDiff.left, + top: refTop - height + effectiveScrollY - top - relativeDiff.top, + }), + [FLOATING_MENU_DIRECTION.RIGHT]: () => ({ + left: refRight + effectiveScrollX + left - relativeDiff.left, + top: + refCenterVertical - + height / 2 + + effectiveScrollY + + top + + 3 - + relativeDiff.top, }), [FLOATING_MENU_DIRECTION.BOTTOM]: () => ({ - start: alignmentStart, - top: refBottom, + left: + refCenterHorizontal - + width / 2 + + effectiveScrollX + + left - + relativeDiff.left, + top: refBottom + effectiveScrollY + top - relativeDiff.top, }), - }[direction](); + }; + + const { left: calculatedLeft, top: calculatedTop } = positions[direction](); return { direction: positionDirection, - start, - top, + start: calculatedLeft, + top: calculatedTop, }; } @@ -281,10 +359,9 @@ abstract class CDSFloatingMenu extends HostListenerMixin( container.appendChild(this); } // Note: `this.position` cannot be referenced until `this.parent` is set - const { direction, start, top } = this.position; - this.style[ - direction !== FLOATING_MENU_POSITION_DIRECTION.RTL ? 'left' : 'right' - ] = `${start}px`; + const { start, top } = this.position; + this.style.left = `${start}px`; + this.style.right = 'auto'; this.style.top = `${top}px`; } if (changedProperties.has('open')) { diff --git a/packages/web-components/src/components/overflow-menu/overflow-menu-body.ts b/packages/web-components/src/components/overflow-menu/overflow-menu-body.ts index fd6b222cd8f4..29987dd6638a 100644 --- a/packages/web-components/src/components/overflow-menu/overflow-menu-body.ts +++ b/packages/web-components/src/components/overflow-menu/overflow-menu-body.ts @@ -1,5 +1,5 @@ /** - * Copyright IBM Corp. 2019, 2025 + * Copyright IBM Corp. 2019, 2026 * * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. @@ -7,6 +7,8 @@ import CDSFloatingMenu, { FLOATING_MENU_DIRECTION, + getMenuOffset, + type MenuOffset, } from '../floating-menu/floating-menu'; import { NAVIGATION_DIRECTION, OVERFLOW_MENU_SIZE } from './defs'; @@ -69,6 +71,36 @@ class CDSOverflowMenuBody extends CDSFloatingMenu { @property({ reflect: true }) size = OVERFLOW_MENU_SIZE.MEDIUM; + /** + * The adjustment in position applied to the floating menu. + */ + @property({ attribute: false }) + menuOffset?: MenuOffset; + + /** + * The adjustment in position applied to the floating menu when flipped. + */ + @property({ attribute: false }) + menuOffsetFlip?: MenuOffset; + + /** + * Gets the appropriate offset (menuOffset or menuOffsetFlip) based on the flipped state. + * If no offset is provided, falls back to the default centering behavior. + * Overrides `getOffsetConfig` in `CDSFloatingMenu`. + * + * @returns The menu offset configuration (object, function, or undefined). + */ + protected getOffsetConfig(): MenuOffset | undefined { + const offset = this.flipped ? this.menuOffsetFlip : this.menuOffset; + + if (!offset) { + const trigger = this.parent as HTMLElement; + return getMenuOffset(this, this.direction, trigger, this.flipped); + } + + return offset; + } + /** * @param currentItem The currently selected item. * @param direction The navigation direction. diff --git a/packages/web-components/src/components/overflow-menu/overflow-menu.scss b/packages/web-components/src/components/overflow-menu/overflow-menu.scss index a5e60c37fd2a..2489b8b08c0c 100644 --- a/packages/web-components/src/components/overflow-menu/overflow-menu.scss +++ b/packages/web-components/src/components/overflow-menu/overflow-menu.scss @@ -1,5 +1,5 @@ // -// Copyright IBM Corp. 2019, 2024 +// Copyright IBM Corp. 2019, 2026 // // This source code is licensed under the Apache-2.0 license found in the // LICENSE file in the root directory of this source tree. @@ -235,3 +235,9 @@ $caret-size: convert.to-rem(7px); inset-inline-start: $caret-size * 2; } } + +:host(#{$prefix}-overflow-menu-body:dir(rtl)[breadcrumb='true']) { + &::after { + direction: ltr; + } +} From e27d9d67a0b18b0ff1fa1f9663f51bbf96a0f984 Mon Sep 17 00:00:00 2001 From: Mahmoud Date: Thu, 19 Feb 2026 13:46:39 -0500 Subject: [PATCH 2/3] refactor: reintroduce removed resizeobserver --- .../src/components/floating-menu/floating-menu.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/web-components/src/components/floating-menu/floating-menu.ts b/packages/web-components/src/components/floating-menu/floating-menu.ts index 919553355c08..a7cbc6afd1a7 100644 --- a/packages/web-components/src/components/floating-menu/floating-menu.ts +++ b/packages/web-components/src/components/floating-menu/floating-menu.ts @@ -136,6 +136,20 @@ abstract class CDSFloatingMenu extends HostListenerMixin( */ private _hObserveResizeContainer: Handle | null = null; + /** + * The `ResizeObserver` instance for observing element resizes for re-positioning floating menu position. + */ + // TODO: Wait for `.d.ts` update to support `ResizeObserver` + // eslint-disable-next-line @typescript-eslint/ban-ts-comment -- https://github.com/carbon-design-system/carbon/issues/20452 + // @ts-ignore + private _resizeObserver = new ResizeObserver(() => { + const { container, open, parent, position } = this; + if (container && open && parent) { + const { top } = position; + this.style.top = `${top}px`; + } + }); + @HostListener('focusout') // eslint-disable-next-line @typescript-eslint/ban-ts-comment -- https://github.com/carbon-design-system/carbon/issues/20452 // @ts-ignore: The decorator refers to this method but TS thinks this method is not referred to From 0291e840a198f174a3627d1827a04dbf80513dff Mon Sep 17 00:00:00 2001 From: Mahmoud Date: Thu, 19 Feb 2026 14:19:36 -0500 Subject: [PATCH 3/3] chore: format --- packages/react/src/components/OverflowMenu/OverflowMenu.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/components/OverflowMenu/OverflowMenu.tsx b/packages/react/src/components/OverflowMenu/OverflowMenu.tsx index e2b6b4b2edd7..66aeb585d7d6 100644 --- a/packages/react/src/components/OverflowMenu/OverflowMenu.tsx +++ b/packages/react/src/components/OverflowMenu/OverflowMenu.tsx @@ -105,7 +105,7 @@ export const getMenuOffset: MenuOffset = ( direction ); } - + const { offsetWidth: menuWidth } = menuBody; switch (triggerButtonPositionProp) {