Skip to content

Collapsible sidebar #2159

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 17 commits into from
Mar 25, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions docs/user_guide/layout.rst
Original file line number Diff line number Diff line change
Expand Up @@ -354,15 +354,15 @@ By default, it has the following configuration:
.. code-block:: python

html_sidebars = {
"**": ["sidebar-nav-bs", "sidebar-ethical-ads"]
"**": ["sidebar-collapse", "sidebar-nav-bs"]
}

- ``sidebar-collapse.html`` - a button that allows users to expand and collapse the sidebar.

- ``sidebar-nav-bs.html`` - a bootstrap-friendly navigation section.

When there are no pages to show, it will disappear and potentially add extra space for your page's content.

- ``sidebar-ethical-ads.html`` - a placement for ReadTheDocs's Ethical Ads (will only show up on ReadTheDocs).

Primary sidebar end sections
----------------------------

Expand All @@ -382,6 +382,8 @@ By default, it has the following templates:
# ...
}

``sidebar-ethical-ads.html`` is a placement for ReadTheDocs's Ethical Ads (will only show up on ReadTheDocs).

Remove the primary sidebar from pages
-------------------------------------

Expand Down
132 changes: 132 additions & 0 deletions src/pydata_sphinx_theme/assets/scripts/pydata-sphinx-theme.js
Original file line number Diff line number Diff line change
Expand Up @@ -1012,6 +1012,129 @@ async function fetchRevealBannersTogether() {
}, 320);
}

/*******************************************************************************
* Set up expand/collapse button for primary sidebar
*/
function setupCollapseSidebarButton() {
const button = document.getElementById("pst-collapse-sidebar-button");
const sidebar = document.getElementById("pst-primary-sidebar");

// If this page rendered without the button or sidebar, then there's nothing to do.
if (!button || !sidebar) {
return;
}

const sidebarSections = Array.from(sidebar.children);

const expandTooltip = new bootstrap.Tooltip(button, {
title: button.querySelector(".pst-expand-sidebar-label").textContent,

// In manual testing, relying on Bootstrap to handle "hover" and "focus" was buggy.
trigger: "manual",

placement: "left",
fallbackPlacements: ["right"],

// Offsetting the tooltip a bit more than the default [0, 0] solves an issue
// where the appearance of the tooltip triggers a mouseleave event which in
// turn triggers the call to hide the tooltip. So in certain areas around
// the button, it would appear to the user that tooltip flashes in and then
// back out.
offset: [0, 12],
});

const showTooltip = () => {
// Only show the "expand sidebar" tooltip when the sidebar is not expanded
if (button.getAttribute("aria-expanded") === "false") {
expandTooltip.show();
}
};
const hideTooltip = () => {
expandTooltip.hide();
};

function squeezeSidebar(prefersReducedMotion, done) {
// Before squeezing the sidebar, freeze the widths of its subsections.
// Otherwise, the subsections will also narrow and cause the text in the
// sidebar to reflow and wrap, which we don't want. This is necessary
// because we do not remove the sidebar contents from the layout (with
// `display: none`). Rather, we hide the contents from both sighted users
// and screen readers (with `visibility: hidden`). This provides better
// stability to the overall layout.
sidebarSections.forEach(
(el) => (el.style.width = el.getBoundingClientRect().width + "px"),
);

const afterSqueeze = () => {
// After squeezing the sidebar, set aria-expanded to false
button.setAttribute("aria-expanded", "false"); // "false" is in quotes because HTML attributes are strings

button.dataset.busy = false;
};

if (prefersReducedMotion) {
sidebar.classList.add("pst-squeeze");
afterSqueeze();
} else {
sidebar.addEventListener("transitionend", function onTransitionEnd() {
afterSqueeze();
sidebar.removeEventListener("transitionend", onTransitionEnd);
});
sidebar.classList.add("pst-squeeze");
}
}

function expandSidebar(prefersReducedMotion, done) {
hideTooltip();

const afterExpand = () => {
// After expanding the sidebar (which may be delayed by a CSS transition),
// unfreeze the widths of the subsections that were frozen when the sidebar
// was squeezed.
sidebarSections.forEach((el) => (el.style.width = null));

// After expanding the sidebar, set aria-expanded to "true" - in quotes
// because HTML attributes are strings.
button.setAttribute("aria-expanded", "true");

button.dataset.busy = false;
};

if (prefersReducedMotion) {
sidebar.classList.remove("pst-squeeze");
afterExpand();
} else {
sidebar.addEventListener("transitionend", function onTransitionEnd() {
afterExpand();
sidebar.removeEventListener("transitionend", onTransitionEnd);
});
sidebar.classList.remove("pst-squeeze");
}
}

button.addEventListener("click", () => {
if (button.dataset.busy === "true") {
return;
}
button.dataset.busy = "true";

const prefersReducedMotion = window.matchMedia(
"(prefers-reduced-motion)", // must be in parentheses
).matches;

if (button.getAttribute("aria-expanded") === "true") {
squeezeSidebar(prefersReducedMotion);
} else {
expandSidebar(prefersReducedMotion);
}
});

button.addEventListener("focus", showTooltip);
button.addEventListener("mouseenter", showTooltip);
button.addEventListener("mouseleave", hideTooltip);
button.addEventListener("blur", hideTooltip);
}

