Skip to content

Commit

Permalink
feat: Sticky sync tabs and URL param
Browse files Browse the repository at this point in the history
Add a Sphinx configuration option that is named
`sphinx_design_sync_tabs_storage_key` so that users
can set a project-specific local storage key rather
than risk clobbering the value for `sphinx-design-last-tab`
across different projects.

Add support for an optional URL search param of
like `?tab=blah` or `?code=blah` so that I can copy
and paste a URL and the receiver sees the tab
content that I see. By default, no param and you
get to add "Oh, and click blah" in your message.

If the URL has search param `?tab=blah`, set the
local storage value to `blah`. If `blah` matches
a synchronization key for a tab, show the tab.

When someone clicks a tab, set the local storage
value to the synchronization key value and also
update the URL search params with `?tab=<value>`
if the project configures a URL param.
  • Loading branch information
mikemckiernan committed Oct 26, 2022
1 parent 42077cf commit 0f4f48f
Show file tree
Hide file tree
Showing 4 changed files with 126 additions and 2 deletions.
27 changes: 27 additions & 0 deletions docs/tabs.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,3 +228,30 @@ class-label

class-content
: Additional CSS classes for the content element.

## Synchronised tab options

### URL parameter

By default, there is no indication in the URL of the tab that you selected.
To display a URL parameter, add a setting like the following example in your `conf.py` file:

```python
sphinx_design_sync_tabs_url_param = "tab"
```

With a setting like the preceding example, after you browse to a URL like `https://www.example.com/project-A/index.html` and select a tab that you configured with the `sync` option of `python`, the URL changes to `https://www.example.com/project-A/index.html?tab=python`.

### Local storage key

Synchronised tabs and tabbed code examples store the value of the `sync` option in browser local storage so that a returning visit opens the most-recently viewed tab.
The storage applies to a single domain, such as `https://www.example.com` and as a result, two projects on the same domain can interfere with each other.

To use project-specific storage, add a setting like the following example in your `conf.py` file:

```python
sphinx_design_sync_tabs_storage_key = "proj-a"
```

The default value for the storage key is `sphinx-design-last-tab`.
For more information about local storage, see [Using the Web Storage API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API/Using_the_Web_Storage_API) in the Mozilla developer documentation.
34 changes: 33 additions & 1 deletion sphinx_design/compiled/sd_tabs.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
var sd_labels_by_text = {};

const storageKey = '&{storage_key}';
// The default value for paramKey is the string 'None'. That value indicates
// not to set a URL search parameter.
const paramKey = '&{param_key}';

function ready() {
const tabParam = new URLSearchParams(window.location.search).get(paramKey);
if (tabParam) {
window.localStorage.setItem(storageKey, tabParam)
}

const li = document.getElementsByClassName("sd-tab-label");
const previousChoice = window.localStorage.getItem(storageKey)

for (const label of li) {
syncId = label.getAttribute("data-sync-id");
if (syncId) {
Expand All @@ -10,6 +22,10 @@ function ready() {
sd_labels_by_text[syncId] = [];
}
sd_labels_by_text[syncId].push(label);
if (previousChoice === syncId) {
label.previousElementSibling.checked = true;
updateSearchParams(syncId)
}
}
}
}
Expand All @@ -21,7 +37,23 @@ function onLabelClick() {
if (label === this) continue;
label.previousElementSibling.checked = true;
}
window.localStorage.setItem("sphinx-design-last-tab", syncId);
window.localStorage.setItem(storageKey, syncId);
updateSearchParams(syncId)
}

/**
* @param {string} syncId
*/
function updateSearchParams(syncId) {
if (paramKey == 'None') {
return;
}
const params = new URLSearchParams(window.location.search);
if (params.get(paramKey) === syncId) {
return;
}
params.set(paramKey, syncId);
window.history.replaceState({}, '', `${window.location.pathname}?${params}`)
}

