feat: introduce Layout::Header::SmartNavMenu – overflow-aware, user-customisable extension navigation#118
Open
feat: introduce Layout::Header::SmartNavMenu – overflow-aware, user-customisable extension navigation#118
Conversation
Replaces the static `next-catalog-menu-items` navigation bar with a smart, overflow-aware `<Layout::Header::SmartNavMenu />` component that solves the header-overflow problem caused by an ever-growing number of installed Fleetbase extensions. ## Problem The existing header renders every registered extension as an inline link. With modules such as Ledger and Pallet now installed alongside Fleet-Ops, Storefront, Developers, IAM, and Extensions, the bar is at capacity. One additional extension would push the right-side controls (locale, notifications, chat, org/user menus) out of view. ## Solution – SmartNavMenu ### Priority+ overflow A `ResizeObserver` on the container element measures available width on every viewport resize. Items that do not fit — or that exceed the configurable `@maxVisible` cap (default 5) — are automatically moved into a "More ▾" dropdown. The "More" button only appears when there is overflow; when all items fit it is hidden. ### User customisation (AWS-console style) A gear-icon (⊞) button opens a two-column customiser panel: - **Left column** — "Pinned to bar": shows the current pinned items in drag-sortable order (via `ember-drag-sort`). Users can unpin items with the × button. - **Right column** — "All extensions": a grid of every available extension. Pinned items are highlighted in blue. Clicking toggles pin state; the column is disabled once `@maxVisible` is reached. - **Footer** — "Reset to default" restores the first-N ordering, plus Cancel / Apply buttons. Preferences (ordered pinned IDs) are persisted to `localStorage` via `currentUser.setOption` / `getOption` so they survive page refreshes and are scoped per user. ### Component tree ``` Layout::Header::SmartNavMenu ← container + ResizeObserver Layout::Header::SmartNavMenu::Item ← single bar link (route or onClick) Layout::Header::SmartNavMenu::Dropdown ← "More" overflow panel Layout::Header::SmartNavMenu::Customizer ← two-column preference panel ``` ### CSS A new `addon/styles/components/smart-nav-menu.css` file (imported in `addon.css`) provides all `snm-*` scoped styles with full light/dark theme support and a responsive mobile layout for the customiser panel. ### Breaking changes / migration - `<Layout::Header />` no longer accepts `@menuItems` for the extension nav bar — items are sourced exclusively from `universe.headerMenuItems` inside `SmartNavMenu`. The `@mutateMenuItems` callback arg is forwarded to `SmartNavMenu` for consumers who need to mutate items. - The `@maxVisibleNavItems` arg on `<Layout::Header />` can override the default cap of 5. - `this.menuItems` tracked property and `mergeMenuItems()` method have been removed from `LayoutHeaderComponent` as they are now handled internally by `SmartNavMenu`. Closes: header overflow issue with 6+ installed extensions
added 7 commits
February 28, 2026 03:01
…text-role The linter rule (part of recommended config) requires that elements with role="menuitem" have an immediate parent element with role="menu", "group", or "menubar". The wrapper div was missing a role attribute, so the two elements inside it (the for onClick items and the for route items) were flagged at lines 15 and 33 respectively. Fix: move and from the outer container div (which is a visual/positioning wrapper only) onto the div that directly parents the menuitem elements. This satisfies the ARIA context requirement while keeping the semantic structure correct — the outer div is now a plain presentational container, and the inner div is the true menu landmark.
Root causes identified and fixed:
1. Dropdown never appeared because `allItems` was loaded once in the
constructor as a `@tracked` property. At construction time the
universe registry is still empty (extensions register their menu items
asynchronously after boot), so `visibleItems` and `overflowItems`
were both empty arrays and `hasOverflow` was always false.
Fix: convert `allItems` to a **native getter** that reads directly
from `universe.headerMenuItems`. Because the registry is backed by
`TrackedMap` + `TrackedObject` + Ember `A()` arrays, Glimmer's
auto-tracking automatically invalidates the getter whenever a new
extension registers its menu item – no manual event wiring needed for
the initial render.
2. Added a `universe.menuService.on('menuItem.registered')` listener as
a belt-and-suspenders mechanism to call `_distributeFromAllItems`
after each registration, ensuring the visible/overflow split is
recalculated even in edge cases where auto-tracking may not fire.
3. `overflow:hidden` on `.snm-container` was clipping the absolutely-
positioned dropdown panel. Removed the overflow rule; the container
now only uses `position: relative` so the dropdown escapes correctly.
4. `snm-more-btn` updated to icon-only (28 × 28 px square, centred)
matching the customise button. The HBS template now uses
`<FaIcon @ICON="ellipsis" />` (horizontal three-dot icon) instead of
the previous text label + icon combination.
5. `item.js` – replaced the classic `@computed` decorator on `isActive`
with a plain native getter for Glimmer component compatibility.
6. `dropdown.js` – simplified: removed unused `navigateTo` action;
`handleItemClick` now receives the full `menuItem` object and calls
`menuItem.onClick(menuItem)` directly. The `openCustomizer` action
was removed since the template now calls `@onOpenCustomizer` directly.
7. `dropdown.hbs` – the "Customise navigation" footer button now calls
`@onOpenCustomizer` (arg) instead of `this.openCustomizer` (removed
action), fixing a runtime error when the footer link was clicked.
…haviour
Fixes two bugs reported during testing:
1. Dropdown panel was tiny (constrained to 57px header height)
Root cause: .snm-dropdown used position:absolute inside the header's
DOM tree. The inherited .next-dd-menu class applies height:100% and
min-height:100%, which resolved to the 57px header height, leaving
only the panel header visible and the items hidden.
Fix:
- Render the dropdown via EmberWormhole into #application-root-wormhole
(already present in console/app/templates/application.hbs and used
throughout the codebase for the same purpose).
- Switch .snm-dropdown to position:fixed with top/left coordinates
injected as inline style from JS (getBoundingClientRect on the More
button, calculated in _calculateDropdownPosition() before opening).
- Remove the next-dd-menu class dependency entirely; all colours,
borders, shadows and scrollbar styles are now self-contained in
snm-* CSS rules, with full dark/light theme coverage.
- Dropdown is now 320px wide, auto-height up to calc(100vh - 80px),
with a proper header, close button, scrollable items list, and footer.
- On mobile (<768px) the panel pins full-width below the header.
2. Pinned selection was ignored – bar still showed 5 items even when
user had only 2 pinned
Root cause: _distributeFromAllItems() built a [pinned, ...rest] ordered
array then called _distributeItems() which sliced by maxVisible (5),
filling the bar with unpinned items after the 2 pinned ones.
Fix: When pinnedIds is set, ONLY pinned items appear in the bar.
Everything else (unpinned) goes directly to overflowItems regardless
of maxVisible. The cap still applies as an upper bound on the bar.
_distributeFromAllItems() now handles both paths explicitly:
- No saved preference → first maxVisible items in bar (default behaviour)
- Saved preference → exactly the pinned items in bar, rest in overflow
Additional changes:
- registerMoreWrapper renamed to registerMoreBtn (simpler, no wrapper div)
- _handleOutsideClick now checks #application-root-wormhole so clicks
inside the wormhole-rendered dropdown don't close it
- snm-more-btn gets an is-open active state style
- Removed now-unused .snm-more-wrapper CSS rule
…ow dropdown
Root cause: the <LinkToExternal /> elements inside the dropdown had an
{{on "click" @onclose}} modifier attached. When clicked, the modifier
fired synchronously, setting isMoreOpen=false which caused Glimmer to
tear down the EmberWormhole portal and destroy the <LinkToExternal />
element from the DOM mid-transition. With the anchor gone, the browser
fell back to a full page reload.
Fix: remove the {{on "click" @onclose}} modifier from all
<LinkToExternal /> elements in the dropdown template entirely. Instead,
the parent SmartNavMenu component now registers a routeDidChange listener
on the router service (matching the pattern used by mobile-navbar.js and
sidebar/item.js). The listener sets isMoreOpen=false AFTER Ember has
successfully completed the transition, so the link element is never
destroyed before the router can act on it.
Also:
- Restore item.hbs to the correct dual-branch pattern (onClick if
defined, otherwise LinkToExternal) — this is the more robust pattern
that handles both route-based and programmatic navigation items.
- Apply the same dual-branch pattern to dropdown.hbs so bar items and
dropdown items behave identically.
- Simplify dropdown.js: handleItemClick now calls onClose after invoking
the item's onClick handler (safe because onClick items are not
LinkToExternal and do not trigger a router transition).
- Add router + hostRouter service injections and _getRouter() helper
(matching mobile-navbar.js pattern) to smart-nav-menu.js.
… lint errors
The template linter enforces two rules that the dropdown panel violated:
- no-inline-styles: elements cannot have inline style attributes
- style-concatenation: concatenated styles must be marked as htmlSafe
Fix: move the position style computation out of the template and into a
getter in dropdown.js. The getter uses htmlSafe() from @ember/template
to produce a SafeString, then the template binds it with
style={{this.positionStyle}} — the same pattern used by
attach/popover.js and activity-log.js throughout the codebase.
Root cause: both column headers share .snm-customizer-col-header but their content differs — the left header has two flex children (title span + badge span) while the right header has only one (title span). Even with identical py-2.5 padding, the badge's own py-0.5 vertical padding and font-mono metrics can produce a fractional height difference between the two headers. Fix: replace py-2.5 with an explicit height: 40px and min-height: 40px on .snm-customizer-col-header. This guarantees both column headers are pixel-perfect identical regardless of their content, and box-sizing: border-box ensures the border is included in the 40px measurement. The light-theme override only touches bg/border colours so no change is needed there.
…close-on-navigate - Add quickPin() @action and atPinnedLimit getter to SmartNavMenu JS. quickPin() adds the item's ID to pinnedIds, saves preferences, and re-distributes items so the extension immediately moves from the overflow dropdown to the bar. It is a no-op when atPinnedLimit is true. - Pass @onQuickPin and @atPinnedLimit down to the Dropdown component via smart-nav-menu.hbs. - In dropdown.hbs, each item row is now wrapped in .snm-dropdown-item-row (a flex container) that holds: 1. The link/button (flex-1, takes all available space) 2. A .snm-dropdown-pin-btn thumbtack icon button (flex-shrink-0, hidden by default, revealed on row hover via CSS opacity transition, only rendered when @atPinnedLimit is false) - Safe close-on-navigate for LinkToExternal items: the link is wrapped in a .snm-dropdown-item-link-wrapper <div> that has {{on "click" @onclose}}. The div fires @onclose synchronously on click but does NOT call preventDefault(), so the browser event continues to bubble and LinkToExternal handles the router transition normally. The dropdown closes cleanly without destroying the link element mid-transition. - Update dropdown.js JSDoc to document the two new args. - Add .snm-dropdown-item-row, .snm-dropdown-item-link-wrapper, and .snm-dropdown-pin-btn CSS rules (dark + light theme) to smart-nav-menu.css.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR introduces
<Layout::Header::SmartNavMenu />, a smart, overflow-aware extension navigation component that replaces the staticnext-catalog-menu-itemsdiv inside<Layout::Header />.The motivation is clear from the current state of the header: with Fleet-Ops, Ledger, Pallet, Storefront, Developers, IAM, and Extensions already installed, the navigation bar is at capacity. One additional extension will push the right-side controls (locale selector, notifications, chat, org/user menus) completely out of view.
Problem
The existing implementation renders every registered extension as an inline
<LinkToExternal>inside a flex container with no overflow handling. There is no cap, no collapse mechanism, and no way for users to choose which extensions they want visible.Solution
Priority+ Overflow (automatic)
A
ResizeObservermonitors the container width on every viewport resize. Items that exceed the available width — or that exceed the hard@maxVisiblecap (default 5) — are automatically moved into a "More ▾" dropdown. The "More" button only appears when there is actual overflow.User Customisation (AWS-console style)
A ⊞ gear button opens a two-column customiser panel:
@maxVisiblelimit is reachedThe footer provides Reset to default, Cancel, and Apply actions.
Preferences (ordered pinned IDs) are persisted to
localStorageviacurrentUser.setOption/getOption, scoped per user, and survive page refreshes.Component Architecture
New files
addon/components/layout/header/smart-nav-menu.jsaddon/components/layout/header/smart-nav-menu.hbsaddon/components/layout/header/smart-nav-menu/item.{js,hbs}addon/components/layout/header/smart-nav-menu/dropdown.{js,hbs}addon/components/layout/header/smart-nav-menu/customizer.{js,hbs}addon/styles/components/smart-nav-menu.csssnm-*scoped styles, light + dark theme, responsiveapp/components/layout/header/smart-nav-menu*.jsModified files
addon/components/layout/header.hbsnext-catalog-menu-itemsdiv with<Layout::Header::SmartNavMenu />addon/components/layout/header.jsmenuItemstracked property andmergeMenuItems()(now internal to SmartNavMenu)addon/styles/addon.css@import 'components/smart-nav-menu.css'API / Usage
Breaking Changes
<Layout::Header @menuItems={{...}} />no longer controls the extension nav bar. Extension items are sourced exclusively fromuniverse.headerMenuItemsinsideSmartNavMenu. The@mutateMenuItemscallback is still forwarded.this.menuItemsandmergeMenuItems()have been removed fromLayoutHeaderComponent.Testing Checklist
{{#unless (media "isMobile")}}guard)