diff --git a/data/structures/nav.yml b/data/structures/nav.yml index 4e4455b2f..f37213555 100644 --- a/data/structures/nav.yml +++ b/data/structures/nav.yml @@ -41,10 +41,20 @@ 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: 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/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 }}