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.actions.ts b/packages/braid-design-system/src/lib/components/MenuRenderer/MenuRenderer.actions.ts
index 68f33172db5..8468965471f 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,26 @@
// 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,
} 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 }
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..17dd11fe5fe 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,
@@ -77,18 +81,40 @@ 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 {
+ 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 +146,103 @@ 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),
+ };
+ }
+ default:
+ return state;
+ }
+ }, initialState);
+
+ useEffect(() => {
+ dispatch({ type: CLIENT_ENVIRONMENT });
+ }, []);
useEffect(() => {
if (lastOpen.current === open) {
@@ -290,19 +334,22 @@ export const MenuRenderer = ({
{trigger(triggerProps, { open })}
-
+ {hasRenderedOnClient && (
+
+ )}
{open ? (
@@ -342,8 +389,9 @@ interface MenuProps {
highlightIndex: number;
open: boolean;
children: ReactChild[];
- position?: 'absolute' | 'relative';
+ triggerPosition?: position;
}
+
export function Menu({
offsetSpace,
align,
@@ -355,61 +403,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}
+
+ );
+ })}
+
+
+
+
+
);
}