Skip to content
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

Make overlay sidebars behave like modals #1942

Merged
merged 3 commits into from
Aug 29, 2024
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
163 changes: 79 additions & 84 deletions src/pydata_sphinx_theme/assets/scripts/pydata-sphinx-theme.js
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,29 @@ var changeSearchShortcutKey = () => {
}
};

const closeDialogOnBackdropClick = ({
currentTarget: dialog,
clientX,
clientY,
}) => {
if (!dialog.open) {
return;
}

// Dialog.getBoundingClientRect() does not include ::backdrop. (This is the
// trick that allows us to determine if click was inside or outside of the
// dialog: click handler includes backdrop, getBoundingClientRect does not.)
const { left, right, top, bottom } = dialog.getBoundingClientRect();

// 0, 0 means top left
const clickWasOutsideDialog =
clientX < left || right < clientX || clientY < top || bottom < clientY;

if (clickWasOutsideDialog) {
dialog.close();
}
};

/**
* Activate callbacks for search button popup
*/
Expand All @@ -306,27 +329,7 @@ var setupSearchButtons = () => {
// If user clicks outside the search modal dialog, then close it.
const searchDialog = document.getElementById("pst-search-dialog");
// Dialog click handler includes clicks on dialog ::backdrop.
searchDialog.addEventListener("click", (event) => {
if (!searchDialog.open) {
return;
}

// Dialog.getBoundingClientRect() does not include ::backdrop. (This is the
// trick that allows us to determine if click was inside or outside of the
// dialog: click handler includes backdrop, getBoundingClientRect does not.)
const { left, right, top, bottom } = searchDialog.getBoundingClientRect();

// 0, 0 means top left
const clickWasOutsideDialog =
event.clientX < left ||
right < event.clientX ||
event.clientY < top ||
bottom < event.clientY;

if (clickWasOutsideDialog) {
searchDialog.close();
}
});
searchDialog.addEventListener("click", closeDialogOnBackdropClick);
};

