Skip to content

UX: Ensure menus handle vertical overflow#1

Draft
n-lark wants to merge 2 commits intomasterfrom
5377/ux-menu-vertical-overflow
Draft

UX: Ensure menus handle vertical overflow#1
n-lark wants to merge 2 commits intomasterfrom
5377/ux-menu-vertical-overflow

Conversation

@n-lark
Copy link
Owner

@n-lark n-lark commented Jan 8, 2026

  • Bugfix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)

Proposed changes

See comment below

Checklist

  • I have read the contribution guidelines
  • For non-bugfix PRs, I have discussed this change on the forum/slack team.
  • I have run npm run test to verify the unit tests pass
  • I have added suitable unit tests to cover the new/changed functionality

@n-lark n-lark self-assigned this Jan 8, 2026
@n-lark
Copy link
Owner Author

n-lark commented Jan 8, 2026

Menu Overflow Fix - Manual Testing Plan 🧪

Issue

node-red#5377: UX: Ensure menus handle vertical overflow

Summary of Changes ✨

This fix enables menus to scroll when they exceed viewport height while still allowing submenus to pop out horizontally without being clipped.

Technical Approach 🔧

  • CSS limitation prevents overflow-y: auto and overflow-x: visible from working together
  • Solution: "Portal" submenus to <body> when parent menu is scrolling, then manually position using getBoundingClientRect()
  • Added .red-ui-header-menu class to preserve dark theme styling on portaled header submenus

Technical Deep Dive 🤿

The Problem 🐛

When a menu exceeds the viewport height, the natural solution is to add scrolling:

.menu {
  max-height: calc(100vh - 20px);
  overflow-y: auto; /* Enable vertical scrolling */
  overflow-x: visible; /* Allow submenus to pop out */
}

However, this doesn't work. Per CSS specification, when one overflow axis is set to auto or scroll, the other axis automatically becomes auto as well - even if explicitly set to visible. This means submenus get clipped inside the scrolling container instead of popping out horizontally.

The Solution: Portal Pattern 🌀

When a submenu needs special positioning, we "portal" it to <body>. This moves it outside any overflow containers entirely, allowing it to render anywhere on screen.

When portaling is triggered:

  1. Parent menu is scrolling (overflow-y: auto or scroll)
  2. Submenu would overflow the right edge of the viewport

How it works:

  1. Detection - When hovering over a submenu trigger, check if portaling is needed via doesSubmenuNeedPortaling()
  2. Portal - If needed, move the submenu <ul> from its nested position to <body>
  3. Position - Calculate absolute position using getBoundingClientRect() on the trigger element, with automatic flip logic
  4. Return - When the submenu closes, move it back to its original DOM location

Key Implementation Details 🔑

Submenu positioning (menu.js):

function getSubmenuPosition(parentMenuEl, submenuEl, preferredSide) {
  var parentMenuRect = parentMenuEl.getBoundingClientRect();
  var submenuWidth = submenuEl.offsetWidth || 230;
  var submenuHeight = submenuEl.offsetHeight || 200;
  var viewportWidth = window.innerWidth;
  var viewportHeight = window.innerHeight;
  var padding = 10;

  var x;
  var y = parentMenuRect.top;

  // Shift y if it would overflow bottom of viewport
  if (y + submenuHeight > viewportHeight - padding) {
    y = viewportHeight - submenuHeight - padding;
  }
  // Shift y if it would overflow top of viewport
  if (y < padding) {
    y = padding;
  }

  // Calculate x position based on preferred side with flip logic
  if (preferredSide === "left") {
    x = parentMenuRect.left - submenuWidth;
    if (x < padding) {
      x = parentMenuRect.right;
      if (x + submenuWidth > viewportWidth - padding) {
        x = viewportWidth - submenuWidth - padding;
      }
    }
  } else {
    x = parentMenuRect.right;
    if (x + submenuWidth > viewportWidth - padding) {
      x = parentMenuRect.left - submenuWidth;
      if (x < padding) {
        x = padding;
      }
    }
  }

  return { x: x, y: y };
}

Portal detection (doesSubmenuNeedPortaling()):

function doesSubmenuNeedPortaling() {
  var parentMenu = item.closest(".red-ui-menu-dropdown");
  var overflowY = parentMenu.css("overflow-y");
  // If parent menu is scrolling, portal the submenu
  if (overflowY === "auto" || overflowY === "scroll") {
    return true;
  }

  var itemRect = item[0].getBoundingClientRect();
  var submenuWidth = submenu.outerWidth() || 230;
  var viewportWidth = window.innerWidth;
  var padding = 10;

  // If submenu would overflow right edge, portal it
  if (itemRect.right + submenuWidth > viewportWidth - padding) {
    return true;
  }
  return false;
}

