Skip to content

Commit 4ed402f

Browse files
author
Jérémy Bobbio (Lunar)
committed
Degrade gracefully when JavaScript is disabled
Support for light/dark themes has been implemented using a `data-theme` attribute set on the `<html/>` tag. As this attribute is set using JavaScript, this meant that it was left unset when visitors had JavaScript disabled. This resulted in several important CSS rules not being matched and a “broken feeling” due to wrong colors, and logo or images shown twice. To better support browsers with JavaScript disabled: 1. Add the same CSS rules as for the light theme when the `data-theme` attribute is not set. This creates a safe fallback in every situation. 2. If `default_mode` is set, write its value to the `data-theme` attribute when writing the HTML files. This enables theme users to present their preferred mode to visitors with JavaScript disabled. 3. Use JavaScript to add the search, theme switcher and version switcher interface as they require JavaScript to work. This avoid unusable UI elements to be shown to visitors with JavaScript disabled. 4. Use JavaScript to write the logo for the “other theme”, depending on the value of `default_mode`, defaulting to “light” if unset. 5. Use JavaScript to write the announcement block to the HTML if the announcement is a remote URL. While this last change might seem redundant considering the other ones, it does make the resulting pages better for search engines and text browsers. Closes: #1145
1 parent 75ee781 commit 4ed402f

File tree

11 files changed

+195
-99
lines changed

11 files changed

+195
-99
lines changed

src/pydata_sphinx_theme/assets/styles/variables/_color.scss

