From 69ff7243bf6d90c556b92def406928d67825e27e Mon Sep 17 00:00:00 2001 From: "Antoine Vandevenne (anv)" Date: Wed, 23 Feb 2022 10:08:44 +0000 Subject: [PATCH] [IMP] extensions: add content tabs (backport of cf6ca0fb) closes odoo/documentation#1626 X-original-commit: 286b01a241aede0fcb18774ba36e8dcb862ed336 Signed-off-by: Antoine Vandevenne (anv) --- conf.py | 7 + content/administration/install.rst | 2 + .../documentation/rst_cheat_sheet.rst | 213 ++++++++++ extensions/sphinx_tabs/__init__.py | 5 + extensions/sphinx_tabs/static/tabs.css | 100 +++++ extensions/sphinx_tabs/static/tabs.js | 145 +++++++ extensions/sphinx_tabs/tabs.py | 370 ++++++++++++++++++ 7 files changed, 842 insertions(+) create mode 100644 extensions/sphinx_tabs/__init__.py create mode 100644 extensions/sphinx_tabs/static/tabs.css create mode 100644 extensions/sphinx_tabs/static/tabs.js create mode 100644 extensions/sphinx_tabs/tabs.py diff --git a/conf.py b/conf.py index 90d900cce..ee852e7ce 100644 --- a/conf.py +++ b/conf.py @@ -124,9 +124,13 @@ extensions = [ # Code switcher (switcher and case directives) 'switcher', + # Content tabs + 'sphinx_tabs.tabs', + # Strange html domain logic used in memento pages 'html_domain', ] + if odoo_dir_in_path: # GitHub links generation extensions += [ @@ -179,6 +183,9 @@ languages_names = { # The specifications of redirect rules used by the redirects extension. redirects_file = 'redirects.txt' +sphinx_tabs_disable_tab_closing = True +sphinx_tabs_disable_css_loading = False + #=== Options for HTML output ===# html_theme = 'odoo_theme' diff --git a/content/administration/install.rst b/content/administration/install.rst index fb9e3025b..f3f071944 100644 --- a/content/administration/install.rst +++ b/content/administration/install.rst @@ -4,6 +4,8 @@ Install ======= +.. If you add content on this page, remove the redirect rule 'install -> install/install' + .. toctree:: install/install diff --git a/content/contributing/documentation/rst_cheat_sheet.rst b/content/contributing/documentation/rst_cheat_sheet.rst index dc50dc0c3..75553747a 100644 --- a/content/contributing/documentation/rst_cheat_sheet.rst +++ b/content/contributing/documentation/rst_cheat_sheet.rst @@ -521,6 +521,219 @@ Render Customize this admonition with a **Title** of your choice. +.. _contributing/tabs: + +Content tabs +============ + +.. caution:: + The `tabs` directive may not work well in some situations. In particular: + + - The tabs' headers cannot be translated. + - A tab cannot contain :ref:`headings `. + - An :ref:`admonition ` cannot contain tabs. + - A tab cannot contain :ref:`internal hyperlink targets + `. + +Basic tabs +---------- + +RST +~~~ + +.. code-block:: rst + + .. tabs:: + + .. tab:: Odoo Online + + Content dedicated to Odoo Online users. + + .. tab:: Odoo.sh + + Alternative for Odoo.sh users. + + .. tab:: On-premise + + Third version for On-premise users. + +Render +~~~~~~ + +.. tabs:: + + .. tab:: Odoo Online + + Content dedicated to Odoo Online users. + + .. tab:: Odoo.sh + + Alternative for Odoo.sh users. + + .. tab:: On-premise + + Third version for On-premise users. + +Nested tabs +----------- + +RST +~~~ + +.. code-block:: rst + + .. tabs:: + + .. tab:: Stars + + .. tabs:: + + .. tab:: The Sun + + The closest star to us. + + .. tab:: Proxima Centauri + + The second closest star to us. + + .. tab:: Polaris + + The North Star. + + .. tab:: Moons + + .. tabs:: + + .. tab:: The Moon + + Orbits the Earth. + + .. tab:: Titan + + Orbits Jupiter. + +Render +~~~~~~ + +.. tabs:: + + .. tab:: Stars + + .. tabs:: + + .. tab:: The Sun + + The closest star to us. + + .. tab:: Proxima Centauri + + The second closest star to us. + + .. tab:: Polaris + + The North Star. + + .. tab:: Moons + + .. tabs:: + + .. tab:: The Moon + + Orbits the Earth. + + .. tab:: Titan + + Orbits Jupiter. + +Group tabs +---------- + +RST +~~~ + +.. code-block:: rst + + .. tabs:: + + .. group-tab:: C++ + + C++ + + .. group-tab:: Python + + Python + + .. group-tab:: Java + + Java + + .. tabs:: + + .. group-tab:: C++ + + .. code-block:: c++ + + int main(const int argc, const char **argv) { + return 0; + } + + .. group-tab:: Python + + .. code-block:: python + + def main(): + return + + .. group-tab:: Java + + .. code-block:: java + + class Main { + public static void main(String[] args) {} + } + +Render +~~~~~~ + +.. tabs:: + + .. group-tab:: C++ + + C++ + + .. group-tab:: Python + + Python + + .. group-tab:: Java + + Java + +.. tabs:: + + .. group-tab:: C++ + + .. code-block:: c++ + + int main(const int argc, const char **argv) { + return 0; + } + + .. group-tab:: Python + + .. code-block:: python + + def main(): + return + + .. group-tab:: Java + + .. code-block:: java + + class Main { + public static void main(String[] args) {} + } + .. _contributing/document-metadata: Document metadata diff --git a/extensions/sphinx_tabs/__init__.py b/extensions/sphinx_tabs/__init__.py new file mode 100644 index 000000000..ecaad733e --- /dev/null +++ b/extensions/sphinx_tabs/__init__.py @@ -0,0 +1,5 @@ +""" Salvaged from https://github.com/executablebooks/sphinx-tabs """ + +__version__ = "3.2.0" + +__import__("pkg_resources").declare_namespace(__name__) diff --git a/extensions/sphinx_tabs/static/tabs.css b/extensions/sphinx_tabs/static/tabs.css new file mode 100644 index 000000000..02eaeb4a5 --- /dev/null +++ b/extensions/sphinx_tabs/static/tabs.css @@ -0,0 +1,100 @@ +.sphinx-tabs { + margin-bottom: 1rem; +} + +[role="tablist"] { + border-bottom: 1px solid #a0b3bf; +} + +.sphinx-tabs-tab { + position: relative; + font-family: Lato,'Helvetica Neue',Arial,Helvetica,sans-serif; + color: #1D5C87; + line-height: 24px; + margin: 0; + font-size: 16px; + font-weight: 400; + background-color: rgba(255, 255, 255, 0); + border-radius: 5px 5px 0 0; + border: 0; + padding: 1rem 1.5rem; + margin-bottom: 0; +} + +.sphinx-tabs-tab[aria-selected="true"] { + font-weight: 700; + border: 1px solid #a0b3bf; + border-bottom: 1px solid white; + margin: -1px; + background-color: white; +} + +.sphinx-tabs-tab:focus { + z-index: 1; + outline-offset: 1px; +} + +.sphinx-tabs-panel { + position: relative; + padding: 1rem; + border: 1px solid #a0b3bf; + margin: 0px -1px -1px -1px; + border-radius: 0 0 5px 5px; + border-top: 0; + background: white; +} + +.sphinx-tabs-panel.code-tab { + padding: 0.4rem; +} + +.sphinx-tab img { + margin-bottom: 24 px; +} + +/* Dark theme preference styling */ + +@media (prefers-color-scheme: dark) { + body:not([data-theme="light"]) .sphinx-tabs-panel { + color: white; + background-color: rgb(50, 50, 50); + } + + body:not([data-theme="light"]) .sphinx-tabs-tab { + color: white; + font-size: 16px; + font-weight: 400; + background-color: rgba(255, 255, 255, 0.05); + } + + body:not([data-theme="light"]) .sphinx-tabs-tab[aria-selected="true"] { + font-weight: 700; + border: 1px solid #a0b3bf; + border-bottom: 1px solid rgb(50, 50, 50); + margin: -1px; + background-color: rgb(50, 50, 50); + } +} + +/* Explicit dark theme styling */ + +body[data-theme="dark"] .sphinx-tabs-panel { + color: white; + background-color: rgb(50, 50, 50); +} + +body[data-theme="dark"] .sphinx-tabs-tab { + color: white; + font-size: 16px; + font-weight: 400; + border: 1px solid rgba(255, 255, 255, 0.15); + background-color: rgba(255, 255, 255, 0.05); +} + +body[data-theme="dark"] .sphinx-tabs-tab[aria-selected="true"] { + font-weight: 700; + border: 1px solid #a0b3bf; + border-bottom: 2px solid rgb(50, 50, 50); + margin: -1px; + background-color: rgb(50, 50, 50); +} diff --git a/extensions/sphinx_tabs/static/tabs.js b/extensions/sphinx_tabs/static/tabs.js new file mode 100644 index 000000000..48dc303c8 --- /dev/null +++ b/extensions/sphinx_tabs/static/tabs.js @@ -0,0 +1,145 @@ +try { + var session = window.sessionStorage || {}; +} catch (e) { + var session = {}; +} + +window.addEventListener("DOMContentLoaded", () => { + const allTabs = document.querySelectorAll('.sphinx-tabs-tab'); + const tabLists = document.querySelectorAll('[role="tablist"]'); + + allTabs.forEach(tab => { + tab.addEventListener("click", changeTabs); + }); + + tabLists.forEach(tabList => { + tabList.addEventListener("keydown", keyTabs); + }); + + // Restore group tab selection from session + const lastSelected = session.getItem('sphinx-tabs-last-selected'); + if (lastSelected != null) selectNamedTabs(lastSelected); +}); + +/** + * Key focus left and right between sibling elements using arrows + * @param {Node} e the element in focus when key was pressed + */ +function keyTabs(e) { + const tab = e.target; + let nextTab = null; + if (e.keyCode === 39 || e.keyCode === 37) { + tab.setAttribute("tabindex", -1); + // Move right + if (e.keyCode === 39) { + nextTab = tab.nextElementSibling; + if (nextTab === null) { + nextTab = tab.parentNode.firstElementChild; + } + // Move left + } else if (e.keyCode === 37) { + nextTab = tab.previousElementSibling; + if (nextTab === null) { + nextTab = tab.parentNode.lastElementChild; + } + } + } + + if (nextTab !== null) { + nextTab.setAttribute("tabindex", 0); + nextTab.focus(); + } +} + +/** + * Select or deselect clicked tab. If a group tab + * is selected, also select tab in other tabLists. + * @param {Node} e the element that was clicked + */ +function changeTabs(e) { + // Use this instead of the element that was clicked, in case it's a child + const notSelected = this.getAttribute("aria-selected") === "false"; + const positionBefore = this.parentNode.getBoundingClientRect().top; + const notClosable = !this.parentNode.classList.contains("closeable"); + + deselectTabList(this); + + if (notSelected || notClosable) { + selectTab(this); + const name = this.getAttribute("name"); + selectNamedTabs(name, this.id); + + if (this.classList.contains("group-tab")) { + // Persist during session + session.setItem('sphinx-tabs-last-selected', name); + } + } + + const positionAfter = this.parentNode.getBoundingClientRect().top; + const positionDelta = positionAfter - positionBefore; + // Scroll to offset content resizing + window.scrollTo(0, window.scrollY + positionDelta); +} + +/** + * Select tab and show associated panel. + * @param {Node} tab tab to select + */ +function selectTab(tab) { + tab.setAttribute("aria-selected", true); + + // Show the associated panel + document + .getElementById(tab.getAttribute("aria-controls")) + .removeAttribute("hidden"); +} + +/** + * Hide the panels associated with all tabs within the + * tablist containing this tab. + * @param {Node} tab a tab within the tablist to deselect + */ +function deselectTabList(tab) { + const parent = tab.parentNode; + const grandparent = parent.parentNode; + + Array.from(parent.children) + .forEach(t => t.setAttribute("aria-selected", false)); + + Array.from(grandparent.children) + .slice(1) // Skip tablist + .forEach(panel => panel.setAttribute("hidden", true)); +} + +/** + * Select grouped tabs with the same name, but no the tab + * with the given id. + * @param {Node} name name of grouped tab to be selected + * @param {Node} clickedId id of clicked tab + */ +function selectNamedTabs(name, clickedId=null) { + const groupedTabs = document.querySelectorAll(`.sphinx-tabs-tab[name="${name}"]`); + const tabLists = Array.from(groupedTabs).map(tab => tab.parentNode); + + tabLists + .forEach(tabList => { + // Don't want to change the tabList containing the clicked tab + const clickedTab = tabList.querySelector(`[id="${clickedId}"]`); + if (clickedTab === null ) { + // Select first tab with matching name + const tab = tabList.querySelector(`.sphinx-tabs-tab[name="${name}"]`); + deselectTabList(tab); + selectTab(tab); + } + }) +} + +if (typeof exports === 'undefined') { + exports = {}; +} + +exports.keyTabs = keyTabs; +exports.changeTabs = changeTabs; +exports.selectTab = selectTab; +exports.deselectTabList = deselectTabList; +exports.selectNamedTabs = selectNamedTabs; diff --git a/extensions/sphinx_tabs/tabs.py b/extensions/sphinx_tabs/tabs.py new file mode 100644 index 000000000..025563661 --- /dev/null +++ b/extensions/sphinx_tabs/tabs.py @@ -0,0 +1,370 @@ +""" Tabbed views for Sphinx, with HTML builder + +Salvaged from https://github.com/executablebooks/sphinx-tabs + +""" + +import base64 +from pathlib import Path +from functools import partial +import sphinx + + +from docutils import nodes +from docutils.parsers.rst import directives +from pygments.lexers import get_all_lexers +from sphinx.highlighting import lexer_classes +from sphinx.util.docutils import SphinxDirective +from sphinx.directives.code import CodeBlock + + +FILES = [ + "tabs.js", + "tabs.css", +] + + +LEXER_MAP = {} +for lexer in get_all_lexers(): + for short_name in lexer[1]: + LEXER_MAP[short_name] = lexer[0] + + +def get_compatible_builders(app): + builders = [ + "html", + "singlehtml", + "dirhtml", + "readthedocs", + "readthedocsdirhtml", + "readthedocssinglehtml", + "readthedocssinglehtmllocalmedia", + "spelling", + ] + builders.extend(app.config["sphinx_tabs_valid_builders"]) + return builders + + +class SphinxTabsContainer(nodes.container): + tagname = "div" + + +class SphinxTabsPanel(nodes.container): + tagname = "div" + + +class SphinxTabsTab(nodes.paragraph): + tagname = "button" + + +class SphinxTabsTablist(nodes.container): + tagname = "div" + + +def visit(translator, node): + # Borrowed from `sphinx-inline-tabs` + attrs = node.attributes.copy() + attrs.pop("classes") + attrs.pop("ids") + attrs.pop("names") + attrs.pop("dupnames") + attrs.pop("backrefs") + text = translator.starttag(node, node.tagname, **attrs) + translator.body.append(text.strip()) + + +def depart(translator, node): + translator.body.append(f"") + + +class TabsDirective(SphinxDirective): + """Top-level tabs directive""" + + has_content = True + + def run(self): + """Parse a tabs directive""" + self.assert_has_content() + + node = nodes.container(type="tab-element") + node["classes"].append("sphinx-tabs") + + if "next_tabs_id" not in self.env.temp_data: + self.env.temp_data["next_tabs_id"] = 0 + if "tabs_stack" not in self.env.temp_data: + self.env.temp_data["tabs_stack"] = [] + + tabs_id = self.env.temp_data["next_tabs_id"] + tabs_key = f"tabs_{tabs_id}" + self.env.temp_data["next_tabs_id"] += 1 + self.env.temp_data["tabs_stack"].append(tabs_id) + + self.env.temp_data[tabs_key] = {} + self.env.temp_data[tabs_key]["tab_ids"] = [] + self.env.temp_data[tabs_key]["tab_titles"] = [] + self.env.temp_data[tabs_key]["is_first_tab"] = True + + self.state.nested_parse(self.content, self.content_offset, node) + + if self.env.app.builder.name in get_compatible_builders(self.env.app): + tablist = SphinxTabsTablist() + tablist["role"] = "tablist" + tablist["aria-label"] = "Tabbed content" + if not self.env.config["sphinx_tabs_disable_tab_closing"]: + tablist["classes"].append("closeable") + + tab_titles = self.env.temp_data[tabs_key]["tab_titles"] + for idx, [data_tab, tab_name] in enumerate(tab_titles): + tab_name.attributes["role"] = "tab" + tab_name["ids"] = [f"tab-{tabs_id}-{data_tab}"] + tab_name["name"] = data_tab + tab_name["tabindex"] = "0" if idx == 0 else "-1" + tab_name["aria-selected"] = "true" if idx == 0 else "false" + tab_name["aria-controls"] = tab_name["ids"][0].replace("tab-", "panel-") + + tablist += tab_name + + node.insert(0, tablist) + + self.env.temp_data["tabs_stack"].pop() + return [node] + + +class TabDirective(SphinxDirective): + """Tab directive, for adding a tab to a collection of tabs""" + + has_content = True + + def __init__(self, *args, **kwargs): + self.tab_id = None + self.tab_classes = set() + super().__init__(*args, **kwargs) + + def run(self): + """Parse a tab directive""" + self.assert_has_content() + + tabs_id = self.env.temp_data["tabs_stack"][-1] + tabs_key = f"tabs_{tabs_id}" + + include_tabs_id_in_data_tab = False + if self.tab_id is None: + tab_id = self.env.new_serialno(tabs_key) + include_tabs_id_in_data_tab = True + else: + tab_id = self.tab_id + + tab_name = SphinxTabsTab() + self.state.nested_parse(self.content[0:1], 0, tab_name) + # Remove the paragraph node that is created by nested_parse + tab_name.children[0].replace_self(tab_name.children[0].children) + + tab_name["classes"].append("sphinx-tabs-tab") + tab_name["classes"].extend(sorted(self.tab_classes)) + + i = 1 + while tab_id in self.env.temp_data[tabs_key]["tab_ids"]: + tab_id = f"{tab_id}-{i}" + i += 1 + self.env.temp_data[tabs_key]["tab_ids"].append(tab_id) + + data_tab = str(tab_id) + if include_tabs_id_in_data_tab: + data_tab = f"{tabs_id}-{data_tab}" + + self.env.temp_data[tabs_key]["tab_titles"].append((data_tab, tab_name)) + + panel = SphinxTabsPanel() + panel["role"] = "tabpanel" + panel["ids"] = [f"panel-{tabs_id}-{data_tab}"] + panel["name"] = data_tab + panel["tabindex"] = 0 + panel["aria-labelledby"] = panel["ids"][0].replace("panel-", "tab-") + panel["classes"].append("sphinx-tabs-panel") + panel["classes"].extend(sorted(self.tab_classes)) + + if self.env.temp_data[tabs_key]["is_first_tab"]: + self.env.temp_data[tabs_key]["is_first_tab"] = False + else: + panel["hidden"] = "true" + + self.state.nested_parse(self.content[2:], self.content_offset, panel) + + if self.env.app.builder.name not in get_compatible_builders(self.env.app): + # Use base docutils classes + outer_node = nodes.container() + tab = nodes.container() + tab_name = nodes.container() + panel = nodes.container() + + self.state.nested_parse(self.content[0:1], 0, tab_name) + self.state.nested_parse(self.content[2:], self.content_offset, panel) + + tab += tab_name + outer_node += tab + outer_node += panel + + return [outer_node] + + return [panel] + + +class GroupTabDirective(TabDirective): + """Tab directive that toggles with same tab names across page""" + + has_content = True + + def run(self): + self.assert_has_content() + self.tab_classes.add("group-tab") + group_name = self.content[0] + if self.tab_id is None: + self.tab_id = base64.b64encode(group_name.encode("utf-8")).decode("utf-8") + + node = super().run() + return node + + +class CodeTabDirective(GroupTabDirective): + """Tab directive with a codeblock as its content""" + + has_content = True + required_arguments = 1 # Lexer name + optional_arguments = 1 # Custom label + final_argument_whitespace = True + option_spec = { # From sphinx CodeBlock + "force": directives.flag, + "linenos": directives.flag, + "dedent": int, + "lineno-start": int, + "emphasize-lines": directives.unchanged_required, + "caption": directives.unchanged_required, + "class": directives.class_option, + "name": directives.unchanged, + } + + def run(self): + """Parse a code-tab directive""" + self.assert_has_content() + + if len(self.arguments) > 1: + tab_name = self.arguments[1] + elif self.arguments[0] in lexer_classes and not isinstance( + lexer_classes[self.arguments[0]], partial + ): + tab_name = lexer_classes[self.arguments[0]].name + else: + try: + tab_name = LEXER_MAP[self.arguments[0]] + except KeyError as invalid_lexer_error: + raise ValueError( + f"Lexer not implemented: {self.arguments[0]}" + ) from invalid_lexer_error + + self.tab_classes.add("code-tab") + + # All content parsed as code + code_block = CodeBlock.run(self) + + # Reset to generate panel + self.content.data = [tab_name, ""] + self.content.items = [(None, 0), (None, 1)] + + node = super().run() + node[0].extend(code_block) + + return node + + +class _FindTabsDirectiveVisitor(nodes.NodeVisitor): + """Visitor pattern than looks for a sphinx tabs + directive in a document""" + + def __init__(self, document): + nodes.NodeVisitor.__init__(self, document) + self._found = False + + def unknown_visit(self, node): + if ( + not self._found + and isinstance(node, nodes.container) + and "classes" in node + and isinstance(node["classes"], list) + ): + self._found = "sphinx-tabs" in node["classes"] + + @property + def found_tabs_directive(self): + """Return whether a sphinx tabs directive was found""" + return self._found + + +def update_config(app, config): + """Adds sphinx-tabs CSS and JS asset files""" + for path in [Path(path) for path in FILES]: + if not config.sphinx_tabs_disable_css_loading and path.suffix == ".css": + if "add_css_file" in dir(app): + app.add_css_file(path.as_posix()) + else: + app.add_stylesheet(path.as_posix()) + if path.suffix == ".js": + if "add_script_file" in dir(app): + app.add_script_file(path.as_posix()) + else: + app.add_js_file(path.as_posix()) + + +# pylint: disable=unused-argument +def update_context(app, pagename, templatename, context, doctree): + """Remove sphinx-tabs CSS and JS asset files if not used in a page""" + if doctree is None: + return + visitor = _FindTabsDirectiveVisitor(doctree) + doctree.walk(visitor) + + include_assets_in_all_pages = False + if sphinx.version_info >= (4, 1, 0): + include_assets_in_all_pages = app.registry.html_assets_policy == "always" + + if not visitor.found_tabs_directive and not include_assets_in_all_pages: + paths = [Path("_static") / f for f in FILES] + if "css_files" in context: + context["css_files"] = context["css_files"][:] + for path in paths: + if path.suffix == ".css" and path in context["css_files"]: + context["css_files"].remove(path.as_posix()) + if "script_files" in context: + context["script_files"] = context["script_files"][:] + for path in paths: + if path.suffix == ".js" and path.as_posix() in context["script_files"]: + context["script_files"].remove(path.as_posix()) + + +# pylint: enable=unused-argument + + +def setup(app): + """Set up the plugin""" + app.add_config_value("sphinx_tabs_valid_builders", [], "") + app.add_config_value("sphinx_tabs_disable_css_loading", False, "html", [bool]) + app.add_config_value("sphinx_tabs_disable_tab_closing", False, "html", [bool]) + app.add_node(SphinxTabsContainer, html=(visit, depart)) + app.add_node(SphinxTabsPanel, html=(visit, depart)) + app.add_node(SphinxTabsTab, html=(visit, depart)) + app.add_node(SphinxTabsTablist, html=(visit, depart)) + app.add_directive("tabs", TabsDirective) + app.add_directive("tab", TabDirective) + app.add_directive("group-tab", GroupTabDirective) + app.add_directive("code-tab", CodeTabDirective) + static_dir = Path(__file__).parent / "static" + app.connect( + "builder-inited", + (lambda app: app.config.html_static_path.append(static_dir.as_posix())), + ) + app.connect("config-inited", update_config) + app.connect("html-page-context", update_context) + + return { + "parallel_read_safe": True, + "parallel_write_safe": True, + }