Skip to content

#40539 - ContextMenu: scrollable and always visible - #114

Merged
cristian-spiescu merged 18 commits into
master-flower-platformfrom
valeriu-RM40539-ContextMenu-scrollable-visible
Jun 27, 2026
Merged

#40539 - ContextMenu: scrollable and always visible#114
cristian-spiescu merged 18 commits into
master-flower-platformfrom
valeriu-RM40539-ContextMenu-scrollable-visible

Conversation

@valeriu-vi

Copy link
Copy Markdown
Collaborator

Proposed Change:

What is your change

Change type

Put an x in the boxes that apply

  • [ X] Bugfix
  • New feature

Checklist

  • I have added tests that prove my fix is effective or that my feature works
  • [ X] I have added necessary documentation (if appropriate)

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines 36 to 46
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
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Suggested change
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
}
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

Comment on lines +84 to +103
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 })
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Suggested change
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 })
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

Comment on lines +128 to +130
<Popup id={CONTEXT_MENU_ID} basic wide='very' data-testid={testids.popup} context={this.getPopupContext()}
style={{ maxHeight: '80vh', maxWidth: '80vh', overflow: 'auto' }}
positionFixed

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Update the Popup component to use the instance-specific this.menuId instead of the global CONTEXT_MENU_ID.

Suggested change
<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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

Comment on lines +59 to +63
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)
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since the initial coordinates are now set directly in the constructor, we can simplify componentDidMount to only call adjustPopup without the redundant setState call.

Suggested change
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);
}
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

@valeriu-vi

Copy link
Copy Markdown
Collaborator Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread CHANGELOG.md Outdated
Comment thread src/components/ContextMenu/ContextMenu.tsx Outdated
Comment thread src/components/ContextMenu/ContextMenu.tsx
Comment thread src/components/ContextMenu/ContextMenu.tsx Outdated
valeriu-vi and others added 5 commits June 11, 2026 16:03
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>
@valeriu-vi

Copy link
Copy Markdown
Collaborator Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +56 to 65
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
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

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').

Suggested change
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
);
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

Comment on lines 73 to 79
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)
});
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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);
        }
      });
    }
  }

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

@valeriu-vi

Copy link
Copy Markdown
Collaborator Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +137 to 142
<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)}>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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:

  1. If the cursor is near the top of the screen, the popup will overflow the top of the viewport, but adjustPopup will not detect or correct this because it only checks for bottom overflow (y + height > window.innerHeight).
  2. If the cursor is near the bottom of the screen and bottom overflow is triggered, adjustPopup sets finalY = y - height. Since the popup is rendered above finalY, its top will be positioned at y - 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)}>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

Comment thread src/components/ContextMenu/ContextMenu.tsx Outdated
valeriu-vi and others added 2 commits June 11, 2026 16:19
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
@valeriu-vi

Copy link
Copy Markdown
Collaborator Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
id = _.uniqueId("ContextMenu");
menuRef = React.createRef<HTMLDivElement>();

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NU merge asa cu ref ca intoarece un wraper semantic ui...

Comment on lines +94 to +95
// Get the current dimensions of the popup element
const { width = 0, height = 0 } = document.getElementById(this.id)?.getBoundingClientRect() || {} as DOMRect;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
// 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;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

idem

Comment on lines +137 to +144
<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 >

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
<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 >

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

idem

@valeriu-vi valeriu-vi changed the title #40539 - ContextMenu: scrolable and visible #40539 - ContextMenu: scrollable and always visible Jun 11, 2026
@cristian-spiescu
cristian-spiescu merged commit a772532 into master-flower-platform Jun 27, 2026
2 checks passed
@cristian-spiescu
cristian-spiescu deleted the valeriu-RM40539-ContextMenu-scrollable-visible branch June 27, 2026 08:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants