Skip to content

Commit 21a1870

Browse files
gabalafoudrammocktrallardsmeragoel
authored
Collapsible sidebar (#2159)
Fixes #1072. Screenshot showing vertical and horizontal alignment guidelines: <img width="1280" alt="image" src="https://github.com/user-attachments/assets/8d5194a8-33ce-4aa1-a419-1b85fc5b7353" /> --------- Co-authored-by: Daniel McCloy <[email protected]> Co-authored-by: Tania Allard <[email protected]> Co-authored-by: Smera Goel <[email protected]>
1 parent e2ac35d commit 21a1870

File tree

24 files changed

+605
-33
lines changed

24 files changed

+605
-33
lines changed

docs/user_guide/layout.rst

+5-3
Original file line numberDiff line numberDiff line change
@@ -354,15 +354,15 @@ By default, it has the following configuration:
354354
.. code-block:: python
355355
356356
html_sidebars = {
357-
"**": ["sidebar-nav-bs", "sidebar-ethical-ads"]
357+
"**": ["sidebar-collapse", "sidebar-nav-bs"]
358358
}
359359
360+
- ``sidebar-collapse.html`` - a button that allows users to expand and collapse the sidebar.
361+
360362
- ``sidebar-nav-bs.html`` - a bootstrap-friendly navigation section.
361363

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

364-
- ``sidebar-ethical-ads.html`` - a placement for ReadTheDocs's Ethical Ads (will only show up on ReadTheDocs).
365-
366366
Primary sidebar end sections
367367
----------------------------
368368

@@ -382,6 +382,8 @@ By default, it has the following templates:
382382
# ...
383383
}
384384
385+
``sidebar-ethical-ads.html`` is a placement for ReadTheDocs's Ethical Ads (will only show up on ReadTheDocs).
386+
385387
Remove the primary sidebar from pages
386388
-------------------------------------
387389

src/pydata_sphinx_theme/assets/scripts/pydata-sphinx-theme.js

+132
Original file line numberDiff line numberDiff line change
@@ -1012,6 +1012,129 @@ async function fetchRevealBannersTogether() {
10121012
}, 320);
10131013
}
10141014

