Skip to content

Add ability to do nested dropdowns in navbar #291

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 16 commits into from
Jul 10, 2025
Merged
Show file tree
Hide file tree
Changes from 13 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
2 changes: 1 addition & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

project = "sunpy-sphinx-theme test docs"
author = "The SunPy Community"
copyright = f"{datetime.datetime.now(datetime.UTC).year}, {author}" # NOQA: A001
copyright = f"{datetime.datetime.now(datetime.timezone.utc).year}, {author}" # NOQA: A001
extensions = [
"sphinx_automodapi.automodapi",
"sphinx_automodapi.smart_resolver",
Expand Down
38 changes: 24 additions & 14 deletions src/sunpy_sphinx_theme/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,30 +36,36 @@ def default_navbar():
(
"Documentation",
[
# Core goes first always
# Core packages shown directly
("sunpy", "https://docs.sunpy.org/", 3),
# Other affiliated packages are in alphabetical order
("aiapy", "https://aiapy.readthedocs.io/", 3),
("dkist", "https://docs.dkist.nso.edu/projects/python-tools", 3),
("drms", "https://docs.sunpy.org/projects/drms/", 3),
("irispy-lmsal", "https://irispy-lmsal.readthedocs.io/", 3),
("ndcube", "https://docs.sunpy.org/projects/ndcube/", 3),
("roentgen", "https://roentgen.readthedocs.io/", 3),
("solarmach", "https://solarmach.readthedocs.io/en/stable/", 3),
("sunkit-image", "https://docs.sunpy.org/projects/sunkit-image/", 3),
("sunkit-instruments ", "https://docs.sunpy.org/projects/sunkit-instruments/", 3),
("sunkit-instruments", "https://docs.sunpy.org/projects/sunkit-instruments/", 3),
("sunkit-magex", "https://docs.sunpy.org/projects/sunkit-magex/", 3),
("sunkit-pyvista", "https://docs.sunpy.org/projects/sunkit-pyvista/", 3),
("sunpy-soar", "https://docs.sunpy.org/projects/soar/", 3),
("solarmach", "https://solarmach.readthedocs.io/en/stable/", 3),
("sunraster", "https://docs.sunpy.org/projects/sunraster/", 3),
("xrtpy", "https://xrtpy.readthedocs.io/", 3),
# Then we have provisional packages
("pyflct", "https://pyflct.readthedocs.io/", 3),
("radiospectra", "https://docs.sunpy.org/projects/radiospectra/", 3),
# These are tools which are not affiliated but are maintained by SunPy
("ablog", "https://ablog.readthedocs.io/", 3),
("mpl-animators", "https://docs.sunpy.org/projects/mpl-animators/", 3),
("streamtracer", "https://docs.sunpy.org/projects/streamtracer/", 3),
# Provisional packages submenu
(
"Provisional",
[
("pyflct", "https://pyflct.readthedocs.io/", 3),
("radiospectra", "https://docs.sunpy.org/projects/radiospectra/", 3),
],
),
# Tools submenu
(
"Tools",
[
("ablog", "https://ablog.readthedocs.io/en/stable/", 3),
("mpl-animators", "https://docs.sunpy.org/projects/mpl-animators/", 3),
("streamtracer", "https://docs.sunpy.org/projects/streamtracer/", 3),
],
),
],
),
("Packages", "affiliated/", 2),
Expand Down Expand Up @@ -182,6 +188,10 @@ def setup(app: Sphinx):
"https://gc.zgo.at/count.js",
loading_method="async",
)
app.add_js_file(
"js/submenu-concertina-toggle.js",
loading_method="async",
)

return {
"parallel_read_safe": True,
Expand Down
1 change: 1 addition & 0 deletions src/sunpy_sphinx_theme/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"svg_icon",
]


html_theme = "sunpy"
html_theme_options = {}