/*******************************************************************************
* Call functions after document loading.
*/
Expand All @@ -1026,6 +1149,15 @@ documentReady(addTOCInteractivity);
documentReady(setupSearchButtons);
documentReady(setupSearchAsYouType);
documentReady(setupMobileSidebarKeyboardHandlers);
documentReady(() => {
try {
setupCollapseSidebarButton();
} catch (err) {
// This exact error message is used in pytest tests
console.log("[PST] Error setting up collapse sidebar button");
console.error(err);
}
});

// Determining whether an element has scrollable content depends on stylesheets,
// so we're checking for the "load" event rather than "DOMContentLoaded"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
/**
* The collapse/expand primary sidebar button
*/

.bd-sidebar-primary {
.sidebar-primary-item.pst-sidebar-collapse {
padding-top: 0;
}

#pst-collapse-sidebar-button {
// Only show this button when there's enough width for both sidebar and main
// content. Do not show the button in the mobile menu where it would not
// make any sense.
@include media-breakpoint-down($breakpoint-sidebar-primary) {
display: none;
}

border: 0; // reset
padding: 0; // reset
text-align: start; // reset;
background-color: transparent;
outline-offset: $focus-ring-offset;
display: flex;
flex-direction: row;
align-items: center;
gap: 0.25rem;

// min width and height of the button must be 24px to meet WCAG Success Criterion 2.5.8
min-width: 24px;
min-height: 24px;
position: relative;
bottom: 0.2em;

.pst-icon {
color: var(--pst-color-link);

// The padding value was chosen by trial and error. For reference, the
// svg-inline--fa class normally applies a -.125em adjustment but this
// adjustment doesn't work when the icon is within a flex box. Important:
// the padding top value must match the padding bottom value because the
// icon undergoes a 180deg rotation when the sidebar is collapsed.
padding: 0.4em 0;

// This value was also chosen by trial and error until it looked good.
height: 1.3em;
}

.pst-collapse-sidebar-label {
// // inline-flex so we can set dimensions (width, height)
// display: inline-flex;
width: 100%;
height: 100%;

@include link-style-default;
}

.pst-expand-sidebar-label {
// inline-flex so we can set dimensions (width, height)
// display: inline-flex;

// When the sidebar is squeezed, there is no space to show the "expand
// sidebar" label. However, the label text is copied to a Bootstrap
// tooltip. It's also exposed to screen readers with this
// `visually-hidden` mixin from Bootstrap.
@include visually-hidden;

// Turn off for screen readers initially because the sidebar starts off in the expanded state.
// When the
visibility: hidden;
}

&:hover {
.pst-icon {
color: var(--pst-color-link-hover);
}

.pst-collapse-sidebar-label {
@include link-style-hover;
}
}
}

// Define transitions (if the environment permits animation)
@media (prefers-reduced-motion: no-preference) {
$duration: 400ms;

transition: width $duration ease;

#pst-collapse-sidebar-button {
.pst-icon {
transition:
transform $duration ease,
padding $duration ease;
}
}

@each $selector,
$delay
in (
// When the sidebar is collapsing, we need to delay the transition of
// properties that make the elements invisible so the user can see the
// opacity transition from 1 to 0 first.
".pst-squeeze": $duration,
// When the sidebar is expanding, it's the opposite: we need to transition
// the properties that make the elements visible immediately so the user
// can watch the opacity transition from 0 to 1.
":not(.pst-squeeze)": "0s"
)
{
&#{$selector} {
.pst-collapse-sidebar-label {
transition:
opacity $duration linear,
visibility 0s linear $delay,
width 0s linear $delay,
height 0s linear $delay;
}

.sidebar-primary-item:not(.pst-sidebar-collapse) {
transition:
opacity $duration linear,
visibility 0s linear $delay;
}

// There is no need to transition any other properties on the expand
// label (width, height, opacity) because it is always visually hidden
// (i.e., width 0, height 0, etc), but toggles its availability to
// screen readers as the sidebar collapses or expands via the
// `visibility` property.
.pst-expand-sidebar-label {
transition: visibility 0s linear $delay;
}
}
}
}

