Skip to content
Open
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

## Unreleased

- ✨ NEW: `accordion` directive groups `dropdown`s into an exclusive set —
opening one item automatically collapses the others, using the native,
JavaScript-free `<details name>` attribute. It degrades gracefully on
browsers that predate the feature (each item stays independently
collapsible), and offers a `:flush:` edge-to-edge variant and a `class`
option ({pr}`291`)
- ♻️ IMPROVE: Replace the Sass/Node build with a dependency-free Python CSS
generator (`tools/generate_css.py` driven by `style/design.toml` and
hand-authored `style/*.css`); `package.json` is gone. The compiled
Expand Down
116 changes: 116 additions & 0 deletions docs/accordions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
(sd-accordions)=

# Accordions

An `accordion` groups a set of [dropdowns](sd-dropdowns) so that they behave as
one *exclusive* set: opening one item automatically closes the others. This is
useful for FAQ-style content, where only one answer should be visible at a time.

The behaviour is native to HTML and requires **no JavaScript**: the directive
simply gives every item a shared `name`, and the browser does the rest.

::::{accordion}

:::{dropdown} What is an accordion?
A container of dropdowns where opening one collapses the others.
:::

:::{dropdown} How do I add one?
Nest `dropdown` directives inside an `accordion` directive (see the syntax
below).
:::

:::{dropdown} Does it need JavaScript?
No. It uses the native `name` attribute shared across `<details>` elements.
:::
::::

`````{dropdown-syntax}

````{tab-set-code}
```{literalinclude} ./snippets/myst/accordion-basic.txt
:language: markdown
```
```{literalinclude} ./snippets/rst/accordion-basic.txt
:language: rst
```
````
`````

## The `:open:` interaction

Because only one item in an exclusive group can be open at a time, you may mark
**at most one** child dropdown with the [`:open:`](sd-dropdowns) option, to have
the accordion start with that item expanded. If several items are marked
`:open:`, only the first is kept open (the rest are collapsed and a
`design.accordion` warning is emitted).

## Flush variant

Add the `:flush:` flag for an edge-to-edge look (like Bootstrap's
*accordion-flush*): the outer side borders and corner rounding are removed, so
the accordion blends into its parent container.

::::{accordion}
:flush:

:::{dropdown} First item
{{ loremipsum }}
:::

:::{dropdown} Second item
{{ loremipsum }}
:::
::::

## Nested dropdowns

Only the **direct** children of an accordion join the exclusive group. A
dropdown nested inside another item's *body* keeps its own independent
open/close behaviour, so you can freely nest expandable content within an
accordion item.

:::::{accordion}

::::{dropdown} Item with nested content
:open:

This item stays open independently of the dropdown below it:

:::{dropdown} A nested dropdown
:icon: quote

Opening or closing me does not affect the sibling accordion items.
:::
::::

::::{dropdown} Another item
Opening me closes the first item, but the *nested* dropdown is unaffected.
::::
:::::

(sd-accordion-browser-support)=

## Browser support

The exclusive-open behaviour relies on the native `name` attribute on
`<details>` elements, which is
[Baseline *Newly* Available](https://web.dev/baseline) (Chrome/Edge 120+,
Firefox 130+ and Safari 17.2+, all shipped in 2024). This is a deliberate
exception to the project's [*Baseline Widely Available*](get_started.md) policy,
permitted because it **degrades gracefully**: browsers that predate the feature
simply ignore the shared `name` and render each item as an ordinary,
independently collapsible dropdown. Nothing breaks — you only lose the automatic
collapsing of sibling items, and no JavaScript fallback is involved.

## `accordion` options

flush
: Flag. Remove the outer side borders and corner rounding for an edge-to-edge
(Bootstrap *accordion-flush*) appearance.

class
: Additional CSS classes for the accordion container element.

The individual items are ordinary [dropdowns](sd-dropdowns) and accept all of
the `dropdown` options.
8 changes: 8 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ get_started
grids
cards
dropdowns
accordions
tabs
badges_buttons
additional
Expand Down Expand Up @@ -126,6 +127,13 @@ Flexible and extensible content containers.
Hide content in expandable containers.
:::

:::{grid-item-card} {octicon}`fold-down` Accordions
:link: accordions
:link-type: doc

Group dropdowns into an exclusive set.
:::

