Skip to content

Commit 8977bc5

Browse files
authored
TabsPlugin: fix overflow and support vertical tabs (#9511)
## 📝 Summary <!-- If this PR closes any issues, list them here by number (e.g., Closes #123). Detail the specific changes made in this pull request. Explain the problem addressed and how it was resolved. If applicable, provide before and after comparisons, screenshots, or any relevant details to help reviewers understand the changes easily. --> Closes #9482 and support vertical tabs <img width="926" height="689" alt="image" src="https://github.com/user-attachments/assets/610949d1-a6e5-4a6a-8dc3-35c82837e705" /> <img width="573" height="196" alt="image" src="https://github.com/user-attachments/assets/9086dc72-8697-4a25-b085-1b3a394f01e4" /> ## 📋 Pre-Review Checklist <!-- These checks need to be completed before a PR is reviewed --> - [x] For large changes, or changes that affect the public API: this change was discussed or approved through an issue, on [Discord](https://marimo.io/discord?ref=pr), or the community [discussions](https://github.com/marimo-team/marimo/discussions) (Please provide a link if applicable). - [x] Any AI generated code has been reviewed line-by-line by the human PR author, who stands by it. - [x] Video or media evidence is provided for any visual changes (optional). <!-- PR is more likely to be merged if evidence is provided for changes made --> ## ✅ Merge Checklist - [x] I have read the [contributor guidelines](https://github.com/marimo-team/marimo/blob/main/CONTRIBUTING.md). - [x] Documentation has been updated where applicable, including docstrings for API changes. - [x] Tests have been added for the changes made.
1 parent d718cac commit 8977bc5

5 files changed

Lines changed: 482 additions & 9 deletions

File tree

frontend/src/plugins/impl/TabsPlugin.tsx

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
TabsList,
88
TabsTrigger,
99
} from "../../components/ui/tabs";
10+
import { cn } from "../../utils/cn";
1011
import { renderHTML } from "../core/RenderHTML";
1112
import type { IPlugin, IPluginProps } from "../types";
1213
import { Labeled } from "./common/labeled";
@@ -17,6 +18,7 @@ interface Data {
1718
*/
1819
tabs: string[];
1920
label: string | null;
21+
orientation: "horizontal" | "vertical";
2022
}
2123

