Skip to content

Commit

Permalink
feat: tab support (lab=experimental)
Browse files Browse the repository at this point in the history
We already used vuetify tabs for the multipage feature in the AppLayout
but users were not able to override those. Now we have a Tabs component
which makes using tabs easier than raw vuetify, and we can use it
for doing custom tabs in the AppLayout.
  • Loading branch information
maartenbreddels committed May 26, 2023
1 parent 9c258e1 commit 662f1bd
Show file tree
Hide file tree
Showing 10 changed files with 437 additions and 31 deletions.
2 changes: 1 addition & 1 deletion solara/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,10 @@ def _using_solara_server():
use_state_widget,
) # noqa: F403, F401
from reacton.core import Element # noqa: F403, F401
import reacton.ipyvuetify as v
from . import util

from .reactive import *
import reacton.ipyvuetify as v

# flake8: noqa: F402
from .datatypes import *
Expand Down
53 changes: 29 additions & 24 deletions solara/components/applayout.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,11 @@ def without(portal_elements):
@solara.component
def AppBar(children=[]):
"""Puts its children in the app bar of the AppLayout (or any layout that supports it).
This component does not need to be a direct child of the AppLayout, it can be at any level in your component tree.
If a [Tabs](/api/tabs) component is used as direct child of the app bar, it will be shown under the app bar.
## Example showing an app bar
```solara
import solara
Expand Down Expand Up @@ -221,23 +224,37 @@ def AppLayout(
show_app_bar = title or (len(routes) > 1 and navigation) or children_appbar or use_drawer
if not show_app_bar and not children_sidebar and len(children) == 1:
return children[0]

def set_path(index):
path = paths[index]
location.pathname = path

v_slots = []

tabs = None
for child_appbar in children_appbar.copy():
if child_appbar.component == solara.lab.Tabs:
if tabs is not None:
raise ValueError("Only one Tabs component is allowed in the AppBar")
tabs = child_appbar
children_appbar.remove(tabs)

if (tabs is None) and routes and navigation and (len(routes) > 1):
with solara.lab.Tabs(value=index, on_value=set_path, align="center") as tabs:
for route in routes:
name = route.path if route.path != "/" else "Home"
solara.lab.Tab(name)
# with v.Tabs(v_model=index, on_v_model=set_path, centered=True) as tabs:
# for route in routes:
# name = route.path if route.path != "/" else "Home"
# v.Tab(children=[name])
if tabs is not None:
v_slots = [{"name": "extension", "children": tabs}]
if embedded_mode and not fullscreen:
# this version doesn't need to run fullscreen
# also ideal in jupyter notebooks
with v.Html(tag="div") as main:
if show_app_bar or use_drawer:

def set_path(index):
path = paths[index]
location.pathname = path

v_slots = []
if routes and navigation and len(routes) > 1:
with v.Tabs(v_model=index, on_v_model=set_path, centered=True) as tabs:
for route in routes:
name = route.path if route.path != "/" else "Home"
v.Tab(children=[name])
v_slots = [{"name": "extension", "children": tabs}]
with v.AppBar(color="primary" if toolbar_dark else None, dark=toolbar_dark, v_slots=v_slots):
if use_drawer:
icon = AppIcon(sidebar_open, on_click=lambda: set_sidebar_open(not sidebar_open), v_on="x.on")
Expand Down Expand Up @@ -280,18 +297,6 @@ def set_path(index):
else:
AppIcon(sidebar_open, on_click=lambda: set_sidebar_open(not sidebar_open), style_="position: absolute; z-index: 2")
if show_app_bar:

def set_path(index):
path = paths[index]
location.pathname = path

v_slots = []
if routes and navigation and len(routes) > 1:
with v.Tabs(v_model=index, on_v_model=set_path, centered=True) as tabs:
for route in routes:
name = route.path if route.path != "/" else "Home"
v.Tab(children=[name])
v_slots = [{"name": "extension", "children": tabs}]
# if hide_on_scroll is True, and we have a little bit of scrolling, vuetify seems to act strangely
# when scolling (on @mariobuikhuizen/vuetify v2.2.26-rc.0
with v.AppBar(color="primary", dark=True, app=True, clipped_left=True, hide_on_scroll=False, v_slots=v_slots):
Expand Down
1 change: 0 additions & 1 deletion solara/lab/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
# isort: skip_file

from .components import * # noqa: F401, F403
from .toestand import Reactive, Ref, State # noqa: F401

Expand Down
1 change: 1 addition & 0 deletions solara/lab/components/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .tabs import Tab, Tabs # noqa: F401
268 changes: 268 additions & 0 deletions solara/lab/components/tabs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
from typing import Callable, Dict, List, Optional, TypeVar, Union

import solara
from solara import v


@solara.component
def Tab(
label: Union[None, str, solara.Element],
icon_name: Optional[str] = None,
path_or_route: Union[None, str, "solara.Route"] = None,
disabled=False,
classes: List[str] = [],
style: Union[str, Dict[str, str], None] = None,
children: List[solara.Element] = [],
tab_children: List[Union[solara.Element, str]] = [],
):
"""An item in a Tabs component.
(*Note: [This component is experimental and its API may change in the future](/docs/lab).*)
Should be a direct child of a [Tabs](/api/tabs).
## Arguments
* `label`: The label of the tab.
* `icon_name`: The name of the icon to display in the tab.
* `path_or_route`: The path or route to navigate to when the tab is clicked.
* `disabled`: Whether the tab is disabled.
* `classes`: Additional CSS classes to apply.
* `style`: CSS style to apply.
* `children`: The children of the tab. These will be displayed when the tab is active.
* `tab_children`: The children of the tab header. These will be displayed in the tab
header, if a label or icon_name is provided they are prepended to the `tab_children`.
"""
if label is not None:
tab_children = [label] + tab_children
if icon_name:
tab_children = [v.Icon(left=bool(label), children=[icon_name])] + tab_children
style_flat = solara.util._flatten_style(style)
class_ = solara.util._combine_classes(classes)
# note: children is not used, it is only used in the Tabs component
return v.Tab(children=tab_children, disabled=disabled, class_=class_, style_=style_flat)


T = TypeVar("T")


@solara.component
def Tabs(
value: Union[None, int, "solara.Reactive[int]"] = None,
on_value: Optional[Callable[[int], None]] = None,
color: Optional[str] = None,
background_color: Optional[str] = None,
slider_color: Optional[str] = None,
dark: bool = False,
grow: bool = False,
vertical=False,
align: str = "left",
lazy=False,
children: List[solara.Element] = [],
):
"""A tabbed container showing one tab at a time.
(*Note: [This component is experimental and its API may change in the future](/docs/lab).*)
Note that if Tabs are used as a child of the [AppBar](/api/appbar) component, the tabs
will be placed under the app bar. See our [authorization app](/apps/authorization) for an example.
If the children [Tab](/api/tab) elements are passed a `path_or_route` argument, the active tab
will be based on the path of the current page.
## Examples
### Only tabs headers
```solara
import solara
import solara.lab
@solara.component
def Page():
with solara.lab.Tabs():
solara.lab.Tab("Tab 1")
solara.lab.Tab("Tab 2")
```
### Tabs with content
This is usually only used when the tabs are placed in the [AppBar](/api/appbar) component.
```solara
import solara
import solara.lab
@solara.component
def Page():
with solara.lab.Tabs():
with solara.lab.Tab("Tab 1"):
solara.Markdown("Hello")
with solara.lab.Tab("Tab 2"):
solara.Markdown("World")
```
### Tabs events
The `value` on the Tabs component is a reactive value that can be used to
listen to changes in the selected tab and make the UI respond to it.
```solara
import solara
import solara.lab
tab_index = solara.reactive(0)
@solara.component
def Page():
solara.Title(f"Tab {tab_index.value + 1}")
with solara.lab.Tabs(value=tab_index):
with solara.lab.Tab("Tab 1"):
solara.Markdown("Hello")
with solara.lab.Tab("Tab 2"):
solara.Markdown("World")
with solara.lab.Tab("Disabled", disabled=True):
solara.Markdown("World")
```
### Advanced tabs
Tabs can be nested, styled and placed vertically.
```solara
import solara
import solara.lab
@solara.component
def Page():
with solara.lab.Tabs(background_color="primary", dark=True):
with solara.lab.Tab("Home", icon_name="mdi-home"):
solara.Markdown("Hello")
with solara.lab.Tab("Advanced", icon_name="mdi-apps"):
with solara.lab.Tabs(grow=True, background_color="primary", dark=True, slider_color="green"):
with solara.lab.Tab("Settings", icon_name="mdi-cogs"):
with solara.lab.Tabs(vertical=True, slider_color="green"):
with solara.lab.Tab("User", icon_name="mdi-account"):
solara.Markdown("User settings")
with solara.lab.Tab("Sytem", icon_name="mdi-access-point"):
solara.Markdown("System settings")
with solara.lab.Tab("Analytics", icon_name="mdi-chart-line"):
with solara.lab.Tabs(vertical=True):
with solara.lab.Tab("User", icon_name="mdi-account"):
solara.Markdown("User analytics")
with solara.lab.Tab("Sytem", icon_name="mdi-access-point"):
solara.Markdown("System analytics")
```
### Many tabs
If many tabs are shown, paginations arrows are shown.
```solara
import solara
import solara.lab
tab_count = 30
@solara.component
def Page():
with solara.lab.Tabs():
for i in range(tab_count):
with solara.lab.Tab(f"Tab {i+1}"):
solara.Markdown(f"Content for tab {i+1}")
```
## Arguments
* `value`: The index of the selected tab. If `None`, the first tab is selected or it is based in the route/path.
* `on_value`: A callback that is called when the selected tab changes.
* `color`: The color of text in the tab headers (only for dark=False).
* `background_color`: The background color of the tab headers.
* `slider_color`: The color of the slider.
* `dark`: Apply a dark theme.
* `grow`: Whether the tabs should grow to fill the available space.
* `vertical`: Whether the tabs are vertical.
* `align`: The alignment of the tabs, possible values are 'left', 'start', 'center', 'right' or 'end'.
* `lazy`: Whether the child components of the inactive tabs are rendered or not. If lazy=True, components of inactive tabs are not rendered.
* `classes`: Additional CSS classes to apply.
* `style`: CSS style to apply.
"""

paths_of_routes = [child.kwargs.get("path_or_route") for child in children]
paths = [solara.resolve_path(path_or_route, level=0) if path_or_route else None for path_or_route in paths_of_routes]
router = solara.use_router()
if value is None:
if router.path in paths:
value = paths.index(router.path)
else:
value = 0

def safe_on_value(index: Optional[int]):
if on_value and index is not None:
on_value(index)

reactive_value = solara.use_reactive(value, safe_on_value)
del value

has_content = False
for i, child in enumerate(children):
if not child.component == Tab:
raise ValueError(f"Tabs children must be Tab components, but child {i} is {child.component}")
if child.kwargs.get("children"):
has_content = True

def on_v_model(index: Optional[int]):
if index is not None:
path = paths[index]
if path:
router.push(path)
reactive_value.value = index

if align not in ["left", "start", "center", "right", "end"]:
raise ValueError(f"Tabs align must be one of 'left', 'start', 'center', 'right', 'end', but is {align}")

with v.Tabs(
v_model=reactive_value.value,
on_v_model=on_v_model,
centered=align == "center",
right=align in ["right", "end"],
children=children,
vertical=vertical,
color=color,
background_color=background_color,
show_arrows=True,
grow=grow,
dark=dark,
) as tabs:
v.TabsSlider(color=slider_color)
if has_content:
with v.TabsItems(v_model=reactive_value.value, on_v_model=on_v_model):
for i, child in enumerate(children):
if not lazy or reactive_value.value == i:
v.TabItem(children=child.kwargs.get("children", []), value=i)
else:
v.TabItem(
value=i,
children=[
# Nice idea, but by using the widget interface the tab does not change without binding using
# v-model. So we would need to implement this using a vuetify template.
# v.SkeletonLoader(
# class_="mx-auto",
# max_width="300",
# type="card",
# )
],
)

return tabs
Loading

0 comments on commit 662f1bd

Please sign in to comment.