:::{grid-item-card} {octicon}`duplicate` Tabs
:link: tabs
:link-type: doc
Expand Down
16 changes: 16 additions & 0 deletions docs/snippets/myst/accordion-basic.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
::::{accordion}

:::{dropdown} First item
Opening another item closes this one.
:::

:::{dropdown} Second item
:open:

Content of the second item.
:::

:::{dropdown} Third item
Content of the third item.
:::
::::
14 changes: 14 additions & 0 deletions docs/snippets/rst/accordion-basic.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
.. accordion::

.. dropdown:: First item

Opening another item closes this one.

.. dropdown:: Second item
:open:

Content of the second item.

.. dropdown:: Third item

Content of the third item.
140 changes: 140 additions & 0 deletions sphinx_design/accordion.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
"""The ``accordion`` directive: a group of mutually-exclusive dropdowns.

An accordion is a thin wrapper around a set of :mod:`~sphinx_design.dropdown`
directives. It assigns every *direct* child dropdown a shared, project-unique
``name``, which the HTML writer stamps onto the underlying ``<details>``
elements. Browsers that support the native ``name`` attribute on ``<details>``
then make the group mutually exclusive: opening one item automatically closes
the others -- with **no JavaScript**.

This degrades gracefully: engines that predate ``<details name>`` simply ignore
the attribute and render each item as an independent, individually collapsible
dropdown (see ``docs/accordions.md`` for the browser-support note).
"""

import hashlib

from docutils import nodes
from docutils.parsers.rst import directives
from sphinx.application import Sphinx
from sphinx.util.logging import getLogger

from .shared import (
WARNING_TYPE,
SdDirective,
create_component,
is_component,
is_ignorable_child,
)

LOGGER = getLogger(__name__)


def setup_accordion(app: Sphinx) -> None:
app.add_directive("accordion", AccordionDirective)


class AccordionDirective(SdDirective):
"""A container of ``dropdown`` items behaving as one exclusive group.

Opening one item collapses the others, via a shared native
``<details name="...">`` attribute (no JavaScript). The directive:

- parses its content, which should be a sequence of ``dropdown`` directives;
- warns (subtype ``design.accordion``) about any non-dropdown child, while
silently keeping structurally inert nodes (comments, targets, ...);
- stamps every direct child dropdown with a shared ``details_name``, so the
HTML transform can emit the grouping ``name`` attribute; and
- enforces at most one initially-``open`` item (warning and keeping the
first, since an exclusive group can only have one member open at a time).

Nested dropdowns (inside an item's *body*) are deliberately left untouched:
only *direct* children join the group.
"""

has_content = True
option_spec = {
"flush": directives.flag, # edge-to-edge styling (no outer borders)
"class": directives.class_option,
}

def _group_name(self) -> str:
"""Build a build-stable group name, unique across the whole project.

The name combines three parts:

- a readable slug of the docname (``nodes.make_id``), for legibility;
- a short digest of the *raw* docname. ``make_id`` is lossy (``a/b``,
``a-b`` and ``a.b`` all fold to ``a-b``), so the slug alone is not
collision-free; the digest disambiguates docnames that share a slug.
This matters for single-page/concatenated builds (``singlehtml``),
where accordions from *different* source documents land on one HTML
page and must not accidentally share a ``<details name>`` group; and
- a per-document serial counter, distinguishing multiple accordions
within the same document.

SHA-1 is used purely as a stable, deterministic fingerprint (not for
security), so rebuilds of unchanged content produce byte-identical
output -- there is no randomness anywhere in the name.
"""
docname = self.env.docname
slug = nodes.make_id(docname) or "doc"
# SHA-1 here is only a stable, deterministic fingerprint of the raw
# docname (usedforsecurity=False), never a security primitive.
digest = hashlib.sha1(
docname.encode("utf-8"), usedforsecurity=False
).hexdigest()[:8]
serial = self.env.new_serialno("sd-accordion")
return f"sd-accordion-{slug}-{digest}-{serial}"

def run_with_defaults(self) -> list[nodes.Node]:
self.assert_has_content()

classes = ["sd-accordion"]
if "flush" in self.options:
classes.append("sd-accordion-flush")
classes.extend(self.options.get("class", []))

