Skip to content

Animate remotely loaded banners together #1808

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 8 commits into from
May 23, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
20 changes: 14 additions & 6 deletions docs/user_guide/announcements.rst
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,20 @@ For example, the following configuration tells the theme to load the ``custom-te
Update or remove announcement banner
------------------------------------

To update or remove the announcement banner, you can change the value of
``html_theme_options["announcement"]`` in your ``conf.py`` or you can edit the
contents of the ``custom-template.html`` file directly. For example, if you have a
temporary announcement that you want to remove without rebuilding your
documentation pages, you can use an empty ``custom-template.html`` file and the
banner will be hidden.
If you set ``html_theme_options["announcement"]`` to plain text or HTML, then to
update the announcement banner you need to modify this string and rebuild your
documentation pages. To remove the announcement banner, set this value to an
empty string and rebuild your documentation pages.

If you set ``html_theme_options["announcement"]`` to a URL string (starts with
``http``), then you can edit the file at that URL to update the announcement
banner. Saving an empty file at that URL will remove the announcement banner.
That's the main advantage of using a URL--you can change the announcement banner
without rebuilding and redeploying all of your documentation pages. For example,
if you point the announcement to the URL of a file in your repo, as we do on
this documentation site (see previous section), then you can edit, save and push
your changes to just that file (empty file = remove announcement) without
rebuilding and redeploying all your docs.

.. _version-warning-banners:

Expand Down
129 changes: 80 additions & 49 deletions src/pydata_sphinx_theme/assets/scripts/pydata-sphinx-theme.js
Original file line number Diff line number Diff line change
Expand Up @@ -476,16 +476,13 @@ function showVersionWarningBanner(data) {
return;
}
// now construct the warning banner
var outer = document.createElement("aside");
// TODO: add to translatable strings
outer.setAttribute("aria-label", "Version warning");
const banner = document.querySelector(".bd-header-version-warning");
const middle = document.createElement("div");
const inner = document.createElement("div");
const bold = document.createElement("strong");
const button = document.createElement("a");
// these classes exist since pydata-sphinx-theme v0.10.0
// the init class is used for animation
outer.classList = "bd-header-version-warning container-fluid init";
middle.classList = "bd-header-announcement__content";
inner.classList = "sidebar-message";
button.classList =
Expand All @@ -510,35 +507,12 @@ function showVersionWarningBanner(data) {
} else {
bold.innerText = `version ${version}`;
}
outer.appendChild(middle);
banner.appendChild(middle);
middle.appendChild(inner);
inner.appendChild(bold);
inner.appendChild(document.createTextNode("."));
inner.appendChild(button);
const skipLink = document.getElementById("pst-skip-link");
skipLink.after(outer);
// At least 3rem height
const autoHeight = Math.max(
outer.offsetHeight,
3 * parseFloat(getComputedStyle(document.documentElement).fontSize),
);
// Set height and vertical padding to 0 to prepare the height transition
outer.style.setProperty("height", 0);
outer.style.setProperty("padding-top", 0);
outer.style.setProperty("padding-bottom", 0);
outer.classList.remove("init");
// Set height to the computed height with a small timeout to activate the transition
setTimeout(() => {
outer.style.setProperty("height", `${autoHeight}px`);
// Wait for a bit more than 300ms (the transition duration) then remove the
// forcefully set styles and let CSS take over
setTimeout(() => {
outer.style.removeProperty("padding-top");
outer.style.removeProperty("padding-bottom");
outer.style.removeProperty("height");
outer.style.setProperty("min-height", "3rem");
}, 320);
}, 10);
banner.classList.remove("d-none");
}

