From 42336ae7e7bc31e35b7747993467e49fe2c0f25c Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Tue, 4 Aug 2026 01:20:01 +0200 Subject: [PATCH] feat(sidebar): flatten the top-level nav heading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single top-level heading (the common one-registry site) rendered as a collapsible
wrapping every subgroup, indenting the whole nav one level too deep and adding a redundant "DOCUMENTATION" fold. Now subgroups are the menu's top level: a lone heading is dropped entirely, and several headings render as static `.menu-title` labels (mt-4 spacing, no fold) — only subgroups collapse. Same rule for the Landing doc index, which printed its "Documentation" h2 directly above a same-text per-group h3 on single-heading sites. No config, JS, or CSS-contract change: nav_groups keeps its shape, docs-nav keys persistence by summary text (the removed details just orphans one stale localStorage key), and menu-title was already @source inline'd. Refs #65 --- README.md | 6 ++ app/components/docs_ui/landing.rb | 10 ++- app/components/docs_ui/sidebar.rb | 33 +++++---- docs/app/views/docs/pages/configuration.rb | 2 +- spec/docs_ui/landing_spec.rb | 62 ++++++++++++++++ spec/docs_ui/sidebar_spec.rb | 86 ++++++++++++++++++++++ 6 files changed, 180 insertions(+), 19 deletions(-) create mode 100644 spec/docs_ui/landing_spec.rb diff --git a/README.md b/README.md index 2793fdc..528bf0c 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,12 @@ registry maps a heading to its authored pages (`Doc.nav_items`); a page that isn't written yet is skipped, so there are no dead links. Register a page with one line (see [Add a page](#add-a-page)) and it appears in the sidebar. +The registry's page groups ("Getting started", "REST API", …) are the top level +of the rendered menu — each an open, collapsible section. The heading above them +only appears when you register **several** headings, and then as a static label +(no fold): a site with one registry gets no redundant "Documentation" level, and +nothing in the sidebar is indented deeper than group → page. + ### The two homes, the brand link, and dark code themes These knobs cover what sites used to shim by subclassing `DocsUI::Shell` or diff --git a/app/components/docs_ui/landing.rb b/app/components/docs_ui/landing.rb index df31df8..9ce5e1e 100644 --- a/app/components/docs_ui/landing.rb +++ b/app/components/docs_ui/landing.rb @@ -162,15 +162,17 @@ def doc_index div(class: "mt-16") do h2(class: "text-sm font-semibold uppercase tracking-wide text-base-content/50") { "Documentation" } div(class: "mt-6 grid gap-8 sm:grid-cols-2") do - groups.each { |heading, items| doc_index_group(heading, items) } + # A lone heading would just repeat the h2 above (often literally + # "Documentation"), so only label the columns when there are several. + groups.each { |heading, items| doc_index_group(heading, items, labeled: groups.size > 1) } end end end - def doc_index_group(heading, items) + def doc_index_group(heading, items, labeled:) div do - h3(class: "text-xs font-semibold uppercase tracking-wide text-base-content/40") { heading } - ul(class: "mt-3 flex flex-col gap-2") do + h3(class: "text-xs font-semibold uppercase tracking-wide text-base-content/40") { heading } if labeled + ul(class: labeled ? "mt-3 flex flex-col gap-2" : "flex flex-col gap-2") do items.each { |item| li { a(href: item.href, class: "link link-hover text-sm") { item.label } } } end end diff --git a/app/components/docs_ui/sidebar.rb b/app/components/docs_ui/sidebar.rb index 1cc7cee..e900251 100644 --- a/app/components/docs_ui/sidebar.rb +++ b/app/components/docs_ui/sidebar.rb @@ -8,6 +8,11 @@ module DocsUI # # nav_groups is an ordered Hash: # { "Heading" => { "Subgroup" => [DocsKit::NavItem, ...] } } + # + # Subgroups are the top level of the rendered menu. A lone heading (the common + # single-registry site) is not rendered at all — the brand masthead already + # labels the sidebar; with several headings, each renders as a STATIC + # `.menu-title` label (no fold) and only subgroups collapse. class Sidebar < Phlex::HTML include Phlex::Rails::Helpers::Request include DaisyUI @@ -29,7 +34,8 @@ def view_template header_section div(class: "flex-1 overflow-y-auto px-2 pb-6") do Menu(class: "w-full gap-1") do - nav_groups.each { |heading, grouped| nav_group(heading, grouped) } + groups = nav_groups.reject { |_, grouped| grouped.nil? || grouped.empty? } + groups.each { |heading, grouped| nav_group(heading, grouped, labeled: groups.size > 1) } end end end @@ -48,21 +54,20 @@ def header_section end end - # A top-level collapsible group (e.g. "Docs") holding collapsible sub-groups - # (e.g. "Guide", "Examples"). `grouped` is a { subgroup => [items] } Hash. - def nav_group(heading, grouped) - return if grouped.nil? || grouped.empty? - - li do - details(open: true) do - summary(class: "text-xs font-semibold uppercase tracking-wider text-base-content/50 #{MARKER_RESET}") do - heading - end - ul do - grouped.each { |subgroup, items| nav_subgroup(subgroup, items) } - end + # A top-level group (e.g. "Docs") holding collapsible sub-groups (e.g. + # "Guide", "Examples"). `grouped` is a { subgroup => [items] } Hash. The + # heading label only renders when the sidebar shows SEVERAL groups + # (labeled:) — as a static `.menu-title`, never a
, so subgroups + # stay at the menu's top level instead of gaining a nesting indent. The + # mt-4/first:mt-0 pair is the breathing room between one group's links and + # the next group's label. + def nav_group(heading, grouped, labeled:) + if labeled + li(class: "menu-title mt-4 text-xs font-semibold uppercase tracking-wider text-base-content/50 first:mt-0") do + heading end end + grouped.each { |subgroup, items| nav_subgroup(subgroup, items) } end # A collapsible sub-group: its title is a so the whole section folds diff --git a/docs/app/views/docs/pages/configuration.rb b/docs/app/views/docs/pages/configuration.rb index 597e52b..b329fa0 100644 --- a/docs/app/views/docs/pages/configuration.rb +++ b/docs/app/views/docs/pages/configuration.rb @@ -124,7 +124,7 @@ def nav_section code { "DocsKit::Registry" } plain " method returning " code { "{ group => [NavItem] }" } - plain "), and the whole sidebar derives from it with zero site nav code. A heading whose registry has no authored pages is dropped, so no empty group renders." + plain "), and the whole sidebar derives from it with zero site nav code. A heading whose registry has no authored pages is dropped, so no empty group renders. The registry's groups are the top level of the menu; the heading itself only renders — as a static label — when several headings are registered, so a one-registry site gets no redundant top fold." end end DocsUI::Code(<<~RUBY, filename: "config/initializers/docs_kit.rb") diff --git a/spec/docs_ui/landing_spec.rb b/spec/docs_ui/landing_spec.rb new file mode 100644 index 0000000..5649f2a --- /dev/null +++ b/spec/docs_ui/landing_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +RSpec.describe DocsUI::Landing do + # The doc index reads only DocsKit.configuration.nav_groups (no Rails request), + # so — like sidebar_spec's header-only render — exercise just that fragment + # through a tiny subclass instead of composing the whole Shell. + let(:index_only) do + Class.new(described_class) do + def view_template = doc_index + end + end + + def nav_item(href, label) + DocsKit::NavItem.new(href: href, label: label) + end + + describe "the doc index with a single heading" do + before do + DocsKit.configure do |c| + c.nav = lambda { + { "Documentation" => { + "Getting started" => [nav_item("/docs/overview", "Overview")], + "REST API" => [nav_item("/docs/accounts", "Accounts")] + } } + } + end + end + + it "renders the section heading once, with no duplicate per-group label" do + html = index_only.new.call + + expect(html.scan("Documentation").count).to eq(1) + expect(html).not_to include(" { "Guide" => [nav_item("/docs/install", "Installation")] }, + "Demos" => { "Examples" => [nav_item("/demos/counter", "Counter")] } + } + } + end + end + + it "labels each column with its heading" do + html = index_only.new.call + + expect(html).to match(%r{]*>Docs}) + expect(html).to match(%r{]*>Demos}) + end + end +end diff --git a/spec/docs_ui/sidebar_spec.rb b/spec/docs_ui/sidebar_spec.rb index d8d3502..722e7c7 100644 --- a/spec/docs_ui/sidebar_spec.rb +++ b/spec/docs_ui/sidebar_spec.rb @@ -24,4 +24,90 @@ def view_template = header_section expect(html).to include('href="/docs"') end end + + # The nav renders from config.nav_groups ({ heading => { subgroup => [NavItem] } }). + # Icon-less items keep the render free of rails_icons; #current_path degrades to + # nil outside a Rails request, so the full component renders standalone. + def nav_item(href, label) + DocsKit::NavItem.new(href: href, label: label) + end + + describe "the nav with a single top-level heading" do + before do + DocsKit.configure do |c| + c.nav = lambda { + { "Documentation" => { + "Getting started" => [nav_item("/docs/overview", "Overview")], + "REST API" => [nav_item("/docs/accounts", "Accounts")] + } } + } + end + end + + let(:html) { described_class.new.call } + + it "drops the heading entirely" do + expect(html).not_to include("Documentation") + end + + it "renders the subgroups as top-level open collapsibles" do + expect(html.scan(/]*>\s*([^<]+?)\s* { "Guide" => [nav_item("/docs/install", "Installation")] }, + "Demos" => { "Examples" => [nav_item("/demos/counter", "Counter")] } + } + } + end + end + + let(:html) { described_class.new.call } + + it "renders each heading as a static menu-title label, not a collapsible summary" do + expect(html).to match(%r{
  • Docs
  • }) + expect(html).to match(%r{
  • Demos
  • }) + expect(html.scan(/]*>\s*([^<]+?)\s* { "Guide" => [nav_item("/docs/install", "Installation")] }, + "Empty" => {} + } + } + end + + expect(html).not_to include("Empty") + # One non-empty heading remains, so the single-heading rule applies. + expect(html).not_to include("Docs") + end + end end