Theme preservation: 🎨

Header menus use a dark theme while context menus use light. When portaling to <body>, menus lose their parent styling context. We solve this with two approaches:

Dark theme (header menus): 🌑

  1. Tracking isHeaderMenu flag through menu creation
  2. Adding .red-ui-header-menu class to portaled header submenus
  3. Defining standalone dark theme styles for .red-ui-header-menu in header.scss

Light theme (context menus): ☀️

  1. All portaled submenus get .red-ui-menu-dropdown-portaled class
  2. This class includes complete light-theme styling in dropdownMenu.scss
  3. Styles include: background, text color, hover states, keyboard shortcuts, disabled states, dividers, icons

Files Modified 📁

File Changes
menu.js Added getSubmenuPosition() for positioning calculations. Added IIFE with doesSubmenuNeedPortaling(), portalSubmenu(), updatePositionOfSubmenu(), cleanUpSubmenu() functions. Portals submenus when parent is scrolling OR when submenu would overflow viewport edges. Tracks isHeaderMenu flag.
contextMenu.js Added viewport measurement and maxHeight/overflow-y application for context menus.
tabs.js Added scrolling support for tab dropdown menus.
header.scss Added .red-ui-header-menu class with dark theme styles for portaled submenus. Added scrollbar styling via mixin.
dropdownMenu.scss Added .red-ui-menu-dropdown-portaled class for fixed positioning. Added scrollbar styling via mixin.
mixins.scss Added menu-light-theme, menu-dark-theme, menu-scrollbar-light, menu-scrollbar-dark mixins for DRY styling.

Manual Testing Checklist ☑️

1. Header Menus (Dark Theme) 🌑

1.1 Hamburger Menu (☰)

Submenus: Projects, Edit, View, Arrange, Flows, Subflows, Groups

Test Pass
Open menu at normal viewport - no scrolling needed
Shrink viewport height, then open menu - scrollbar appears
Scroll menu and hover over submenu item - submenu pops out (not clipped)
Hover over different submenu items while scrolled - positioning updates
Move mouse from parent item to submenu - stays open
Move mouse away from both - submenu closes
Dark theme styling preserved on all submenus
Keyboard shortcuts show disabled/muted color
No text wrapping on menu items

1.2 Deploy Menu 🚀

Test Pass
Open deploy dropdown - correct styling (312px width)
Shrink viewport height, then open - scrollbar appears
No text wrapping on menu items

1.3 User Menu (if logged in) 👤

Test Pass
Open user menu - dark theme styling
Scrolling works if many items

2. Context Menus (Light Theme) ☀️

How to trigger: Right-click on workspace

2.1 No Selection

Submenus: Insert (Node, Junction, Link Nodes, Import, Import Example)

Test Pass
Right-click near bottom of screen - menu repositions or scrolls
Right-click with small viewport - menu scrolls
Hover over "Insert" submenu - popup positions correctly
Light theme styling (not dark)

2.2 With Node(s) Selected 🎯

Additional submenus: Node, Group, Arrange (if multiple)

Test Pass
Select a node, right-click - Node and Group submenus appear
Select multiple nodes - Arrange submenu appears
Shrink viewport, test scrolling with all submenus
Submenus pop out correctly while parent is scrolling

3. Tabs Menus (Light Theme) 📑

Note: These menus have no submenus

3.1 Flow Tab Dropdown (▼ button on tabs bar)

Test Pass
Click dropdown button - menu appears
Shrink viewport - scrolling works

3.2 Flow Tab Context Menu

Test Pass
Right-click on a flow tab - menu appears
Test with small viewport

4. Submenu Styling (Portaled Menus) 🎨

Test these when submenus are portaled (parent scrolling or near viewport edge)

4.1 Header Menu Submenus (Dark Theme) 🌑

Test Pass
Dark background color preserved
Light text color preserved
Hover highlight shows correctly
Keyboard shortcuts show muted/disabled color
No text underline on menu items
Disabled items show muted color
Dividers visible with correct color
Icons/checkmarks positioned correctly

4.2 Context Menu Submenus (Light Theme) ☀️

Test Pass
Light background color preserved
Dark text color preserved
Hover highlight shows correctly
Keyboard shortcuts styled correctly
No text underline on menu items
Disabled items show muted color
Dividers visible with correct color
Icons/checkmarks positioned correctly

5. Edge Cases 🔪

Test Pass
Rapid hovering between different submenu items
ESC key closes menus
Click outside closes menus
Menu near left viewport edge - submenu positions correctly
Menu near right viewport edge - submenu positions correctly
Narrow viewport - submenus flip to left side when no room on right