/*******************************************************************************
Expand Down Expand Up @@ -572,27 +546,29 @@ function initRTDObserver() {
observer.observe(document.body, config);
}

// fetch the JSON version data (only once), then use it to populate the version
// switcher and maybe show the version warning bar
var versionSwitcherBtns = document.querySelectorAll(
".version-switcher__button",
);
const hasSwitcherMenu = versionSwitcherBtns.length > 0;
const hasVersionsJSON = DOCUMENTATION_OPTIONS.hasOwnProperty(
"theme_switcher_json_url",
);
const wantsWarningBanner = DOCUMENTATION_OPTIONS.show_version_warning_banner;

if (hasVersionsJSON && (hasSwitcherMenu || wantsWarningBanner)) {
const data = await fetchVersionSwitcherJSON(
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

When I discovered this line I was a bit surprised. An await in the middle of a module file causes all the rest of the code below it to be deferred until the promise is settled. So this represents a pretty serious slowdown in executing this file, since pretty much all of the code in this file is actually run at the end of the file.

This was introduced in #1344. Previously, the fetch was also executed at the module level, but without the await syntax, so it didn't hold back the rest of the file from executing.

So I decided to put all of this code into a new asynchronous function, fetchAndUseVersions which is called when the document is ready.

DOCUMENTATION_OPTIONS.theme_switcher_json_url,
async function fetchAndUseVersions() {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This function definition should arguably moved to a different part of the file, next to other code that has to do with the version switcher, but I didn't want to make the diff harder to compare so I left this code at the same place in the file.

// fetch the JSON version data (only once), then use it to populate the version
// switcher and maybe show the version warning bar
var versionSwitcherBtns = document.querySelectorAll(
".version-switcher__button",
);
const hasSwitcherMenu = versionSwitcherBtns.length > 0;
const hasVersionsJSON = DOCUMENTATION_OPTIONS.hasOwnProperty(
"theme_switcher_json_url",
);
// TODO: remove the `if(data)` once the `return null` is fixed within fetchVersionSwitcherJSON.
// We don't really want the switcher and warning bar to silently not work.
if (data) {
populateVersionSwitcher(data, versionSwitcherBtns);
if (wantsWarningBanner) {
showVersionWarningBanner(data);
const wantsWarningBanner = DOCUMENTATION_OPTIONS.show_version_warning_banner;

if (hasVersionsJSON && (hasSwitcherMenu || wantsWarningBanner)) {
const data = await fetchVersionSwitcherJSON(
DOCUMENTATION_OPTIONS.theme_switcher_json_url,
);
// TODO: remove the `if(data)` once the `return null` is fixed within fetchVersionSwitcherJSON.
// We don't really want the switcher and warning bar to silently not work.
if (data) {
populateVersionSwitcher(data, versionSwitcherBtns);
if (wantsWarningBanner) {
showVersionWarningBanner(data);
}
}
}
}
Expand Down Expand Up @@ -718,10 +694,65 @@ function debounce(callback, wait) {
};
}

/*******************************************************************************
* Announcement banner - fetch and load remote HTML
*/
async function setupAnnouncementBanner() {
const banner = document.querySelector(".bd-header-announcement");
const { pstAnnouncementUrl } = banner.dataset;

if (!pstAnnouncementUrl) {
return;
}

try {
const response = await fetch(pstAnnouncementUrl);
const data = await response.text();
if (data.length === 0) {
console.log(`[PST]: Empty announcement at: ${pstAnnouncementUrl}`);
return;
}
banner.innerHTML = `<div class="bd-header-announcement__content">${data}</div>`;
banner.classList.remove("d-none");
} catch (_error) {
console.log(`[PST]: Failed to load announcement at: ${pstAnnouncementUrl}`);
console.error(_error);
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Previously the error was not being printed to the console. I thought it might be helpful to our users to print the error.

}
}

/*******************************************************************************
* Reveal (and animate) the banners (version warning, announcement) together
*/
async function fetchRevealBannersTogether() {
// Wait until finished fetching and loading banners
await Promise.allSettled([fetchAndUseVersions(), setupAnnouncementBanner()]);

// The revealer element should have CSS rules that set height to 0, overflow
// to hidden, and an animation transition on the height (unless the user has
// turned off animations)
const revealer = document.querySelector(".pst-async-banner-revealer");

// Remove the d-none (display-none) class to calculate the children heights.
revealer.classList.remove("d-none");

// Add together the heights of the element's children
const height = Array.from(revealer.children).reduce(
(height, el) => height + el.offsetHeight,
0,
);

// Use the calculated height to give the revealer a non-zero height (if
// animations allowed, the height change will animate)
revealer.style.setProperty("height", `${height}px`);
}

