diff --git a/.changeset/fair-horses-sneeze.md b/.changeset/fair-horses-sneeze.md new file mode 100644 index 00000000000..fbc5ee2b627 --- /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 its trigger element is inside a container with overflow hidden. diff --git a/packages/braid-design-system/src/lib/components/MenuRenderer/MenuRenderer.actions.ts b/packages/braid-design-system/src/lib/components/MenuRenderer/MenuRenderer.actions.ts index 68f33172db5..c232bd084ef 100644 --- a/packages/braid-design-system/src/lib/components/MenuRenderer/MenuRenderer.actions.ts +++ b/packages/braid-design-system/src/lib/components/MenuRenderer/MenuRenderer.actions.ts @@ -1,24 +1,27 @@ // Action type IDs (allows action type names to be minified) export const actionTypes = { - MENU_TRIGGER_UP: 0, - MENU_ITEM_UP: 1, - MENU_TRIGGER_DOWN: 2, - MENU_ITEM_DOWN: 3, - MENU_ITEM_ESCAPE: 4, - MENU_ITEM_TAB: 5, - MENU_ITEM_ENTER: 6, - MENU_ITEM_SPACE: 7, - MENU_ITEM_CLICK: 8, - MENU_ITEM_HOVER: 9, - MENU_TRIGGER_ENTER: 10, - MENU_TRIGGER_SPACE: 11, - MENU_TRIGGER_CLICK: 12, - MENU_TRIGGER_TAB: 13, - MENU_TRIGGER_ESCAPE: 14, - BACKDROP_CLICK: 15, + CLIENT_ENVIRONMENT: 0, + MENU_TRIGGER_UP: 1, + MENU_ITEM_UP: 2, + MENU_TRIGGER_DOWN: 3, + MENU_ITEM_DOWN: 4, + MENU_ITEM_ESCAPE: 5, + MENU_ITEM_TAB: 6, + MENU_ITEM_ENTER: 7, + MENU_ITEM_SPACE: 8, + MENU_ITEM_CLICK: 9, + MENU_ITEM_HOVER: 10, + MENU_TRIGGER_ENTER: 11, + MENU_TRIGGER_SPACE: 12, + MENU_TRIGGER_CLICK: 13, + MENU_TRIGGER_TAB: 14, + MENU_TRIGGER_ESCAPE: 15, + BACKDROP_CLICK: 16, + WINDOW_RESIZE: 17, } as const; export type Action = + | { type: typeof actionTypes.CLIENT_ENVIRONMENT } | { type: typeof actionTypes.MENU_TRIGGER_UP } | { type: typeof actionTypes.MENU_ITEM_UP } | { type: typeof actionTypes.MENU_TRIGGER_DOWN } @@ -49,4 +52,5 @@ export type Action = | { type: typeof actionTypes.MENU_TRIGGER_CLICK } | { type: typeof actionTypes.MENU_TRIGGER_TAB } | { type: typeof actionTypes.MENU_TRIGGER_ESCAPE } - | { type: typeof actionTypes.BACKDROP_CLICK }; + | { type: typeof actionTypes.BACKDROP_CLICK } + | { type: typeof actionTypes.WINDOW_RESIZE }; 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..f82eca13818 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; @@ -59,6 +62,7 @@ export interface MenuRendererProps { } const { + CLIENT_ENVIRONMENT, MENU_TRIGGER_UP, MENU_ITEM_UP, MENU_TRIGGER_DOWN, @@ -75,20 +79,43 @@ const { MENU_TRIGGER_TAB, MENU_TRIGGER_ESCAPE, BACKDROP_CLICK, + WINDOW_RESIZE, } = 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 { + onClient: boolean; open: boolean; highlightIndex: number; closeReason: CloseReason; + triggerPosition?: position; } const CLOSED_INDEX = -1; const CLOSE_REASON_EXIT: CloseReasonExit = { reason: 'exit' }; const initialState: State = { + onClient: false, open: false, highlightIndex: CLOSED_INDEX, closeReason: CLOSE_REASON_EXIT, + triggerPosition: undefined, }; export const MenuRenderer = ({ @@ -120,85 +147,109 @@ 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 => { - switch (action.type) { - case MENU_TRIGGER_UP: - case MENU_ITEM_UP: { - return { - ...state, - open: true, - closeReason: CLOSE_REASON_EXIT, - highlightIndex: getNextIndex(-1, state.highlightIndex, itemCount), - }; - } - case MENU_TRIGGER_DOWN: - case MENU_ITEM_DOWN: { - return { - ...state, - open: true, - closeReason: CLOSE_REASON_EXIT, - highlightIndex: getNextIndex(1, state.highlightIndex, itemCount), - }; - } - case BACKDROP_CLICK: - case MENU_TRIGGER_ESCAPE: - case MENU_TRIGGER_TAB: - case MENU_ITEM_ESCAPE: - case MENU_ITEM_TAB: { - return { - ...state, - open: false, - closeReason: CLOSE_REASON_EXIT, - highlightIndex: CLOSED_INDEX, - }; - } - case MENU_ITEM_ENTER: - case MENU_ITEM_SPACE: - case MENU_ITEM_CLICK: { - // Don't close the menu if the user clicked a "form element" item, e.g. checkbox - if ('formElement' in action && action.formElement) { - return state; - } - - return { - ...state, - open: false, - closeReason: { - reason: 'selection', - index: action.index, - id: action.id, - }, - highlightIndex: CLOSED_INDEX, - }; - } - case MENU_ITEM_HOVER: { - return { ...state, highlightIndex: action.value }; - } - case MENU_TRIGGER_ENTER: - case MENU_TRIGGER_SPACE: { - const nextOpen = !state.open; - return { - ...state, - open: nextOpen, - closeReason: CLOSE_REASON_EXIT, - highlightIndex: nextOpen ? 0 : CLOSED_INDEX, - }; - } - case MENU_TRIGGER_CLICK: { - const nextOpen = !state.open; - return { - ...state, - open: nextOpen, - closeReason: CLOSE_REASON_EXIT, - }; - } - default: + const [ + { + onClient: hasRenderedOnClient, + open, + highlightIndex, + closeReason, + triggerPosition, + }, + dispatch, + ] = useReducer((state: State, action: Action): State => { + switch (action.type) { + case CLIENT_ENVIRONMENT: { + return { ...state, onClient: true }; + } + case MENU_TRIGGER_UP: + case MENU_ITEM_UP: { + return { + ...state, + open: true, + closeReason: CLOSE_REASON_EXIT, + highlightIndex: getNextIndex(-1, state.highlightIndex, itemCount), + triggerPosition: buttonRef && getPosition(buttonRef.current), + }; + } + case MENU_TRIGGER_DOWN: + case MENU_ITEM_DOWN: { + return { + ...state, + open: true, + closeReason: CLOSE_REASON_EXIT, + highlightIndex: getNextIndex(1, state.highlightIndex, itemCount), + triggerPosition: buttonRef && getPosition(buttonRef.current), + }; + } + case BACKDROP_CLICK: + case MENU_TRIGGER_ESCAPE: + case MENU_TRIGGER_TAB: + case MENU_ITEM_ESCAPE: + case MENU_ITEM_TAB: { + return { + ...state, + open: false, + closeReason: CLOSE_REASON_EXIT, + highlightIndex: CLOSED_INDEX, + }; + } + case MENU_ITEM_ENTER: + case MENU_ITEM_SPACE: + case MENU_ITEM_CLICK: { + // Don't close the menu if the user clicked a "form element" item, e.g. checkbox + if ('formElement' in action && action.formElement) { return state; + } + + return { + ...state, + open: false, + closeReason: { + reason: 'selection', + index: action.index, + id: action.id, + }, + highlightIndex: CLOSED_INDEX, + }; } - }, - initialState, - ); + case MENU_ITEM_HOVER: { + return { ...state, highlightIndex: action.value }; + } + case MENU_TRIGGER_ENTER: + case MENU_TRIGGER_SPACE: { + const nextOpen = !state.open; + return { + ...state, + 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), + }; + } + case WINDOW_RESIZE: { + return { + ...state, + triggerPosition: buttonRef && getPosition(buttonRef.current), + }; + } + default: + return state; + } + }, initialState); + + useEffect(() => { + dispatch({ type: CLIENT_ENVIRONMENT }); + }, []); useEffect(() => { if (lastOpen.current === open) { @@ -220,6 +271,18 @@ export const MenuRenderer = ({ } }; + useEffect(() => { + const handleResize = () => { + dispatch({ type: WINDOW_RESIZE }); + }; + + if (open) { + window.addEventListener('resize', handleResize); + } else { + window.removeEventListener('resize', handleResize); + } + }, [open]); + const onTriggerKeyUp = (event: KeyboardEvent) => { const targetKey = normalizeKey(event); @@ -290,19 +353,22 @@ export const MenuRenderer = ({ {trigger(triggerProps, { open })} - - {items} - + {hasRenderedOnClient && ( + + {items} + + )} {open ? ( @@ -342,8 +408,9 @@ interface MenuProps { highlightIndex: number; open: boolean; children: ReactChild[]; - position?: 'absolute' | 'relative'; + triggerPosition?: position; } + export function Menu({ offsetSpace, align, @@ -355,61 +422,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} + + ); + })} + + + + + ); }