6. Browser Compatibility 🌐

Browser Scrollbar Styling Functionality
Chrome
Firefox
Safari See node-red#3 below 🦁

Known Limitations ⚠️

  1. Window resize while menu open 📏 - Menu does not reposition or update maxHeight when window is resized while menu is open. User must close and reopen the menu. To fix, could add a $(window).on("resize", ...) listener when menu opens and remove it when menu closes.

  2. No double-nested submenus 🪆 - The portal mechanism supports single-level submenus only. (Node-RED currently has no double-nested submenus, so this is not an issue.)

  3. Safari scrollbar styling 🦁 - Safari uses native overlay scrollbars and ignores ::-webkit-scrollbar customizations. The scrollbars remain functional but appear in Safari's default style rather than matching the menu theme.


@n-lark n-lark marked this pull request as draft January 8, 2026 02:00
@n-lark
Copy link
Owner Author

n-lark commented Jan 21, 2026

Node-RED Contribution: Written Explanation 📝

1. What did you choose to build, and why?

Problem Selection 🎯

I chose to address Issue node-red#5377: "UX: Ensure menus handle vertical overflow" from the Node-RED repository.

The original issue: "Our current menus (main menu, context menu) do not cope well if there is not enough vertical space to show them. If the bottom is off-screen, the options need to be scrollable so they can be accessed."

Why this problem:

  • Clear scope: The issue had a well-defined problem statement and reproducible behavior
  • Real user impact: This affects usability when working with Node-RED on smaller screens or with many menu items
  • Reasonable timebox: I estimated this could be understood and implemented within 2-3 hours
  • Technical learning: It involved both understanding an existing codebase and solving a fundamental CSS limitation

My Interpretation 🧩

The core challenge was: How do you enable vertical scrolling on a menu while allowing submenus to escape horizontally?

The submenu problem I discovered: While testing the initial vertical scrolling implementation, I noticed a critical UX issue: when adding overflow-y: auto to make menus scrollable, submenus got clipped inside the scrolling container. The natural CSS approach (overflow-y: auto + overflow-x: visible) doesn't work due to CSS specification constraints - when one axis is set to auto/scroll, the other automatically becomes auto as well, clipping any horizontally-extending submenus.

This wasn't explicitly mentioned in the original issue, but I recognized it would create a poor user experience. Users wouldn't be able to access submenu items if the parent menu needed to scroll. I decided to scope this into the fix to ensure a complete, polished solution.

What I Focused On ⚡

Core functionality:

  1. Detection logic - Identify when a submenu needs special positioning (parent is scrolling OR submenu would overflow viewport)
  2. Portal pattern - Move submenus to <body> to escape overflow containers
  3. Manual positioning - Calculate positions using getBoundingClientRect() with flip logic inspired by Floating UI
  4. Theme preservation - Maintain dark/light theme styling when submenus are portaled

Implementation locations:

  • Header menus (hamburger, deploy, user menus) - Dark theme
  • Context menus (workspace right-click) - Light theme
  • Tab menus (flow dropdowns) - Light theme

What I Intentionally Left Out 🔪