document.addEventListener("DOMContentLoaded", ready, false);
20 changes: 19 additions & 1 deletion sphinx_design/extension.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import hashlib
import importlib.resources as resources
from pathlib import Path
from string import Template

from docutils import nodes
from docutils.parsers.rst import directives
Expand All @@ -21,6 +22,15 @@
from .tabs import setup_tabs


class AmpersandTemplate(Template):
"""We need a Template with a delimiter other than dollar sign
to template the JavaScript file. This is shamelessly stolen from
test_string.py in the Python repository.
"""

delimiter = "&"


def setup_extension(app: Sphinx) -> None:
"""Set up the sphinx extension."""
app.connect("builder-inited", update_css_js)
Expand All @@ -42,6 +52,10 @@ def setup_extension(app: Sphinx) -> None:
"div", Div, override=True
) # override sphinx-panels implementation
app.add_transform(AddFirstTitleCss)
app.add_config_value(
"sphinx_design_sync_tabs_storage_key", "sphinx-design-last-tab", rebuild=True
)
app.add_config_value("sphinx_design_sync_tabs_url_param", None, rebuild=True)
setup_badges_and_buttons(app)
setup_cards(app)
setup_grids(app)
Expand All @@ -64,7 +78,11 @@ def update_css_js(app: Sphinx):
js_path = static_path / "design-tabs.js"
app.add_js_file(js_path.name)
if not js_path.exists():
content = resources.read_text(static_module, "sd_tabs.js")
stor_key = app.env.config.sphinx_design_sync_tabs_storage_key
url_key = app.env.config.sphinx_design_sync_tabs_url_param
content = AmpersandTemplate(
resources.read_text(static_module, "sd_tabs.js")
).substitute(storage_key=stor_key, param_key=url_key)
js_path.write_text(content)
# Read the css content and hash it
content = resources.read_text(static_module, "style.min.css")
Expand Down
47 changes: 47 additions & 0 deletions tests/test_opts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Test extension configuration options."""
from pathlib import Path
from typing import Callable

from .conftest import SphinxBuilder


def test_tabs_opts_default_values(sphinx_builder: Callable[..., SphinxBuilder]):
"""Test the sphinx_design_sync_tabs_xxx options default values."""
builder = sphinx_builder()
assert "sphinx_design_sync_tabs_storage_key" in builder.app.env.config
assert "sphinx_design_sync_tabs_url_param" in builder.app.env.config
assert (
builder.app.env.config.sphinx_design_sync_tabs_storage_key
== "sphinx-design-last-tab"
)
assert builder.app.env.config.sphinx_design_sync_tabs_url_param is None
content = "Heading\n-------\n\ncontent"
builder.src_path.joinpath("index.rst").write_text(content, encoding="utf8")
builder.build()
design_tabs_js = Path(builder.out_path) / "_static" / "design-tabs.js"
with design_tabs_js.open() as f:
lines = f.read()
assert "sphinx-design-last-tab" in lines


def test_tabs_storage_key_set(sphinx_builder: Callable[..., SphinxBuilder]):
"""Test the sphinx_design_sync_tabs_storage_key option."""
builder = sphinx_builder(
conf_kwargs={
"extensions": ["myst_parser", "sphinx_design"],
"myst_enable_extensions": ["colon_fence"],
"sphinx_design_sync_tabs_storage_key": "spam-42",
"sphinx_design_sync_tabs_url_param": "bag_of_ham",
}
)
assert "sphinx_design_sync_tabs_storage_key" in builder.app.env.config
assert builder.app.env.config.sphinx_design_sync_tabs_storage_key == "spam-42"
content = "Heading\n-------\n\ncontent"
builder.src_path.joinpath("index.rst").write_text(content, encoding="utf8")
builder.build()
design_tabs_js = Path(builder.out_path) / "_static" / "design-tabs.js"
with design_tabs_js.open() as f:
lines = f.read()
assert "spam-42" in lines
assert "bag_of_ham" in lines
assert "sphinx-design-last-tab" not in lines

0 comments on commit 0f4f48f

Please sign in to comment.