`
);
-/** Shows the panel of the selected tab, out of the panels next to the tabs. */
+/** Selects the tab in every bar of the `data-tab-group` and shows its panel. */
const showPanel = (event: CustomEvent<{ value: number }>) => {
- const panels = (event.target as Element).parentElement?.querySelectorAll('[role="tabpanel"]');
- panels?.forEach((panel, index) => (panel.hidden = index !== event.detail.value));
+ const group = (event.target as Element).closest("[data-tab-group]");
+ group?.querySelectorAll("or-vaadin-tabs").forEach((tabs) => (tabs.selected = event.detail.value));
+ group
+ ?.querySelectorAll('[role="tabpanel"]')
+ .forEach((panel, index) => (panel.hidden = index !== event.detail.value));
};
+/** Tabs for the pages, without content. */
+const plainTabs = () => pages.map(({ name }) => html`${name}`);
+
+/** An example with a caption naming what it sets. */
+const captioned = (caption: string, example: unknown) => html`
+
+
+ ${caption}
+
+ ${example}
+
+`;
+
+/** More tabs than a narrow bar fits, so that it overflows. */
+const overflowTabs = () =>
+ ["First", "Second", "Third", "Fourth", "Fifth", "Sixth"].map((name) => html`${name} tab`);
+
+/** Tabs with the given variant, captioned with it. */
+const themed = (variant: string, tabs = plainTabs, style = "") =>
+ captioned(
+ `theme="${variant}"`,
+ html`${tabs()}`
+ );
+
+/** Tabs without a variant above tabs with the given one, so that the difference shows. */
+const compared = (variant: string) => html`
+
+`;
+
const meta: Meta = {
title: "Playground/or-vaadin-components/tabs",
component: tagName,
args,
argTypes,
- render: (storyArgs) =>
- template(storyArgs, html`${pages.map(({ name }) => html`${name}`)}`),
+ render: (storyArgs) => template(storyArgs, html`${plainTabs()}`),
excludeStories: /^[a-z].*/,
parameters: {
actions: {
@@ -79,61 +116,201 @@ const meta: Meta = {
docs: {
subtitle: `<${tagName}>`,
description:
- "Tabs is a more mobile-friendly alternative to `` for switching between views or pages. The `` elements share a pill-shaped bar equally, and an indicator slides behind the selected one. Selection works as in ``, through the `selected` index and the `selected-changed` event, and the colors, sizes and spacing are set through `--or-tabs-*` and `--or-tab-*` properties.",
+ 'Tabs switch between views or pages. They have the Lumo look and variants of ``. Set `theme="sliding"` for a more mobile-friendly bar with a contrasting background, where the `` elements share the width equally and an indicator slides behind the selected one. Selection works as in ``, through the `selected` index and the `selected-changed` event, and the colors, sizes, spacing, rounding and timing of the sliding variant are set through `--or-tabs-*` and `--or-tab-*` properties.',
},
},
};
export const Primary: Story = {
args: {
- selected: 1,
+ selected: 0,
},
};
-/** Tabs that switch the content below them. */
-export const Content: Story = {
- name: "Content",
+/** Tabs stacked vertically beside their content. */
+export const Vertical: Story = {
+ name: "Vertical",
parameters: {
- title: "Content",
- summary: "Listen to `selected-changed` to show the content of the selected tab.",
+ title: "Vertical",
+ summary:
+ 'Set `orientation="vertical"` to stack the tabs, so the content can sit beside them. Listen to `selected-changed` to show the content of the selected tab.',
docs: {
story: {
- height: "160px",
+ height: "170px",
},
},
},
render: () => html`
-
`,
};
-/** Tabs stacked vertically beside their content. */
-export const Vertical: Story = {
- name: "Vertical",
+/** Tabs that share the width of the bar equally. */
+export const EqualWidth: Story = {
+ name: "Equal width",
parameters: {
- title: "Vertical",
+ title: "Equal width",
+ summary:
+ 'The Lumo `theme="equal-width-tabs"` variant makes the tabs share the width of the bar equally. Sliding tabs always do this.',
+ docs: {
+ story: {
+ height: "210px",
+ },
+ },
+ },
+ render: () => compared("equal-width-tabs"),
+};
+
+/** Tabs centered in the bar. */
+export const Centered: Story = {
+ name: "Centered",
+ parameters: {
+ title: "Centered",
+ summary: 'The Lumo `theme="centered"` variant centers the tabs in the bar.',
+ docs: {
+ story: {
+ height: "210px",
+ },
+ },
+ },
+ render: () => compared("centered"),
+};
+
+/** Smaller tabs. */
+export const Small: Story = {
+ name: "Small",
+ parameters: {
+ title: "Small",
+ summary: 'The Lumo `theme="small"` variant makes the tabs smaller, for where space is limited.',
+ docs: {
+ story: {
+ height: "210px",
+ },
+ },
+ },
+ render: () => compared("small"),
+};
+
+/** Tabs with as little styling as possible. */
+export const Minimal: Story = {
+ name: "Minimal",
+ parameters: {
+ title: "Minimal",
+ summary:
+ 'The Lumo `theme="minimal"` variant reduces the styling to a minimum, without the line along the bar and the underline below the selected tab.',
+ docs: {
+ story: {
+ height: "210px",
+ },
+ },
+ },
+ render: () => compared("minimal"),
+};
+
+/** An overflowing bar with and without its scroll buttons. */
+export const ScrollButtons: Story = {
+ name: "Scroll buttons",
+ parameters: {
+ title: "Scroll buttons",
summary:
- 'Set `orientation="vertical"` to stack the tabs, and the indicator then slides vertically. The content can then sit beside the tabs.',
+ 'A bar that overflows shows scroll buttons. The Lumo `theme="hide-scroll-buttons"` variant hides them and fades out the overflowing ends instead, and the tabs can still be scrolled. The `theme="show-scroll-buttons"` variant keeps them on touch devices, which hide them by default in other themes. Lumo shows them whenever the bar overflows, so here it looks like the default.',
docs: {
story: {
- height: "200px",
+ height: "210px",
},
},
},
render: () => html`
-
+ `,
+};
+
+/** The sliding variant, horizontally and vertically. */
+export const Sliding: Story = {
+ name: "Sliding",
+ parameters: {
+ title: "Sliding",
+ summary:
+ 'The sliding variant is a more mobile-friendly alternative to the default tabs, for switching between a few views on a phone. Set `theme="sliding"` for a bar with a contrasting background, where the tabs share its width as large touch targets and an indicator slides behind the selected one. With `orientation="vertical"` the indicator slides vertically. The two bars here share one set of content, as selecting a tab in one sets `selected` on the other from its `selected-changed` event.',
+ docs: {
+ story: {
+ height: "260px",
+ },
+ },
+ },
+ render: () => html`
+
+ `,
+};
+
+const roundings = [
+ { caption: "0px", radius: "0px" },
+ { caption: "3px", radius: "3px" },
+ { caption: "Default, the Lumo medium radius", radius: undefined },
+ { caption: "12px", radius: "12px" },
+ { caption: "99px, a full pill", radius: "99px" },
+];
+
+/** The sliding variant with a range of `--or-tabs-radius` values. */
+export const Rounding: Story = {
+ name: "Rounding",
+ parameters: {
+ title: "Rounding",
+ summary:
+ "`--or-tabs-radius` rounds the tabs and the indicator, up to a full pill, and the corners of the bar follow it around the padding. The rounding is the same in either `orientation`.",
+ docs: {
+ story: {
+ height: "760px",
+ },
+ },
+ },
+ render: () => html`
+
`,
};
-export const examples = [Content, Vertical];
+export const examples = [Vertical, EqualWidth, Centered, Small, Minimal, ScrollButtons, Sliding, Rounding];
export { customElements, packageJson };
diff --git a/ui/component/or-vaadin-components/test/or-vaadin-tabs.test.ts b/ui/component/or-vaadin-components/test/or-vaadin-tabs.test.ts
index 8956032bed..939797bf3e 100644
--- a/ui/component/or-vaadin-components/test/or-vaadin-tabs.test.ts
+++ b/ui/component/or-vaadin-components/test/or-vaadin-tabs.test.ts
@@ -18,14 +18,9 @@
*/
import { ct, expect } from "./fixtures";
-import { OrVaadinTab } from "@openremote/or-vaadin-components/or-vaadin-tab";
import { OrVaadinTabs } from "@openremote/or-vaadin-components/or-vaadin-tabs";
-const tabs = [
- "Status",
- "Mode",
- "Settings",
-];
+const tabs = ["Status", "Mode", "Settings"];
ct("should select the clicked tab", async ({ mount }) => {
const selected: number[] = [];
@@ -37,7 +32,6 @@ ct("should select the clicked tab", async ({ mount }) => {
selected.push(detail.value);
},
},
- hooksConfig: { components: [OrVaadinTab] },
});
await expect(component.getByRole("tab", { selected: true })).toHaveText("Mode");
@@ -48,12 +42,26 @@ ct("should select the clicked tab", async ({ mount }) => {
await expect.poll(() => selected.at(-1)).toBe(2);
});
+ct("should show the indicator only with the sliding variant", async ({ mount }) => {
+ const component = await mount(OrVaadinTabs, {
+ props: { selected: 0 },
+ slots: { default: tabs },
+ });
+ const indicator = component.locator("#indicator");
+
+ await expect(indicator).toBeHidden();
+
+ await component.evaluate((el) => el.setAttribute("theme", "sliding"));
+
+ await expect(indicator).toBeVisible();
+});
+
ct("should move the indicator behind the selected tab", async ({ mount }) => {
const component = await mount(OrVaadinTabs, {
props: { selected: 0 },
slots: { default: tabs },
- hooksConfig: { components: [OrVaadinTab] },
});
+ await component.evaluate((el) => el.setAttribute("theme", "sliding"));
const indicator = component.locator("#indicator");
const settings = component.getByRole("tab", { name: "Settings" });
@@ -72,8 +80,25 @@ ct("should hide the indicator when no tab is selected", async ({ mount }) => {
const component = await mount(OrVaadinTabs, {
props: { selected: -1 },
slots: { default: tabs },
- hooksConfig: { components: [OrVaadinTab] },
});
+ await component.evaluate((el) => el.setAttribute("theme", "sliding"));
await expect(component.locator("#indicator")).toBeHidden();
});
+
+ct("should give the tabs equal widths with the equal-width-tabs variant", async ({ mount }) => {
+ const component = await mount(OrVaadinTabs, {
+ props: { selected: 0 },
+ slots: { default: tabs },
+ });
+ await component.evaluate((el) => el.setAttribute("theme", "equal-width-tabs"));
+
+ await expect
+ .poll(async () => {
+ const widths = await component
+ .getByRole("tab")
+ .evaluateAll((elements) => elements.map((el) => Math.round(el.getBoundingClientRect().width)));
+ return new Set(widths).size;
+ })
+ .toBe(1);
+});
diff --git a/ui/component/theme/src/components/index.css b/ui/component/theme/src/components/index.css
index 78d1ea3418..b1571eb4ca 100644
--- a/ui/component/theme/src/components/index.css
+++ b/ui/component/theme/src/components/index.css
@@ -38,6 +38,7 @@
@import "./or-vaadin-radio-group.css";
@import "./or-vaadin-select.css";
@import "./or-vaadin-slider.css";
+@import "./or-vaadin-tabs.css";
@import "./or-vaadin-text-area.css";
@import "./or-vaadin-text-field.css";
@import "./or-vaadin-time-picker.css"; /* Must be after the date-time-picker */
diff --git a/ui/component/theme/src/components/or-vaadin-tabs.css b/ui/component/theme/src/components/or-vaadin-tabs.css
new file mode 100644
index 0000000000..abb8cbffe6
--- /dev/null
+++ b/ui/component/theme/src/components/or-vaadin-tabs.css
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2026, OpenRemote Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+@import "@vaadin/vaadin-lumo-styles/components/tabs.css";
+
+/* The `` elements inside get the Lumo tab look from the import above */
+:root::before,
+:host::before {
+ --_lumo-or-vaadin-tabs-inject: 1;
+ --_lumo-or-vaadin-tabs-inject-modules: lumo_components_tabs;
+}
From 4fed346c54b8a1a4514b736619ea7a8b53d36ca9 Mon Sep 17 00:00:00 2001
From: Ekhorn
Date: Mon, 26 Oct 2026 17:01:33 +0100
Subject: [PATCH 4/4] Reduce the tabs stories to what OpenRemote adds
Drop the stories that repeat the Vaadin style variants and link the Vaadin docs instead.
---
.../stories/or-vaadin-tabs.stories.ts | 176 ++----------------
1 file changed, 14 insertions(+), 162 deletions(-)
diff --git a/ui/component/or-vaadin-components/stories/or-vaadin-tabs.stories.ts b/ui/component/or-vaadin-components/stories/or-vaadin-tabs.stories.ts
index 6aaf171eb1..ec2b4baf0f 100644
--- a/ui/component/or-vaadin-components/stories/or-vaadin-tabs.stories.ts
+++ b/ui/component/or-vaadin-components/stories/or-vaadin-tabs.stories.ts
@@ -37,41 +37,19 @@ const pages = [
{ name: "Settings", content: "Change the name, location and schedule of the asset." },
];
-/** Tabs for the pages, linked to the panels with the given id prefix. */
-const pageTabs = (prefix: string, tabPrefix = prefix) =>
- pages.map(
- ({ name }) =>
- html`${name}`
- );
-
-/** Panels with the content of the pages, of which only the selected one is shown. */
-const pagePanels = (prefix: string, selected: number) =>
- pages.map(
- ({ name, content }, index) =>
- html`
- ${content}
-
`
- );
+/** Tabs for the pages, without content. */
+const plainTabs = () => pages.map(({ name }) => html`${name}`);
-/** Selects the tab in every bar of the `data-tab-group` and shows its panel. */
-const showPanel = (event: CustomEvent<{ value: number }>) => {
+/** Selects the tab in every bar of the `data-tab-group` and shows its page in the group's `
`. */
+const showContent = (event: CustomEvent<{ value: number }>) => {
const group = (event.target as Element).closest("[data-tab-group]");
group?.querySelectorAll("or-vaadin-tabs").forEach((tabs) => (tabs.selected = event.detail.value));
- group
- ?.querySelectorAll('[role="tabpanel"]')
- .forEach((panel, index) => (panel.hidden = index !== event.detail.value));
+ const content = group?.querySelector("p");
+ if (content) {
+ content.textContent = pages[event.detail.value]?.content ?? "";
+ }
};
-/** Tabs for the pages, without content. */
-const plainTabs = () => pages.map(({ name }) => html`${name}`);
-
/** An example with a caption naming what it sets. */
const captioned = (caption: string, example: unknown) => html`
@@ -84,24 +62,6 @@ const captioned = (caption: string, example: unknown) => html`
`;
-/** More tabs than a narrow bar fits, so that it overflows. */
-const overflowTabs = () =>
- ["First", "Second", "Third", "Fourth", "Fifth", "Sixth"].map((name) => html`${name} tab`);
-
-/** Tabs with the given variant, captioned with it. */
-const themed = (variant: string, tabs = plainTabs, style = "") =>
- captioned(
- `theme="${variant}"`,
- html`${tabs()}`
- );
-
-/** Tabs without a variant above tabs with the given one, so that the difference shows. */
-const compared = (variant: string) => html`
-
-`;
-
const meta: Meta = {
title: "Playground/or-vaadin-components/tabs",
component: tagName,
@@ -116,7 +76,7 @@ const meta: Meta = {
docs: {
subtitle: `<${tagName}>`,
description:
- 'Tabs switch between views or pages. They have the Lumo look and variants of ``. Set `theme="sliding"` for a more mobile-friendly bar with a contrasting background, where the `` elements share the width equally and an indicator slides behind the selected one. Selection works as in ``, through the `selected` index and the `selected-changed` event, and the colors, sizes, spacing, rounding and timing of the sliding variant are set through `--or-tabs-*` and `--or-tab-*` properties.',
+ 'Tabs switch between views or pages. They have the Lumo look of `` and work the same, so see the [Vaadin Tabs docs](https://vaadin.com/docs/latest/components/tabs) for their usage and [style variants](https://vaadin.com/docs/latest/components/tabs/styling). Set `theme="sliding"` for a more mobile-friendly bar with a contrasting background, where the `` elements share the width equally and an indicator slides behind the selected one. Its colors, sizes, spacing, rounding and timing are set through `--or-tabs-*` and `--or-tab-*` properties.',
},
},
};
@@ -127,112 +87,6 @@ export const Primary: Story = {
},
};
-/** Tabs stacked vertically beside their content. */
-export const Vertical: Story = {
- name: "Vertical",
- parameters: {
- title: "Vertical",
- summary:
- 'Set `orientation="vertical"` to stack the tabs, so the content can sit beside them. Listen to `selected-changed` to show the content of the selected tab.',
- docs: {
- story: {
- height: "170px",
- },
- },
- },
- render: () => html`
-
- `,
-};
-
-/** Tabs that share the width of the bar equally. */
-export const EqualWidth: Story = {
- name: "Equal width",
- parameters: {
- title: "Equal width",
- summary:
- 'The Lumo `theme="equal-width-tabs"` variant makes the tabs share the width of the bar equally. Sliding tabs always do this.',
- docs: {
- story: {
- height: "210px",
- },
- },
- },
- render: () => compared("equal-width-tabs"),
-};
-
-/** Tabs centered in the bar. */
-export const Centered: Story = {
- name: "Centered",
- parameters: {
- title: "Centered",
- summary: 'The Lumo `theme="centered"` variant centers the tabs in the bar.',
- docs: {
- story: {
- height: "210px",
- },
- },
- },
- render: () => compared("centered"),
-};
-
-/** Smaller tabs. */
-export const Small: Story = {
- name: "Small",
- parameters: {
- title: "Small",
- summary: 'The Lumo `theme="small"` variant makes the tabs smaller, for where space is limited.',
- docs: {
- story: {
- height: "210px",
- },
- },
- },
- render: () => compared("small"),
-};
-
-/** Tabs with as little styling as possible. */
-export const Minimal: Story = {
- name: "Minimal",
- parameters: {
- title: "Minimal",
- summary:
- 'The Lumo `theme="minimal"` variant reduces the styling to a minimum, without the line along the bar and the underline below the selected tab.',
- docs: {
- story: {
- height: "210px",
- },
- },
- },
- render: () => compared("minimal"),
-};
-
-/** An overflowing bar with and without its scroll buttons. */
-export const ScrollButtons: Story = {
- name: "Scroll buttons",
- parameters: {
- title: "Scroll buttons",
- summary:
- 'A bar that overflows shows scroll buttons. The Lumo `theme="hide-scroll-buttons"` variant hides them and fades out the overflowing ends instead, and the tabs can still be scrolled. The `theme="show-scroll-buttons"` variant keeps them on touch devices, which hide them by default in other themes. Lumo shows them whenever the bar overflows, so here it looks like the default.',
- docs: {
- story: {
- height: "210px",
- },
- },
- },
- render: () => html`
-