diff --git a/conf.py b/conf.py index 967383e2f..c28c7d3d5 100644 --- a/conf.py +++ b/conf.py @@ -145,6 +145,9 @@ extensions = [ # Content tabs 'sphinx_tabs.tabs', + # Spoilers + 'spoilers', + # Strange html domain logic used in memento pages 'html_domain', ] diff --git a/content/contributing/documentation/rst_cheat_sheet.rst b/content/contributing/documentation/rst_cheat_sheet.rst index ee7689c2f..69c057883 100644 --- a/content/contributing/documentation/rst_cheat_sheet.rst +++ b/content/contributing/documentation/rst_cheat_sheet.rst @@ -714,6 +714,24 @@ Code blocks def main(): print("Hello world!") +.. _contributing/spoilers: + +Spoilers +======== + +.. list-table:: + :class: o-showcase-table + + * - .. spoiler:: Answer to the Ultimate Question of Life, the Universe, and Everything + + **42** + + * - .. code-block:: text + + .. spoiler:: Answer to the Ultimate Question of Life, the Universe, and Everything + + **42** + .. _contributing/tabs: Content tabs diff --git a/extensions/odoo_theme/static/scss/_variables.scss b/extensions/odoo_theme/static/scss/_variables.scss index dbec98217..d5797e8d6 100644 --- a/extensions/odoo_theme/static/scss/_variables.scss +++ b/extensions/odoo_theme/static/scss/_variables.scss @@ -241,3 +241,10 @@ $padding-l: 3rem; $margin-s: $padding-s; $margin-m: $padding-m; $margin-l: $padding-l; + +//------------------------------------------------------------------------------ +// Components +//------------------------------------------------------------------------------ + +// Accordion +$o-accordion-bg: tint-color($doc_exercise, 90%)!default; diff --git a/extensions/odoo_theme/static/scss/bootstrap_overridden.scss b/extensions/odoo_theme/static/scss/bootstrap_overridden.scss index 7094ece0c..42e8a5486 100644 --- a/extensions/odoo_theme/static/scss/bootstrap_overridden.scss +++ b/extensions/odoo_theme/static/scss/bootstrap_overridden.scss @@ -27,3 +27,17 @@ $dropdown-min-width: 4.5rem; // Modals $modal-backdrop-opacity: .7; + +// Accordion +$accordion-body-padding-y: 0 !default; +$accordion-body-padding-x: 0 !default; +$accordion-color: shade-color($doc_exercise, 35%) !default; +$accordion-bg: $o-accordion-bg; + +$accordion-button-color: $accordion-color !default; +$accordion-button-active-color: $accordion-button-color !default; +$accordion-button-bg: $accordion-bg !default; +$accordion-button-active-bg: $accordion-button-bg !default; + +$accordion-icon-transform: rotate(0deg) !default; + diff --git a/extensions/odoo_theme/static/style.scss b/extensions/odoo_theme/static/style.scss index afb64d7bc..40ba37368 100644 --- a/extensions/odoo_theme/static/style.scss +++ b/extensions/odoo_theme/static/style.scss @@ -1094,6 +1094,42 @@ header { } } } + +//------------------------------------------------------------------------------ +// Spoilers +//------------------------------------------------------------------------------ + +.o_spoiler { + border-color: tint-color($doc_exercise, 30%); + background-color: $o-accordion-bg; + + .accordion-button { + &::after { + margin: 0 .2rem 0 0; + } + + &.collapsed::after { + transform: rotate(-90deg); + } + } + + .accordion-body { + #wrap & { // FIXME: Temporary workaround to take priority over pre-existing rules. + > *:first-child { + margin-top: 1rem; + } + + code, div[class^="highlight"] { + border-color: tint-color($doc-exercise, 50%); + + &:first-child:last-child { + margin-bottom: 0; + } + } + } + } +} + //------------------------------------------------------------------------------ // Content Tabs //------------------------------------------------------------------------------ diff --git a/extensions/spoilers/__init__.py b/extensions/spoilers/__init__.py new file mode 100644 index 000000000..dfbca29ff --- /dev/null +++ b/extensions/spoilers/__init__.py @@ -0,0 +1,105 @@ +from docutils import nodes +from sphinx.locale import get_translation +from sphinx.util.docutils import SphinxDirective + + +class Spoiler(SphinxDirective): + """ Implement a `spoiler` directive with Bootstrap's accordion component. """ + required_arguments = 0 + optional_arguments = 1 + final_argument_whitespace = True + has_content = True + + def run(self): + """ Process the content of the directive. + + We use custom node classes to represent HTML elements (e.g., 'div') rather than the + corresponding sphinx.nodes.* class (e.g., sphinx.nodes.container) to prevent automatically + setting the name of the node as class (e.g., "container") on the element. + """ + _ = get_translation('spoilers') + self.assert_has_content() + spoiler_id = self.env.new_serialno("spoiler") + heading_id = f'o_spoiler_header_{spoiler_id}' + content_id = f'o_spoiler_content_{spoiler_id}' + + # Create the main accordion container. + accordion_container = Container( + classes=['accordion', 'accordion-flush', 'o_spoiler', 'alert'] + ) # Reuse the SCSS rules already defined for `alert`. Same thing for `alert-title`. + + # Create the accordion item container. + accordion_item_container = Container(classes=['accordion-item']) + accordion_container.append(accordion_item_container) + + # Create the header. + header = Header(ids=[heading_id], classes=['accordion-header']) + accordion_item_container.append(header) + + # Create the toggle button. + button = Button( + classes=[ + 'accordion-button', 'collapsed', 'flex-row-reverse', 'justify-content-end', + 'fw-bold', 'p-0', 'border-bottom-0' + ], + **{ + 'type': 'button', + 'data-bs-toggle': 'collapse', + 'data-bs-target': f'#{content_id}', + 'aria-expanded': 'false', + 'aria-controls': content_id, + } + ) + header.append(button) + + # Create the button label. + label = self.arguments[0] if self.arguments else _("Spoiler") + button_label = nodes.Text(label) + button.append(button_label) + + # Create the accordion collapse container. + accordion_collapse_container = Container( + ids=[content_id], + classes=['accordion-collapse', 'collapse', 'border-bottom-0'], + **{'aria-labelledby': heading_id}, + ) + accordion_item_container.append(accordion_collapse_container) + + # Create the accordion body container. + accordion_body_container = Container(classes=['accordion-body']) + self.state.nested_parse(self.content, self.content_offset, accordion_body_container) + accordion_collapse_container.append(accordion_body_container) + + return [accordion_container] + + +class Container(nodes.General, nodes.Element): + custom_tag_name = 'div' + + +class Header(nodes.General, nodes.Element): + custom_tag_name = 'span' + + +class Button(nodes.General, nodes.Element): + custom_tag_name = 'button' + + +def visit_node(translator, node): + custom_attr = {k: v for k, v in node.attributes.items() if k not in node.known_attributes} + translator.body.append(translator.starttag(node, node.custom_tag_name, **custom_attr).rstrip()) + + +def depart_node(translator, node): + translator.body.append(f'') + + +def setup(app): + app.add_directive('spoiler', Spoiler) + app.add_node(Container, html=(visit_node, depart_node)) + app.add_node(Header, html=(visit_node, depart_node)) + app.add_node(Button, html=(visit_node, depart_node)) + return { + 'parallel_read_safe': True, + 'parallel_write_safe': True, + }