accordion = create_component("accordion", classes=classes)
self.set_source_info(accordion)
self.state.nested_parse(self.content, self.content_offset, accordion)

group_name = self._group_name()
valid_children: list[nodes.Node] = []
has_open = False
for child in accordion.children:
if is_ignorable_child(child):
# comments and system messages can be dropped, but hyperlink
# targets must be kept so references to them still resolve
if isinstance(child, nodes.target):
valid_children.append(child)
continue
if not is_component(child, "dropdown"):
LOGGER.warning(
f"All children of an 'accordion' "
f"should be 'dropdown' [{WARNING_TYPE}.accordion]",
location=child,
type=WARNING_TYPE,
subtype="accordion",
)
continue # skip invalid children rather than breaking
# only *direct* child dropdowns join the group; nested dropdowns
# keep their own independent open/close behaviour
child["details_name"] = group_name
if child.get("opened"):
if has_open:
# an exclusive group can only have one member open at a time
LOGGER.warning(
f"Multiple open 'dropdown' items in an 'accordion'; "
f"keeping only the first open [{WARNING_TYPE}.accordion]",
location=child,
type=WARNING_TYPE,
subtype="accordion",
)
child["opened"] = False
else:
has_open = True
valid_children.append(child)

accordion.children = valid_children
return [accordion]
13 changes: 10 additions & 3 deletions sphinx_design/dropdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,14 @@ class dropdown_title(nodes.TextElement, nodes.General): # noqa: N801


def visit_dropdown_main(self, node):
attributes = {}
if node.get("details_name"):
# a shared name makes sibling <details> mutually exclusive (the
# native, JS-free basis of the ``accordion`` component)
attributes["name"] = node["details_name"]
if node.get("opened"):
self.body.append(self.starttag(node, "details", open="open"))
else:
self.body.append(self.starttag(node, "details"))
attributes["open"] = "open"
self.body.append(self.starttag(node, "details", **attributes))


def depart_dropdown_main(self, node):
Expand Down Expand Up @@ -176,6 +180,9 @@ def run(self, **kwargs: Any) -> None:
+ (["sd-card"] if use_card else ["sd-d-flex-column"])
+ node["container_classes"],
)
if node.get("details_name"):
# propagate the accordion group name onto the <details> element
newnode["details_name"] = node["details_name"]

if node["has_title"]:
title_text_children = node[0].children
Expand Down
2 changes: 2 additions & 0 deletions sphinx_design/extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from sphinx.application import Sphinx
from sphinx.transforms import SphinxTransform

from .accordion import setup_accordion
from .article_info import setup_article_info
from .badges_buttons import setup_badges_and_buttons
from .cards import setup_cards
Expand Down Expand Up @@ -49,6 +50,7 @@ def setup_extension(app: Sphinx) -> None:
setup_cards(app)
setup_grids(app)
setup_dropdown(app)
setup_accordion(app)
setup_icons(app)
setup_tabs(app)
setup_article_info(app)
Expand Down
2 changes: 1 addition & 1 deletion sphinx_design/static/sphinx-design.min.css

Large diffs are not rendered by default.

24 changes: 24 additions & 0 deletions style/accordion.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/* Accordion: a group of dropdowns behaving as one exclusive set -- opening an
item collapses its siblings, via a shared native `<details name>` attribute
and no JavaScript. Assembled directly after dropdown.css so these rules win
over the dropdown/card defaults at equal specificity. Browsers that ignore
`<details name>` keep each item independently collapsible; see
docs/accordions.md for the graceful-degradation note. */

/* Collapse the margin *between* grouped items so they read as a single
connected stack. Each child dropdown carries a default sd-mb-3; the last
item keeps it, preserving the gap below the accordion as a whole. The
!important is required to override the sd-mb-3 utility (itself !important). */
.sd-accordion > .sd-dropdown:not(:last-child) {
margin-bottom: 0 !important;
}

/* Flush variant (cf. Bootstrap's accordion-flush): drop the outer side borders
and corner rounding so the accordion sits edge-to-edge within its container.
!important beats the higher-specificity closed-state dropdown header rule. */
.sd-accordion-flush > .sd-dropdown,
.sd-accordion-flush > .sd-dropdown > .sd-card-header {
border-left: none !important;
border-right: none !important;
border-radius: 0 !important;
}
Loading
Loading