1015+
/*******************************************************************************
1016+
* Set up expand/collapse button for primary sidebar
1017+
*/
1018+
function setupCollapseSidebarButton() {
1019+
const button = document.getElementById("pst-collapse-sidebar-button");
1020+
const sidebar = document.getElementById("pst-primary-sidebar");
1021+
1022+
// If this page rendered without the button or sidebar, then there's nothing to do.
1023+
if (!button || !sidebar) {
1024+
return;
1025+
}
1026+
1027+
const sidebarSections = Array.from(sidebar.children);
1028+
1029+
const expandTooltip = new bootstrap.Tooltip(button, {
1030+
title: button.querySelector(".pst-expand-sidebar-label").textContent,
1031+
1032+
// In manual testing, relying on Bootstrap to handle "hover" and "focus" was buggy.
1033+
trigger: "manual",
1034+
1035+
placement: "left",
1036+
fallbackPlacements: ["right"],
1037+
1038+
// Offsetting the tooltip a bit more than the default [0, 0] solves an issue
1039+
// where the appearance of the tooltip triggers a mouseleave event which in
1040+
// turn triggers the call to hide the tooltip. So in certain areas around
1041+
// the button, it would appear to the user that tooltip flashes in and then
1042+
// back out.
1043+
offset: [0, 12],
1044+
});
1045+
1046+
const showTooltip = () => {
1047+
// Only show the "expand sidebar" tooltip when the sidebar is not expanded
1048+
if (button.getAttribute("aria-expanded") === "false") {
1049+
expandTooltip.show();
1050+
}
1051+
};
1052+
const hideTooltip = () => {
1053+
expandTooltip.hide();
1054+
};
1055+
1056+
function squeezeSidebar(prefersReducedMotion, done) {
1057+
// Before squeezing the sidebar, freeze the widths of its subsections.
1058+
// Otherwise, the subsections will also narrow and cause the text in the
1059+
// sidebar to reflow and wrap, which we don't want. This is necessary
1060+
// because we do not remove the sidebar contents from the layout (with
1061+
// `display: none`). Rather, we hide the contents from both sighted users
1062+
// and screen readers (with `visibility: hidden`). This provides better
1063+
// stability to the overall layout.
1064+
sidebarSections.forEach(
1065+
(el) => (el.style.width = el.getBoundingClientRect().width + "px"),
1066+
);
1067+
1068+
const afterSqueeze = () => {
1069+
// After squeezing the sidebar, set aria-expanded to false
1070+
button.setAttribute("aria-expanded", "false"); // "false" is in quotes because HTML attributes are strings
1071+
1072+
button.dataset.busy = false;
1073+
};
1074+
1075+
if (prefersReducedMotion) {
1076+
sidebar.classList.add("pst-squeeze");
1077+
afterSqueeze();
1078+
} else {
1079+
sidebar.addEventListener("transitionend", function onTransitionEnd() {
1080+
afterSqueeze();
1081+
sidebar.removeEventListener("transitionend", onTransitionEnd);
1082+
});
1083+
sidebar.classList.add("pst-squeeze");
1084+
}
1085+
}
1086+
1087+
function expandSidebar(prefersReducedMotion, done) {
1088+
hideTooltip();
1089+
1090+
const afterExpand = () => {
1091+
// After expanding the sidebar (which may be delayed by a CSS transition),
1092+
// unfreeze the widths of the subsections that were frozen when the sidebar
1093+
// was squeezed.
1094+
sidebarSections.forEach((el) => (el.style.width = null));
1095+
1096+
// After expanding the sidebar, set aria-expanded to "true" - in quotes
1097+
// because HTML attributes are strings.
1098+
button.setAttribute("aria-expanded", "true");
1099+
1100+
button.dataset.busy = false;
1101+
};
1102+
1103+
if (prefersReducedMotion) {
1104+
sidebar.classList.remove("pst-squeeze");
1105+
afterExpand();
1106+
} else {
1107+
sidebar.addEventListener("transitionend", function onTransitionEnd() {
1108+
afterExpand();
1109+
sidebar.removeEventListener("transitionend", onTransitionEnd);
1110+
});
1111+
sidebar.classList.remove("pst-squeeze");
1112+
}
1113+
}
1114+
1115+
button.addEventListener("click", () => {
1116+
if (button.dataset.busy === "true") {
1117+
return;
1118+
}
1119+
button.dataset.busy = "true";
1120+
1121+
const prefersReducedMotion = window.matchMedia(
1122+
"(prefers-reduced-motion)", // must be in parentheses
1123+
).matches;
1124+
1125+
if (button.getAttribute("aria-expanded") === "true") {
1126+
squeezeSidebar(prefersReducedMotion);
1127+
} else {
1128+
expandSidebar(prefersReducedMotion);
1129+
}
1130+
});
1131+
1132+
button.addEventListener("focus", showTooltip);
1133+
button.addEventListener("mouseenter", showTooltip);
1134+
button.addEventListener("mouseleave", hideTooltip);
1135+
button.addEventListener("blur", hideTooltip);
1136+
}
1137+
10151138
/*******************************************************************************
10161139
* Call functions after document loading.
10171140
*/
@@ -1026,6 +1149,15 @@ documentReady(addTOCInteractivity);
10261149
documentReady(setupSearchButtons);
10271150
documentReady(setupSearchAsYouType);
10281151
documentReady(setupMobileSidebarKeyboardHandlers);
1152+
documentReady(() => {
1153+
try {
1154+
setupCollapseSidebarButton();
1155+
} catch (err) {
1156+
// This exact error message is used in pytest tests
1157+
console.log("[PST] Error setting up collapse sidebar button");
1158+
console.error(err);
1159+
}
1160+
});
10291161

