From 92e6dadf6227a1937bf71d681aa3a452febea18b Mon Sep 17 00:00:00 2001 From: Mark Dumay <61946753+markdumay@users.noreply.github.com> Date: Sun, 6 Sep 2026 12:52:44 +0200 Subject: [PATCH 1/3] fix(nav): collapse a responsive button group instead of hiding it `responsive` hides the nav controls below the breakpoint and renders a dropdown in their place, but the hide applied to every tab type while the dropdown was gated on the type not being 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. Render the companion for every type, so the two gates agree. The button group announces it through `data-companion` the way the list already announces its own, and its toggle takes the button treatment assets/table.html gives the filter group it collapses: a control standing in for buttons has to read as one rather than as body copy. Two identifier defects sat underneath, because the dropdown carries no behavior of its own and works only by replaying a click on the control its `data-link` names. `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. The wrapper now carries the caller's value verbatim, and only when one was given -- stamping the shared fallback there would repeat a single id down the page. The two branches also spelled `{id}-btn-{index}` differently, the button group from the argument's fallback and the list and the `nav-show` lookup from the raw argument. A nav rendered without an id emitted `id="%!s()-btn-0"` while pointing its panes and its companion at `0-btn-0`, so `nav-show` could never match and the companion could never address a control. Both now read the same variable. Assertions in tests/templates cover all three, at the partial and through the shortcodes, along with guards for an opted-out group that must not gain a dropdown it did not ask for and an id-less nav that must not stamp the fallback on its wrapper. Co-Authored-By: Claude Opus 5 (1M context) --- data/structures/nav.yml | 3 + layouts/_partials/assets/nav.html | 67 ++++++--- tests/templates/content/blog/nav-shortcode.md | 8 + tests/templates/hugo.toml | 31 ++++ .../layouts/_partials/assets/hero-image.html | 8 + tests/templates/layouts/index.html | 140 ++++++++++++++++++ 6 files changed, 233 insertions(+), 24 deletions(-) create mode 100644 tests/templates/content/blog/nav-shortcode.md create mode 100644 tests/templates/layouts/_partials/assets/hero-image.html diff --git a/data/structures/nav.yml b/data/structures/nav.yml index 4e4455b2f..2063e3f5f 100644 --- a/data/structures/nav.yml +++ b/data/structures/nav.yml @@ -45,6 +45,9 @@ arguments: pane: responsive: release: v1.4.0 + comment: >- + Flag to collapse the nav controls into a dropdown below the configured + breakpoint. Applies to every tab type, "buttons" included. width: default: 12 group: partial diff --git a/layouts/_partials/assets/nav.html b/layouts/_partials/assets/nav.html index bc89a951b..ad68d3e38 100644 --- a/layouts/_partials/assets/nav.html +++ b/layouts/_partials/assets/nav.html @@ -41,15 +41,39 @@ {{/* The companion dropdown mirrors the nav, so it has to start on the same entry. `nav-show` names that entry by item id rather than position, so resolve it to an index here - built from the same - `{args.id}-btn-{index}` expression the nav's own buttons use below, so the two cannot disagree. - Falls back to the first entry when `nav-show` is unset or names an item that does not exist. */}} + `{id}-btn-{index}` expression the nav's own controls use below, so the two cannot disagree. + Falls back to the first entry when `nav-show` is unset or names an item that does not exist. + + It is `$id` rather than `$args.id`, because `$id` carries the fallback a caller that omits the + argument gets. Every `-btn-` expression in this file has to spell the identifier the same way or + a nav rendered without an id addresses buttons that were emitted under another name. */}} {{- $active := 0 -}} {{- if $args.navShow -}} {{- range $index, $item := $titles -}} - {{- if eq $args.navShow (printf "%s-btn-%d" $args.id $index) -}}{{- $active = $index -}}{{- end -}} + {{- if eq $args.navShow (printf "%s-btn-%d" $id $index) -}}{{- $active = $index -}}{{- end -}} {{- end -}} {{- end -}} +{{/* Arguments of the companion dropdown that stands in for the control group below the breakpoint. + Every nav type gets one. `responsive` promises the controls collapse rather than disappear, and + the group is hidden below the breakpoint whatever its type - so a type left without a dropdown + loses its controls outright at narrow widths, with the panes still rendered and no way to reach + any but the active one. + + The toggle takes the button treatment for a button group and the borderless text treatment for a + standard nav, which is the split assets/table.html already makes for its filter group: a control + standing in for buttons has to read as a button rather than as body copy. */}} +{{- $dropdownArgs := dict + "id" $id + "class" (printf "d-%s-none py-%d" $breakpoint.current $padding.y) + "titles" $titles + "wrap" $wrap + "active" $active +-}} +{{- if $isBtnGroup -}} + {{- $dropdownArgs = merge $dropdownArgs (dict "toggleClass" "btn btn-outline-primary") -}} +{{- end -}} + {{/* Tab-content class — spacing depends on type and controls-placement. tabs/callout: border acts as the visual separator, no margin needed. vertical: left margin to offset from the vertical nav rail. @@ -64,18 +88,18 @@ {{/* Main code */}} {{- if not $error -}} -
+{{- /* 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/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/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..99b651b6b 100644 --- a/tests/templates/layouts/index.html +++ b/tests/templates/layouts/index.html @@ -671,3 +671,143 @@ {{- 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 }} From 540c8606b43447b6878c8180a5e34af20f71c313 Mon Sep 17 00:00:00 2001 From: Mark Dumay <61946753+markdumay@users.noreply.github.com> Date: Sun, 6 Sep 2026 13:00:49 +0200 Subject: [PATCH 2/3] fix(nav): start a nav on its first entry when no item claims one assets/nav.html has always marked the first control active when `nav-show` is unset, but a 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`, so the fallback can only be resolved where the whole set is visible. The nav-item shortcode now hands its parent a description of its pane rather than the rendered markup, and the nav shortcode resolves the entry once -- an explicit `show` if any item reported one, its first entry otherwise -- before rendering the panes and passing the same value on as `nav-show`. Control, pane and companion dropdown then read one decision instead of each defaulting on its own, and an author who did mark an item still wins. The accordion rendering keeps its eager path. Nothing in the theme reads `alternative`, but an override of the nav shortcode reaches it the way it reached `inner`, so the contract is left as it was. Co-Authored-By: Claude Opus 5 (1M context) --- layouts/_shortcodes/nav-item.html | 20 ++++-- layouts/_shortcodes/nav.html | 23 ++++++- tests/templates/content/blog/nav-unmarked.md | 8 +++ tests/templates/layouts/index.html | 72 ++++++++++++++++++++ 4 files changed, 116 insertions(+), 7 deletions(-) create mode 100644 tests/templates/content/blog/nav-unmarked.md 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-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/layouts/index.html b/tests/templates/layouts/index.html index 99b651b6b..94b6ed67f 100644 --- a/tests/templates/layouts/index.html +++ b/tests/templates/layouts/index.html @@ -811,3 +811,75 @@ {{- 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 }} From 2def4403859376e1c0f3fb5f2127de4c5d45f6f7 Mon Sep 17 00:00:00 2001 From: Mark Dumay <61946753+markdumay@users.noreply.github.com> Date: Sun, 6 Sep 2026 13:08:17 +0200 Subject: [PATCH 3/3] feat(nav): accept the fade cascade on the group The nav-item shortcode reads `fade` off its parent and falls back to it when the item sets none, and the shared argument definition marks `fade` as a cascading one. The nav structure never declared it, so the cascade could not be reached: InitArgs rejected `fade` on the group as an unsupported argument, `$args.err` was set, and assets/nav.html rendered nothing at all -- an author reaching for the documented cascade lost the whole nav rather than gaining an animation. Declare it, scoped to the shortcode group, since the cascade lives in the item shortcode and a partial caller renders its own panes. The behaviour it unlocks is the item shortcode's existing one: the group's value is or-ed with the item's, so a group that sets `fade` fades every pane and an item cannot opt back out. Co-Authored-By: Claude Opus 5 (1M context) --- data/structures/nav.yml | 7 ++++ tests/templates/content/blog/nav-fade.md | 8 +++++ tests/templates/layouts/index.html | 44 ++++++++++++++++++++++++ 3 files changed, 59 insertions(+) create mode 100644 tests/templates/content/blog/nav-fade.md diff --git a/data/structures/nav.yml b/data/structures/nav.yml index 2063e3f5f..f37213555 100644 --- a/data/structures/nav.yml +++ b/data/structures/nav.yml @@ -41,6 +41,13 @@ arguments: vertical: word-wrap: release: v1.0.0 + fade: + group: shortcode + release: v3.24.0 + comment: >- + Flag to make the tab panes fade in. Cascades to every nav-item that does + not set it, which is the only way to reach a pane rendered by this + shortcode. class: pane: responsive: 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/layouts/index.html b/tests/templates/layouts/index.html index 94b6ed67f..5aa6c7654 100644 --- a/tests/templates/layouts/index.html +++ b/tests/templates/layouts/index.html @@ -883,3 +883,47 @@ {{- 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 }}