Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions app/components/docs_ui/landing.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 19 additions & 14 deletions app/components/docs_ui/sidebar.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 <details>, 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 <summary> so the whole section folds
Expand Down
2 changes: 1 addition & 1 deletion docs/app/views/docs/pages/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
62 changes: 62 additions & 0 deletions spec/docs_ui/landing_spec.rb
Original file line number Diff line number Diff line change
@@ -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("<h3")
end

it "still links every page" do
html = index_only.new.call

expect(html).to include('href="/docs/overview"').and include('href="/docs/accounts"')
end
end

describe "the doc index with multiple headings" do
before do
DocsKit.configure do |c|
c.nav = lambda {
{
"Docs" => { "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{<h3[^>]*>Docs</h3>})
expect(html).to match(%r{<h3[^>]*>Demos</h3>})
end
end
end
86 changes: 86 additions & 0 deletions spec/docs_ui/sidebar_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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(/<summary[^>]*>\s*([^<]+?)\s*</).flatten)
.to contain_exactly("Getting started", "REST API")
end

it "renders every page link" do
expect(html).to include('href="/docs/overview"').and include('href="/docs/accounts"')
end

it "server-renders every collapsible open (works with JS off)" do
expect(html.scan("<details").count).to eq(html.scan("<details open").count)
end
end

describe "the nav with multiple top-level headings" do
before do
DocsKit.configure do |c|
c.nav = lambda {
{
"Docs" => { "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{<li class="[^"]*menu-title[^"]*">Docs</li>})
expect(html).to match(%r{<li class="[^"]*menu-title[^"]*">Demos</li>})
expect(html.scan(/<summary[^>]*>\s*([^<]+?)\s*</).flatten)
.to contain_exactly("Guide", "Examples")
end

it "spaces headings apart from the group above" do
expect(html).to match(/menu-title[^"]*mt-4|mt-4[^"]*menu-title/)
expect(html).to include("first:mt-0")
end

it "keeps the subgroups collapsible and open" do
expect(html.scan("<details").count).to eq(2)
expect(html.scan("<details open").count).to eq(2)
end

it "skips a heading whose groups are empty" do
DocsKit.configure do |c|
c.nav = lambda {
{
"Docs" => { "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</li>")
end
end
end
Loading