Scope decisions:

  1. Window resize handling - Menus don't reposition when window resizes while open (documented as known limitation)
  2. Double-nested submenus - Only single-level submenu portaling (Node-RED doesn't currently have nested submenus)
  3. External dependencies - Didn't add Floating UI library despite it solving this elegantly, to respect Node-RED's minimal dependency philosophy
  4. Automated tests - Focused on manual testing given the visual/interactive nature and time constraints

Implementation Tradeoffs ⚖️

Architectural choices:

  • Manual positioning over library: Replicated Floating UI's core positioning logic instead of adding the dependency. This increases maintenance burden slightly but keeps bundle size minimal.
  • Class-based theming over inheritance: When portaling to <body>, CSS inheritance breaks. I created standalone theme classes (.red-ui-header-menu, .red-ui-menu-dropdown-portaled) rather than trying to preserve the original DOM structure.
  • IIFE closures over global state: Each submenu item gets its own closure to track state, avoiding global pollution at the cost of slightly more memory per item.

Accepted technical debt:

  • Duplicated styling: Created SASS mixins to encapsulate menu theming and scrollbar styles, but this means menu styling now exists in two places. A future improvement would consolidate all menu styling to use these mixins.
  • Repeated scroll logic: The scroll-enabling pattern (check if menu exceeds viewport, apply maxHeight and overflowY: auto) is duplicated across menu.js and tabs.js in three places. A future refactor could extract this into a shared helper function like applyScrollingIfNeeded(menu, top).
  • Positioning edge cases: The getSubmenuPosition() function uses hardcoded fallback dimensions (offsetWidth || 230, offsetHeight || 200) and viewport-based calculations that may behave unexpectedly with non-100% zoom, RTL layouts, or if menus were ever rendered inside scroll containers (currently they're appended to <body>, so this works).

These are acceptable tradeoffs for the timebox, but would be good candidates for follow-up improvements if the implementation is adopted.


2. How and why did I use AI? 🦾

I used Claude (Anthropic's AI assistant) extensively throughout this work. Here's how:

The Brainstorming Phase: What Didn't Work 🪦

I explored several CSS-only approaches (wrapper elements, overflow tricks, contain property, transform-based positioning), including patterns from CSS-Tricks and AI research. All failed due to the same underlying CSS constraint or broke Node-RED's existing menu behavior. This made it clear the solution needed to escape the overflow container entirely rather than fight CSS limitations. 💀

The Final Solution: Taking Modern Libraries as Inspiration 💡

After these dead ends, I realized this is a solved problem in the positioning library space. Libraries like Floating UI (the modern successor to Popper.js) exist specifically to handle floating element positioning - tooltips, dropdowns, and popovers that need to escape overflow containers with intelligent collision detection and flip behavior.

This sparked the key insight: rather than reinventing the wheel, study how battle-tested libraries solve this problem, then adapt those patterns to Node-RED's vanilla JS architecture. 🔍

AI helped me research Floating UI's approach and understand its core algorithms, but the strategic decision was mine: instead of adding the dependency, replicate its core positioning patterns in vanilla JS matching Node-RED's existing style.

Why not just use Floating UI directly? Floating UI would have solved this elegantly, but Node-RED intentionally avoids frontend dependencies (the editor package.json has zero npm dependencies). Adding it would introduce new maintenance overhead and set a precedent inconsistent with the existing editor architecture. Instead, replicating Floating UI's well-documented positioning patterns in vanilla JS respected the project's philosophy while delivering the same functionality.

What AI Helped With & What I Did Myself 🤖 🧠

AI handled (80% of code generation):

  • Navigating the Node-RED file structure and understanding existing patterns
  • Researching Floating UI's positioning patterns after I suggested it
  • Translating Floating UI patterns into vanilla JS
  • Writing the positioning logic (getSubmenuPosition(), doesSubmenuNeedPortaling())
  • Creating SASS mixins for DRY styling (I suggested this approach so future menu implementations could reuse the theming and scrollbar patterns)
  • Code review and style consistency
  • Formatting and organizing the testing plan and implementation documentation

I handled (100% of decision-making):

  • Choosing which issue to tackle from the Node-RED backlog
  • Key insight: Suggesting we look at Floating UI's approach after CSS solutions failed
  • Deciding against adding Floating UI as a dependency
  • Defining the trigger conditions: portal submenus when parent menu is scrolling OR when submenu would overflow viewport edges
  • Choosing the IIFE closure pattern for state management to match existing code
  • Recognizing when AI suggestions were too framework-oriented (e.g., React patterns vs. jQuery/IIFE)
  • Manual testing across all menu types and browsers
  • UX validation - does it "feel right"?
  • Scoping what's in/out (no window resize handling, no nested submenus)

Tradeoffs in AI usage: 🎭

I chose to use AI heavily for code generation because:

  • Time constraints: 2-3 hour timebox meant speed was critical
  • Accelerated exploration: AI could rapidly navigate Node-RED's structure to find menu-related code and existing patterns
  • Translation work: Converting Floating UI's patterns to vanilla JS was repetitive but not architecturally complex

I retained control over decisions because:

  • Architectural judgment: AI can't assess project philosophy or make dependency decisions
  • Pattern recognition: AI initially suggested React patterns; recognizing the vanilla JS/IIFE style required human judgment
  • UX validation: Only manual testing could confirm the implementation "felt right"

This balance let me deliver a working solution within the timebox while ensuring it fit Node-RED's architecture and standards.


Summary 🎬

This contribution was intentionally scoped to fit a 2–3 hour timebox while still delivering a complete, user-safe solution. I focused on solving the underlying UX problem rather than applying a partial fix, even when that meant identifying edge cases not explicitly called out in the original issue.

I made deliberate tradeoffs to align with Node-RED's existing architecture and philosophy, favoring minimal dependencies, small surface area changes, and predictable behavior.

AI was used as an accelerator for exploration and implementation, but all architectural decisions, scoping choices, and UX validation were made deliberately by me to ensure the solution fit Node-RED's standards and user needs.

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.

1 participant