Skip to content
Merged
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
12 changes: 12 additions & 0 deletions docs/snippets/myst/tab-vertical.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
::::{tab-set}
:orientation: vertical

:::{tab-item} Label1
Content 1
:::

:::{tab-item} Label2
Content 2
:::

::::
10 changes: 10 additions & 0 deletions docs/snippets/rst/tab-vertical.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
.. tab-set::
:orientation: vertical

.. tab-item:: Label1

Content 1

.. tab-item:: Label2

Content 2
49 changes: 49 additions & 0 deletions docs/tabs.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,49 @@ Content 2

See the [Material Design](https://material.io/components/tabs) description for further details.

(tabs-vertical)=

## Vertical Tabs

::::{versionadded} 0.8.0
Add `:orientation: vertical` to a `tab-set` or `tab-set-code` to stack the labels down a rail on the left (on the right on right-to-left pages), with the selected content beside them.
This suits sets holding more labels than fit on one line, where the horizontal layout wraps them into a strip that is hard to scan.
::::

::::{tab-set}
:orientation: vertical

:::{tab-item} Label1
Content 1
:::

:::{tab-item} Label2
Content 2
:::

:::{tab-item} A longer label
Content 3
:::

::::

`````{dropdown-syntax}

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

The rail is as wide as its longest label, up to 35% of the set; a longer label wraps at its spaces.
Below the `md` breakpoint (a viewport 768px wide) the set falls back to the horizontal layout, two columns being too narrow to read at that width.
The breakpoint follows the viewport, not the set's container, so a vertical set in a narrow column (a grid cell, say) stays vertical on a wide screen.
Themes that restyle the tab labels themselves, such as pydata-sphinx-theme and sphinx-book-theme, may draw them less neatly in the rail until they style `sd-tab-set-vertical` too.

## Synchronised Tabs

The Selection of tab items can be synchronised across multiple tab-sets.
Expand Down Expand Up @@ -255,6 +298,9 @@ Content 2

## `tab-set` options

orientation
: `horizontal` (default) or `vertical`, see {ref}`tabs-vertical`.

sync-group
: A group name for synchronised tab sets (default `tab`).

Expand All @@ -266,6 +312,9 @@ class
no-sync
: Disable synchronisation of tabs.

orientation
: `horizontal` (default) or `vertical`, see {ref}`tabs-vertical`.

sync-group
: A group name for synchronised tab sets (default `code`).

Expand Down
2 changes: 1 addition & 1 deletion sphinx_design/static/sphinx-design.min.css

Large diffs are not rendered by default.

31 changes: 29 additions & 2 deletions sphinx_design/tabs.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,25 @@
create_component,
is_component,
is_ignorable_child,
make_choice,
)

LOGGER = getLogger(__name__)

ORIENTATIONS = ("horizontal", "vertical")
"""Layouts a tab set can be rendered in, the first one being the default."""


def orientation_classes(orientation: str | None) -> list[str]:
"""Return the modifier classes carrying a tab set's orientation.

:param orientation: One of :data:`ORIENTATIONS`, or ``None`` for the default.
:return: The classes to append to the tab set's own.
"""
if orientation == "vertical":
return ["sd-tab-set-vertical"]
return []


def setup_tabs(app: Sphinx) -> None:
app.add_directive("tab-set", TabSetDirective)
Expand All @@ -31,14 +46,20 @@ class TabSetDirective(SdDirective):

has_content = True
option_spec = {
"orientation": make_choice(ORIENTATIONS),
"sync-group": directives.unchanged_required,
"class": directives.class_option,
}

def run_with_defaults(self) -> list[nodes.Node]:
self.assert_has_content()
tab_set = create_component(
"tab-set", classes=["sd-tab-set", *self.options.get("class", [])]
"tab-set",
classes=[
"sd-tab-set",
*orientation_classes(self.options.get("orientation")),
*self.options.get("class", []),
],
)
self.set_source_info(tab_set)
self.state.nested_parse(self.content, self.content_offset, tab_set)
Expand Down Expand Up @@ -143,6 +164,7 @@ class TabSetCodeDirective(SdDirective):
has_content = True
option_spec = {
"no-sync": directives.flag,
"orientation": make_choice(ORIENTATIONS),
"sync-group": directives.unchanged_required,
"class-set": directives.class_option,
"class-item": directives.class_option,
Expand All @@ -151,7 +173,12 @@ class TabSetCodeDirective(SdDirective):
def run_with_defaults(self) -> list[nodes.Node]:
self.assert_has_content()
tab_set = create_component(
"tab-set", classes=["sd-tab-set", *self.options.get("class-set", [])]
"tab-set",
classes=[
"sd-tab-set",
*orientation_classes(self.options.get("orientation")),
*self.options.get("class-set", []),
],
)
self.set_source_info(tab_set)
self.state.nested_parse(self.content, self.content_offset, tab_set)
Expand Down
61 changes: 61 additions & 0 deletions style/tabs.css
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,64 @@ html .sd-tab-set > label:hover {
.sd-tab-content > .sd-tab-set {
margin: 0;
}

/* Vertical orientation, opted into with the tab-set's `:orientation: vertical`.

The labels, their radio inputs and the panes are flat siblings, so the visual
order cannot come from the source order. Grid placement supplies it instead:
every label is assigned the first column, and the one pane that is not
`display: none` takes the second across every row. No separate HTML structure
is needed for this mode.

Everything below is the horizontal layout turned a quarter turn: the label's
underline becomes a border on its inline-end edge, and the pane's overline
becomes a single rule down its inline-start edge, which the active label's
border merges into. Logical properties keep that true on right-to-left pages,
where the rail sits on the right.

Mobile first: below the `md` breakpoint (768px, `style/design.toml`) none of
this applies, so a narrow screen gets the plain horizontal layout and nothing
has to be restated or undone. */
@media (min-width: 768px) {
.sd-tab-set-vertical {
display: grid;
/* The rail is as wide as its longest label, up to 35% of the set: a longer
label wraps rather than squeezing the pane. */
grid-template-columns: fit-content(35%) minmax(0, 1fr);
align-items: start;
}

/* The rail and the pane share an edge, so the active label's border lands on
the pane's rule instead of being held away from it by a column gap. Padding
opens the space between the two columns. */
.sd-tab-set-vertical > label {
grid-column: 1;
border-bottom: none;
border-inline-end: .125rem solid var(--sd-color-tabs-underline-inactive);
padding-inline-end: 1rem;
text-align: start;
}

.sd-tab-set-vertical > .sd-tab-content {
grid-column: 2;
/* Span every row rather than the first one alone, whose height the pane
would otherwise set, pushing the labels below it off the rail. `1 / -1`
cannot say this here: `-1` counts back from the last explicit row, and
this grid declares columns only. The span overshoots any real tab count
instead. When the pane is taller than the label stack, the extra height
goes to the empty rows after the last label, so the labels stay packed at
the top. (Labels after the 99th would sit below the pane.) */
grid-row: 1 / span 99;
/* Stretch across those rows, or the rule runs only as deep as the pane's own
content instead of the full rail. */
align-self: stretch;
/* A border rather than the horizontal box-shadow, which has no logical
(direction-aware) form; the negative margin tucks it under the label's
border, which is drawn above it. */
box-shadow: none;
border-inline-start: .0625rem solid var(--sd-color-tabs-overline);
margin-inline-start: -.0625rem;
padding: 0;
padding-inline-start: 1.5rem;
}
}
17 changes: 17 additions & 0 deletions tests/test_snippets/snippet_post_tab-vertical.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<document source="index">
<section ids="heading" names="heading">
<title>
Heading
<container classes="sd-tab-set sd-tab-set-vertical" design_component="tab-set" is_div="True">
<sd_tab_input checked="True" id="sd-tab-item-0" set_id="sd-tab-set-0" type="radio">
<sd_tab_label aria_controls="sd-tab-item-0-content" classes="sd-tab-label" input_id="sd-tab-item-0">
Label1
<container classes="sd-tab-content" design_component="tab-content" ids="sd-tab-item-0-content" is_div="True">
<paragraph>
Content 1
<sd_tab_input checked="False" id="sd-tab-item-1" set_id="sd-tab-set-0" type="radio">
<sd_tab_label aria_controls="sd-tab-item-1-content" classes="sd-tab-label" input_id="sd-tab-item-1">
Label2
<container classes="sd-tab-content" design_component="tab-content" ids="sd-tab-item-1-content" is_div="True">
<paragraph>
Content 2
17 changes: 17 additions & 0 deletions tests/test_snippets/snippet_pre_tab-vertical.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<document source="index">
<section ids="heading" names="heading">
<title>
Heading
<container classes="sd-tab-set sd-tab-set-vertical" design_component="tab-set" is_div="True">
<container classes="sd-tab-item" design_component="tab-item" is_div="True" selected="False">
<rubric classes="sd-tab-label">
Label1
<container classes="sd-tab-content" design_component="tab-content" is_div="True">
<paragraph>
Content 1
<container classes="sd-tab-item" design_component="tab-item" is_div="True" selected="False">
<rubric classes="sd-tab-label">
Label2
<container classes="sd-tab-content" design_component="tab-content" is_div="True">
<paragraph>
Content 2
Loading
Loading