Skip to content

Navigation: make the top nav configurable in navigation.yml - #3792

Open
theletterf wants to merge 4 commits into
mainfrom
feature/configurable-top-nav
Open

Navigation: make the top nav configurable in navigation.yml#3792
theletterf wants to merge 4 commits into
mainfrom
feature/configurable-top-nav

Conversation

@theletterf

@theletterf theletterf commented Aug 6, 2026

Copy link
Copy Markdown
Member

Preview: https://docs-v3-preview.elastic.dev/elastic/docs-builder/docs/3792 (assembled build — this is where the top nav renders; isolated previews do not show this bar)

Why

The bar under the global Elastic header is a hardcoded list of three links in _SecondaryNav.cshtml, so changing it means changing a template.

This extracts the configurable top nav prototyped in #3223 / the nav-v2 branch, rebuilt on the current navigation system. None of the nav-v2 types (NavigationV2File, SiteNavigationV2, SectionNavigationNode, config/navigation-v2.yml) are involved; only the markup shape, the CSS and the YAML shape were carried over.

What

A top_nav: key in config/navigation.yml. Each entry is a plain link, an external link (rendered with a ↗ icon and target="_blank"), or a dropdown:

top_nav:
  - title: Guides
    url: /guides/
  - title: Products
    children:                      # children => dropdown
      - title: Stack products      # a child with children => group heading
        children:
          - title: Elasticsearch
            page: docs-content://products/elasticsearch/v9.md
      - title: All products        # childless children are listed without a heading
        url: /products/
  - title: APIs
    url: https://www.elastic.co/docs/api/   # absolute URL => external

Links target either a site-relative url: or a cross-repo page:. page: refs are resolved once per assemble through the existing CrossLinkResolver + PublishEnvironmentUriResolver, so they survive page moves the same way body cross-links do. An unresolvable page: is a build error.

TopNavResolver produces an immutable TopNavRenderModel with final hrefs, which is stamped onto every set's BuildContext and flows to the layout from there.

The active item is a server-side longest-prefix match on whole path segments, so /reference/ does not claim /references/x and a dropdown child highlights its parent label. This is safe because hx-boost swaps the whole <body> and #secondary-nav is not hx-preserved, so the header re-renders on every navigation.

What changes visually

The bar is now left aligned and the static "Docs" link is gone, so it reads as one row of navigation rather than a brand plus a right-aligned menu. That applies to both the configured and the fallback rendering.

The links themselves do not change: the shipped default top_nav: reproduces today's Release notes / Troubleshoot / Reference, and if top_nav: is absent the template renders those same three links from the built-in fallback. Only assembler builds populate the model — isolated and Codex builds do not render this bar at all. The #htmx-indicator contract with _GlobalLayout.cshtml is preserved in both branches.

Notes

  • Dropdowns are native <details>/<summary>. That does not close on outside click or Escape, which the nav-v2 version left as a defect; secondary-nav.ts adds both with delegated listeners so the behaviour survives htmx body swaps.
  • The dropdown label is a pure toggle, not a link — this avoids the anchor-in-summary click conflict in the prototype. A dropdown that also needs a landing page would be a follow-up.
  • The tab bar gains overflow-x-auto so it scrolls sideways rather than overflowing once several items are configured; secondary-nav-dropdown.css uses :has() to let an open menu escape that clipping.

Testing

  • SiteNavigationFileTests — YAML parsing through the real static deserializer (links, external, page:, dropdown groups, absent key, invalid URI).
  • TopNavResolverTests — path-prefix application, external detection, page: resolution through a link index, unresolved/conflicting/nesting errors, active-URL matching.
  • SecondaryNavRenderingTests — renders the actual slice: fallback markup, configured links, external attributes, dropdown structure, active state, left alignment with no brand link, #htmx-indicator in both branches.
  • secondary-nav.test.ts — outside click, sibling close, clicks inside the panel, Escape + focus restore.

2,871 C# tests and 152 JS tests pass; dotnet format and prettier are clean.

Rendering was additionally checked in a browser against the real built styles.css and main.js bundle: the panel matches the target design, the :has() rule flips the container to overflow: visible only while open, the menu escapes the container, and outside-click/Escape close as expected.

Verified on the assembled preview. The preview above renders the bar from top_nav: in config/navigation.yml (the single-line anchor markup is the configured branch, not the built-in fallback), with the environment path prefix correctly applied to each href (/elastic/docs-builder/docs/3792/release-notes/). That exercises YAML parsing, TopNavResolver and the BuildContext threading in a real assemble. All 24 CI checks pass.

🤖 Generated with Claude Code