// Why "squeeze" and not "collapse"? Bootstrap uses the class name `collapse`
// so it seemed best to avoid possible confusion. (Later the class name was
// prefixed with `pst-`.)
&.pst-squeeze {
width: 4rem;
overflow: hidden;

#pst-collapse-sidebar-button {
.pst-icon {
transform: translateX(0.25em) rotate(180deg);
}

.pst-collapse-sidebar-label {
opacity: 0;
visibility: hidden;
width: 0;
height: 0;
}

.pst-expand-sidebar-label {
visibility: visible;
}
}

.sidebar-primary-item:not(.pst-sidebar-collapse) {
opacity: 0;
visibility: hidden;
}
}

&:not(.pst-squeeze) {
// When the sidebar is expanded, hide the "Expand Sidebar" label.
.pst-expand-sidebar-label {
visibility: hidden;
}

// This block is shorter than its counterpart above because there's no need,
// for example, to explicitly set `visibility: visible` or `opacity: 1` on
// the collapse label and sidebar subsections because those are the default
// values.
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
@import "./components/prev-next";
@import "./components/search";
@import "./components/searchbox";
@import "./components/sidebar-collapse";
@import "./components/switcher-theme";
@import "./components/switcher-version";
@import "./components/toc-inpage";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ $sidebar-padding-right: 1rem;
@include make-col(3);

// Borders padding and whitespace
padding: 2rem $sidebar-padding-right 1rem 1rem;
padding: $sidebar-padding-right;
border-right: 1px solid var(--pst-color-border);
background-color: var(--pst-color-background);
overflow-y: auto;
overflow: hidden auto;
Copy link
Collaborator Author

@gabalafou gabalafou Mar 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note to self: this is the one change that I worry about having unintended consequences. I don't see any reason why the primary sidebar should scroll in the horizontal direction and show a horizontal scroll bar, but I may not be aware of some sites or configurations that need the primary sidebar to scroll along the x axis.

Copy link
Collaborator Author

@gabalafou gabalafou Mar 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(The reason why I don't think it needs horizontal scroll is that the section table of contents is configured to reflow, and ethical ads are also responsive.)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can happend when creating API documentation. By design sphinx will use the full path to the method/class you want to describe which can become very very long if unchecked. I have created an example myself. I faced the problem when I was doing this: https://geetools.readthedocs.io/en/stable/autoapi/geetools/index.html and I was forced to change the template which is not straightfoward for new users. I know the title can be responsive as well but I find it easier to understand when things remain on 1 single line (at least for API names)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True, this is one such instance where we'd have horizontal overflow.
@12rambau would it be possible to see how the new feature would render for your API docs? I am curious on whether we will be hitting some unexpected behaviours.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm currently travelling with only my phone so I cannot really make checks outside of the github realm and building this documentation requires a new release from my side. I'll let you know once I'm back home and I can locally do a test

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No rush at all. Thanks for considering.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By design sphinx will use the full path to the method/class you want to describe which can become very very long if unchecked.

Yeah, we can see that on the PST API docs page with the long method name pydata_sphinx_theme.edit_this_page.

I believe the alternative would be to force the method name on one line which, if the primary sidebar is narrow enough, will cause part of the string to be hidden on the right side (in the sidebar's overflow). That means the user will have to scroll horizontally in order to see the rest of the name. That feels to me like it makes the table of contents even harder to absorb visually than if the method name is broken across two or more lines.

Here are two screenshots to show the difference.

No wrapping

With the page a bit narrow and text wrapping turned off, the user can only make out "edit this pa":

With wrapping

With the page a bit narrow and with text wrapping enabled, the user can see the entire method name:

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the text wrap even if it looks a bit chunky at least you see everything.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the wrapping is fine, I do not expect this to be a very frequent situation, and if it causes problems we can reevaluate/iterate.

font-size: var(--pst-sidebar-font-size-mobile);

@include media-breakpoint-up($breakpoint-sidebar-primary) {
Expand Down
Loading
Loading