/*******************************************************************************
Expand Down Expand Up @@ -535,7 +538,7 @@ function showVersionWarningBanner(data) {
const versionsAreComparable = validate(version) && validate(preferredVersion);
if (versionsAreComparable && compare(version, preferredVersion, "=")) {
console.log(
"This is the prefered version of the docs, not showing the warning banner.",
"[PST]: This is the preferred version of the docs, not showing the warning banner.",
Carreau marked this conversation as resolved.
Show resolved Hide resolved
);
return;
}
Expand Down Expand Up @@ -665,84 +668,76 @@ async function fetchAndUseVersions() {
}

/*******************************************************************************
* Add keyboard functionality to mobile sidebars.
*
* Wire up the hamburger-style buttons using the click event which (on buttons)
* handles both mouse clicks and the space and enter keys.
* Sidebar modals (for mobile / narrow screens)
*/
function setupMobileSidebarKeyboardHandlers() {
// These are hidden checkboxes at the top of the page whose :checked property
// allows the mobile sidebars to be hidden or revealed via CSS.
const primaryToggle = document.getElementById("pst-primary-sidebar-checkbox");
const secondaryToggle = document.getElementById(
"pst-secondary-sidebar-checkbox",
// These are the left and right sidebars for wider screens. We cut and paste
// the content from these widescreen sidebars into the mobile dialogs, when
// the user clicks the hamburger icon button
const primarySidebar = document.getElementById("pst-primary-sidebar");
const secondarySidebar = document.getElementById("pst-secondary-sidebar");

// These are the corresponding left/right <dialog> elements, which are empty
// until the user clicks the hamburger icon
const primaryDialog = document.getElementById("pst-primary-sidebar-modal");
const secondaryDialog = document.getElementById(
"pst-secondary-sidebar-modal",
);
const primarySidebar = document.querySelector(".bd-sidebar-primary");
const secondarySidebar = document.querySelector(".bd-sidebar-secondary");

// Toggle buttons -
//
// These are the hamburger-style buttons in the header nav bar. When the user
// clicks, the button transmits the click to the hidden checkboxes used by the
// CSS to control whether the sidebar is open or closed.
const primaryClickTransmitter = document.querySelector(".primary-toggle");
const secondaryClickTransmitter = document.querySelector(".secondary-toggle");

// These are the hamburger-style buttons in the header nav bar. They only
// appear at narrow screen width.
const primaryToggle = document.querySelector(".primary-toggle");
const secondaryToggle = document.querySelector(".secondary-toggle");

// Cut nodes and classes from `from`, paste into/onto `to`
const cutAndPasteNodesAndClasses = (from, to) => {
Array.from(from.childNodes).forEach((node) => to.appendChild(node));
Array.from(from.classList).forEach((cls) => {
from.classList.remove(cls);
to.classList.add(cls);
});
};
Carreau marked this conversation as resolved.
Show resolved Hide resolved

// Hook up the ways to open and close the dialog
[
[primaryClickTransmitter, primaryToggle, primarySidebar],
[secondaryClickTransmitter, secondaryToggle, secondarySidebar],
].forEach(([clickTransmitter, toggle, sidebar]) => {
if (!clickTransmitter) {
[primaryToggle, primaryDialog, primarySidebar],
[secondaryToggle, secondaryDialog, secondarySidebar],
].forEach(([toggleButton, dialog, sidebar]) => {
if (!toggleButton || !dialog || !sidebar) {
return;
}
clickTransmitter.addEventListener("click", (event) => {

// Clicking the button can only open the sidebar, not close it.
// Clicking the button is also the *only* way to open the sidebar.
toggleButton.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
toggle.checked = !toggle.checked;

// If we are opening the sidebar, move focus to the first focusable item
// in the sidebar
if (toggle.checked) {
// Note: this selector is not exhaustive, and we may need to update it
// in the future
const tabStop = sidebar.querySelector("a, button");
// use setTimeout because you cannot move focus synchronously during a
// click in the handler for the click event
setTimeout(() => tabStop.focus(), 100);
}

// When we open the dialog, we cut and paste the nodes and classes from
// the widescreen sidebar into the dialog
cutAndPasteNodesAndClasses(sidebar, dialog);
Carreau marked this conversation as resolved.
Show resolved Hide resolved

dialog.showModal();
});
});

// Escape key -
//
// When sidebar is open, user should be able to press escape key to close the
// sidebar.
[
[primarySidebar, primaryToggle, primaryClickTransmitter],
[secondarySidebar, secondaryToggle, secondaryClickTransmitter],
].forEach(([sidebar, toggle, transmitter]) => {
if (!sidebar) {
return;
}
sidebar.addEventListener("keydown", (event) => {
// Listen for clicks on the backdrop in order to close the dialog
dialog.addEventListener("click", closeDialogOnBackdropClick);

// We have to manually attach the escape key because there's some code in
// Sphinx's Sphinx_highlight.js that prevents the default behavior of the
// escape key
dialog.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
toggle.checked = false;
transmitter.focus();
dialog.close();
}
});
});

// When the <label> overlay is clicked to close the sidebar, return focus to
// the opener button in the nav bar.
[
[primaryToggle, primaryClickTransmitter],
[secondaryToggle, secondaryClickTransmitter],
].forEach(([toggle, transmitter]) => {
toggle.addEventListener("change", (event) => {
if (!event.currentTarget.checked) {
transmitter.focus();
}
// When the dialog is closed, move the nodes (and classes) back to their
// original place
dialog.addEventListener("close", () => {
cutAndPasteNodesAndClasses(dialog, sidebar);
});
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,34 +72,6 @@ $sidebar-padding-right: 1rem;
margin-bottom: 0.5rem;
}

// The dropdown toggle for extra links just shows them all instead.
Carreau marked this conversation as resolved.
Show resolved Hide resolved
.nav-item.dropdown {
// On mobile, the dropdown behaves like any other link, no hiding
button {
display: none;
}

.dropdown-menu {
display: flex;
flex-direction: column;
padding: 0;
margin: 0;
border: none;
background-color: inherit;
font-size: inherit;

.dropdown-item {
&:hover,
&:focus {
// In the mobile sidebar, the dropdown menu is inlined with the
// other links, which do not have background-color changes on hover
// and focus
background-color: unset;
}
}
}
}

.bd-navbar-elements {
.nav-link {
&:focus-visible {
Expand Down
76 changes: 14 additions & 62 deletions src/pydata_sphinx_theme/assets/styles/sections/_sidebar-toggle.scss
Original file line number Diff line number Diff line change
Expand Up @@ -6,56 +6,11 @@
* It is broken up into major sections below.
*/

/*******************************************************************************
* Buttons and overlays
*/
input.sidebar-toggle {
display: none;
}

// Background overlays
label.overlay {
background-color: black;
opacity: 0.5;
height: 0;
width: 0;
position: fixed;
top: 0;
left: 0;
transition: opacity $animation-time ease-out;
z-index: $zindex-modal-backdrop;
}

input {
// Show the correct overlay when its input is checked
&#pst-primary-sidebar-checkbox:checked + label.overlay.overlay-primary,
&#pst-secondary-sidebar-checkbox:checked + label.overlay.overlay-secondary {
height: 100vh;
width: 100vw;
}

// Primary sidebar slides in from the left
&#pst-primary-sidebar-checkbox:checked ~ .bd-container .bd-sidebar-primary {
visibility: visible;
margin-left: 0;
}

// Secondary sidebar slides in from the right
&#pst-secondary-sidebar-checkbox:checked
~ .bd-container
.bd-sidebar-secondary {
visibility: visible;
margin-right: 0;
}
}

/*******************************************************************************
* Sidebar drawer behavior
*/

/**
* Behavior for sliding drawer elements that will be toggled with an input
*
* NOTE: We use this mixin to define the toggle behavior on narrow screens,
* And the wide-screen behavior of the sections is defined in their own section
* .scss files.
Expand All @@ -73,6 +28,7 @@ input {
visibility $animation-time ease-out,
margin $animation-time ease-out;
visibility: hidden;
border: 0;

@if $side == "right" {
margin-right: -75%;
Expand All @@ -83,33 +39,29 @@ input {
}
}

// Primary sidebar hides/shows at earlier widths
@include media-breakpoint-up($breakpoint-sidebar-primary) {
.sidebar-toggle.primary-toggle {
display: none;
}

input#pst-primary-sidebar-checkbox {
&:checked + label.overlay.overlay-primary {
height: 0;
width: 0;
}
}

.bd-sidebar-primary {
margin-left: 0;
visibility: visible;
}
.bd-sidebar::backdrop {
background-color: black;
opacity: 0.5;
}

.bd-sidebar-primary {
@include media-breakpoint-down($breakpoint-sidebar-primary) {
@include sliding-drawer("left");
}

&[open] {
margin-left: 0;
visibility: visible;
}
}

.bd-sidebar-secondary {
@include media-breakpoint-down($breakpoint-sidebar-secondary) {
@include sliding-drawer("right");
}

&[open] {
margin-right: 0;
visibility: visible;
}
}
16 changes: 4 additions & 12 deletions src/pydata_sphinx_theme/theme/pydata_sphinx_theme/layout.html
Original file line number Diff line number Diff line change
Expand Up @@ -52,16 +52,6 @@
</button>
{%- endif %}

{# checkbox to toggle primary sidebar #}
<input type="checkbox"
class="sidebar-toggle"
id="pst-primary-sidebar-checkbox"/>
<label class="overlay overlay-primary" for="pst-primary-sidebar-checkbox"></label>
{# Checkboxes to toggle the secondary sidebar #}
<input type="checkbox"
class="sidebar-toggle"
id="pst-secondary-sidebar-checkbox"/>
<label class="overlay overlay-secondary" for="pst-secondary-sidebar-checkbox"></label>
{# A search field pop-up that will only show when the search button is clicked #}
<dialog id="pst-search-dialog">
{% include "../components/search-field.html" %}
Expand All @@ -82,7 +72,8 @@
{% if suppress_sidebar_toctree(includehidden=theme_sidebar_includehidden | tobool) %}
{% set sidebars = sidebars | reject("in", "sidebar-nav-bs.html") | list %}
{% endif %}
<div class="bd-sidebar-primary bd-sidebar{% if not sidebars %} hide-on-wide{% endif %}">
<dialog id="pst-primary-sidebar-modal"></dialog>
<div id="pst-primary-sidebar" class="bd-sidebar-primary bd-sidebar{% if not sidebars %} hide-on-wide{% endif %}">
{% include "sections/sidebar-primary.html" %}
</div>
{# Using an ID here so that the skip-link works #}
Expand Down Expand Up @@ -117,7 +108,8 @@
{# Secondary sidebar #}
{% block docs_toc %}
{% if not remove_sidebar_secondary %}
<div class="bd-sidebar-secondary bd-toc">{% include "sections/sidebar-secondary.html" %}</div>
<dialog id="pst-secondary-sidebar-modal"></dialog>
<div id="pst-secondary-sidebar" class="bd-sidebar-secondary bd-toc">{% include "sections/sidebar-secondary.html" %}</div>
{% endif %}
{% endblock docs_toc %}
</div>
Expand Down
2 changes: 1 addition & 1 deletion tests/test_build/sidebar_subpage.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<div class="bd-sidebar-primary bd-sidebar">
<div class="bd-sidebar-primary bd-sidebar" id="pst-primary-sidebar">
<div class="sidebar-header-items sidebar-primary__section">
<div class="sidebar-header-items__center">
<div class="navbar-item">
Expand Down
Loading