Expand Down
95 changes: 66 additions & 29 deletions src/sunpy_sphinx_theme/theme/sunpy/components/navbar_center.html
Original file line number Diff line number Diff line change
@@ -1,38 +1,75 @@
{# When we are rendering the top nav bar (wide screens) we use bootstrap dropdowns, #}
{# when we render the sidebar (narrow screens) use collapse instead. #}
{# TODO: If you have a section uncollapsed and then expand the width of your screen, weird shit happens #}
{% if in_header %}
{% set toggle="dropdown" %}
{% set list_class="dropdown-menu" %}
{% else %}
{% set toggle="collapse" %}
{% set list_class="collapse" %}
{% endif %}
{% macro render_nav_item(item, depth=0) %}
{# Determine item type and a unique, context-prefixed submenu ID #}
{% set is_leaf = item[1] is string %}
{% set is_nested = item[1] is iterable and (item[1]|length > 0) and (item[1][0] is sequence or item[1][0] is mapping) %}
{% set context_prefix = 'header' if in_header else 'sidebar' %}
{% set base_id = item[0]|replace(" ", "_")|lower %}
{% set submenu_id = context_prefix ~ '_' ~ base_id ~ '_' ~ depth %}

{# ----------------------------- LEAF LINK ----------------------------- #}
{% if is_leaf %}
<li class="nav-item{% if depth > 0 %} dropdown-submenu{% endif %} {% if depth == 0 %}ms-2{% endif %}">
<a class="{% if depth == 0 %}nav-link{% else %}dropdown-item{% endif %}{% if depth > 1 %} concertina-subitem{% endif %}" href="{{ sst_pathto(*item[1:]) }}">
{{ item[0] }}
</a>
</li>

{# ----------------------------- TOP LEVEL DROPDOWN ----------------------------- #}
{% elif depth == 0 and is_nested %}
<li class="nav-item dropdown ms-2">
{% if in_header %}
<a href="#" class="dropdown-toggle nav-link" id="dropdownMenu_{{ submenu_id }}"
data-bs-toggle="dropdown" role="button" aria-expanded="false">
{{ item[0] }}
</a>
<ul class="dropdown-menu" aria-labelledby="dropdownMenu_{{ submenu_id }}">
{% else %}
<a href="#" class="nav-link d-flex justify-content-between align-items-center"
data-bs-toggle="collapse" data-bs-target="#collapse_{{ submenu_id }}"
role="button" aria-expanded="false" aria-controls="collapse_{{ submenu_id }}">
{{ item[0] }}
<span class="caret" aria-hidden="true">▾</span>
</a>
<ul class="collapse" id="collapse_{{ submenu_id }}">
{% endif %}
{% for subitem in item[1] %}
{{ render_nav_item(subitem, depth + 1) }}
{% endfor %}
</ul>
</li>

{# ----------------------------- NESTED CONCERTINA ----------------------------- #}
{% elif depth > 0 and is_nested %}
<li>
<button class="dropdown-item concertina-toggle d-flex align-items-center px-0 position-relative"
aria-expanded="false"
aria-controls="submenu_{{ submenu_id }}">
<span class="concertina-label ps-4">{{ item[0] }}</span>
<span class="concertina-icon position-absolute start-0" aria-hidden="true">▸</span>
</button>
<ul id="submenu_{{ submenu_id }}" class="concertina-submenu collapse">
{% for subitem in item[1] %}
{{ render_nav_item(subitem, depth + 1) }}
{% endfor %}
</ul>
</li>
{% endif %}
{% endmacro %}

<nav class="navbar-nav">
<ul class="bd-navbar-elements navbar-nav">
{% if theme_navbar_links %}
{%- for navlink in theme_navbar_links %}
{% if navlink[1] is not string %}
<li class="nav-item dropdown ms-2 has-children">
<a class="nav-link dropdown-toggle" href="#" role="button" id="dropdownMenuLink" data-bs-toggle="{{ toggle }}" data-bs-target="#{{ navlink[0] }}" aria-haspopup="true" aria-expanded="false">{{ navlink[0] }}<b class="caret"></b></a>
<ul class="{{ list_class }}" id="{{ navlink[0] }}" aria-labelledby="dropdownMenuLink">
{%- for link in navlink[1] %}
<li class="nav-item">
<a class="nav-link" href="{{ sst_pathto(*link[1:]) }}">{{ link[0] }}</a>
</li>
{%- endfor %}
</ul>
</li>
{% else %}
<li class="nav-item ms-2"><a class="nav-link" role="button" href="{{ sst_pathto(*navlink[1:]) }}">{{ navlink[0] }}</a></li>
{% endif %}
{%- endfor %}
{% for navlink in theme_navbar_links %}
{{ render_nav_item(navlink) }}
{% endfor %}
{% endif %}

{% if theme_external_links %}
{%- for external_link in theme_external_links %}
<li class="nav-item ms-2"><a class="nav-link nav-external" role="button" href="{{ external_link['url'] }}">{{ external_link['name'] }}</a></li>
{%- endfor %}
{% for ext in theme_external_links %}
<li class="nav-item ms-2">
<a class="nav-link nav-external" href="{{ ext['url'] }}">{{ ext['name'] }}</a>
</li>
{% endfor %}
{% endif %}
</ul>
</nav>
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
document.addEventListener("DOMContentLoaded", function () {
// Handle concertina toggle clicks inside dropdown
document.querySelectorAll(".concertina-toggle").forEach(function (toggle) {
toggle.addEventListener("click", function (e) {
e.preventDefault();
e.stopPropagation();

const submenuId = toggle.getAttribute("aria-controls");
const submenu = document.getElementById(submenuId);
if (!submenu) return;
submenu.classList.add("collapse"); // Remove bootstraps control to ensure we can toggle
const expanded = submenu.classList.contains("show");
submenu.classList.toggle("show", !expanded);
submenu.classList.toggle("collapse", !expanded);
toggle.setAttribute("aria-expanded", String(!expanded));
});
});

// Prevent dropdown from closing when clicking inside the concertina
document.querySelectorAll(".dropdown-menu").forEach(function (menu) {
menu.addEventListener("click", function (e) {
if (
e.target.closest(".concertina-toggle") ||
e.target.closest(".concertina-submenu")
) {
e.stopPropagation();
}
});
});

// Reset all concertinas when dropdown is hidden
document.querySelectorAll(".dropdown").forEach(function (dropdown) {
dropdown.addEventListener("hide.bs.dropdown", function () {
dropdown
.querySelectorAll(".concertina-submenu.show")
.forEach(function (submenu) {
submenu.classList.remove("show");
});
dropdown
.querySelectorAll(".concertina-toggle")
.forEach(function (toggle) {
toggle.setAttribute("aria-expanded", "false");
});
});
});
});
62 changes: 62 additions & 0 deletions src/sunpy_sphinx_theme/theme/sunpy/static/sunpy_style.css
Original file line number Diff line number Diff line change
Expand Up @@ -412,3 +412,65 @@ html[data-theme="dark"] .search-button-field:hover {
align-items: center;
}
}

.concertina-icon {
left: 0;
transition: transform 0.3s ease;
pointer-events: none;
text-decoration: none !important; /* otherwise couldn't get the icon to not be underlined */
}

.concertina-toggle[aria-expanded="true"] .concertina-icon {
transform: rotate(90deg);
}

.concertina-submenu {
list-style: none;
margin: 0;
padding: 0;
display: none; /* Controlled via the concertina toggle JS */
}

.concertina-submenu.show {
display: block;
}
.concertina-submenu.collapse {
width: 100%;
}

.concertina-subitem {
padding-left: 2rem;
}

/* Have to restore some pst styling since we have removed some bs classes in the navbar_center template */
.navbar-nav .concertina-toggle {
color: var(--pst-color-text-muted);
}
.bd-header .dropdown-item,
.bd-header .concertina-toggle {
color: var(--sst-header-text);
}

/* Hover and focus styles */
.concertina-toggle.dropdown-item:hover,
.navbar-nav .concertina-toggle:hover,
.bd-header .dropdown-item:hover,
.bd-header .concertina-toggle:hover {
color: var(--pst-color-link-hover);
background-color: transparent;
text-decoration: underline;
}

.navbar-nav .concertina-toggle:hover {
text-decoration-thickness: max(3px, 0.1875rem, 0.12em);
text-underline-offset: 0.1578em;
}
.bd-header .concertina-toggle .concertina-icon {
left: 0.4rem !important;
}
/* Limit the height of dropdown menu to keep on screen */
.bd-header .dropdown-menu {
max-height: 80vh;
overflow-y: auto;
overscroll-behavior: contain;
}