diff --git a/solara/autorouting.py b/solara/autorouting.py index 542224b64..9bb003e83 100644 --- a/solara/autorouting.py +++ b/solara/autorouting.py @@ -205,11 +205,12 @@ def get_args(f): content = solara.Markdown(path.read_text(), unsafe_solara_execute=True) main = solara.Div( + classes=["solara-autorouter-content"], children=[ solara.Title(route_current.label or "No title"), content, navigation, - ] + ], ) main = wrap_in_layouts(main, layouts) else: @@ -218,17 +219,25 @@ def get_args(f): title = route_current.label or "No title" title_element = solara.Title(title) module = None - if route_current.module is not None: + Page = route_current.component + # translate the default RenderPage as no value given (None) + if Page is RenderPage: + Page = None + if route_current.module is not None and (Page is None): + # if not a custom component is given, we try to find a Page component + # in the module assert route_current.module is not None module = route_current.module namespace = module.__dict__ - Page = nested_get(namespace, main_name, None) + Page = nested_get(namespace, main_name, Page) if Page is None: # app is for backwards compatibility - Page = namespace.get("page", namespace.get("app")) + Page = namespace.get("page", namespace.get("app", Page)) Page = nested_get(namespace, main_name, Page) - else: - Page = route_current.component + + if Page is None and route_current.children: + # we we did not get a component, but we recursively render + Page = RenderPage if isinstance(Page, ipywidgets.Widget): # If we have a widget, we need to execute this again for each # connection, since we cannot share widgets between connections/users. @@ -253,10 +262,11 @@ def get_args(f): Page = getattr(modules[route_current.path], "app", None) Page = getattr(modules[route_current.path], "page", Page) main = solara.Div( + classes=["solara-autorouter-content"], children=[ title_element, Page, - ] + ], ) main = wrap_in_layouts(main, layouts) elif Page is not None: @@ -268,10 +278,11 @@ def get_args(f): else: page = solara.Error(f"{Page} is not a component or element, but {type(Page)}") main = solara.Div( + classes=["solara-autorouter-content"], children=[ title_element, page, - ] + ], ) main = wrap_in_layouts(main, layouts) else: @@ -393,7 +404,7 @@ def generate_routes(module: ModuleType) -> List[solara.Route]: continue else: # skip empty modules - if get_renderable(submod) is None: + if get_renderable(submod) is None and not hasattr(submod, "routes"): continue children = getattr(submod, "routes", []) if subfile: diff --git a/solara/components/applayout.py b/solara/components/applayout.py index eb32f06c8..034d6a046 100644 --- a/solara/components/applayout.py +++ b/solara/components/applayout.py @@ -306,7 +306,12 @@ def set_path(index): with v.Row(no_gutters=False, class_="solara-content-main"): v.Col(cols=12, children=children_content) else: - with v.Html(tag="div", style_="min-height: 100vh") as main: + # this limits the height of the app to the height of the screen + # and further down we use overflow: auto to add scrollbars to the main content + # the navigation drawer adds it own scrollbars + # NOTE: while developing this we added overflow: hidden, but this does not seem + # to be necessary anymore + with v.Html(tag="div", style_="height: 100vh") as main: with solara.HBox(): if use_drawer: with v.NavigationDrawer( @@ -340,8 +345,13 @@ def set_path(index): if fullscreen: solara.Button(icon_name="mdi-fullscreen-exit", on_click=lambda: set_fullscreen(False), icon=True, dark=False) - with v.Content(class_="solara-content-main"): - v.Col(cols=12, children=children_content) + with v.Content(class_="solara-content-main", style_="height: 100%;"): + # make sure the scrollbar does no go under the appbar by adding overflow: auto + # to a child of content, because content has padding-top: 64px (set by vuetify) + # the padding: 12px is needed for backward compatibility with the previously used + # v.Col which has this by default. If we do not use this, a solara.Column will + # use a margin: -12px which will make a horizontal scrollbar appear + solara.Div(style_="height: 100%; overflow: auto; padding: 12px;", children=children_content) if fullscreen: with v.Dialog(v_model=True, children=[], fullscreen=True, hide_overlay=True, persistent=True, no_click_animation=True) as dialog: v.Sheet(class_="overflow-y-auto overflow-x-auto", children=[main]) diff --git a/solara/components/columns.py b/solara/components/columns.py index eff4e177e..c5164e4cc 100644 --- a/solara/components/columns.py +++ b/solara/components/columns.py @@ -74,7 +74,10 @@ def Page(): style_flat = solara.util._flatten_style(style) with rv.Row(class_=class_, no_gutters=not gutters, dense=gutters_dense, style_=style_flat) as main: for child, width in zip(children, cycle(widths)): - with rv.Col(children=[child], style_=f"flex-grow: {width}; overflow: auto" if width != 0 else "flex-grow: 0;"): + # we add height: 100% because this will trigger a chain of height set if it is set on the parent + # via the style. If we do not set the height, it will have no effect. Furthermore, we only have + # a single child, so this cannot interfere with other siblings. + with rv.Col(children=[child], style_=f"height: 100%; flex-grow: {width}; overflow: auto" if width != 0 else "flex-grow: 0"): pass return main diff --git a/solara/server/assets/style.css b/solara/server/assets/style.css index 10ec9ed43..9045feacd 100644 --- a/solara/server/assets/style.css +++ b/solara/server/assets/style.css @@ -109,3 +109,7 @@ div.highlight { .solara-content-main { animation: solara-fade-in 0.5s ease; } + +.solara-autorouter-content { + height: 100%; +} diff --git a/solara/website/pages/apps/scrolling.py b/solara/website/pages/apps/scrolling.py new file mode 100644 index 000000000..a38cf95ee --- /dev/null +++ b/solara/website/pages/apps/scrolling.py @@ -0,0 +1,63 @@ +import solara + +github_url = solara.util.github_url(__file__) + +# list of nice soft colors +colors = [ + "#e6194b", + "#3cb44b", + "#ffe119", + "#4363d8", + "#f58231", + "#911eb4", +] + + +@solara.component +def Page1(): + with solara.Sidebar(): + solara.Button(label="View source", icon_name="mdi-github-circle", attributes={"href": github_url, "target": "_blank"}, text=True, outlined=True) + solara.Markdown("The sidebar will get scrollbars automatically, independently of the main content.") + for i in range(10): + with solara.Card(f"Card {i}"): + solara.Info(f"Text {i}", color=colors[i % len(colors)]) + + solara.Markdown("The main content will get scrollbars automatically, independently of the sidebar.") + for i in range(10): + with solara.Card(f"Card {i}"): + solara.Info(f"Text {i}", color=colors[i % len(colors)]) + + +@solara.component +def Page2(): + # it is important we do not interrupt the height 100% chain + limit_content_height = solara.use_reactive(True) + # warning: if we add "margin": "10px" to the style, we will trigger + # scrollbars in the parent div, if you really need to add margins, + # you should correct for that in the height using "calc(100% - 20px)" + with solara.Column(style={"height": "100%"}): + with solara.Sidebar(): + solara.Button(label="View source", icon_name="mdi-github-circle", attributes={"href": github_url, "target": "_blank"}, text=True, outlined=True) + solara.Markdown("Main content columns will scroll together, or independently if we limit height to 100%") + solara.Checkbox(label="Limit content height", value=limit_content_height) + + # if we do not limit height to 100%, solara's AppLayout will add a scroll bar + # to the main content, which will be independent of the sidebar's scroll bar + # but both columns will scroll together + with solara.Columns([2, 4], style={"height": "100%"} if limit_content_height.value else {}): + with solara.Card("I have my own scroll bar"): + solara.Markdown("") + for i in range(10): + with solara.Card(f"Card {i}"): + solara.Info(f"Text {i}", color=colors[i % len(colors)]) + with solara.Card("I also have my own scroll bar"): + solara.Markdown("") + for i in range(20): + with solara.Card(f"Card {i}"): + solara.Info(f"Text {i}", color=colors[i % len(colors)]) + + +routes = [ + solara.Route(path="/", component=Page1, label="Scrolling"), + solara.Route(path="custom", component=Page2, label="Custom scrolling"), +] diff --git a/solara/website/pages/examples/fullscreen/scrolling.py b/solara/website/pages/examples/fullscreen/scrolling.py new file mode 100644 index 000000000..2df8722b5 --- /dev/null +++ b/solara/website/pages/examples/fullscreen/scrolling.py @@ -0,0 +1,3 @@ +redirect = "/apps/scrolling" + +Page = True diff --git a/solara/website/public/examples/fullscreen/scrolling.png b/solara/website/public/examples/fullscreen/scrolling.png new file mode 100644 index 000000000..84718728f Binary files /dev/null and b/solara/website/public/examples/fullscreen/scrolling.png differ diff --git a/tests/unit/autorouting_test.py b/tests/unit/autorouting_test.py index 17f12aafe..bf551d6ee 100644 --- a/tests/unit/autorouting_test.py +++ b/tests/unit/autorouting_test.py @@ -134,7 +134,7 @@ def test_routes_examples_docs(): def test_routes_directory(): routes = solara.autorouting.generate_routes_directory(HERE.parent / "solara_test_apps" / "multipage") - assert len(routes) == 7 + assert len(routes) == 8 assert routes[0].path == "/" assert routes[0].label == "Home" @@ -152,11 +152,18 @@ def test_routes_directory(): assert routes[4].path == "and-notebooks" assert routes[4].label == "And Notebooks" - assert routes[5].path == "single-file-directory" - assert routes[5].label == "Single File Directory" + assert routes[5].path == "custom-routes" + assert routes[5].label == "Custom Routes" + assert routes[5].children[0].path == "/" + assert routes[5].children[0].label == "Hi1" + assert routes[5].children[1].path == "page2" + assert routes[5].children[1].label == "Hi2" - assert routes[6].path == "some-other-python-script" - assert routes[6].label == "Some Other Python Script" + assert routes[6].path == "single-file-directory" + assert routes[6].label == "Single File Directory" + + assert routes[7].path == "some-other-python-script" + assert routes[7].label == "Some Other Python Script" main_object = solara.autorouting.RenderPage() solara_context = solara.RoutingProvider(children=[main_object], routes=routes, pathname="/") @@ -201,6 +208,16 @@ def test_routes_directory(): nav.location = "/a-directory/wrong-path" assert "Page not found" in rc._find(v.Alert).widget.children[0] + # custom routes in a single file + + nav.location = "/custom-routes" + button = rc._find(v.Btn, children=["hi1"]).widget + assert button.children[0] == "hi1" + + nav.location = "/custom-routes/page2" + button = rc._find(v.Btn, children=["hi2"]).widget + assert button.children[0] == "hi2" + def test_routes_regular_widgets(): # routes = solara.autorouting.generate_routes_directory(HERE.parent / "solara_test_apps" / "multipage") diff --git a/tests/unit/solara_test_apps/multipage/06-custom-routes.py b/tests/unit/solara_test_apps/multipage/06-custom-routes.py new file mode 100644 index 000000000..c3214c156 --- /dev/null +++ b/tests/unit/solara_test_apps/multipage/06-custom-routes.py @@ -0,0 +1,17 @@ +import solara + + +@solara.component +def Page1(): + solara.Button("hi1") + + +@solara.component +def Page2(): + solara.Button("hi2") + + +routes = [ + solara.Route(path="/", component=Page1, label="Hi1"), + solara.Route(path="page2", component=Page2, label="Hi2"), +]