The bar under the global Elastic header was a hardcoded list of three
links in _SecondaryNav.cshtml, so changing it meant changing a template.

Add a `top_nav:` key to navigation.yml. Each entry is a link, an external
link, or a dropdown with grouped children. Links target either a site
relative `url:` or a cross-repo `page:` resolved at assemble time through
the existing CrossLinkResolver. The resolved model is stamped onto every
set's BuildContext once per assemble and reaches the layout from there.

Only assembler builds set it; isolated and codex builds leave it null and
keep rendering the built-in links, as does an assemble whose navigation.yml
has no `top_nav:`. The shipped default reproduces the current three links,
so the rendered header does not change on merge.

Dropdowns are native <details>, which does not close on outside click or
Escape; secondary-nav.ts adds that with delegated listeners so the
behaviour survives htmx body swaps.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The bar put a static "Docs" link on the left and pushed the items to the
right edge. Align the items to the left instead and remove the link, so
the bar reads as one row of navigation rather than a brand plus a menu.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Guides points at the docs home and APIs at the API reference, matching
what nav-v2 declared for the same two sections.

Guides uses `/`, which prefix-matches every page, so it is the highlighted
entry wherever no more specific entry claims the page. That is the intended
catch-all behaviour rather than an accident of prefix matching, so cover it
with a test. Also guard the shipped navigation.yml itself: its top_nav drives
every assembled page, so a typo there breaks the site rather than one doc.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@theletterf

Copy link
Copy Markdown
Member Author

@florent-leborgne — the dropdown from #3223 is fully supported here, it just moved to a slightly different schema. Here is how to turn Products on when the pages are ready.

Drop this into top_nav: in config/navigation.yml, between Guides and APIs (list order is bar order):

  - title: Products
    children:
      - title: Stack products      # a child WITH children => group heading
        children:
          - title: Elasticsearch
            page: docs-content://products/elasticsearch/v9.md
          - title: Kibana
            page: docs-content://products/kibana/v9.md

That renders exactly the panel in your screenshot: a "Stack products" heading with the two links under it.

What changed from your prototype

#3223 here why
dropdown: true (nothing) the presence of children: is what makes it a dropdown, so there is no flag to forget
section: / label: / group: title: everywhere one key at every depth; position in the tree decides whether it is a tab, a heading or a link
url: on the dropdown ignored (warns) see below

The dropdown label is a pure toggle, not a link. Your version put an <a> inside <summary>, which fights the native <details> toggle on click — the first click had to both navigate and open. Here the label only opens the panel. If you set url: or page: on an entry that has children:, the build warns and ignores it rather than silently dropping the link.

So if Products needs its own /products/ landing page, add it as the first entry inside the panel:

  - title: Products
    children:
      - title: All products        # childless entries render as links with no heading
        url: /products/
      - title: Stack products
        children:
          - title: Elasticsearch
            page: docs-content://products/elasticsearch/v9.md

Childless children are collected into an unlabelled run at the position you put them, so this puts "All products" above the "Stack products" heading. If a dropdown landing page turns out to be a real requirement, say so and I will look at making the label both toggle and link properly.

Things that will fail the build

  • An unresolvable page: is an error, not a silent drop. page: refs go through the same CrossLinkResolver as body cross-links, so they survive page moves — but the top nav is on every page, so a bad ref fails the assemble rather than shipping a broken tab. I have not checked whether products/elasticsearch/v9.md and products/kibana/v9.md currently resolve on main; if they do not yet exist, that entry will need to wait or point elsewhere.
  • Nesting is capped at item → group → link. A third level inside the panel is an error, not a silently truncated render (your version just stopped drawing at depth 2).
  • Setting both url: and page: on one entry is an error.

Not ported

The - title: X placeholder that rendered a greyed-out soon badge. Here an entry with no url:, page: or children: is a config error. It is maybe 15 lines to bring back (a TopNavStubItem plus the .secondary-nav-dropdown-stub CSS, which I left out of secondary-nav-dropdown.css) — worth doing if the hub pages land before the product pages, so just ask.

Two behaviours you get for free

  • Active state. A page under any dropdown child highlights the Products label, the same way Reference highlights on /reference/*. Longest match wins, so a nested entry beats its parent.
  • Outside click and Escape close the panel. Native <details> does neither — the prototype's CSS comment claimed the browser handled outside-click, but it does not, so an open panel sat over the page until you clicked the summary again. secondary-nav.ts fixes both, and opening one dropdown closes its siblings.

@reakaleek reakaleek left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This won't work on mobile once we add "Products".

The current items are taking up the whole width already.

image

We need a solution for the mobile view.

But really nice idea on making it configurable.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants