Add `collapsible` option to admonition directives (#12507)

This PR adds the `collapsible` and option to the core admonition type directives, which are propagated to the nodes, e.g.

```restructuredtext
.. admonition:: title
   :collapsible:

   content

.. note:: content
   :collapsible: closed
```

For the HTML5 writer, this replaces the outer `div` with a `details` tag, and the title `p`  with a `summary` tag (with an `open` attribute if set), e.g.

```html
<div class="admonition note">
<p class="admonition-title">Note</p>
<p>hallo</p>
</div>
```

changes to

```html
<details class="admonition note" open="open">
<summary class="admonition-title">Note</summary>
<p>hallo</p>
</details>
```

Co-authored-by: Adam Turner <9087854+aa-turner@users.noreply.github.com>
This commit is contained in:
Chris Sewell
2025-01-29 18:06:57 +01:00
committed by GitHub
parent 4936b27996
commit 134bd7f1fb
11 changed files with 340 additions and 54 deletions

View File

@@ -88,6 +88,9 @@ Features added
* #13271: Support the ``:abstract:`` option for
classes, methods, and properties in the Python domain.
Patch by Adam Turner.
* #12507: Add the :ref:`collapsible <collapsible-admonitions>` option
to admonition directives.
Patch by Chris Sewell.
Bugs fixed
----------

View File

@@ -31,6 +31,9 @@
--icon-warning: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M13 14h-2v-4h2m0 8h-2v-2h2M1 21h22L12 2 1 21z"/></svg>');
--icon-failure: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 2c5.53 0 10 4.47 10 10s-4.47 10-10 10S2 17.53 2 12 6.47 2 12 2m3.59 5L12 10.59 8.41 7 7 8.41 10.59 12 7 15.59 8.41 17 12 13.41 15.59 17 17 15.59 13.41 12 17 8.41 15.59 7z"/></svg>');
--icon-spark: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M11.5 20l4.86-9.73H13V4l-5 9.73h3.5V20M12 2c2.75 0 5.1 1 7.05 2.95C21 6.9 22 9.25 22 12s-1 5.1-2.95 7.05C17.1 21 14.75 22 12 22s-5.1-1-7.05-2.95C3 17.1 2 14.75 2 12s1-5.1 2.95-7.05C6.9 3 9.25 2 12 2z"/></svg>');
/* icons used for details summaries */
--icon-details-open: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M8.59 16.58 13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.42Z"/></svg>');
}
body {
@@ -409,7 +412,7 @@ table td, table th {
padding: 0.2em 0.5em 0.2em 0.5em;
}
div.admonition, div.warning {
div.admonition, div.warning, details.admonition {
font-size: 0.9em;
margin: 1em 0 1em 0;
border: 1px solid #86989B;
@@ -418,16 +421,16 @@ div.admonition, div.warning {
padding: 1rem;
}
div.admonition > p, div.warning > p {
div.admonition > p, div.warning > p, details.admonition > p {
margin: 0;
padding: 0;
}
div.admonition > pre, div.warning > pre {
div.admonition > pre, div.warning > pre, details.admonition > pre {
margin: 0.4em 1em 0.4em 1em;
}
div.admonition > p.admonition-title {
div.admonition > p.admonition-title, details.admonition > summary.admonition-title {
position: relative;
font-weight: 500;
background-color: var(--color-admonition-bg);
@@ -436,33 +439,78 @@ div.admonition > p.admonition-title {
border-radius: var(--admonition-radius) var(--admonition-radius) 0 0;
}
details.admonition:not([open]) {
padding-bottom: 0;
}
details.admonition > summary.admonition-title {
list-style: none;
cursor: pointer;
padding-right: .5rem;
}
details.admonition > summary.admonition-title::after {
background-color: currentcolor;
content: "";
height: 1.2rem;
width: 1.2rem;
-webkit-mask-image: var(--icon-details-open);
mask-image: var(--icon-details-open);
-webkit-mask-position: center;
mask-position: center;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-size: contain;
mask-size: contain;
transform: rotate(0deg);
transition: transform .25s;
float: right;
}
details.admonition[open] > summary.admonition-title::after {
transform: rotate(90deg);
}
details.admonition:not([open]) > summary.admonition-title {
margin-bottom: 0;
border-radius: var(--admonition-radius);
}
div.attention > p.admonition-title,
div.danger > p.admonition-title,
div.error > p.admonition-title {
div.error > p.admonition-title,
details.attention > summary.admonition-title,
details.danger > summary.admonition-title,
details.error > summary.admonition-title {
background-color: var(--colour-error-bg);
}
div.important > p.admonition-title,
div.caution > p.admonition-title,
div.warning > p.admonition-title {
div.warning > p.admonition-title,
details.important > summary.admonition-title,
details.caution > summary.admonition-title,
details.warning > summary.admonition-title {
background-color: var(--colour-warning-bg);
}
div.note > p.admonition-title {
div.note > p.admonition-title,
details.note > summary.admonition-title {
background-color: var(--colour-note-bg);
}
div.hint > p.admonition-title,
div.tip > p.admonition-title,
div.seealso > p.admonition-title {
div.seealso > p.admonition-title,
details.hint > summary.admonition-title,
details.tip > summary.admonition-title,
details.seealso > summary.admonition-title {
background-color: var(--colour-success-bg);
}
div.admonition-todo > p.admonition-title {
div.admonition-todo > p.admonition-title,
details.admonition-todo > summary.admonition-title {
background-color: var(--colour-todo-bg);
}
p.admonition-title::before {
p.admonition-title::before,
summary.admonition-title::before {
content: "";
height: 1rem;
left: .5rem;
@@ -472,69 +520,81 @@ p.admonition-title::before {
background-color: #5f5f5f;
}
div.admonition > p.admonition-title::before {
div.admonition > p.admonition-title::before,
details.admonition > summary.admonition-title::before {
background-color: var(--color-admonition-fg);
-webkit-mask-image: var(--icon-abstract);
mask-image: var(--icon-abstract);
}
div.attention > p.admonition-title::before {
div.attention > p.admonition-title::before,
details.attention > summary.admonition-title::before {
background-color: var(--colour-error-fg);
-webkit-mask-image: var(--icon-warning);
mask-image: var(--icon-warning);
}
div.caution > p.admonition-title::before {
div.caution > p.admonition-title::before,
details.caution > summary.admonition-title::before {
background-color: var(--colour-warning-fg);
-webkit-mask-image: var(--icon-spark);
mask-image: var(--icon-spark);
}
div.danger > p.admonition-title::before {
div.danger > p.admonition-title::before,
details.danger > summary.admonition-title::before {
background-color: var(--colour-error-fg);
-webkit-mask-image: var(--icon-spark);
mask-image: var(--icon-spark);
}
div.error > p.admonition-title::before {
div.error > p.admonition-title::before,
details.error > summary.admonition-title::before {
background-color: var(--colour-error-fg);
-webkit-mask-image: var(--icon-failure);
mask-image: var(--icon-failure);
}
div.hint > p.admonition-title::before {
div.hint > p.admonition-title::before,
details.hint > summary.admonition-title::before {
background-color: var(--colour-success-fg);
-webkit-mask-image: var(--icon-question);
mask-image: var(--icon-question);
}
div.important > p.admonition-title::before {
div.important > p.admonition-title::before,
details.important > summary.admonition-title::before {
background-color: var(--colour-warning-fg);
-webkit-mask-image: var(--icon-flame);
mask-image: var(--icon-flame);
}
div.note > p.admonition-title::before {
div.note > p.admonition-title::before,
details.note > summary.admonition-title::before {
background-color: var(--colour-note-fg);
-webkit-mask-image: var(--icon-pencil);
mask-image: var(--icon-pencil);
}
div.seealso > p.admonition-title::before {
div.seealso > p.admonition-title::before,
details.seealso > summary.admonition-title::before {
background-color: var(--colour-success-fg);
-webkit-mask-image: var(--icon-info);
mask-image: var(--icon-info);
}
div.tip > p.admonition-title::before {
div.tip > p.admonition-title::before,
details.tip > summary.admonition-title::before {
background-color: var(--colour-success-fg);
-webkit-mask-image: var(--icon-info);
mask-image: var(--icon-info);
}
div.admonition-todo > p.admonition-title::before {
div.admonition-todo > p.admonition-title::before,
details.admonition-todo > summary.admonition-title::before {
background-color: var(--colour-todo-fg);
-webkit-mask-image: var(--icon-pencil);
mask-image: var(--icon-pencil);
}
div.warning > p.admonition-title::before {
div.warning > p.admonition-title::before,
details.warning > summary.admonition-title::before {
background-color: var(--colour-warning-fg);
-webkit-mask-image: var(--icon-warning);
mask-image: var(--icon-warning);
}
div.caution,
div.important,
div.warning {
div.warning, details.warning {
border-color: var(--colour-warning-fg);
}
div.attention,

View File

@@ -480,6 +480,56 @@ and the generic :rst:dir:`admonition` directive.
Documentation for tar archive files, including GNU tar extensions.
.. _collapsible-admonitions:
.. rubric:: Collapsible text
.. versionadded:: 8.2
Each admonition directive supports a ``:collapsible:`` option,
to make the content of the admonition collapsible
(where supported by the output format).
This can be useful for content that is not always relevant.
By default, collapsible admonitions are initially open,
but this can be controlled with the ``open`` and ``closed`` arguments
to the ``:collapsible:`` option, which change the default state.
In output formats that don't support collapsible content,
the text is always included.
For example:
.. code-block:: rst
.. note::
:collapsible:
This note is collapsible, and initially open by default.
.. admonition:: Example
:collapsible: open
This example is collapsible, and initially open.
.. hint::
:collapsible: closed
This hint is collapsible, but initially closed.
.. note::
:collapsible:
This note is collapsible, and initially open by default.
.. admonition:: Example
:collapsible: open
This example is collapsible, and initially open.
.. hint::
:collapsible: closed
This hint is collapsible, but initially closed.
Describing changes between versions
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

View File

@@ -95,6 +95,7 @@ builtin_extensions: tuple[str, ...] = (
'sphinx.domains.rst',
'sphinx.domains.std',
'sphinx.directives',
'sphinx.directives.admonitions',
'sphinx.directives.code',
'sphinx.directives.other',
'sphinx.directives.patches',

View File

@@ -0,0 +1,107 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from docutils import nodes
from docutils.parsers.rst.directives.admonitions import BaseAdmonition
from sphinx import addnodes
from sphinx.util.docutils import SphinxDirective
if TYPE_CHECKING:
from typing import ClassVar
from docutils.nodes import Node
from sphinx.application import Sphinx
from sphinx.util.typing import ExtensionMetadata, OptionSpec
def _collapsible_arg(argument: str | None) -> str:
if argument is None:
return 'open'
if (value := argument.lower().strip()) in {'open', 'closed'}:
return value
msg = f'"{argument}" unknown; choose from "open" or "closed".'
raise ValueError(msg)
class SphinxAdmonition(BaseAdmonition, SphinxDirective):
option_spec: ClassVar[OptionSpec] = BaseAdmonition.option_spec.copy() # type: ignore[union-attr]
option_spec |= {
'collapsible': _collapsible_arg,
}
node_class: type[nodes.Admonition] = nodes.admonition
"""Subclasses must set this to the appropriate admonition node class."""
def run(self) -> list[Node]:
(admonition_node,) = super().run()
return [admonition_node]
class Admonition(SphinxAdmonition):
required_arguments = 1
node_class = nodes.admonition
class Attention(SphinxAdmonition):
node_class = nodes.attention
class Caution(SphinxAdmonition):
node_class = nodes.caution
class Danger(SphinxAdmonition):
node_class = nodes.danger
class Error(SphinxAdmonition):
node_class = nodes.error
class Hint(SphinxAdmonition):
node_class = nodes.hint
class Important(SphinxAdmonition):
node_class = nodes.important
class Note(SphinxAdmonition):
node_class = nodes.note
class Tip(SphinxAdmonition):
node_class = nodes.tip
class Warning(SphinxAdmonition):
node_class = nodes.warning
class SeeAlso(SphinxAdmonition):
"""An admonition mentioning things to look at as reference."""
node_class = addnodes.seealso
def setup(app: Sphinx) -> ExtensionMetadata:
app.add_directive('admonition', Admonition, override=True)
app.add_directive('attention', Attention, override=True)
app.add_directive('caution', Caution, override=True)
app.add_directive('danger', Danger, override=True)
app.add_directive('error', Error, override=True)
app.add_directive('hint', Hint, override=True)
app.add_directive('important', Important, override=True)
app.add_directive('note', Note, override=True)
app.add_directive('tip', Tip, override=True)
app.add_directive('warning', Warning, override=True)
app.add_directive('seealso', SeeAlso, override=True)
return {
'version': 'builtin',
'parallel_read_safe': True,
'parallel_write_safe': True,
}

View File

@@ -7,7 +7,6 @@ from typing import TYPE_CHECKING, cast
from docutils import nodes
from docutils.parsers.rst import directives
from docutils.parsers.rst.directives.admonitions import BaseAdmonition
from docutils.parsers.rst.directives.misc import Class
from docutils.parsers.rst.directives.misc import Include as BaseInclude
from docutils.statemachine import StateMachine
@@ -215,12 +214,6 @@ class Author(SphinxDirective):
return ret
class SeeAlso(BaseAdmonition):
"""An admonition mentioning things to look at as reference."""
node_class = addnodes.seealso
class TabularColumns(SphinxDirective):
"""Directive to give an explicit tabulary column definition to LaTeX."""
@@ -427,7 +420,6 @@ def setup(app: Sphinx) -> ExtensionMetadata:
directives.register_directive('sectionauthor', Author)
directives.register_directive('moduleauthor', Author)
directives.register_directive('codeauthor', Author)
directives.register_directive('seealso', SeeAlso)
directives.register_directive('tabularcolumns', TabularColumns)
directives.register_directive('centered', Centered)
directives.register_directive('acks', Acks)

View File

@@ -12,11 +12,10 @@ import operator
from typing import TYPE_CHECKING, cast
from docutils import nodes
from docutils.parsers.rst import directives
from docutils.parsers.rst.directives.admonitions import BaseAdmonition
import sphinx
from sphinx import addnodes
from sphinx.directives.admonitions import SphinxAdmonition
from sphinx.domains import Domain
from sphinx.errors import NoUri
from sphinx.locale import _, __
@@ -46,35 +45,25 @@ class todolist(nodes.General, nodes.Element):
pass
class Todo(BaseAdmonition, SphinxDirective):
class Todo(SphinxAdmonition):
"""A todo entry, displayed (if configured) in the form of an admonition."""
node_class = todo_node
has_content = True
required_arguments = 0
optional_arguments = 0
final_argument_whitespace = False
option_spec: ClassVar[OptionSpec] = {
'class': directives.class_option,
'name': directives.unchanged,
}
def run(self) -> list[Node]:
if not self.options.get('class'):
self.options['class'] = ['admonition-todo']
(todo,) = super().run()
if isinstance(todo, nodes.system_message):
if not isinstance(todo, todo_node):
return [todo]
elif isinstance(todo, todo_node):
todo.insert(0, nodes.title(text=_('Todo')))
todo['docname'] = self.env.docname
self.add_name(todo)
self.set_source_info(todo)
self.state.document.note_explicit_target(todo)
return [todo]
else:
raise TypeError # never reached here
todo.insert(0, nodes.title(text=_('Todo')))
todo['docname'] = self.env.docname
self.add_name(todo)
self.set_source_info(todo)
self.state.document.note_explicit_target(todo)
return [todo]
class TodoDomain(Domain):

View File

@@ -367,12 +367,21 @@ class HTML5Translator(SphinxTranslator, BaseTranslator): # type: ignore[misc]
# overwritten
def visit_admonition(self, node: Element, name: str = '') -> None:
self.body.append(self.starttag(node, 'div', CLASS=('admonition ' + name)))
attributes = {}
tag_name = 'div'
if collapsible := node.get('collapsible'):
tag_name = 'details'
if collapsible == 'open':
attributes['open'] = 'open'
self.body.append(
self.starttag(node, tag_name, CLASS=f'admonition {name}', **attributes)
)
self.context.append(f'</{tag_name}>\n')
if name:
node.insert(0, nodes.title(name, admonitionlabels[name]))
def depart_admonition(self, node: Element | None = None) -> None:
self.body.append('</div>\n')
self.body.append(self.context.pop())
def visit_seealso(self, node: Element) -> None:
self.visit_admonition(node, 'seealso')
@@ -500,6 +509,15 @@ class HTML5Translator(SphinxTranslator, BaseTranslator): # type: ignore[misc]
)
self.body.append('<span class="caption-text">')
self.context.append('</span></p>\n')
elif (
isinstance(node.parent, nodes.Admonition)
and isinstance(node.parent, nodes.Element)
and 'collapsible' in node.parent
):
self.body.append(
self.starttag(node, 'summary', '', CLASS='admonition-title')
)
self.context.append('</summary>\n')
else:
super().visit_title(node)
self.add_secnumber(node)

View File

@@ -0,0 +1,22 @@
test-directives-admonition-collapse
===================================
.. note::
:class: standard
This is a standard note.
.. note::
:collapsible:
This note is collapsible, and initially open by default.
.. admonition:: Example
:collapsible: open
This example is collapsible, and initially open.
.. hint::
:collapsible: closed
This hint is collapsible, but initially closed.

View File

@@ -715,3 +715,47 @@ def test_html_pep_695_trailing_comma_in_multi_line_signatures(app):
r'.//dt[@id="MyList"][1]',
chk('class MyList[\nT\n](list[T])'),
)
@pytest.mark.sphinx('html', testroot='directives-admonition-collapse')
def test_html_admonition_collapse(app):
app.build()
fname = app.outdir / 'index.html'
etree = etree_parse(fname)
def _create_check(text: str, open: bool): # type: ignore[no-untyped-def]
def _check(els):
assert len(els) == 1
el = els[0]
if open:
assert el.attrib['open'] == 'open'
else:
assert 'open' not in el.attrib
assert el.find('p').text == text
return _check
check_xpath(
etree,
fname,
r'.//div[@class="standard admonition note"]//p',
'This is a standard note.',
)
check_xpath(
etree,
fname,
r'.//details[@class="admonition note"]',
_create_check('This note is collapsible, and initially open by default.', True),
)
check_xpath(
etree,
fname,
r'.//details[@class="admonition-example admonition"]',
_create_check('This example is collapsible, and initially open.', True),
)
check_xpath(
etree,
fname,
r'.//details[@class="admonition hint"]',
_create_check('This hint is collapsible, but initially closed.', False),
)