+59-41
Original file line numberDiff line numberDiff line change
@@ -80,53 +80,71 @@ $pst-semantic-colors: (
8080

8181
/*******************************************************************************
8282
* write the color rules for each theme (light/dark)
83-
*
84-
* NOTE: @each {...} is like a for-loop
85-
* https://sass-lang.com/documentation/at-rules/control/each
86-
* and #{...} inserts a variable into a CSS selector or property name
87-
* https://sass-lang.com/documentation/interpolation
8883
*/
89-
@each $mode in (light, dark) {
90-
html[data-theme="#{$mode}"] {
91-
@each $name, $value in $pst-semantic-colors {
92-
// check if this color is defined differently for light/dark
93-
@if type-of($value) == map {
94-
$value: map-get($value, $mode);
95-
}
84+
85+
/* NOTE:
86+
* Mixins enable us to reuse the same definitions for the different modes
87+
* https://sass-lang.com/documentation/at-rules/mixin
88+
* #{...} inserts a variable into a CSS selector or property name
89+
* https://sass-lang.com/documentation/interpolation
90+
*/
91+
@mixin theme-colors($mode) {
92+
// check if this color is defined differently for light/dark
93+
@each $name, $value in $pst-semantic-colors {
94+
@if type-of($value) == map {
95+
$value: map-get($value, $mode);
96+
}
97+
& {
9698
--pst-color-#{$name}: #{$value};
9799
}
98-
// assign the "duplicate" colors (ones that just reference other variables)
100+
}
101+
// assign the "duplicate" colors (ones that just reference other variables)
102+
& {
99103
--pst-color-link: var(--pst-color-primary);
100104
--pst-color-link-hover: var(--pst-color-warning);
101-
// adapt to light/dark-specific content
102-
@if $mode == "light" {
103-
.only-dark {
104-
display: none !important;
105-
}
106-
} @else {
107-
.only-light {
108-
display: none !important;
109-
}
110-
/* Adjust images in dark mode (unless they have class .only-dark or
111-
* .dark-light, in which case assume they're already optimized for dark
112-
* mode).
113-
*/
114-
img:not(.only-dark):not(.dark-light) {
115-
filter: brightness(0.8) contrast(1.2);
116-
}
117-
/* Give images a light background in dark mode in case they have
118-
* transparency and black text (unless they have class .only-dark or .dark-light, in
119-
* which case assume they're already optimized for dark mode).
120-
*/
121-
.bd-content img:not(.only-dark):not(.dark-light) {
122-
background: rgb(255, 255, 255);
123-
border-radius: 0.25rem;
124-
}
125-
// MathJax SVG outputs should be filled to same color as text.
126-
.MathJax_SVG * {
127-
fill: var(--pst-color-text-base);
128-
}
105+
}
106+
// adapt to light/dark-specific content
107+
@if $mode == "light" {
108+
.only-dark {
109+
display: none !important;
110+
}
111+
} @else {
112+
.only-light {
113+
display: none !important;
114+
}
115+
/* Adjust images in dark mode (unless they have class .only-dark or
116+
* .dark-light, in which case assume they're already optimized for dark
117+
* mode).
118+
*/
119+
img:not(.only-dark):not(.dark-light) {
120+
filter: brightness(0.8) contrast(1.2);
129121
}
122+
/* Give images a light background in dark mode in case they have
123+
* transparency and black text (unless they have class .only-dark or .dark-light, in
124+
* which case assume they're already optimized for dark mode).
125+
*/
126+
.bd-content img:not(.only-dark):not(.dark-light) {
127+
background: rgb(255, 255, 255);
128+
border-radius: 0.25rem;
129+
}
130+
// MathJax SVG outputs should be filled to same color as text.
131+
.MathJax_SVG * {
132+
fill: var(--pst-color-text-base);
133+
}
134+
}
135+
}
136+
137+
/* Defaults to light mode if data-theme is not set */
138+
html:not([data-theme]) {
139+
@include theme-colors("light");
140+
}
141+
142+
/* NOTE: @each {...} is like a for-loop
143+
* https://sass-lang.com/documentation/at-rules/control/each
144+
*/
145+
@each $mode in (light, dark) {
146+
html[data-theme="#{$mode}"] {
147+
@include theme-colors($mode);
130148
}
131149
}
132150

src/pydata_sphinx_theme/theme/pydata_sphinx_theme/components/navbar-logo.html

+10-2
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,16 @@
1313
{% set is_logo = "light" in theme_logo["image_relative"] %}
1414
{% set alt = theme_logo.get("alt_text", "Logo image") %}
1515
{% if is_logo %}
16-
<img src="{{ theme_logo['image_relative']['light'] }}" class="logo__image only-light" alt="{{ alt }}"/>
17-
<img src="{{ theme_logo['image_relative']['dark'] }}" class="logo__image only-dark" alt="{{ alt }}"/>
16+
{# Theme switching is only available when JavaScript is enabled.
17+
# Thus we should add the extra image using JavaScript, defaulting
18+
# depending on the value of default_mode; and light if unset.
19+
#}
20+
{% if default_mode is undefined %}
21+
{% set default_mode = "light" %}
22+
{% endif %}
23+
{% set js_mode = "light" if default_mode == "dark" else "dark" %}
24+
<img src="{{ theme_logo['image_relative'][default_mode] }}" class="logo__image only-{{ default_mode }}" alt="{{ alt }}"/>
25+
<script>document.write(`<img src="{{ theme_logo['image_relative'][js_mode] }}" class="logo__image only-{{ js_mode }}" alt="{{ alt }}"/>`);</script>
1826
{% endif %}
1927
{% if not is_logo or theme_logo.get("text") %}
2028
<p class="title logo__title">{{ theme_logo.get("text") or docstitle }}</p>
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
{# A button that, when clicked, will trigger a search popup overlay #}
2-
<button class="btn btn-sm navbar-btn search-button search-button__button" title="{{ _('Search') }}" aria-label="{{ _('Search') }}" data-bs-placement="bottom" data-bs-toggle="tooltip">
3-
<i class="fa-solid fa-magnifying-glass"></i>
4-
</button>
1+
{# A button that, when clicked, will trigger a search popup overlay.
2+
#
3+
# As this function will only work when JavaScript is enabled, we add it through JavaScript.
4+
#}
5+
<script>
6+
document.write(`
7+
<button class="btn btn-sm navbar-btn search-button search-button__button" title="{{ _('Search') }}" aria-label="{{ _('Search') }}" data-bs-placement="bottom" data-bs-toggle="tooltip">
8+
<i class="fa-solid fa-magnifying-glass"></i>
9+
</button>
10+
`);
11+
</script>
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
1-
<button class="theme-switch-button btn btn-sm btn-outline-primary navbar-btn rounded-circle" title="{{ _('light/dark') }}" aria-label="{{ _('light/dark') }}" data-bs-placement="bottom" data-bs-toggle="tooltip">
1+
{# As the theme switcher will only work when JavaScript is enabled, we add it through JavaScript.
2+
#}
3+
<script>
4+
document.write(`
5+
<button class="theme-switch-button btn btn-sm btn-outline-primary navbar-btn rounded-circle" title="{{ _('light/dark') }}" aria-label="{{ _('light/dark') }}" data-bs-placement="bottom" data-bs-toggle="tooltip">
26
<span class="theme-switch" data-mode="light"><i class="fa-solid fa-sun"></i></span>
37
<span class="theme-switch" data-mode="dark"><i class="fa-solid fa-moon"></i></span>
48
<span class="theme-switch" data-mode="auto"><i class="fa-solid fa-circle-half-stroke"></i></span>
5-
</button>
9+
</button>
10+
`);
11+
</script>
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
1-
<div class="version-switcher__container dropdown">
1+
{# As the version switcher will only work when JavaScript is enabled, we add it through JavaScript.
2+
#}
3+
<script>
4+
document.write(`
5+
<div class="version-switcher__container dropdown">
26
<button type="button" class="version-switcher__button btn btn-sm navbar-btn dropdown-toggle" data-bs-toggle="dropdown">
3-
{{ theme_switcher.get('version_match') }} <!-- this text may get changed later by javascript -->
4-
<span class="caret"></span>
7+
{{ theme_switcher.get('version_match') }} <!-- this text may get changed later by javascript -->
8+
<span class="caret"></span>
59
</button>
610
<div class="version-switcher__menu dropdown-menu list-group-flush py-0">
711
<!-- dropdown will be populated by javascript on page load -->
12+
</div>
813
</div>
9-
</div>
14+
`);
15+
</script>

src/pydata_sphinx_theme/theme/pydata_sphinx_theme/layout.html

+7-4
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
{# We redefine <html/> for "basic/layout.html" to add a default `data-theme` attribute when
2+
# a default mode has been set. This also improves compatibility when JavaScript is disabled.
3+
#}
4+
{% set html_tag %}
5+
<html{% if not html5_doctype %} xmlns="http://www.w3.org/1999/xhtml"{% endif %}{% if language is not none %} lang="{{ language }}"{% endif %} {% if default_mode %}data-theme="{{ default_mode }}"{% endif %}>
6+
{% endset %}
17
{%- extends "basic/layout.html" %}
28
{%- import "static/webpack-macros.html" as _webpack with context %}
39
{# Metadata and asset linking #}
@@ -64,10 +70,7 @@
6470
<div class="search-button__search-container">{% include "../components/search-field.html" %}</div>
6571
</div>
6672
{%- if theme_announcement -%}
67-
<div class="bd-header-announcement container-fluid"
68-
id="header-announcement">
69-
{% include "sections/announcement.html" %}
70-
</div>
73+
{% include "sections/announcement.html" %}
7174
{%- endif %}
7275
{% block docs_navbar %}
7376
<nav class="bd-header navbar navbar-expand-lg bd-navbar" id="navbar-main">
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1+
{% set header_classes = ["bd-header-announcement", "container-fluid"] %}
12
{% set is_remote=theme_announcement.startswith("http") %}
23
{# If we are remote, add a script to make an HTTP request for the value on page load #}
34
{%- if is_remote %}
45
<script>
6+
document.write(`<div id="header-announcement"></div>`);
57
fetch("{{ theme_announcement }}")
68
.then(res => {return res.text();})
79
.then(data => {
810
div = document.querySelector("#header-announcement");
11+
div.classList.add(...{{ header_classes | tojson }});
912
div.innerHTML = `<div class="bd-header-announcement__content">${data}</div>`;
1013
})
1114
.catch(error => {
@@ -14,5 +17,7 @@
1417
</script>
1518
{#- if announcement text is not remote, populate announcement w/ local content -#}
1619
{%- else %}
17-
<div class="bd-header-announcement__content">{{ theme_announcement }}</div>
20+
<div class="{{ header_classes | join(' ') }}" id="header-announcement">
21+
<div class="bd-header-announcement__content">{{ theme_announcement }}</div>
22+
</div>
1823
{% endif %}

tests/test_build.py

+53-2
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,51 @@ def test_logo_two_images(sphinx_build_factory):
201201
assert "Foo Title" in index_str
202202

203203

204+
def test_primary_logo_is_light_when_no_default_mode(sphinx_build_factory):
205+
"""Test that the primary logo image is light
206+
(and secondary, written through JavaScript, is dark)
207+
when no default mode is set."""
208+
# Ensure no default mode is set
209+
confoverrides = {
210+
"html_context": {},
211+
}
212+
sphinx_build = sphinx_build_factory("base", confoverrides=confoverrides).build()
213+
index_html = sphinx_build.html_tree("index.html")
214+
navbar_brand = index_html.select(".navbar-brand")[0]
215+
assert navbar_brand.find("img", class_="only-light") is not None
216+
assert navbar_brand.find("script", string=re.compile("only-dark")) is not None
217+
218+
219+
def test_primary_logo_is_light_when_default_mode_is_light(sphinx_build_factory):
220+
"""Test that the primary logo image is light
221+
(and secondary, written through JavaScript, is dark)
222+
when default mode is set to light."""
223+
# Ensure no default mode is set
224+
confoverrides = {
225+
"html_context": {"default_mode": "light"},
226+
}
227+
sphinx_build = sphinx_build_factory("base", confoverrides=confoverrides).build()
228+
index_html = sphinx_build.html_tree("index.html")
229+
navbar_brand = index_html.select(".navbar-brand")[0]
230+
assert navbar_brand.find("img", class_="only-light") is not None
231+
assert navbar_brand.find("script", string=re.compile("only-dark")) is not None
232+
233+
234+
def test_primary_logo_is_dark_when_default_mode_is_dark(sphinx_build_factory):
235+
"""Test that the primary logo image is dark
236+
(and secondary, written through JavaScript, is light)
237+
when default mode is set to dark."""
238+
# Ensure no default mode is set
239+
confoverrides = {
240+
"html_context": {"default_mode": "dark"},
241+
}
242+
sphinx_build = sphinx_build_factory("base", confoverrides=confoverrides).build()
243+
index_html = sphinx_build.html_tree("index.html")
244+
navbar_brand = index_html.select(".navbar-brand")[0]
245+
assert navbar_brand.find("img", class_="only-dark") is not None
246+
assert navbar_brand.find("script", string=re.compile("only-light")) is not None
247+
248+
204249
def test_logo_missing_image(sphinx_build_factory):
205250
"""Test that a missing image will raise a warning."""
206251
# Test with a specified title and a dark logo
@@ -665,7 +710,9 @@ def test_version_switcher(sphinx_build_factory, file_regression, url):
665710

666711
if url == "switcher.json": # this should work
667712
index = sphinx_build.html_tree("index.html")
668-
switcher = index.select(".version-switcher__container")[0]
713+
switcher = index.select(".navbar-header-items")[0].find(
714+
"script", string=re.compile(".version-switcher__container")
715+
)
669716
file_regression.check(
670717
switcher.prettify(), basename="navbar_switcher", extension=".html"
671718
)
@@ -683,7 +730,11 @@ def test_theme_switcher(sphinx_build_factory, file_regression):
683730
"""Regression test the theme switcher btn HTML"""
684731

685732
sphinx_build = sphinx_build_factory("base").build()
686-
switcher = sphinx_build.html_tree("index.html").select(".theme-switch-button")[0]
733+
switcher = (
734+
sphinx_build.html_tree("index.html")
735+
.find(string=re.compile("theme-switch-button"))
736+
.find_parent("script")
737+
)
687738
file_regression.check(
688739
switcher.prettify(), basename="navbar_theme", extension=".html"
689740
)

tests/test_build/navbar_switcher.html

+13-11
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1-
<div class="version-switcher__container dropdown">
2-
<button class="version-switcher__button btn btn-sm navbar-btn dropdown-toggle" data-bs-toggle="dropdown" type="button">
3-
0.7.1
4-
<!-- this text may get changed later by javascript -->
5-
<span class="caret">
6-
</span>
7-
</button>
8-
<div class="version-switcher__menu dropdown-menu list-group-flush py-0">
9-
<!-- dropdown will be populated by javascript on page load -->
10-
</div>
11-
</div>
1+
<script>
2+
document.write(`
3+
<div class="version-switcher__container dropdown">
4+
<button type="button" class="version-switcher__button btn btn-sm navbar-btn dropdown-toggle" data-bs-toggle="dropdown">
5+
0.7.1 <!-- this text may get changed later by javascript -->
6+
<span class="caret"></span>
7+
</button>
8+
<div class="version-switcher__menu dropdown-menu list-group-flush py-0">
9+
<!-- dropdown will be populated by javascript on page load -->
10+
</div>
11+
</div>
12+
`);
13+
</script>

tests/test_build/navbar_theme.html

+9-14
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,9 @@
1-
<button aria-label="light/dark" class="theme-switch-button btn btn-sm btn-outline-primary navbar-btn rounded-circle" data-bs-placement="bottom" data-bs-toggle="tooltip" title="light/dark">
2-
<span class="theme-switch" data-mode="light">
3-
<i class="fa-solid fa-sun">
4-
</i>
5-
</span>
6-
<span class="theme-switch" data-mode="dark">
7-
<i class="fa-solid fa-moon">
8-
</i>
9-
</span>
10-
<span class="theme-switch" data-mode="auto">
11-
<i class="fa-solid fa-circle-half-stroke">
12-
</i>
13-
</span>
14-
</button>
1+
<script>
2+
document.write(`
3+
<button class="theme-switch-button btn btn-sm btn-outline-primary navbar-btn rounded-circle" title="light/dark" aria-label="light/dark" data-bs-placement="bottom" data-bs-toggle="tooltip">
4+
<span class="theme-switch" data-mode="light"><i class="fa-solid fa-sun"></i></span>
5+
<span class="theme-switch" data-mode="dark"><i class="fa-solid fa-moon"></i></span>
6+
<span class="theme-switch" data-mode="auto"><i class="fa-solid fa-circle-half-stroke"></i></span>
7+
</button>
8+
`);
9+
</script>

tests/test_build/sidebar_subpage.html

+9-14
Original file line numberDiff line numberDiff line change
@@ -36,20 +36,15 @@
3636
</div>
3737
<div class="sidebar-header-items__end">
3838
<div class="navbar-end-item">
39-
<button aria-label="light/dark" class="theme-switch-button btn btn-sm btn-outline-primary navbar-btn rounded-circle" data-bs-placement="bottom" data-bs-toggle="tooltip" title="light/dark">
40-
<span class="theme-switch" data-mode="light">
41-
<i class="fa-solid fa-sun">
42-
</i>
43-
</span>
44-
<span class="theme-switch" data-mode="dark">
45-
<i class="fa-solid fa-moon">
46-
</i>
47-
</span>
48-
<span class="theme-switch" data-mode="auto">
49-
<i class="fa-solid fa-circle-half-stroke">
50-
</i>
51-
</span>
52-
</button>
39+
<script>
40+
document.write(`
41+
<button class="theme-switch-button btn btn-sm btn-outline-primary navbar-btn rounded-circle" title="light/dark" aria-label="light/dark" data-bs-placement="bottom" data-bs-toggle="tooltip">
42+
<span class="theme-switch" data-mode="light"><i class="fa-solid fa-sun"></i></span>
43+
<span class="theme-switch" data-mode="dark"><i class="fa-solid fa-moon"></i></span>
44+
<span class="theme-switch" data-mode="auto"><i class="fa-solid fa-circle-half-stroke"></i></span>
45+
</button>
46+
`);
47+
</script>
5348
</div>
5449
<div class="navbar-end-item">
5550
<ul aria-label="Icon Links" class="navbar-nav" id="navbar-icon-links">

0 commit comments

Comments
 (0)