diff --git a/.changeset/fair-horses-sneeze.md b/.changeset/fair-horses-sneeze.md new file mode 100644 index 00000000000..a334c15d9b9 --- /dev/null +++ b/.changeset/fair-horses-sneeze.md @@ -0,0 +1,10 @@ +--- +'braid-design-system': minor +--- + +--- +updated: + - MenuRenderer +--- + +**MenuRenderer** Ensure menu is visible, even when it's trigger element is inside a container with overflow hidden. diff --git a/packages/braid-design-system/src/lib/components/MenuRenderer/MenuRenderer.css.ts b/packages/braid-design-system/src/lib/components/MenuRenderer/MenuRenderer.css.ts index 8bbbdd32072..d42fd6b57c0 100644 --- a/packages/braid-design-system/src/lib/components/MenuRenderer/MenuRenderer.css.ts +++ b/packages/braid-design-system/src/lib/components/MenuRenderer/MenuRenderer.css.ts @@ -7,6 +7,21 @@ export const backdrop = style({ height: '100vh', }); +export const triggerVars = { + top: createVar(), + left: createVar(), + bottom: createVar(), + right: createVar(), +}; + +// Top and bottom reversed to allow for a more natural API +export const menuPosition = style({ + top: triggerVars.bottom, + bottom: triggerVars.top, + left: triggerVars.left, + right: triggerVars.right, +}); + export const menuIsClosed = style({ transform: `translateY(${calc(vars.grid).negate().multiply(2)})`, visibility: 'hidden', @@ -23,10 +38,6 @@ export const width = styleVariants({ small, medium, large }, (w) => [ { vars: { [widthVar]: w } }, ]); -export const placementBottom = style({ - bottom: '100%', -}); - export const menuYPadding = 'xxsmall'; export const menuHeightLimit = style({ diff --git a/packages/braid-design-system/src/lib/components/MenuRenderer/MenuRenderer.tsx b/packages/braid-design-system/src/lib/components/MenuRenderer/MenuRenderer.tsx index ce3874e9d5c..0f92b1ae17b 100644 --- a/packages/braid-design-system/src/lib/components/MenuRenderer/MenuRenderer.tsx +++ b/packages/braid-design-system/src/lib/components/MenuRenderer/MenuRenderer.tsx @@ -24,6 +24,9 @@ import buildDataAttributes, { type DataAttributeMap, } from '../private/buildDataAttributes'; import * as styles from './MenuRenderer.css'; +import { triggerVars } from './MenuRenderer.css'; +import { BraidPortal } from '../BraidPortal/BraidPortal'; +import { assignInlineVars } from '@vanilla-extract/dynamic'; interface TriggerProps { 'aria-haspopup': boolean; @@ -77,10 +80,29 @@ const { BACKDROP_CLICK, } = actionTypes; +type position = { top: number; bottom: number; left: number; right: number }; + +const getPosition = (element: HTMLElement | null): position | undefined => { + if (!element) { + return undefined; + } + + const { top, bottom, left, right } = element.getBoundingClientRect(); + const { scrollX, scrollY, innerWidth, innerHeight } = window; + + return { + top: innerHeight - top - scrollY, + bottom: bottom + scrollY, + left: left + scrollX, + right: innerWidth - right - scrollX, + }; +}; + interface State { open: boolean; highlightIndex: number; closeReason: CloseReason; + triggerPosition?: position; } const CLOSED_INDEX = -1; @@ -89,6 +111,7 @@ const initialState: State = { open: false, highlightIndex: CLOSED_INDEX, closeReason: CLOSE_REASON_EXIT, + triggerPosition: undefined, }; export const MenuRenderer = ({ @@ -120,8 +143,8 @@ export const MenuRenderer = ({ 'All child nodes within a menu component must be a MenuItem, MenuItemLink, MenuItemCheckbox or MenuItemDivider: https://seek-oss.github.io/braid-design-system/components/MenuRenderer', ); - const [{ open, highlightIndex, closeReason }, dispatch] = useReducer( - (state: State, action: Action): State => { + const [{ open, highlightIndex, closeReason, triggerPosition }, dispatch] = + useReducer((state: State, action: Action): State => { switch (action.type) { case MENU_TRIGGER_UP: case MENU_ITEM_UP: { @@ -130,6 +153,7 @@ export const MenuRenderer = ({ open: true, closeReason: CLOSE_REASON_EXIT, highlightIndex: getNextIndex(-1, state.highlightIndex, itemCount), + triggerPosition: buttonRef && getPosition(buttonRef.current), }; } case MENU_TRIGGER_DOWN: @@ -139,6 +163,7 @@ export const MenuRenderer = ({ open: true, closeReason: CLOSE_REASON_EXIT, highlightIndex: getNextIndex(1, state.highlightIndex, itemCount), + triggerPosition: buttonRef && getPosition(buttonRef.current), }; } case BACKDROP_CLICK: @@ -183,22 +208,23 @@ export const MenuRenderer = ({ open: nextOpen, closeReason: CLOSE_REASON_EXIT, highlightIndex: nextOpen ? 0 : CLOSED_INDEX, + triggerPosition: buttonRef && getPosition(buttonRef.current), }; } case MENU_TRIGGER_CLICK: { const nextOpen = !state.open; + return { ...state, open: nextOpen, closeReason: CLOSE_REASON_EXIT, + triggerPosition: buttonRef && getPosition(buttonRef.current), }; } default: return state; } - }, - initialState, - ); + }, initialState); useEffect(() => { if (lastOpen.current === open) { @@ -300,6 +326,7 @@ export const MenuRenderer = ({ reserveIconSpace={reserveIconSpace} focusTrigger={focusTrigger} dispatch={dispatch} + triggerPosition={triggerPosition} > {items} @@ -342,8 +369,9 @@ interface MenuProps { highlightIndex: number; open: boolean; children: ReactChild[]; - position?: 'absolute' | 'relative'; + triggerPosition?: position; } + export function Menu({ offsetSpace, align, @@ -355,61 +383,71 @@ export function Menu({ focusTrigger, highlightIndex, reserveIconSpace, - position = 'absolute', + triggerPosition, }: MenuProps) { let dividerCount = 0; + const inlineVars = + triggerPosition && + assignInlineVars({ + [triggerVars[placement]]: `${triggerPosition[placement]}px`, + [triggerVars[align]]: `${triggerPosition[align]}px`, + }); + return ( - - - - {Children.map(children, (item, i) => { - if (isDivider(item)) { - dividerCount++; - return item; - } - - const menuItemIndex = i - dividerCount; - - return ( - - {item} - - ); - })} - - + + - - + background="surface" + marginTop={placement === 'bottom' ? offsetSpace : undefined} + marginBottom={placement === 'top' ? offsetSpace : undefined} + transition="fast" + opacity={!open ? 0 : undefined} + overflow="hidden" + className={[ + styles.menuPosition, + !open && styles.menuIsClosed, + width !== 'content' && styles.width[width], + ]} + > + + {Children.map(children, (item, i) => { + if (isDivider(item)) { + dividerCount++; + return item; + } + const menuItemIndex = i - dividerCount; + return ( + + {item} + + ); + })} + + + + + ); }