2224
// Selected tab index
@@ -28,6 +30,7 @@ export class TabsPlugin implements IPlugin<T, Data> {
2830
validator = z.object({
2931
tabs: z.array(z.string()),
3032
label: z.string().nullable(),
33+
orientation: z.enum(["horizontal", "vertical"]).default("horizontal"),
3134
});
3235

3336
render(props: IPluginProps<T, Data>): JSX.Element {
@@ -51,6 +54,7 @@ interface TabComponentProps extends Data {
5154
const TabComponent = ({
5255
tabs,
5356
label,
57+
orientation,
5458
value,
5559
setValue,
5660
children,
@@ -70,19 +74,43 @@ const TabComponent = ({
7074
setInternalValue(value);
7175
}
7276

77+
const isVertical = orientation === "vertical";
78+
const childArray =
79+
children == null ? [] : Array.isArray(children) ? children : [children];
80+
7381
return (
74-
<Labeled label={label} align="top">
75-
<Tabs value={internalValue} onValueChange={handleChange}>
76-
<TabsList>
82+
<Labeled label={label} align="top" fullWidth={true}>
83+
<Tabs
84+
value={internalValue}
85+
onValueChange={handleChange}
86+
orientation={orientation}
87+
className={cn(isVertical && "flex flex-row gap-3")}
88+
>
89+
<TabsList
90+
className={cn(
91+
"scrollbar-thin",
92+
isVertical
93+
? "flex flex-col items-stretch justify-start h-auto max-h-none shrink-0 min-w-[10rem] overflow-y-auto"
94+
: "max-w-full overflow-x-auto justify-start",
95+
)}
96+
>
7797
{tabs.map((tab, index) => (
78-
<TabsTrigger key={index} value={index.toString()}>
98+
<TabsTrigger
99+
key={index}
100+
value={index.toString()}
101+
className={cn(isVertical && "w-full justify-start")}
102+
>
79103
{renderHTML({ html: tab })}
80104
</TabsTrigger>
81105
))}
82106
</TabsList>
83-
{React.Children.map(children, (child, index) => {
84-
return <TabsContent value={index.toString()}>{child}</TabsContent>;
85-
})}
107+
<div className={cn(isVertical && "flex-1 min-w-0")}>
108+
{childArray.map((child, index) => (
109+
<TabsContent key={index} value={index.toString()}>
110+
{child}
111+
</TabsContent>
112+
))}
113+
</div>
86114
</Tabs>
87115
</Labeled>
88116
);
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
/* Copyright 2026 Marimo. All rights reserved. */
2+
3+
import { fireEvent, render, screen } from "@testing-library/react";
4+
import { beforeAll, describe, expect, it, vi } from "vitest";
5+
import type { z } from "zod";
6+
import { initialModeAtom } from "@/core/mode";
7+
import { store } from "@/core/state/jotai";
8+
import type { IPluginProps } from "../../types";
9+
import { TabsPlugin } from "../TabsPlugin";
10+
11+
describe("TabsPlugin", () => {
12+
beforeAll(() => {
13+
store.set(initialModeAtom, "edit");
14+
});
15+
16+
const renderPlugin = (
17+
data: z.input<TabsPlugin["validator"]>,
18+
initialValue = "0",
19+
) => {
20+
const plugin = new TabsPlugin();
21+
const host = document.createElement("div");
22+
const setValue = vi.fn();
23+
const children = [
24+
<span key="0">Content 0</span>,
25+
<span key="1">Content 1</span>,
26+
<span key="2">Content 2</span>,
27+
];
28+
const makeProps = (
29+
value: string,
30+
): IPluginProps<string, z.infer<TabsPlugin["validator"]>> => ({
31+
data: plugin.validator.parse(data),
32+
value,
33+
setValue,
34+
host,
35+
functions: {},
36+
children,
37+
});
38+
const result = render(plugin.render(makeProps(initialValue)));
39+
return {
40+
...result,
41+
setValue,
42+
rerenderWithValue: (newValue: string) =>
43+
result.rerender(plugin.render(makeProps(newValue))),
44+
};
45+
};
46+
47+
it("renders all tab triggers", () => {
48+
renderPlugin({
49+
tabs: ["First", "Second", "Third"],
50+
label: null,
51+
});
52+
expect(screen.getByRole("tab", { name: "First" })).toBeInTheDocument();
53+
expect(screen.getByRole("tab", { name: "Second" })).toBeInTheDocument();
54+
expect(screen.getByRole("tab", { name: "Third" })).toBeInTheDocument();
55+
});
56+
57+
it("supports vertical orientation", () => {
58+
renderPlugin({
59+
tabs: ["First", "Second"],
60+
label: null,
61+
orientation: "vertical",
62+
});
63+
const tablist = screen.getByRole("tablist");
64+
expect(tablist).toHaveAttribute("data-orientation", "vertical");
65+
expect(tablist.className).toMatch(/flex-col/);
66+
// Horizontal scroll classes should not be applied in vertical mode.
67+
expect(tablist.className).not.toMatch(/overflow-x-auto/);
68+
});
69+
70+
it("falls back to horizontal when orientation is omitted (back-compat)", () => {
71+
// Older Python kernels won't send `orientation` — make sure the validator
72+
// defaults it so the frontend keeps working.
73+
const plugin = new TabsPlugin();
74+
const parsed = plugin.validator.parse({
75+
tabs: ["First"],
76+
label: null,
77+
});
78+
expect(parsed.orientation).toBe("horizontal");
79+
});
80+
81+
it("selects the tab matching the initial value", () => {
82+
renderPlugin({ tabs: ["First", "Second", "Third"], label: null }, "1");
83+
const tabs = screen.getAllByRole("tab");
84+
expect(tabs[0]).toHaveAttribute("data-state", "inactive");
85+
expect(tabs[1]).toHaveAttribute("data-state", "active");
86+
expect(tabs[2]).toHaveAttribute("data-state", "inactive");
87+
});
88+
89+
it("defaults to the first tab when value is empty", () => {
90+
renderPlugin({ tabs: ["First", "Second"], label: null }, "");
91+
const tabs = screen.getAllByRole("tab");
92+
expect(tabs[0]).toHaveAttribute("data-state", "active");
93+
expect(tabs[1]).toHaveAttribute("data-state", "inactive");
94+
});
95+
96+
it("calls setValue with the clicked tab's index", () => {
97+
const { setValue } = renderPlugin({
98+
tabs: ["First", "Second", "Third"],
99+
label: null,
100+
});
101+
// Radix Tabs' trigger reacts to mousedown (left button), not click —
102+
// see https://github.com/radix-ui/primitives/blob/main/packages/react/tabs/src/Tabs.tsx
103+
fireEvent.mouseDown(screen.getByRole("tab", { name: "Third" }), {
104+
button: 0,
105+
});
106+
expect(setValue).toHaveBeenCalledWith("2");
107+
});
108+
109+
it("syncs selection when value is updated externally", () => {
110+
const { rerenderWithValue } = renderPlugin(
111+
{ tabs: ["First", "Second", "Third"], label: null },
112+
"0",
113+
);
114+
expect(screen.getAllByRole("tab")[0]).toHaveAttribute(
115+
"data-state",
116+
"active",
117+
);
118+
119+
rerenderWithValue("2");
120+
const tabs = screen.getAllByRole("tab");
121+
expect(tabs[0]).toHaveAttribute("data-state", "inactive");
122+
expect(tabs[2]).toHaveAttribute("data-state", "active");
123+
});
124+
125+
it("renders HTML in tab labels via renderHTML", () => {
126+
renderPlugin({
127+
tabs: ["<strong>Bold</strong>", "Plain"],
128+
label: null,
129+
});
130+
const boldTab = screen.getByRole("tab", { name: "Bold" });
131+
// The label markup is preserved (not escaped as text), so the trigger
132+
// contains a real <strong> element.
133+
expect(boldTab.querySelector("strong")).not.toBeNull();
134+
});
135+
136+
it("renders no tabpanels when tabs and children are empty", () => {
137+
// When the Python side passes `tabs={}`, slotted HTML is empty and the
138+
// resulting React children are null/undefined. We should render zero
139+
// `TabsContent`s — not a stray one paired to a non-existent trigger.
140+
const plugin = new TabsPlugin();
141+
const host = document.createElement("div");
142+
const props: IPluginProps<string, z.infer<TabsPlugin["validator"]>> = {
143+
data: plugin.validator.parse({ tabs: [], label: null }),
144+
value: "",
145+
setValue: vi.fn(),
146+
host,
147+
functions: {},
148+
children: null,
149+
};
150+
render(plugin.render(props));
151+
expect(screen.queryAllByRole("tab")).toHaveLength(0);
152+
expect(screen.queryAllByRole("tabpanel")).toHaveLength(0);
153+
});
154+
});

marimo/_plugins/ui/_impl/tabs.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Copyright 2026 Marimo. All rights reserved.
22
from __future__ import annotations
33

4-
from typing import TYPE_CHECKING, Final
4+
from typing import TYPE_CHECKING, Final, Literal
55

66
from marimo._output.formatting import as_html
77
from marimo._output.md import md
@@ -41,6 +41,14 @@ class tabs(UIElement[str, str]):
4141
)
4242
```
4343
44+
Stack tabs vertically (useful when there are many tabs or long labels):
45+
```python
46+
tabs = mo.ui.tabs(
47+
{f"Section {i}": f"Content {i}" for i in range(20)},
48+
orientation="vertical",
49+
)
50+
```
51+
4452
Attributes:
4553
value (str): The name of the selected tab.
4654
@@ -51,6 +59,8 @@ class tabs(UIElement[str, str]):
5159
lazy (bool, optional): Whether to lazily load the tab content.
5260
This is a convenience that wraps each tab in a `mo.lazy`
5361
component. Defaults to False.
62+
orientation ("horizontal" | "vertical", optional): The orientation of
63+
the tab bar. Use "vertical" to stack the tabs in a side panel. Defaults to "horizontal".
5464
label (str, optional): A descriptive name for the tab. Defaults to "".
5565
on_change (Callable[[dict[str, object]], None], optional): Optional callback
5666
to run when this element's value changes.
@@ -64,6 +74,7 @@ def __init__(
6474
value: str | None = None,
6575
lazy: bool = False,
6676
*,
77+
orientation: Literal["horizontal", "vertical"] = "horizontal",
6778
label: str = "",
6879
on_change: Callable[[str], None] | None = None,
6980
) -> None:
@@ -94,7 +105,7 @@ def render_content(tab: object) -> str:
94105
component_name=self._name,
95106
initial_value=index or "",
96107
label=label,
97-
args={"tabs": tab_labels},
108+
args={"tabs": tab_labels, "orientation": orientation},
98109
on_change=on_change,
99110
slotted_html=tab_items,
100111
)

0 commit comments

Comments
 (0)