From 359493c0ca67e374f6ba1d840aacf32cf068b9c5 Mon Sep 17 00:00:00 2001 From: Mark Dumay <61946753+markdumay@users.noreply.github.com> Date: Sun, 16 Aug 2026 09:45:16 +0200 Subject: [PATCH 1/2] fix(sidebar): highlight a group that is the current page The client-side active pass marks the entry matching the current URL, and when that entry is a group title the class lands on `.sidebar-item-group > div > a`. Nothing styled it. The one rule that came close, the dark-mode `.sidebar-item-group.active`, targets the wrapper the class never reached, so it had never matched either. A reader on a group's own page - `/docs/getting-started/` rather than a page beneath it - saw a sidebar with no active row at all, in both colour modes. Mark the wrapper as well as the anchor. The wrapper is the row: it spans the collapse chevron, which the anchor does not, so it is the element already carrying the hover fill and the only one that can give an active group the same surface an active leaf gets. `aria-current` stays on the link it describes. Ancestors of the current page are still expanded without being highlighted. They are the trail to the row, not the row, and lighting the whole trail would leave a reader unable to tell which of them they are on. Co-Authored-By: Claude Opus 5 (1M context) --- assets/js/critical/sidebar-active.js | 14 +++++++++++++- assets/scss/components/_sidebar.scss | 20 +++++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/assets/js/critical/sidebar-active.js b/assets/js/critical/sidebar-active.js index b4227822..5b28eadc 100644 --- a/assets/js/critical/sidebar-active.js +++ b/assets/js/critical/sidebar-active.js @@ -16,6 +16,11 @@ // // Ancestor matching is a property of the link's href, so it needs no opt-in marker in the // markup: every link in the nav is a candidate. Do not reintroduce one. +// +// Exactly one entry is marked, whether it is a leaf or a group title. The groups above it +// are expanded but not highlighted: they are the trail to the current page, not the current +// page, and marking them would leave a reader unable to tell which of the four rows lit up +// is the one they are on. (function () { 'use strict' @@ -64,14 +69,21 @@ var link = match.link if (!link) return + // A group title is not the row it heads: the row is the `.sidebar-item-group` around it, + // which also spans the collapse chevron beside the title. Marking only the anchor leaves + // nothing for the stylesheet to give an active group the treatment an active leaf gets, + // because a leaf *is* its own row. So the class goes on both - the anchor for the text, + // the row for its surface - while `aria-current` stays on the link it describes. + var group = link.closest('.sidebar-item-group') + nav.classList.add('sidebar-no-transition') link.classList.add('active') + if (group) group.classList.add('active') link.setAttribute('aria-current', match.exact ? 'page' : 'true') // Expand the collapse trail: the matched group's own collapse (when the link is a group // title) plus every collapse ancestor within this nav instance. var own = null - var group = link.closest('.sidebar-item-group') if (group && group.parentElement) { own = group.parentElement.querySelector(':scope > .collapse') } diff --git a/assets/scss/components/_sidebar.scss b/assets/scss/components/_sidebar.scss index 7e5da3db..cb3dcdc6 100644 --- a/assets/scss/components/_sidebar.scss +++ b/assets/scss/components/_sidebar.scss @@ -112,12 +112,26 @@ html.sidebar-pre-collapsed .sidebar-collapsible .sidebar-brand { border-radius: #{$theme-border-radius}; + // A group is the current page whenever its own title is what the reader navigated to, + // and `critical/sidebar-active.js` marks it here rather than only on the title anchor: + // the row's surface is this element - it spans the chevron too, which the anchor does + // not - so an active group otherwise could not be given the same treatment as an active + // leaf. The anchor is restated because `> div > a` below sets a colour of its own, which + // an inherited one would lose to. + &.active { + color: $primary; + + > div > a { + color: $primary; + } + } + &:hover, &:focus { color: $primary; background-color: tint-color($primary, 90%); } - + > div > a { display: block; width: 100%; @@ -263,6 +277,10 @@ $sidebar-item-icon-column: calc(1.25em + #{$spacer * 0.25}); &.active { color: $primary-text-emphasis-dark !important; + + > div > a { + color: $primary-text-emphasis-dark !important; + } } &:hover, From b88ad52586189210270040835763c0e203866e89 Mon Sep 17 00:00:00 2001 From: Mark Dumay <61946753+markdumay@users.noreply.github.com> Date: Sun, 16 Aug 2026 09:45:50 +0200 Subject: [PATCH 2/2] fix(sidebar): keep every row's highlight the full width of the sidebar The nesting indent sat on the list holding the row - `ps-3`, or a wider class when the parent group led with an icon - so it moved the row's box along with its text. A row's hover and active surface is its box, which left a child's highlight starting one indent to the right of its parent's while both still ended at the sidebar's edge: the deeper the row, the narrower its highlight, and the trail read as ragged rather than as a column. Spend the indent on the row instead. The text lands exactly where it did, and every box now runs the full width of the column at every depth. Measured on a three-level menu, all rows span 40..266 while their labels still step 48, 72, 88. Padding on a row does not accumulate the way padding on nested lists does, so the renderer sums the depth as it recurses and publishes the total on each child list as a step count, which a table in the stylesheet turns back into a length. A count rather than a length because the sidebar is rendered through `partialCached` and a site under a Content-Security-Policy cannot carry an inline `style`. The count rides a class. A data attribute reads better but does not survive the build: PurgeCSS runs against `hugo_stats.json`, where Hugo records only tags, classes and ids, so an attribute selector is invisible to it and the whole step table is dropped from the production stylesheet - leaving every row on the `0rem` fallback and the nesting flat. A site can list an attribute under PurgeCSS's `dynamicAttributes`, but a theme cannot require every consumer to have done so, and the `ps-3` this replaces was a class for the same reason. Only a minified build shows the difference, so the template tests pin the class shape rather than just the arithmetic. Rendered markup is otherwise untouched: against a stock build of the same page the only difference is the indent carrier on the 27 nested lists. Co-Authored-By: Claude Opus 5 (1M context) --- assets/scss/components/_sidebar.scss | 74 +++++++++++++++++----- layouts/_partials/assets/sidebar.html | 30 ++++++--- tests/templates/layouts/index.html | 91 +++++++++++++++++++++++++++ 3 files changed, 172 insertions(+), 23 deletions(-) diff --git a/assets/scss/components/_sidebar.scss b/assets/scss/components/_sidebar.scss index cb3dcdc6..48bf847b 100644 --- a/assets/scss/components/_sidebar.scss +++ b/assets/scss/components/_sidebar.scss @@ -15,6 +15,41 @@ $sidebar-collapse-clipping-transition-delayed: white-space 0s $sidebar-collapse- // capped by the room instead (see `.sidebar-item-label`). $sidebar-label-max-width: 200px !default; +// The unit a nested sidebar row is indented by. +// +// The indent used to sit on the list that held the row - `ps-3`, or a wider class when the +// parent group led with an icon - which moved the row's box along with its text. A row's +// hover and active surface is its box, so a child's highlight started one indent to the +// right of its parent's while both ended at the sidebar's edge, and the deeper the row the +// narrower its highlight. The indent is now spent as padding on the row itself: the text +// lands exactly where it did, and every box stays the full width of the sidebar. +// +// That trades a nesting list for arithmetic, because padding on a row does not accumulate +// the way padding on nested lists does - so `assets/sidebar.html` sums the depth as it +// recurses and publishes the total on each child list as a step count, which the table at +// the foot of this file turns back into a length. +// +// A step count rather than the length itself because the markup is rendered through +// `partialCached` and a Content-Security-Policy site cannot carry an inline `style`. And the +// count rides a *class* rather than a data attribute, which the enumeration below exists to +// decode: PurgeCSS runs against `hugo_stats.json`, and Hugo records only tags, classes and +// ids there - an attribute selector is invisible to it and the whole table is dropped from +// the production stylesheet, leaving every row at the `0rem` fallback and the nesting flat. +// A consuming site can list an attribute under PurgeCSS's `dynamicAttributes`, but a theme +// cannot require every consumer to have done so, and the `ps-3` this replaces was a class +// for the same reason. +// +// Two steps is the plain nesting indent, the width of the `ps-3` it replaces. Three is what +// a group with a leading icon gives its children so they line up with its label rather than +// its icon: `fa-fw` (1.25rem) plus the icon's `me-1` gutter (0.25rem). Retuning the step +// therefore rescales the plain indent and the icon column together, and only the plain one +// is free to move - the other has to match the icon it hangs under. +$sidebar-nav-indent-step: 0.5rem !default; + +// How deep the step table goes. Each level costs one selector, and a menu deeper than this +// stops indenting further rather than losing the alignment it has. +$sidebar-nav-indent-max: 16 !default; + // scss-docs-start sidebar .sidebar { top: var(--navbar-offset); @@ -96,6 +131,11 @@ html.sidebar-pre-collapsed .sidebar-collapsible .sidebar-brand { display: block; padding: 0.1875rem 0.5rem !important; + // The nesting indent, paid by the row so its box stays full width - see + // `$sidebar-nav-indent-step`. Restated after the shorthand above, which would + // otherwise reset it; `!important` only because that shorthand carries one. + padding-left: calc(0.5rem + var(--sidebar-indent, 0rem)) !important; + &.active { color: $primary; } @@ -146,8 +186,11 @@ html.sidebar-pre-collapsed .sidebar-collapsible .sidebar-brand { } } +// A group header sits on the wrapper's padding rather than its own, so the nesting indent +// lands here - which is what keeps `.sidebar-item-group` itself, the row's hover and active +// surface, at the full width of the sidebar. .sidebar-item-group > div { - padding: 0.1875rem 0 0.1875rem 0.5rem !important; + padding: 0.1875rem 0 0.1875rem calc(0.5rem + var(--sidebar-indent, 0rem)) !important; } // Hanging indent for rows that lead with an icon. @@ -183,7 +226,7 @@ $sidebar-item-icon-column: calc(1.25em + #{$spacer * 0.25}); // `padding-left` needs `!important` only because the base `.sidebar-item` // padding above carries one. .sidebar-item:not(.d-flex):has(> svg:first-child, > i:first-child) { - padding-left: calc(0.5rem + #{$sidebar-item-icon-column}) !important; + padding-left: calc(0.5rem + var(--sidebar-indent, 0rem) + #{$sidebar-item-icon-column}) !important; text-indent: calc(-1 * #{$sidebar-item-icon-column}); } @@ -218,7 +261,7 @@ $sidebar-item-icon-column: calc(1.25em + #{$spacer * 0.25}); // `!important` for the same reason the block above needs it: the base // `.sidebar-item` padding carries one. .sidebar-item.sidebar-item-icon-indent { - padding-left: calc(0.5rem + #{$sidebar-item-icon-column}) !important; + padding-left: calc(0.5rem + var(--sidebar-indent, 0rem) + #{$sidebar-item-icon-column}) !important; } // Group headers again sit on the wrapper's padding, so they reserve the column @@ -715,21 +758,20 @@ html.sidebar-pre-collapsed .sidebar-collapsible .sidebar-footer .sidebar-seconda transform: translateX(-50%); } -// Align a group's children with its label rather than its icon. +// The step table: turn the depth `assets/sidebar.html` counted back into a length. // -// A leading icon pushes the parent's own label right by the icon's width plus -// its `me-1` gutter. `ps-3` is narrower than that, so a child list using it -// sits slightly to the *left* of the label it belongs to and the hierarchy -// reads as broken. Groups with an icon get this class from the template in -// place of `ps-3`; groups without one are untouched. +// A list publishes the indent its rows are to spend, and the rows read it through this +// custom property (see `$sidebar-nav-indent-step` for why the renderer counts steps rather +// than emitting the length itself). The property inherits, so a row picks up the value of +// the nearest list above it and a deeper list simply overrides it - a group header, which +// belongs to the list its *parent* opened, is indented by that list rather than by its own. // -// The template sets it rather than CSS deriving it with `:has()`. The collapse -// trail is expanded client side, and a selector that must be re-resolved as a -// subtree goes from display:none to visible can paint the old indent first and -// correct it a frame later, which reads as the sidebar shifting on load. -.sidebar-nav-icon-indent { - // Matches `fa-fw` (--fa-width: 1.25em) plus the icon's `me-1` gutter. - padding-left: calc(1.25rem + 0.25rem); +// Scoped under `.sidebar` so the class means nothing outside a sidebar, and setting only a +// custom property so it cannot compete with the rules that spend it. +@for $level from 1 through $sidebar-nav-indent-max { + .sidebar .sidebar-nav-indent-#{$level} { + --sidebar-indent: #{$level * $sidebar-nav-indent-step}; + } } // scss-docs-end sidebar diff --git a/layouts/_partials/assets/sidebar.html b/layouts/_partials/assets/sidebar.html index b1917cad..ea5b9aa8 100644 --- a/layouts/_partials/assets/sidebar.html +++ b/layouts/_partials/assets/sidebar.html @@ -147,10 +147,24 @@ {{- $pre := $group.pre -}} {{- $iconColumn := .iconColumn | default false -}} {{- $filename := .filename -}} + {{- $indent := .indent | default 0 -}} {{- /* Resolved for this group's own children, who are a list of their own */ -}} {{- $childIconColumn := partial "inline/sidebar/has-icons.html" $group.pages -}} + {{- /* How far this group's children hang under its label, counted in indent steps of + `$sidebar-nav-indent-step` (see components/_sidebar.scss). Two steps is the plain + nesting indent; three is that plus the column a leading icon opens, because the + group's own label starts an icon-width further right and its children line up with + the label rather than the icon. + + Accumulated here rather than left to the lists to nest, because the indent is spent + on the rows and not on the list around them: a row that carries its own indent as + padding keeps its box the full width of the sidebar at every depth, so a child's + hover and active surface is as wide as its parent's. Nested list padding moved the + box instead, and the highlight narrowed one step per level. */ -}} + {{- $childIndent := add $indent (cond $pre 3 2) -}} + {{- $doc_slug := partial "utilities/URLJoin.html" (dict "base" $baseURL "path" ($group.title | urlize)) -}} {{- $href := or $group.link $doc_slug -}} {{- /* Handle absolute paths from hierarchical menu (starting with /) */ -}} @@ -204,13 +218,14 @@