#40539 - ContextMenu: scrollable and always visible - #114
Conversation
There was a problem hiding this comment.
Code Review
This pull request enhances the ContextMenu component by tracking its coordinates in the state and adjusting its position to prevent viewport overflow. The review feedback recommends replacing the hardcoded global CONTEXT_MENU_ID with instance-specific unique IDs to avoid conflicts when multiple menus are rendered. Additionally, it suggests initializing the coordinates directly in the constructor to simplify the component's lifecycle methods and avoid redundant state updates.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| const CONTEXT_MENU_ID = "CONTEXT_MENU_ID"; | ||
|
|
||
| export class ContextMenu extends React.Component<ContextMenuProps, { isOpened?: boolean, x: number, y: number }> { | ||
|
|
||
| constructor(props) { | ||
| super(props); | ||
| this.close = this.close.bind(this); | ||
| this.state = { | ||
| isOpened: props.positionToOpen ? true : false | ||
| isOpened: props.positionToOpen ? true : false, x: 0, y: 0 | ||
| } | ||
| } |
There was a problem hiding this comment.
Using a hardcoded global CONTEXT_MENU_ID can cause conflicts and incorrect positioning calculations if multiple ContextMenu components are mounted on the page simultaneously.
Instead, we can generate a unique ID per instance using _.uniqueId and initialize the state coordinates directly in the constructor to avoid an unnecessary setState call on mount.
| const CONTEXT_MENU_ID = "CONTEXT_MENU_ID"; | |
| export class ContextMenu extends React.Component<ContextMenuProps, { isOpened?: boolean, x: number, y: number }> { | |
| constructor(props) { | |
| super(props); | |
| this.close = this.close.bind(this); | |
| this.state = { | |
| isOpened: props.positionToOpen ? true : false | |
| isOpened: props.positionToOpen ? true : false, x: 0, y: 0 | |
| } | |
| } | |
| export class ContextMenu extends React.Component<ContextMenuProps, { isOpened?: boolean, x: number, y: number }> { | |
| private menuId = _.uniqueId('context-menu-'); | |
| constructor(props) { | |
| super(props); | |
| this.close = this.close.bind(this); | |
| this.state = { | |
| isOpened: props.positionToOpen ? true : false, | |
| x: props.positionToOpen?.x ?? 0, | |
| y: props.positionToOpen?.y ?? 0 | |
| } | |
| } |
| adjustPopup(x: number, y: number) { | ||
| // Get the current dimensions of the popup element | ||
| const { width, height } = document.getElementById(CONTEXT_MENU_ID)?.getBoundingClientRect() || {} as DOMRect; | ||
|
|
||
| let finalX = x, finalY = y; | ||
| // If the popup overflows the right edge of the screen, flip it to the left side of the cursor | ||
| if (x + width > window.innerWidth) { | ||
| // Ensure it doesn't go off the left edge of the screen (min boundary of 0) | ||
| finalX = Math.max(0, x - width); | ||
| } | ||
|
|
||
| // If the popup overflows the bottom edge of the screen, flip it above the cursor | ||
| if (y + height > window.innerHeight) { | ||
| // Ensure it doesn't go off the top edge of the screen (min boundary of 0) | ||
| finalY = Math.max(0, y - height); | ||
| } | ||
|
|
||
| // Update the component state with the safe, adjusted coordinates | ||
| this.setState({ x: finalX, y: finalY }) | ||
| } |
There was a problem hiding this comment.
Update adjustPopup to use the instance-specific this.menuId instead of the global CONTEXT_MENU_ID. Also, default width and height to 0 to prevent NaN calculations if the element is not found in the DOM.
| adjustPopup(x: number, y: number) { | |
| // Get the current dimensions of the popup element | |
| const { width, height } = document.getElementById(CONTEXT_MENU_ID)?.getBoundingClientRect() || {} as DOMRect; | |
| let finalX = x, finalY = y; | |
| // If the popup overflows the right edge of the screen, flip it to the left side of the cursor | |
| if (x + width > window.innerWidth) { | |
| // Ensure it doesn't go off the left edge of the screen (min boundary of 0) | |
| finalX = Math.max(0, x - width); | |
| } | |
| // If the popup overflows the bottom edge of the screen, flip it above the cursor | |
| if (y + height > window.innerHeight) { | |
| // Ensure it doesn't go off the top edge of the screen (min boundary of 0) | |
| finalY = Math.max(0, y - height); | |
| } | |
| // Update the component state with the safe, adjusted coordinates | |
| this.setState({ x: finalX, y: finalY }) | |
| } | |
| adjustPopup(x: number, y: number) { | |
| // Get the current dimensions of the popup element | |
| const { width = 0, height = 0 } = document.getElementById(this.menuId)?.getBoundingClientRect() || {}; | |
| let finalX = x, finalY = y; | |
| // If the popup overflows the right edge of the screen, flip it to the left side of the cursor | |
| if (x + width > window.innerWidth) { | |
| // Ensure it doesn't go off the left edge of the screen (min boundary of 0) | |
| finalX = Math.max(0, x - width); | |
| } | |
| // If the popup overflows the bottom edge of the screen, flip it above the cursor | |
| if (y + height > window.innerHeight) { | |
| // Ensure it doesn't go off the top edge of the screen (min boundary of 0) | |
| finalY = Math.max(0, y - height); | |
| } | |
| // Update the component state with the safe, adjusted coordinates | |
| this.setState({ x: finalX, y: finalY }) | |
| } |
| <Popup id={CONTEXT_MENU_ID} basic wide='very' data-testid={testids.popup} context={this.getPopupContext()} | ||
| style={{ maxHeight: '80vh', maxWidth: '80vh', overflow: 'auto' }} | ||
| positionFixed |
There was a problem hiding this comment.
Update the Popup component to use the instance-specific this.menuId instead of the global CONTEXT_MENU_ID.
| <Popup id={CONTEXT_MENU_ID} basic wide='very' data-testid={testids.popup} context={this.getPopupContext()} | |
| style={{ maxHeight: '80vh', maxWidth: '80vh', overflow: 'auto' }} | |
| positionFixed | |
| <Popup id={this.menuId} basic wide='very' data-testid={testids.popup} context={this.getPopupContext()} | |
| style={{ maxHeight: '80vh', maxWidth: '80vh', overflow: 'auto' }} | |
| positionFixed |
| componentDidMount(): void { | ||
| this.props.positionToOpen && this.setState({ x: this.props.positionToOpen.x, y: this.props.positionToOpen.y }, () => { | ||
| this.props.positionToOpen && this.adjustPopup(this.props.positionToOpen.x, this.props.positionToOpen.y) | ||
| }); | ||
| } |
There was a problem hiding this comment.
Since the initial coordinates are now set directly in the constructor, we can simplify componentDidMount to only call adjustPopup without the redundant setState call.
| componentDidMount(): void { | |
| this.props.positionToOpen && this.setState({ x: this.props.positionToOpen.x, y: this.props.positionToOpen.y }, () => { | |
| this.props.positionToOpen && this.adjustPopup(this.props.positionToOpen.x, this.props.positionToOpen.y) | |
| }); | |
| } | |
| componentDidMount(): void { | |
| if (this.props.positionToOpen) { | |
| this.adjustPopup(this.props.positionToOpen.x, this.props.positionToOpen.y); | |
| } | |
| } |
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request enhances the ContextMenu component to ensure it remains scrollable and fully visible within the viewport boundaries by dynamically adjusting its coordinates upon opening. The review feedback highlights several improvement opportunities: correcting a typo in the changelog, using the props parameter instead of this.props in the constructor to avoid initialization issues, resolving a potential flickering effect by introducing an isAdjusted state to defer visibility until coordinates are calculated, and fixing a typo in the CSS maxWidth property where 80vh was used instead of 80vw.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request makes the ContextMenu scrollable and ensures it remains visible within the viewport boundaries by dynamically adjusting its coordinates. The feedback highlights two critical issues with this implementation: first, shouldComponentUpdate is missing a check for nextState.isAdjusted, which can prevent the component from re-rendering and leave the menu permanently hidden; second, isAdjusted is not reset to false when positionToOpen changes, leading to a visible flicker as the menu briefly renders at unadjusted coordinates.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| shouldComponentUpdate(nextProps: ContextMenuProps, nextState: ContextMenuState) { | ||
| return ( | ||
| nextProps.positionToOpen !== this.props.positionToOpen || | ||
| !_.isEqual(this.props.positionToOpen, nextProps.positionToOpen) || | ||
| !_.isEqual(nextProps.actions, this.props.actions) || | ||
| !_.isEqual(nextProps.paramsForAction, this.props.paramsForAction) || | ||
| nextState.x !== this.state.x || | ||
| nextState.y !== this.state.y || | ||
| nextState.isOpened !== this.state.isOpened | ||
| ); | ||
| } |
There was a problem hiding this comment.
The shouldComponentUpdate method is missing a check for nextState.isAdjusted. If the context menu fits within the viewport boundaries without needing any coordinate adjustments (i.e., finalX === x and finalY === y), the state update isAdjusted: true will be triggered, but shouldComponentUpdate will return false because none of the other state properties changed. This will prevent the component from re-rendering, leaving the context menu permanently hidden (visibility: 'hidden').
| shouldComponentUpdate(nextProps: ContextMenuProps, nextState: ContextMenuState) { | |
| return ( | |
| nextProps.positionToOpen !== this.props.positionToOpen || | |
| !_.isEqual(this.props.positionToOpen, nextProps.positionToOpen) || | |
| !_.isEqual(nextProps.actions, this.props.actions) || | |
| !_.isEqual(nextProps.paramsForAction, this.props.paramsForAction) || | |
| nextState.x !== this.state.x || | |
| nextState.y !== this.state.y || | |
| nextState.isOpened !== this.state.isOpened | |
| ); | |
| } | |
| shouldComponentUpdate(nextProps: ContextMenuProps, nextState: ContextMenuState) { | |
| return ( | |
| !_.isEqual(this.props.positionToOpen, nextProps.positionToOpen) || | |
| !_.isEqual(nextProps.actions, this.props.actions) || | |
| !_.isEqual(nextProps.paramsForAction, this.props.paramsForAction) || | |
| nextState.x !== this.state.x || | |
| nextState.y !== this.state.y || | |
| nextState.isAdjusted !== this.state.isAdjusted || | |
| nextState.isOpened !== this.state.isOpened | |
| ); | |
| } |
| componentDidUpdate(prevProps: Readonly<ContextMenuProps>, prevState: Readonly<{}>, snapshot?: any): void { | ||
| if (this.props.positionToOpen != prevProps.positionToOpen) { | ||
| this.setState({ isOpened: this.props.positionToOpen ? true : false }); | ||
| if (!_.isEqual(this.props.positionToOpen, prevProps.positionToOpen)) { | ||
| this.setState({ isOpened: this.props.positionToOpen ? true : false, x: this.props.positionToOpen?.x ?? 0, y: this.props.positionToOpen?.y ?? 0 }, () => { | ||
| this.props.positionToOpen && this.adjustPopup(this.props.positionToOpen.x, this.props.positionToOpen.y) | ||
| }); | ||
| } | ||
| } |
There was a problem hiding this comment.
When positionToOpen changes, isAdjusted is not reset to false in the initial setState call. If the context menu was previously opened and adjusted, isAdjusted remains true. When it is opened again at a new position, it will briefly render as visible at the unadjusted coordinates before adjustPopup runs, causing a visible flicker. Resetting isAdjusted: false ensures it remains hidden until the new coordinates are calculated.
componentDidUpdate(prevProps: Readonly<ContextMenuProps>, prevState: Readonly<{}>, snapshot?: any): void {
if (!_.isEqual(this.props.positionToOpen, prevProps.positionToOpen)) {
this.setState({
isOpened: this.props.positionToOpen ? true : false,
isAdjusted: false,
x: this.props.positionToOpen?.x ?? 0,
y: this.props.positionToOpen?.y ?? 0
}, () => {
if (this.props.positionToOpen) {
this.adjustPopup(this.props.positionToOpen.x, this.props.positionToOpen.y);
}
});
}
}
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request updates the ContextMenu component to ensure it remains scrollable and visible within the viewport boundaries by dynamically adjusting its coordinates on mount and update. Feedback on these changes includes a critical issue where removing the position prop from the <Popup> component defaults it to 'top left', which breaks the coordinate adjustment math in adjustPopup. Additionally, it is recommended to update the type of prevState in componentDidUpdate to Readonly<ContextMenuState> for improved type safety.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| <Popup id={this.id} basic wide='very' data-testid={testids.popup} context={this.getPopupContext()} | ||
| style={{ maxHeight: '80vh', maxWidth: '80vw', overflow: 'auto', visibility: this.state.isAdjusted ? 'visible' : 'hidden' }} | ||
| positionFixed | ||
| onClose={() => { | ||
| this.close(); | ||
| }} open={(this.state.isOpened && visibleActions.length > 0)}> |
There was a problem hiding this comment.
The position prop was removed from the <Popup> component. In Semantic UI React, the default position for a Popup is 'top left', which renders the popup above the trigger element.
However, the coordinate adjustment logic in adjustPopup (lines 93-112) is designed under the assumption that the popup is rendered below the trigger (i.e., 'bottom left').
If the popup is rendered above the trigger by default:
- If the cursor is near the top of the screen, the popup will overflow the top of the viewport, but
adjustPopupwill not detect or correct this because it only checks for bottom overflow (y + height > window.innerHeight). - If the cursor is near the bottom of the screen and bottom overflow is triggered,
adjustPopupsetsfinalY = y - height. Since the popup is rendered abovefinalY, its top will be positioned aty - 2 * height, pushing it even further off-screen at the top.
To fix this and ensure the adjustment math works correctly, you should explicitly set the position prop to 'bottom left' (or preserve the custom position from paramsForAction with 'bottom left' as a fallback).
<Popup id={this.id} basic wide='very' data-testid={testids.popup} context={this.getPopupContext()}
style={{ maxHeight: '80vh', maxWidth: '80vw', overflow: 'auto', visibility: this.state.isAdjusted ? 'visible' : 'hidden' }}
positionFixed
position={this.props.paramsForAction.position || 'bottom left'}
onClose={() => {
this.close();
}} open={(this.state.isOpened && visibleActions.length > 0)}>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request updates the ContextMenu component to ensure it remains scrollable and visible within the viewport boundaries by dynamically adjusting its coordinates on mount and update. The feedback suggests replacing direct DOM querying (document.getElementById) with a React ref on the Menu component to measure dimensions in a more idiomatic and robust manner.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
|
|
||
| export class ContextMenu extends React.Component<ContextMenuProps, ContextMenuState> { | ||
|
|
||
| id = _.uniqueId("ContextMenu"); |
There was a problem hiding this comment.
Using document.getElementById is an anti-pattern in React as it bypasses the virtual DOM and can lead to race conditions or bugs if multiple instances of the component exist. A more idiomatic and robust approach is to use a React ref to measure the popup's dimensions. Let's define a menuRef to reference the menu element.
| id = _.uniqueId("ContextMenu"); | |
| menuRef = React.createRef<HTMLDivElement>(); |
There was a problem hiding this comment.
NU merge asa cu ref ca intoarece un wraper semantic ui...
| // Get the current dimensions of the popup element | ||
| const { width = 0, height = 0 } = document.getElementById(this.id)?.getBoundingClientRect() || {} as DOMRect; |
There was a problem hiding this comment.
Update the adjustPopup method to measure the popup using the menuRef instead of querying the DOM by ID. This ensures we safely retrieve the dimensions from the React-managed DOM node.
| // Get the current dimensions of the popup element | |
| const { width = 0, height = 0 } = document.getElementById(this.id)?.getBoundingClientRect() || {} as DOMRect; | |
| // Get the current dimensions of the popup element | |
| const rect = this.menuRef.current?.getBoundingClientRect(); | |
| const width = rect?.width ?? 0; | |
| const height = rect?.height ?? 0; |
| <Popup id={this.id} basic wide='very' data-testid={testids.popup} context={this.getPopupContext()} | ||
| style={{ maxHeight: '80vh', maxWidth: '80vw', overflow: 'auto', visibility: this.state.isAdjusted ? 'visible' : 'hidden' }} | ||
| positionFixed | ||
| position='bottom left' | ||
| onClose={() => { | ||
| this.close(); | ||
| }} open={(this.state.isOpened && visibleActions.length > 0)}> | ||
| <Menu className="rct9k-context-menu" secondary vertical> | ||
| <Menu className="rct9k-context-menu" secondary vertical > |
There was a problem hiding this comment.
Remove the id prop from the Popup component and attach the menuRef directly to the Menu component. This allows us to measure the menu's dimensions directly and cleanly.
| <Popup id={this.id} basic wide='very' data-testid={testids.popup} context={this.getPopupContext()} | |
| style={{ maxHeight: '80vh', maxWidth: '80vw', overflow: 'auto', visibility: this.state.isAdjusted ? 'visible' : 'hidden' }} | |
| positionFixed | |
| position='bottom left' | |
| onClose={() => { | |
| this.close(); | |
| }} open={(this.state.isOpened && visibleActions.length > 0)}> | |
| <Menu className="rct9k-context-menu" secondary vertical> | |
| <Menu className="rct9k-context-menu" secondary vertical > | |
| <Popup basic wide='very' data-testid={testids.popup} context={this.getPopupContext()} | |
| style={{ maxHeight: '80vh', maxWidth: '80vw', overflow: 'auto', visibility: this.state.isAdjusted ? 'visible' : 'hidden' }} | |
| positionFixed | |
| position='bottom left' | |
| onClose={() => { | |
| this.close(); | |
| }} open={(this.state.isOpened && visibleActions.length > 0)}> | |
| <Menu ref={this.menuRef} className="rct9k-context-menu" secondary vertical > |
Proposed Change:
What is your change
Change type
Put an
xin the boxes that applyChecklist