/*******************************************************************************
* Call functions after document loading.
*/

// Call this one first to kick off the network request for the version warning
// and announcement banner data as early as possible.
documentReady(fetchRevealBannersTogether);
documentReady(addModeListener);
documentReady(scrollToActive);
documentReady(addTOCInteractivity);
Expand Down
26 changes: 15 additions & 11 deletions src/pydata_sphinx_theme/assets/styles/sections/_announcement.scss
Original file line number Diff line number Diff line change
@@ -1,34 +1,38 @@
.pst-async-banner-revealer {
// Setting height to 0 and overflow to hidden allows us to add up the heights
// of this element's children before revealing them.
height: 0;
overflow: hidden;

// Height to be set by JavaScript, which should trigger the following
// transition rule (unless the user has set their system to reduce motion).
transition: height 300ms ease-in-out;
@media (prefers-reduced-motion) {
transition: none;
}
}

.bd-header-version-warning,
.bd-header-announcement {
min-height: 3rem;
width: 100%;
display: flex;
position: relative;
align-items: center;
justify-content: center;
text-align: center;
transition: height 300ms ease-in-out;
overflow-y: hidden;
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Moved the transition and overflow rules to the new container element.

padding: 0.5rem 12.5%; // Horizontal padding so the width is 75%
// One breakpoint less than $breakpoint-sidebar-primary. See variables/_layout.scss for more info.
@include media-breakpoint-down(lg) {
// Announcements can take a bit more width on mobile
padding: 0.5rem 2%;
}

&.init {
position: fixed;
visibility: hidden;
}

Comment on lines -18 to -22
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This class was added by #1693. It's not needed now.

p {
font-weight: bold;
margin: 0;
}

&:empty {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This functionality—not showing the banner if it's empty—is now taken care of with the changes from #1703 and with the d-none class used in this PR.

display: none;
}

// Ensure there is enough contrast against the background
a {
color: var(--pst-color-inline-code-links);
Expand Down
5 changes: 2 additions & 3 deletions src/pydata_sphinx_theme/theme/pydata_sphinx_theme/layout.html
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,8 @@
<div class="search-button__search-container">{% include "../components/search-field.html" %}</div>
</div>

{%- if theme_announcement -%}
{% include "sections/announcement.html" %}
{%- endif %}
{% include "sections/announcement.html" %}

{% block docs_navbar %}
<header class="bd-header navbar navbar-expand-lg bd-navbar d-print-none">
{%- include "sections/header.html" %}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,50 +1,18 @@
{% set banner_label = _("Announcement") %}
{% set header_classes = ["bd-header-announcement", "container-fluid", "init"] %}
{% set is_remote=theme_announcement.startswith("http") %}
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I removed the Bootstrap container-fluid class because the styles it provides are all now provided or overridden by the styles set directly on the banners in _announcement.scss.

{# If we are remote, add a script to make an HTTP request for the value on page load #}
{%- if is_remote %}
<script>
document.write(`<aside class="bd-header-announcement d-print-none" aria-label="{{ banner_label }}"></aside>`);
fetch("{{ theme_announcement }}")
.then(res => {return res.text();})
.then(data => {
if (data.length === 0) {
console.log("[PST]: Empty announcement at: {{ theme_announcement }}");
return;
}
div = document.querySelector(".bd-header-announcement");
div.classList.add(...{{ header_classes | tojson }});
div.innerHTML = `<div class="bd-header-announcement__content">${data}</div>`;
// At least 3rem height
const autoHeight = Math.min(
div.offsetHeight,
3 * parseFloat(getComputedStyle(document.documentElement).fontSize));
// Set height and vertical padding to 0 to prepare the height transition
div.style.setProperty("height", 0);
div.style.setProperty("padding-top", 0);
div.style.setProperty("padding-bottom", 0);
div.classList.remove("init");
// Set height to the computed height with a small timeout to activate the transition
setTimeout(() => {
div.style.setProperty("height", `${autoHeight}px`);
// Wait for a bit more than 300ms (the transition duration) then remove the
// forcefully set styles and let CSS take over
setTimeout(() => {
div.style.removeProperty("padding-top");
div.style.removeProperty("padding-bottom");
div.style.removeProperty("height");
div.style.setProperty("min-height", "3rem");
}, 320);
}, 10);
})
.catch(error => {
console.log("[PST]: Failed to load announcement at: {{ theme_announcement }}");
});
</script>
{#- if announcement text is not remote, populate announcement w/ local content -#}
{%- else %}
<aside class="{{ header_classes | join(' ') }} bd-header-announcement"
aria-label="{{ banner_label }}">
<div class="bd-header-announcement__content">{{ theme_announcement }}</div>
</aside>
{% endif %}
{#- The "revealer" allows async banners to be loaded, revealed, and animated together in a controlled way -#}
<div class="pst-async-banner-revealer d-none">
{#- Version warning banner is always loaded remotely/asynchronously #}
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This is the crux of the change. Instead of rendering the banners in separate places and animating them each separately, I colocate them within the same container, and animate the container instead.

Something to note: the version warning banner is always loaded at run time, whereas the announcement banner might be rendered at build time or run time (if the configuration variable starts with "http")

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

All of the asynchronously loaded elements are contained within elements that start with the Bootstrap d-none utility class, which applies the display: none CSS rule.

The last step in loading this remote content is to remove the d-none class; this ensures that they will only appear if everything goes well.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Open to name suggestions, since I'm not sure that pst-async-banner-revealer is very clear

<aside class="bd-header-version-warning d-none d-print-none" aria-label="{{ _('Version warning') }}"></aside>
{#- But the announcement banner might be loaded locally or remotely -#}
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I added d-print-none to the version warning banner because that seems consistent with #1770

{%- set announcement_banner_label = _("Announcement") -%}
{%- set announcement_banner_classes = "bd-header-announcement d-print-none" -%}
{%- set announcement_is_remote = theme_announcement and theme_announcement.startswith("http") -%}
{%- set announcement_is_local = theme_announcement and not announcement_is_remote -%}
{%- if announcement_is_remote -%}
<aside class="{{ announcement_banner_classes }} d-none" aria-label="{{ announcement_banner_label }}" data-pst-announcement-url="{{ theme_announcement }}"></aside>
{%- endif %}
</div>
{%- if announcement_is_local %}
<aside class="bd-header-announcement" aria-label="Announcement">
<div class="bd-header-announcement__content">Hello, world!</div>
</aside>
{%- endif %}
40 changes: 40 additions & 0 deletions tests/test_build.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,46 @@ def test_sticky_header(sphinx_build_factory):
assert index_html.select_one("body > .bd-header")


def test_local_announcement_banner(sphinx_build_factory) -> None:
"""If announcement is not a URL, it should be rendered at build time."""
confoverrides = {
"html_theme_options.announcement": "Hello, world!",
}
sphinx_build = sphinx_build_factory("base", confoverrides=confoverrides).build()
index_html = sphinx_build.html_tree("index.html")
results = index_html.find_all(class_="bd-header-announcement")

# Template should only render one announcement banner
assert len(results) == 1
banner = results[0]

# Announcement banner should contain the value from the config
assert banner.text.strip() == "Hello, world!"


def test_remote_announcement_banner(sphinx_build_factory) -> None:
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

These two tests are actually just unit tests for the announcement.html template. We don't really need to go through the sphinx_build_factory; I just don't know how to write this test in another way.

"""If announcement is a URL, it should not be rendered at build time."""
confoverrides = {
"html_theme_options.announcement": "http://example.com/announcement",
}
sphinx_build = sphinx_build_factory("base", confoverrides=confoverrides).build()
index_html = sphinx_build.html_tree("index.html")
results = index_html.find_all(class_="bd-header-announcement")

# Template should only render one announcement banner
assert len(results) == 1
banner = results[0]

# Remote announcement banner URL should be stored as data attribute
assert banner["data-pst-announcement-url"] == "http://example.com/announcement"

# Remote announcement should be empty at build time (filled at run time)
assert not banner.find_all()

# Remote announcement banner should be inside the async banner revealer
assert "pst-async-banner-revealer" in banner.parent["class"]


@pytest.mark.parametrize(
"align,klass",
[
Expand Down
Loading