+{{- /* The wrapper carries the caller's `id` verbatim, which is the only element that does: every
+ other identifier here is derived from it (`nav-{id}` on the list, `dropdown-{id}` on the
+ companion, `{id}-btn-{index}` on the controls), so without this the argument documented as the
+ "unique identifier of the current element" named no element at all and `#{id}` resolved to
+ nothing. Emitted only when the caller supplied one - `$id` falls back to a constant, and
+ stamping that on the wrapper would repeat one id across every nav on the page. */ -}}
+
{{- if $isVertical }}
{{ end -}}
- {{/* Responsive dropdown (standard nav types only, controls-top position) */}}
- {{- if and $args.responsive (not $isBtnGroup) (ne $controlsPlacement "bottom") -}}
- {{ partial "assets/panel-dropdown.html" (dict
- "id" $id
- "class" (printf "d-%s-none py-%d" $breakpoint.current $padding.y)
- "titles" $titles
- "wrap" $wrap
- "active" $active
- ) }}
+ {{/* Responsive dropdown, controls-top position */}}
+ {{- if and $args.responsive (ne $controlsPlacement "bottom") -}}
+ {{ partial "assets/panel-dropdown.html" $dropdownArgs }}
{{- end -}}
{{/* Tab content — rendered first when controls-placement=below */}}
@@ -90,7 +114,8 @@
{{- if $isBtnGroup -}}
{{/* buttons tab-type */}}
{{- range $index, $item := $titles -}}
{{- $itemID := printf "%s-btn-%d" $id $index -}}
@@ -130,7 +155,7 @@
data-companion="dropdown-{{ $id }}"
>
{{- range $index, $item := $titles -}}
- {{ $itemID := printf "%s-btn-%d" $args.id $index -}}
+ {{ $itemID := printf "%s-btn-%d" $id $index -}}
{{- $show := eq $index 0 -}}
{{- if $args.navShow }}{{ $show = eq $args.navShow $itemID }}{{ end -}}
{{- $disabled := in $args.navDisabled $itemID -}}
@@ -160,15 +185,9 @@
{{- end -}}
- {{/* Responsive dropdown (standard nav types only, controls-below position) */}}
- {{- if and $args.responsive (not $isBtnGroup) (eq $controlsPlacement "bottom") -}}
- {{ partial "assets/panel-dropdown.html" (dict
- "id" $id
- "class" (printf "d-%s-none py-%d" $breakpoint.current $padding.y)
- "titles" $titles
- "wrap" $wrap
- "active" $active
- ) }}
+ {{/* Responsive dropdown, controls-below position */}}
+ {{- if and $args.responsive (eq $controlsPlacement "bottom") -}}
+ {{ partial "assets/panel-dropdown.html" $dropdownArgs }}
{{- end -}}
{{- if $isVertical }}
{{ end -}}
diff --git a/layouts/_shortcodes/nav-item.html b/layouts/_shortcodes/nav-item.html
index 6f5d3a27b..b9e7ecf7e 100644
--- a/layouts/_shortcodes/nav-item.html
+++ b/layouts/_shortcodes/nav-item.html
@@ -51,7 +51,15 @@
{{- $current := "" -}}
{{/* Main code */}}
- {{- $output := partial "assets/nav-item.html" (dict
+ {{/*
+ The pane is described here and rendered by the nav shortcode rather than emitted here.
+ Its `active` class is the one property an item cannot settle on its own: when no item
+ carries `show` the group falls back to its first entry, and an item rendered in document
+ order cannot know whether a later sibling will claim that. Handing the parent a
+ description instead of markup is what lets the fallback reach the pane as well as the
+ control - it is the only place that sees the whole set before anything is emitted.
+ */}}
+ {{- $itemArgs := dict
"page" .Page
"id" $id
"parent-id" $parent
@@ -63,29 +71,31 @@
"show" $args.show
"disabled" $args.disabled
"_default" $args.default
- )
-}}
- {{- $current := .Parent.Scratch.Get "inner" -}}
+ {{- $current := .Parent.Scratch.Get "inner-args" -}}
{{- $titles := .Parent.Scratch.Get "inner-title" -}}
{{- $disabled := .Parent.Scratch.Get "inner-disabled" -}}
{{- $icons := .Parent.Scratch.Get "inner-icon" -}}
{{- $itemAttrs := .Parent.Scratch.Get "inner-attrs" -}}
{{- if $args.show }}{{ .Parent.Scratch.Set "inner-show" $itemID }}{{ end -}}
{{- if $current -}}
- {{- .Parent.Scratch.Set "inner" (print $current $output) -}}
+ {{- .Parent.Scratch.Set "inner-args" ($current | append $itemArgs) -}}
{{- .Parent.Scratch.Set "inner-title" ($titles | append $title) -}}
{{- .Parent.Scratch.Set "inner-disabled" ($disabled | append $disabledID) -}}
{{- .Parent.Scratch.Set "inner-icon" ($icons | append $args.icon) -}}
{{- .Parent.Scratch.Set "inner-attrs" ($itemAttrs | append $args.attributes) -}}
{{- else -}}
- {{- .Parent.Scratch.Set "inner" (print $output) -}}
+ {{- .Parent.Scratch.Set "inner-args" (slice $itemArgs) -}}
{{- .Parent.Scratch.Set "inner-title" (slice $title) -}}
{{- .Parent.Scratch.Set "inner-disabled" (slice $disabledID) -}}
{{- .Parent.Scratch.Set "inner-icon" (slice $args.icon) -}}
{{- .Parent.Scratch.Set "inner-attrs" (slice $args.attributes) -}}
{{- end }}
+ {{/* The accordion rendering is kept eager. Nothing in this theme reads `alternative`, but it
+ reaches an override of the nav shortcode the same way `inner` used to, so it stays as it
+ was rather than changing a contract this fix has no reason to touch. */}}
{{- $alternative := partial "assets/nav-item.html" (dict
"page" .Page
"id" $id
diff --git a/layouts/_shortcodes/nav.html b/layouts/_shortcodes/nav.html
index 933a8971f..47f2c7864 100644
--- a/layouts/_shortcodes/nav.html
+++ b/layouts/_shortcodes/nav.html
@@ -17,7 +17,7 @@
) -}}
{{- end -}}
-{{- $inner := .Scratch.Get "inner" -}}
+{{- $innerArgs := .Scratch.Get "inner-args" -}}
{{- $innerTitles := .Scratch.Get "inner-title" -}}
{{- $innerDisabled := .Scratch.Get "inner-disabled" -}}
{{- $innerShow := .Scratch.Get "inner-show" -}}
@@ -39,6 +39,25 @@
{{- .Scratch.Set "resolved-id" $navID -}}
{{- end -}}
{{- end -}}
+ {{/*
+ Resolve the entry the group starts on before any pane is rendered, so a control and its
+ pane cannot disagree about which one it is. An item reports an explicit `show` through
+ `inner-show`; with none, the group falls back to its first entry - which assets/nav.html
+ has always done for the control and no pane ever did, so a nav whose items were all left
+ unmarked rendered an active tab above an empty content area. Passing the resolved value
+ on as `nav-show` keeps that fallback in one place: the control, the pane and the
+ companion dropdown all read the same decision rather than each defaulting on its own.
+ */}}
+ {{- $show := $innerShow -}}
+ {{- if and (not $show) $innerArgs -}}
+ {{- $show = printf "%s-btn-0" $navID -}}
+ {{- end -}}
+ {{- $inner := "" -}}
+ {{- range $itemArgs := $innerArgs -}}
+ {{- $inner = print $inner (partial "assets/nav-item.html" (merge $itemArgs (dict
+ "show" (eq $show (printf "%s-btn-%v" $navID $itemArgs.id))
+ ))) -}}
+ {{- end -}}
{{- partial "assets/nav.html" (dict
"id" $navID
"page" .Page
@@ -46,7 +65,7 @@
"nav-items" $inner
"nav-titles" $innerTitles
"nav-disabled" $innerDisabled
- "nav-show" $innerShow
+ "nav-show" $show
"nav-icons" $innerIcons
"nav-item-attrs" $innerAttrs
"tab-type" (or $args.tabType $args.type)
diff --git a/tests/templates/content/blog/nav-fade.md b/tests/templates/content/blog/nav-fade.md
new file mode 100644
index 000000000..b1d10df1f
--- /dev/null
+++ b/tests/templates/content/blog/nav-fade.md
@@ -0,0 +1,8 @@
+---
+title: Nav Fade
+---
+
+{{< nav tab-type="pills" id="faded" fade=true >}}
+{{< nav-item title="Alpha" >}}Alpha pane body.{{< /nav-item >}}
+{{< nav-item title="Beta" show=true >}}Beta pane body.{{< /nav-item >}}
+{{< /nav >}}
diff --git a/tests/templates/content/blog/nav-shortcode.md b/tests/templates/content/blog/nav-shortcode.md
new file mode 100644
index 000000000..6d1c65c83
--- /dev/null
+++ b/tests/templates/content/blog/nav-shortcode.md
@@ -0,0 +1,8 @@
+---
+title: Nav Shortcode
+---
+
+{{< nav tab-type="buttons" id="probe-nav" >}}
+{{< nav-item title="First" >}}First pane.{{< /nav-item >}}
+{{< nav-item title="Second" show=true >}}Second pane.{{< /nav-item >}}
+{{< /nav >}}
diff --git a/tests/templates/content/blog/nav-unmarked.md b/tests/templates/content/blog/nav-unmarked.md
new file mode 100644
index 000000000..0a2f200a8
--- /dev/null
+++ b/tests/templates/content/blog/nav-unmarked.md
@@ -0,0 +1,8 @@
+---
+title: Nav Unmarked
+---
+
+{{< nav tab-type="pills" id="unmarked" >}}
+{{< nav-item title="Alpha" >}}Alpha pane body.{{< /nav-item >}}
+{{< nav-item title="Beta" >}}Beta pane body.{{< /nav-item >}}
+{{< /nav >}}
diff --git a/tests/templates/hugo.toml b/tests/templates/hugo.toml
index bb1a1817f..abdfd0379 100644
--- a/tests/templates/hugo.toml
+++ b/tests/templates/hugo.toml
@@ -157,3 +157,34 @@ disableKinds = ['taxonomy', 'term', 'RSS', 'sitemap', 'robotsTXT', '404']
[[module.mounts]]
source = '../../data/structures/pagination.yml'
target = 'data/structures/pagination.yml'
+
+# assets/nav.html is the element under test for the nav assertions. Its companion dropdown is
+# rendered by assets/panel-dropdown.html and the identifiers and classes on both are what the
+# assertions read, so neither is stubbed. No case sets `nav-icons`, so the icon call site inside
+# the button group is never reached and the vendored icon partial above is not exercised here.
+[[module.mounts]]
+ source = '../../layouts/_partials/assets/nav.html'
+ target = 'layouts/_partials/assets/nav.html'
+[[module.mounts]]
+ source = '../../layouts/_partials/assets/panel-dropdown.html'
+ target = 'layouts/_partials/assets/panel-dropdown.html'
+[[module.mounts]]
+ source = '../../data/structures/nav.yml'
+ target = 'data/structures/nav.yml'
+
+# The nav shortcodes are mounted for the one thing a direct partial call cannot show: that the
+# identifier a page writes reaches every element that derives one from it. The shortcode resolves
+# the id and assembles the panes, assets/nav-item.html renders them, and the chain the companion
+# dropdown depends on - wrapper, control, pane, data-link - only exists once all four run.
+[[module.mounts]]
+ source = '../../layouts/_shortcodes/nav.html'
+ target = 'layouts/_shortcodes/nav.html'
+[[module.mounts]]
+ source = '../../layouts/_shortcodes/nav-item.html'
+ target = 'layouts/_shortcodes/nav-item.html'
+[[module.mounts]]
+ source = '../../layouts/_partials/assets/nav-item.html'
+ target = 'layouts/_partials/assets/nav-item.html'
+[[module.mounts]]
+ source = '../../data/structures/nav-item.yml'
+ target = 'data/structures/nav-item.yml'
diff --git a/tests/templates/layouts/_partials/assets/hero-image.html b/tests/templates/layouts/_partials/assets/hero-image.html
new file mode 100644
index 000000000..076ccc944
--- /dev/null
+++ b/tests/templates/layouts/_partials/assets/hero-image.html
@@ -0,0 +1,8 @@
+{{- /* Stub for the illustration hook of assets/nav-item.html.
+
+ The partial calls its `hook` whether or not the item carries an illustration, so the real
+ hero image partial is on the path of every nav pane - and it pulls the featured-illustration
+ partial and the image pipeline behind it. None of that bears on what the nav assertions read,
+ which is the identifier a pane is addressed by. Stubbing it keeps the mount list to the
+ templates actually under test; a nav case that needs a rendered illustration would have to
+ mount the real one instead. */ -}}
diff --git a/tests/templates/layouts/index.html b/tests/templates/layouts/index.html
index 1fc81410d..5aa6c7654 100644
--- a/tests/templates/layouts/index.html
+++ b/tests/templates/layouts/index.html
@@ -671,3 +671,259 @@
{{- end }}
CARD PADDING FAILURES: {{ $padFail }}
+
+{{- /*
+ Assertions for the companion dropdown and the identifiers in assets/nav.html.
+
+ `responsive` hides the control group below the breakpoint and puts a dropdown in its place.
+ The hide applied to every nav type; the dropdown was rendered for every type except a button
+ group. A responsive `tab-type="buttons"` therefore had no controls at all at narrow widths -
+ the panes still rendered, with no way to reach any but the active one - and since `responsive`
+ defaults to true, that was what a button group did unless its author opted out. The dropdown
+ now follows the hide, so the two gates agree; the toggle takes the button treatment there,
+ because a control standing in for buttons has to read as one.
+
+ The identifiers are pinned alongside it because the dropdown addresses the group through them.
+ `id` is documented as the "unique identifier of the current element" but named no element: the
+ list took `nav-{id}` and a button group took nothing, so `#{id}` resolved to nothing at all.
+ And the two branches spelled the same expression differently - the button group built
+ `{id}-btn-{index}` from the argument's fallback, the list and the `nav-show` lookup from the
+ raw argument - so a nav rendered without an id emitted buttons under one name and pointed its
+ panes and its companion at another.
+
+ Cases carrying `(none)` as their want are the guards, not the fix: they hold the behavior a
+ naive repair would break - an opted-out nav that gains a dropdown it did not ask for, or an
+ id-less nav that stamps the shared fallback on its wrapper and repeats one id down the page.
+*/ -}}
+{{- $navFail := 0 -}}
+{{- $navPage := site.GetPage "/blog/without-exact" -}}
+{{- $navBase := dict "page" $navPage "nav-titles" (slice "First" "Second") "nav-items" "
" -}}
+{{- $navCases := slice
+ (dict "case" "a responsive button group collapses into a companion dropdown"
+ "args" (dict "tab-type" "buttons" "id" "btn-nav" "responsive" true)
+ "find" `id="dropdown-[^"]*"`
+ "want" `id="dropdown-btn-nav"`)
+ (dict "case" "a responsive button group announces its companion"
+ "args" (dict "tab-type" "buttons" "id" "btn-nav" "responsive" true)
+ "find" `data-companion="[^"]*"`
+ "want" `data-companion="dropdown-btn-nav"`)
+ (dict "case" "the companion of a button group reads as a button"
+ "args" (dict "tab-type" "buttons" "id" "btn-nav" "responsive" true)
+ "find" `class="dropdown-toggle [^"]*"`
+ "want" `class="dropdown-toggle btn btn-outline-primary"`)
+ (dict "case" "the companion of a standard nav stays borderless"
+ "args" (dict "tab-type" "pills" "id" "pill-nav" "responsive" true)
+ "find" `class="dropdown-toggle [^"]*"`
+ "want" `class="dropdown-toggle link-body-emphasis border-0 bg-transparent p-0"`)
+ (dict "case" "responsive defaults to true, so a button group collapses unasked"
+ "args" (dict "tab-type" "buttons" "id" "default-nav")
+ "find" `id="dropdown-[^"]*"`
+ "want" `id="dropdown-default-nav"`)
+ (dict "case" "an opted-out button group renders no companion"
+ "args" (dict "tab-type" "buttons" "id" "static-nav" "responsive" false)
+ "find" `id="dropdown-[^"]*"`
+ "want" "(none)")
+ (dict "case" "an opted-out button group is not hidden below the breakpoint"
+ "args" (dict "tab-type" "buttons" "id" "static-nav" "responsive" false)
+ "find" `d-none`
+ "want" "(none)")
+ (dict "case" "the wrapper carries the caller's id verbatim"
+ "args" (dict "tab-type" "pills" "id" "wrapper-nav" "responsive" false)
+ "find" `
]*aria-selected="true"`
+ "want" `id="probe-nav-btn-1" data-bs-toggle="tab" data-bs-target="#probe-nav-1" role="tab" aria-controls="probe-nav-1" aria-selected="true"`)
+ (dict "case" "the shown pane is the one that control targets"
+ "find" `id="probe-nav-[0-9]" class="tab-pane[^"]*active[^"]*" role="tabpanel" aria-labelledby="[^"]*"`
+ "want" `id="probe-nav-1" class="tab-pane active" role="tabpanel" aria-labelledby="probe-nav-btn-1"`)
+ (dict "case" "the companion replays a click on a control that exists"
+ "find" `class="dropdown-item text-nowrap active" data-link="[^"]*"`
+ "want" `class="dropdown-item text-nowrap active" data-link="#probe-nav-btn-1"`)
+-}}
+{{- range $chainCases -}}
+{{- $got := index (findRE .find $chainHtml 1) 0 | default "(none)" -}}
+{{- if eq $got .want }}
+PASS nav chain: {{ .case }}
+{{- else }}
+FAIL nav chain: {{ .case }}
+ want={{ .want }}
+ got ={{ $got }}
+{{- $chainFail = add $chainFail 1 -}}
+{{- errorf "nav chain mismatch: case=%q want=%q got=%q" .case .want $got -}}
+{{- end -}}
+{{- end }}
+
+NAV CHAIN FAILURES: {{ $chainFail }}
+
+{{- /*
+ Assertions for the entry a nav starts on when no item claims one.
+
+ assets/nav.html has always marked the first control active when `nav-show` is unset, but the
+ pane's `active` class came from the item's own `show` and from nothing else. An author who
+ marked no item - which the shortcode never required and never warned about - got a nav with a
+ highlighted tab above an empty content area, since `.tab-pane` is display:none until something
+ activates it.
+
+ An item cannot settle this alone: it renders in document order and cannot know whether a later
+ sibling will claim `show`. The nav shortcode now resolves the entry once, before any pane is
+ rendered, and hands the same decision to the control, the pane and the companion dropdown.
+
+ Two fixtures, because the fallback has to reach the pane without overriding an author who did
+ mark one: /blog/nav-unmarked marks nothing and must start on its first entry, /blog/nav-shortcode
+ marks its second and must still start there. Both count their active panes, since a fallback
+ that stacked on top of an explicit mark would light two at once and read as correct from either
+ one alone.
+*/ -}}
+{{- $startFail := 0 -}}
+{{- $unmarkedHtml := replaceRE `\s+` " " (site.GetPage "/blog/nav-unmarked").Content -}}
+{{- $markedHtml := replaceRE `\s+` " " (site.GetPage "/blog/nav-shortcode").Content -}}
+{{- $startCases := slice
+ (dict "case" "an unmarked nav activates its first pane"
+ "html" $unmarkedHtml
+ "find" `id="unmarked-[0-9]" class="tab-pane[^"]*active[^"]*"`
+ "want" `id="unmarked-0" class="tab-pane active"`)
+ (dict "case" "an unmarked nav activates exactly one pane"
+ "html" $unmarkedHtml
+ "find" `class="tab-pane[^"]*active[^"]*"`
+ "count" 1)
+ (dict "case" "an unmarked nav marks the control that pane belongs to"
+ "html" $unmarkedHtml
+ "find" `id="unmarked-btn-[0-9]"[^>]*aria-selected="true"`
+ "want" `id="unmarked-btn-0" data-bs-toggle="pill" data-bs-target="#unmarked-0" type="button" role="tab" aria-controls="unmarked-0" aria-selected="true"`)
+ (dict "case" "an unmarked nav starts its companion on the same entry"
+ "html" $unmarkedHtml
+ "find" `class="dropdown-item text-nowrap active" data-link="[^"]*"`
+ "want" `class="dropdown-item text-nowrap active" data-link="#unmarked-btn-0"`)
+ (dict "case" "the fallback does not override an item that claims show"
+ "html" $markedHtml
+ "find" `id="probe-nav-[0-9]" class="tab-pane[^"]*active[^"]*"`
+ "want" `id="probe-nav-1" class="tab-pane active"`)
+ (dict "case" "a marked nav still activates exactly one pane"
+ "html" $markedHtml
+ "find" `class="tab-pane[^"]*active[^"]*"`
+ "count" 1)
+-}}
+{{- range $startCases -}}
+{{- $matches := findRE .find .html -}}
+{{- $got := "" -}}
+{{- $want := "" -}}
+{{- if isset . "count" -}}
+ {{- $got = printf "%d active pane(s)" (len $matches) -}}
+ {{- $want = printf "%d active pane(s)" .count -}}
+{{- else -}}
+ {{- $got = index $matches 0 | default "(none)" -}}
+ {{- $want = .want -}}
+{{- end -}}
+{{- if eq $got $want }}
+PASS nav start: {{ .case }}
+{{- else }}
+FAIL nav start: {{ .case }}
+ want={{ $want }}
+ got ={{ $got }}
+{{- $startFail = add $startFail 1 -}}
+{{- errorf "nav start mismatch: case=%q want=%q got=%q" .case $want $got -}}
+{{- end -}}
+{{- end }}
+
+NAV START FAILURES: {{ $startFail }}
+
+{{- /*
+ Assertions for the `fade` cascade of the nav shortcode.
+
+ The nav-item shortcode reads `fade` off its parent and falls back to it when the item sets
+ none, but the nav structure never declared the argument. Writing it on the group did not
+ cascade, it aborted: InitArgs rejected it as unsupported, `$args.err` was set, and
+ assets/nav.html rendered nothing at all - so the page lost its whole nav rather than its
+ animation. Declaring it makes the cascade the item shortcode already implements reachable.
+
+ Both panes are read, because `fade` alone is not enough to see one. A faded pane is
+ transparent until `show` joins `active`, so an active pane that took `fade` without `show`
+ would occupy the layout and display nothing - the failure the earlier empty-content-area bug
+ produced by another route. The cascade is an `or`: a group that sets `fade` fades every pane
+ and an item cannot opt out, which is the item shortcode's existing semantics and is pinned
+ here only so a change to it is deliberate.
+*/ -}}
+{{- $fadeFail := 0 -}}
+{{- $fadeHtml := replaceRE `\s+` " " (site.GetPage "/blog/nav-fade").Content -}}
+{{- $fadeCases := slice
+ (dict "case" "the group's fade reaches an item that sets none"
+ "find" `id="faded-0" class="[^"]*"`
+ "want" `id="faded-0" class="tab-pane fade"`)
+ (dict "case" "the shown pane fades in visible rather than transparent"
+ "find" `id="faded-1" class="[^"]*"`
+ "want" `id="faded-1" class="tab-pane active show fade"`)
+ (dict "case" "the group still renders its controls"
+ "find" `id="faded-btn-1"[^>]*aria-selected="true"`
+ "want" `id="faded-btn-1" data-bs-toggle="pill" data-bs-target="#faded-1" type="button" role="tab" aria-controls="faded-1" aria-selected="true"`)
+-}}
+{{- range $fadeCases -}}
+{{- $got := index (findRE .find $fadeHtml 1) 0 | default "(none)" -}}
+{{- if eq $got .want }}
+PASS nav fade: {{ .case }}
+{{- else }}
+FAIL nav fade: {{ .case }}
+ want={{ .want }}
+ got ={{ $got }}
+{{- $fadeFail = add $fadeFail 1 -}}
+{{- errorf "nav fade mismatch: case=%q want=%q got=%q" .case .want $got -}}
+{{- end -}}
+{{- end }}
+
+NAV FADE FAILURES: {{ $fadeFail }}