10301162
// Determining whether an element has scrollable content depends on stylesheets,
10311163
// so we're checking for the "load" event rather than "DOMContentLoaded"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
/**
2+
* The collapse/expand primary sidebar button
3+
*/
4+
5+
.bd-sidebar-primary {
6+
.sidebar-primary-item.pst-sidebar-collapse {
7+
padding-top: 0;
8+
}
9+
10+
#pst-collapse-sidebar-button {
11+
// Only show this button when there's enough width for both sidebar and main
12+
// content. Do not show the button in the mobile menu where it would not
13+
// make any sense.
14+
@include media-breakpoint-down($breakpoint-sidebar-primary) {
15+
display: none;
16+
}
17+
18+
border: 0; // reset
19+
padding: 0; // reset
20+
text-align: start; // reset;
21+
background-color: transparent;
22+
outline-offset: $focus-ring-offset;
23+
display: flex;
24+
flex-direction: row;
25+
align-items: center;
26+
gap: 0.25rem;
27+
28+
// min width and height of the button must be 24px to meet WCAG Success Criterion 2.5.8
29+
min-width: 24px;
30+
min-height: 24px;
31+
position: relative;
32+
bottom: 0.2em;
33+
34+
.pst-icon {
35+
color: var(--pst-color-link);
36+
37+
// The padding value was chosen by trial and error. For reference, the
38+
// svg-inline--fa class normally applies a -.125em adjustment but this
39+
// adjustment doesn't work when the icon is within a flex box. Important:
40+
// the padding top value must match the padding bottom value because the
41+
// icon undergoes a 180deg rotation when the sidebar is collapsed.
42+
padding: 0.4em 0;
43+
44+
// This value was also chosen by trial and error until it looked good.
45+
height: 1.3em;
46+
}
47+
48+
.pst-collapse-sidebar-label {
49+
// // inline-flex so we can set dimensions (width, height)
50+
// display: inline-flex;
51+
width: 100%;
52+
height: 100%;
53+
54+
@include link-style-default;
55+
}
56+
57+
.pst-expand-sidebar-label {
58+
// inline-flex so we can set dimensions (width, height)
59+
// display: inline-flex;
60+
61+
// When the sidebar is squeezed, there is no space to show the "expand
62+
// sidebar" label. However, the label text is copied to a Bootstrap
63+
// tooltip. It's also exposed to screen readers with this
64+
// `visually-hidden` mixin from Bootstrap.
65+
@include visually-hidden;
66+
67+
// Turn off for screen readers initially because the sidebar starts off in the expanded state.
68+
// When the
69+
visibility: hidden;
70+
}
71+
72+
&:hover {
73+
.pst-icon {
74+
color: var(--pst-color-link-hover);
75+
}
76+
77+
.pst-collapse-sidebar-label {
78+
@include link-style-hover;
79+
}
80+
}
81+
}
82+
83+
// Define transitions (if the environment permits animation)
84+
@media (prefers-reduced-motion: no-preference) {
85+
$duration: 400ms;
86+
87+
transition: width $duration ease;
88+
89+
#pst-collapse-sidebar-button {
90+
.pst-icon {
91+
transition:
92+
transform $duration ease,
93+
padding $duration ease;
94+
}
95+
}
96+
97+
@each $selector,
98+
$delay
99+
in (
100+
// When the sidebar is collapsing, we need to delay the transition of
101+
// properties that make the elements invisible so the user can see the
102+
// opacity transition from 1 to 0 first.
103+
".pst-squeeze": $duration,
104+
// When the sidebar is expanding, it's the opposite: we need to transition
105+
// the properties that make the elements visible immediately so the user
106+
// can watch the opacity transition from 0 to 1.
107+
":not(.pst-squeeze)": "0s"
108+
)
109+
{
110+
&#{$selector} {
111+
.pst-collapse-sidebar-label {
112+
transition:
113+
opacity $duration linear,
114+
visibility 0s linear $delay,
115+
width 0s linear $delay,
116+
height 0s linear $delay;
117+
}
118+
119+
.sidebar-primary-item:not(.pst-sidebar-collapse) {
120+
transition:
121+
opacity $duration linear,
122+
visibility 0s linear $delay;
123+
}
124+
125+
// There is no need to transition any other properties on the expand
126+
// label (width, height, opacity) because it is always visually hidden
127+
// (i.e., width 0, height 0, etc), but toggles its availability to
128+
// screen readers as the sidebar collapses or expands via the
129+
// `visibility` property.
130+
.pst-expand-sidebar-label {
131+
transition: visibility 0s linear $delay;
132+
}
133+
}
134+
}
135+
}
136+
137+
// Why "squeeze" and not "collapse"? Bootstrap uses the class name `collapse`
138+
// so it seemed best to avoid possible confusion. (Later the class name was
139+
// prefixed with `pst-`.)
140+
&.pst-squeeze {
141+
width: 4rem;
142+
overflow: hidden;
143+
144+
#pst-collapse-sidebar-button {
145+
.pst-icon {
146+
transform: translateX(0.25em) rotate(180deg);
147+
}
148+
149+
.pst-collapse-sidebar-label {
150+
opacity: 0;
151+
visibility: hidden;
152+
width: 0;
153+
height: 0;
154+
}
155+
156+
.pst-expand-sidebar-label {
157+
visibility: visible;
158+
}
159+
}
160+
161+
.sidebar-primary-item:not(.pst-sidebar-collapse) {
162+
opacity: 0;
163+
visibility: hidden;
164+
}
165+
}
166+
167+
&:not(.pst-squeeze) {
168+
// When the sidebar is expanded, hide the "Expand Sidebar" label.
169+
.pst-expand-sidebar-label {
170+
visibility: hidden;
171+
}
172+
173+
// This block is shorter than its counterpart above because there's no need,
174+
// for example, to explicitly set `visibility: visible` or `opacity: 1` on
175+
// the collapse label and sidebar subsections because those are the default
176+
// values.
177+
}
178+
}

src/pydata_sphinx_theme/assets/styles/pydata-sphinx-theme.scss

+1
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
@import "./components/prev-next";
5050
@import "./components/search";
5151
@import "./components/searchbox";
52+
@import "./components/sidebar-collapse";
5253
@import "./components/switcher-theme";
5354
@import "./components/switcher-version";
5455
@import "./components/toc-inpage";

src/pydata_sphinx_theme/assets/styles/sections/_sidebar-primary.scss

+2-2
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ $sidebar-padding-right: 1rem;
1616
@include make-col(3);
1717

1818
// Borders padding and whitespace
19-
padding: 2rem $sidebar-padding-right 1rem 1rem;
19+
padding: $sidebar-padding-right;
2020
border-right: 1px solid var(--pst-color-border);
2121
background-color: var(--pst-color-background);
22-
overflow-y: auto;
22+
overflow: hidden auto;
2323
font-size: var(--pst-sidebar-font-size-mobile);
2424

2525
@include media-breakpoint-up($breakpoint-sidebar-primary) {

0 commit comments

Comments
 (0)