Skip to content

Commit

Permalink
MenuRenderer: Ensure menu is visible in container with overflow hidden
Browse files Browse the repository at this point in the history
  • Loading branch information
felixhabib committed Nov 25, 2024
1 parent 6a118bc commit 8aff9b2
Show file tree
Hide file tree
Showing 3 changed files with 120 additions and 61 deletions.
10 changes: 10 additions & 0 deletions .changeset/fair-horses-sneeze.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -89,6 +111,7 @@ const initialState: State = {
open: false,
highlightIndex: CLOSED_INDEX,
closeReason: CLOSE_REASON_EXIT,
triggerPosition: undefined,
};

export const MenuRenderer = ({
Expand Down Expand Up @@ -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: {
Expand All @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -300,6 +326,7 @@ export const MenuRenderer = ({
reserveIconSpace={reserveIconSpace}
focusTrigger={focusTrigger}
dispatch={dispatch}
triggerPosition={triggerPosition}
>
{items}
</Menu>
Expand Down Expand Up @@ -342,8 +369,9 @@ interface MenuProps {
highlightIndex: number;
open: boolean;
children: ReactChild[];
position?: 'absolute' | 'relative';
triggerPosition?: position;
}

export function Menu({
offsetSpace,
align,
Expand All @@ -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 (
<MenuRendererContext.Provider value={{ reserveIconSpace }}>
<Box
role="menu"
position={position}
zIndex="dropdown"
boxShadow={placement === 'top' ? 'small' : 'medium'}
borderRadius={borderRadius}
background="surface"
marginTop={placement === 'bottom' ? offsetSpace : undefined}
marginBottom={placement === 'top' ? offsetSpace : undefined}
transition="fast"
right={align === 'right' ? 0 : undefined}
opacity={!open ? 0 : undefined}
overflow="hidden"
className={[
!open && styles.menuIsClosed,
width !== 'content' && styles.width[width],
placement === 'top' && styles.placementBottom,
]}
>
<Box paddingY={styles.menuYPadding} className={styles.menuHeightLimit}>
{Children.map(children, (item, i) => {
if (isDivider(item)) {
dividerCount++;
return item;
}

const menuItemIndex = i - dividerCount;

return (
<MenuRendererItemContext.Provider
key={menuItemIndex}
value={{
isHighlighted: menuItemIndex === highlightIndex,
index: menuItemIndex,
dispatch,
focusTrigger,
}}
>
{item}
</MenuRendererItemContext.Provider>
);
})}
</Box>
<Overlay
boxShadow="borderNeutralLight"
<BraidPortal>
<MenuRendererContext.Provider value={{ reserveIconSpace }}>
<Box
role="menu"
position="absolute"
style={inlineVars}
zIndex="dropdown"
boxShadow={placement === 'top' ? 'small' : 'medium'}
borderRadius={borderRadius}
visible
/>
</Box>
</MenuRendererContext.Provider>
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],
]}
>
<Box
paddingY={styles.menuYPadding}
className={styles.menuHeightLimit}
>
{Children.map(children, (item, i) => {
if (isDivider(item)) {
dividerCount++;
return item;
}
const menuItemIndex = i - dividerCount;
return (
<MenuRendererItemContext.Provider
key={menuItemIndex}
value={{
isHighlighted: menuItemIndex === highlightIndex,
index: menuItemIndex,
dispatch,
focusTrigger,
}}
>
{item}
</MenuRendererItemContext.Provider>
);
})}
</Box>
<Overlay
boxShadow="borderNeutralLight"
borderRadius={borderRadius}
visible
/>
</Box>
</MenuRendererContext.Provider>
</BraidPortal>
);
}

0 comments on commit 8aff9b2

Please sign in to comment.