Skip to content

feat: introduce Layout::Header::SmartNavMenu – overflow-aware, user-customisable extension navigation#118

Open
roncodes wants to merge 8 commits intomainfrom
feature/smart-nav-menu
Open

feat: introduce Layout::Header::SmartNavMenu – overflow-aware, user-customisable extension navigation#118
roncodes wants to merge 8 commits intomainfrom
feature/smart-nav-menu

Conversation

@roncodes
Copy link
Member

@roncodes roncodes commented Feb 28, 2026

Summary

This PR introduces <Layout::Header::SmartNavMenu />, a smart, overflow-aware extension navigation component that replaces the static next-catalog-menu-items div 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 ResizeObserver monitors the container width on every viewport resize. Items that exceed the available width — or that exceed the hard @maxVisible cap (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:

Left column Right column
Pinned to bar — current pinned items in drag-sortable order All extensions — every available extension; click to toggle pin
Drag handles for reordering Pinned items highlighted in blue
× button to unpin Disabled when @maxVisible limit is reached

The footer provides Reset to default, Cancel, and Apply actions.

Preferences (ordered pinned IDs) are persisted to localStorage via currentUser.setOption / getOption, scoped per user, and survive page refreshes.


Component Architecture

Layout::Header::SmartNavMenu          ← container + ResizeObserver + preference management
  Layout::Header::SmartNavMenu::Item  ← single bar link (route or onClick handler)
  Layout::Header::SmartNavMenu::Dropdown  ← "More ▾" overflow panel
  Layout::Header::SmartNavMenu::Customizer ← two-column drag-sort preference panel

New files

File Purpose
addon/components/layout/header/smart-nav-menu.js Main component — ResizeObserver, preference load/save, item distribution
addon/components/layout/header/smart-nav-menu.hbs Main template — bar items + More button + gear button + customiser
addon/components/layout/header/smart-nav-menu/item.{js,hbs} Single bar item (route link or onClick)
addon/components/layout/header/smart-nav-menu/dropdown.{js,hbs} Overflow "More" dropdown panel
addon/components/layout/header/smart-nav-menu/customizer.{js,hbs} Two-column drag-sort customiser panel
addon/styles/components/smart-nav-menu.css All snm-* scoped styles, light + dark theme, responsive
app/components/layout/header/smart-nav-menu*.js App-level re-exports (4 files)

Modified files

File Change
addon/components/layout/header.hbs Replace static next-catalog-menu-items div with <Layout::Header::SmartNavMenu />
addon/components/layout/header.js Remove menuItems tracked property and mergeMenuItems() (now internal to SmartNavMenu)
addon/styles/addon.css Add @import 'components/smart-nav-menu.css'

API / Usage

{{! Default – cap of 5, auto overflow }}
<Layout::Header />

{{! Custom cap }}
<Layout::Header @maxVisibleNavItems={{7}} />

{{! Mutate items before render (same contract as before) }}
<Layout::Header @mutateMenuItems={{this.mutateMenuItems}} />

Breaking Changes

  • <Layout::Header @menuItems={{...}} /> no longer controls the extension nav bar. Extension items are sourced exclusively from universe.headerMenuItems inside SmartNavMenu. The @mutateMenuItems callback is still forwarded.
  • this.menuItems and mergeMenuItems() have been removed from LayoutHeaderComponent.

Testing Checklist

  • With ≤5 extensions: all items visible inline, no "More" button, gear icon present
  • With >5 extensions: first 5 visible, "More ▾" button appears with remaining items
  • Viewport resize causes items to move in/out of overflow correctly
  • Customiser opens on gear click; drag-sort reorders pinned items
  • Pinning/unpinning items in customiser updates the bar after Apply
  • Reset to default restores first-N ordering
  • Preferences persist across page refresh
  • Light and dark themes render correctly
  • Mobile viewport: SmartNavMenu is hidden (existing {{#unless (media "isMobile")}} guard)

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
@roncodes roncodes added the enhancement New feature or request label Feb 28, 2026
Manus 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant