diff --git a/.changeset/secondary-tabs-component.md b/.changeset/secondary-tabs-component.md new file mode 100644 index 0000000000..c419294130 --- /dev/null +++ b/.changeset/secondary-tabs-component.md @@ -0,0 +1,9 @@ +--- +"@cloudoperators/juno-ui-components": minor +--- + +Add SecondaryTabs component family (SecondaryTabs, SecondaryTab, SecondaryTabPanel) + +A segmented-control tab strip for hierarchical navigation below primary TabNavigation. Uses native ARIA (`role="tablist"`, `role="tab"`, `role="tabpanel"`, `aria-selected`, `aria-controls`, `aria-labelledby`, `aria-orientation="horizontal"`) without external library dependencies. Supports controlled/uncontrolled mode, per-tab and global disabled state, optional icons left/right, and full keyboard navigation (ArrowRight/Left with wrap-around, Home, End — skips disabled tabs) per the ARIA APG Tabs pattern. Uses `useId()` for unique per-instance tab/panel IDs, enabling multiple SecondaryTabs on the same page without ID collisions. Added 14 CSS design tokens for Light and Dark themes (background, text, border, disabled). + +**Accessibility:** Uses `aria-disabled="true"` instead of native `disabled` so disabled tabs remain in the accessibility tree and are announced by screen readers (NVDA/JAWS remove natively-disabled elements from the AT tree). Roving tabindex ensures only one tab is in the tab sequence at a time while disabled tabs stay reachable via ArrowLeft/Right for AT users who rely on directional navigation. `SecondaryTabs` and `SecondaryTab` both expose a `ref` via `forwardRef`. diff --git a/packages/ui-components/src/components/SecondaryTabs/SecondaryTab.component.tsx b/packages/ui-components/src/components/SecondaryTabs/SecondaryTab.component.tsx new file mode 100644 index 0000000000..70dd17fd8e --- /dev/null +++ b/packages/ui-components/src/components/SecondaryTabs/SecondaryTab.component.tsx @@ -0,0 +1,159 @@ +/* + * SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { forwardRef, ButtonHTMLAttributes, ReactNode } from "react" +import { Icon } from "../Icon/Icon.component" +import { KnownIcons } from "../Icon/Icon.component.js" +import { useSecondaryTabsContext } from "./SecondaryTabs.component" + +export interface SecondaryTabProps extends ButtonHTMLAttributes { + /** Must match the `value` of the corresponding `SecondaryTabPanel`. */ + value: string + /** Icon rendered to the left of the label. */ + iconLeft?: KnownIcons + /** Icon rendered to the right of the label. */ + iconRight?: KnownIcons + children?: ReactNode + /** @default false */ + disabled?: boolean + /** @default "" */ + className?: string + /** Managed by roving tabindex; consumer value is ignored. */ + tabIndex?: number +} + +const tabBaseStyles = ` + jn:relative + jn:flex + jn:items-center + jn:gap-[0.375rem] + jn:rounded-[0.1875rem] + jn:px-[0.625rem] + jn:py-[0.4375rem] + jn:text-sm + jn:font-bold + jn:leading-[1.4] + jn:select-none + jn:transition-colors + jn:duration-150 + jn:border + jn:focus-visible:outline-hidden + jn:focus-visible:ring-2 + jn:focus-visible:ring-theme-focus + jn:focus-visible:ring-offset-1 + jn:focus-visible:ring-offset-theme-focus +` + +const tabDefaultStyles = ` + jn:cursor-pointer + jn:bg-transparent + jn:border-transparent + jn:text-theme-secondary-tab +` + +const tabHoverStyles = `jn:hover:text-theme-secondary-tab-hover` + +const tabActiveStyles = ` + jn:cursor-default + jn:bg-theme-secondary-tab-active + jn:border-theme-secondary-tab-active + jn:text-theme-secondary-tab-active +` + +const tabDisabledStyles = ` + jn:cursor-not-allowed + jn:opacity-50 + jn:text-theme-secondary-tab-disabled +` + +/** + * A single selectable tab inside `SecondaryTabs`. + * @see {@link SecondaryTabProps} + */ +export const SecondaryTab = forwardRef( + ( + { + value, + iconLeft, + iconRight, + disabled: disabledProp, + onClick, + onKeyDown: onKeyDownProp, + tabIndex: _tabIndex, + children, + className = "", + ...rest + }, + ref + ) => { + const { activeTab, setActiveTab, disabled: contextDisabled, tabsId } = useSecondaryTabsContext() + + const isActive = activeTab === value + const isDisabled = disabledProp ?? contextDisabled + + const handleClick = (e: React.MouseEvent) => { + if (isDisabled) return + setActiveTab(value) + onClick?.(e) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + const tablist = e.currentTarget.closest('[role="tablist"]') + if (tablist) { + const enabledTabs = Array.from( + tablist.querySelectorAll('[role="tab"]:not([aria-disabled="true"])') + ) + const currentIndex = enabledTabs.indexOf(e.currentTarget) + let nextTab: HTMLButtonElement | null = null + + if (e.key === "ArrowRight") { + e.preventDefault() + nextTab = enabledTabs[(currentIndex + 1) % enabledTabs.length] + } else if (e.key === "ArrowLeft") { + e.preventDefault() + nextTab = enabledTabs[(currentIndex - 1 + enabledTabs.length) % enabledTabs.length] + } else if (e.key === "Home") { + e.preventDefault() + nextTab = enabledTabs[0] + } else if (e.key === "End") { + e.preventDefault() + nextTab = enabledTabs[enabledTabs.length - 1] + } + + if (nextTab) { + const nextValue = nextTab.dataset.value + if (nextValue) { + setActiveTab(nextValue) + nextTab.focus() + } + } + } + onKeyDownProp?.(e) + } + + return ( + + ) + } +) + +SecondaryTab.displayName = "SecondaryTab" diff --git a/packages/ui-components/src/components/SecondaryTabs/SecondaryTab.stories.tsx b/packages/ui-components/src/components/SecondaryTabs/SecondaryTab.stories.tsx new file mode 100644 index 0000000000..cfe986330f --- /dev/null +++ b/packages/ui-components/src/components/SecondaryTabs/SecondaryTab.stories.tsx @@ -0,0 +1,128 @@ +/* + * SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from "react" +import { Meta, StoryObj } from "@storybook/react-vite" +import { KnownIconsEnum } from "../Icon/Icon.component" +import { SecondaryTabs } from "./SecondaryTabs.component" +import { SecondaryTab } from "./SecondaryTab.component" + +const iconOptions = [undefined, ...Object.values(KnownIconsEnum)] as const + +const meta: Meta = { + title: "WIP/SecondaryTabs/SecondaryTab", + component: SecondaryTab, + decorators: [ + (Story) => ( + + + + ), + ], + argTypes: { + value: { control: "text" }, + disabled: { control: "boolean" }, + iconLeft: { + options: iconOptions, + control: { type: "select" }, + }, + iconRight: { + options: iconOptions, + control: { type: "select" }, + }, + }, +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + parameters: { + docs: { + description: { + story: "Default inactive tab. Becomes active when selected.", + }, + }, + }, + args: { + value: "demo", + children: "Tab Label", + }, +} + +export const Inactive: Story = { + parameters: { + docs: { + description: { + story: "A tab that is not the currently active tab.", + }, + }, + }, + args: { + value: "other", + children: "Inactive Tab", + }, +} + +export const Disabled: Story = { + parameters: { + docs: { + description: { + story: "A tab that cannot be selected. Uses `aria-disabled` to remain in the accessibility tree.", + }, + }, + }, + args: { + value: "demo", + disabled: true, + children: "Disabled Tab", + }, +} + +export const WithIconLeft: Story = { + parameters: { + docs: { + description: { + story: "Tab with an icon rendered to the left of the label.", + }, + }, + }, + args: { + value: "demo", + iconLeft: "openInNew", + children: "With Icon Left", + }, +} + +export const WithIconRight: Story = { + parameters: { + docs: { + description: { + story: "Tab with an icon rendered to the right of the label.", + }, + }, + }, + args: { + value: "demo", + iconRight: "info", + children: "With Icon Right", + }, +} + +export const WithBothIcons: Story = { + parameters: { + docs: { + description: { + story: "Tab with icons on both sides.", + }, + }, + }, + args: { + value: "demo", + iconLeft: "openInNew", + iconRight: "info", + children: "Both Icons", + }, +} diff --git a/packages/ui-components/src/components/SecondaryTabs/SecondaryTab.test.tsx b/packages/ui-components/src/components/SecondaryTabs/SecondaryTab.test.tsx new file mode 100644 index 0000000000..61ae08c7cb --- /dev/null +++ b/packages/ui-components/src/components/SecondaryTabs/SecondaryTab.test.tsx @@ -0,0 +1,95 @@ +/* + * SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as React from "react" +import { render, screen } from "@testing-library/react" +import { describe, expect, test } from "vitest" +import { SecondaryTabs } from "./SecondaryTabs.component" +import { SecondaryTab } from "./SecondaryTab.component" +import { SecondaryTabPanel } from "./SecondaryTabPanel.component" + +const wrap = (props = {}) => + render( + + + Label + + + ) + +describe("SecondaryTab", () => { + describe("Basic Rendering", () => { + test("renders a tab button", () => { + wrap() + expect(screen.getByRole("tab")).toBeInTheDocument() + }) + + test("has juno-secondary-tab class", () => { + wrap() + expect(screen.getByRole("tab")).toHaveClass("juno-secondary-tab") + }) + + test("has juno-secondary-tab-active class when active", () => { + wrap() + expect(screen.getByRole("tab")).toHaveClass("juno-secondary-tab-active") + }) + + test("has aria-selected true when active", () => { + wrap() + expect(screen.getByRole("tab")).toHaveAttribute("aria-selected", "true") + }) + + test("has aria-controls linking to its panel", () => { + render( + + Label + Content + + ) + const tab = screen.getByRole("tab") + const panel = screen.getByRole("tabpanel") + expect(tab.getAttribute("aria-controls")).toBe(panel.id) + }) + + test("has id matching tab id convention", () => { + wrap() + expect(screen.getByRole("tab").id).toMatch(/-tab-x$/) + }) + + test("applies custom className", () => { + wrap({ className: "my-class" }) + expect(screen.getByRole("tab")).toHaveClass("my-class") + }) + + test("forwards additional props", () => { + wrap({ "data-custom": "val" }) + expect(screen.getByRole("tab")).toHaveAttribute("data-custom", "val") + }) + + test("forwards ref to button element", () => { + const ref = React.createRef() + render( + + + Label + + + ) + expect(ref.current).toBe(screen.getByRole("tab")) + }) + }) + + describe("Disabled state", () => { + test("has aria-disabled when disabled prop is passed", () => { + wrap({ disabled: true }) + expect(screen.getByRole("tab")).toHaveAttribute("aria-disabled", "true") + }) + + test("has tabIndex -1 when disabled", () => { + wrap({ disabled: true }) + expect(screen.getByRole("tab")).toHaveAttribute("tabindex", "-1") + }) + }) +}) diff --git a/packages/ui-components/src/components/SecondaryTabs/SecondaryTabPanel.component.tsx b/packages/ui-components/src/components/SecondaryTabs/SecondaryTabPanel.component.tsx new file mode 100644 index 0000000000..753256aae0 --- /dev/null +++ b/packages/ui-components/src/components/SecondaryTabs/SecondaryTabPanel.component.tsx @@ -0,0 +1,42 @@ +/* + * SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { forwardRef, HTMLAttributes, ReactNode } from "react" +import { useSecondaryTabsContext } from "./SecondaryTabs.component" + +export interface SecondaryTabPanelProps extends HTMLAttributes { + /** Must match the `value` of the corresponding `SecondaryTab`. */ + value: string + children?: ReactNode + /** @default "" */ + className?: string +} + +/** + * Content panel for a `SecondaryTab`. Hidden when its `value` doesn't match the active tab. + * @see {@link SecondaryTabPanelProps} + */ +export const SecondaryTabPanel = forwardRef( + ({ value, children, className = "", ...rest }, ref) => { + const { activeTab, tabsId } = useSecondaryTabsContext() + const isActive = activeTab === value + + return ( + + ) + } +) + +SecondaryTabPanel.displayName = "SecondaryTabPanel" diff --git a/packages/ui-components/src/components/SecondaryTabs/SecondaryTabPanel.stories.tsx b/packages/ui-components/src/components/SecondaryTabs/SecondaryTabPanel.stories.tsx new file mode 100644 index 0000000000..aea5373f21 --- /dev/null +++ b/packages/ui-components/src/components/SecondaryTabs/SecondaryTabPanel.stories.tsx @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from "react" +import { Meta, StoryObj } from "@storybook/react-vite" +import { SecondaryTabs } from "./SecondaryTabs.component" +import { SecondaryTab } from "./SecondaryTab.component" +import { SecondaryTabPanel } from "./SecondaryTabPanel.component" + +const meta: Meta = { + title: "WIP/SecondaryTabs/SecondaryTabPanel", + component: SecondaryTabPanel, + decorators: [ + (Story) => ( + + Demo + + + ), + ], + argTypes: { + value: { control: "text" }, + }, +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { + value: "demo", + children:
Panel content
, + }, +} diff --git a/packages/ui-components/src/components/SecondaryTabs/SecondaryTabPanel.test.tsx b/packages/ui-components/src/components/SecondaryTabs/SecondaryTabPanel.test.tsx new file mode 100644 index 0000000000..7a9fd5ade6 --- /dev/null +++ b/packages/ui-components/src/components/SecondaryTabs/SecondaryTabPanel.test.tsx @@ -0,0 +1,85 @@ +/* + * SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as React from "react" +import { render, screen } from "@testing-library/react" +import { describe, expect, test } from "vitest" +import { SecondaryTabs } from "./SecondaryTabs.component" +import { SecondaryTab } from "./SecondaryTab.component" +import { SecondaryTabPanel } from "./SecondaryTabPanel.component" + +const wrap = (props = {}) => + render( + + Tab + + Panel content + + + ) + +describe("SecondaryTabPanel", () => { + describe("Basic Rendering", () => { + test("renders a tabpanel", () => { + wrap() + expect(screen.getByRole("tabpanel")).toBeInTheDocument() + }) + + test("has juno-secondary-tabpanel class", () => { + wrap() + expect(screen.getByRole("tabpanel")).toHaveClass("juno-secondary-tabpanel") + }) + + test("has id matching panel id convention", () => { + wrap() + expect(screen.getByRole("tabpanel").id).toMatch(/-tabpanel-x$/) + }) + + test("has aria-labelledby linking to its tab", () => { + wrap() + const tab = screen.getByRole("tab") + const panel = screen.getByRole("tabpanel") + expect(panel.getAttribute("aria-labelledby")).toBe(tab.id) + }) + + test("is visible when its value matches the active tab", () => { + wrap() + expect(screen.getByText("Panel content")).toBeVisible() + }) + + test("is hidden when its value does not match the active tab", () => { + render( + + Other + Hidden panel + + ) + expect(screen.getByRole("tabpanel", { hidden: true })).not.toBeVisible() + }) + + test("applies custom className", () => { + wrap({ className: "my-class" }) + expect(screen.getByRole("tabpanel")).toHaveClass("my-class") + }) + + test("forwards additional props", () => { + wrap({ "data-custom": "val" }) + expect(screen.getByRole("tabpanel")).toHaveAttribute("data-custom", "val") + }) + + test("forwards ref to div element", () => { + const ref = React.createRef() + render( + + Tab + + Content + + + ) + expect(ref.current).toBe(screen.getByRole("tabpanel")) + }) + }) +}) diff --git a/packages/ui-components/src/components/SecondaryTabs/SecondaryTabs.component.tsx b/packages/ui-components/src/components/SecondaryTabs/SecondaryTabs.component.tsx new file mode 100644 index 0000000000..00922b5d77 --- /dev/null +++ b/packages/ui-components/src/components/SecondaryTabs/SecondaryTabs.component.tsx @@ -0,0 +1,105 @@ +/* + * SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { + createContext, + useContext, + useState, + useId, + Children, + isValidElement, + HTMLAttributes, + ReactNode, + forwardRef, +} from "react" + +export interface SecondaryTabsContextType { + activeTab: string | undefined + setActiveTab: (_value: string) => void + disabled: boolean + tabsId: string +} + +const SecondaryTabsContext = createContext(undefined) + +export const useSecondaryTabsContext = () => { + const ctx = useContext(SecondaryTabsContext) + if (!ctx) throw new Error("useSecondaryTabsContext must be used within SecondaryTabs") + return ctx +} + +export interface SecondaryTabsProps extends HTMLAttributes { + /** Controlled: the currently active tab value. */ + activeTab?: string + /** Uncontrolled: the tab to select on first render. */ + defaultTab?: string + /** Called when the user selects a different tab. */ + onTabChange?: (_value: string) => void + /** Disables all child tabs. @default false */ + disabled?: boolean + children?: ReactNode + /** @default "" */ + className?: string +} + +const tablistStyles = ` + jn:inline-flex + jn:rounded-[0.25rem] + jn:bg-theme-secondary-tabs + jn:p-[0.125rem] +` + +/** + * Segmented-control tab strip. Place `SecondaryTab` children inside; `SecondaryTabPanel` + * children are rendered after the pill container but stay within the same context. + * @see {@link SecondaryTabsProps} + */ +export const SecondaryTabs = forwardRef( + ( + { activeTab: activeTabProp, defaultTab, onTabChange, disabled = false, children, className = "", ...props }, + ref + ) => { + const tabsId = useId() + const isControlled = activeTabProp !== undefined + const [internalTab, setInternalTab] = useState(defaultTab) + + const activeTab = isControlled ? activeTabProp : internalTab + + const setActiveTab = (value: string) => { + if (!isControlled) setInternalTab(value) + onTabChange?.(value) + } + + const tabs: ReactNode[] = [] + const panels: ReactNode[] = [] + + Children.forEach(children, (child) => { + if (!isValidElement(child)) return + const displayName = (child.type as { displayName?: string })?.displayName + if (displayName === "SecondaryTab") { + tabs.push(child) + } else { + panels.push(child) + } + }) + + return ( + +
+ {tabs} +
+ {panels} +
+ ) + } +) + +SecondaryTabs.displayName = "SecondaryTabs" diff --git a/packages/ui-components/src/components/SecondaryTabs/SecondaryTabs.stories.tsx b/packages/ui-components/src/components/SecondaryTabs/SecondaryTabs.stories.tsx new file mode 100644 index 0000000000..11aa9cc509 --- /dev/null +++ b/packages/ui-components/src/components/SecondaryTabs/SecondaryTabs.stories.tsx @@ -0,0 +1,161 @@ +/* + * SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from "react" +import { Meta, StoryObj } from "@storybook/react-vite" +import { SecondaryTabs } from "./SecondaryTabs.component" +import { SecondaryTab } from "./SecondaryTab.component" +import { SecondaryTabPanel } from "./SecondaryTabPanel.component" + +const meta: Meta = { + title: "WIP/SecondaryTabs/SecondaryTabs", + component: SecondaryTabs, + argTypes: { + activeTab: { control: "text" }, + defaultTab: { control: "text" }, + disabled: { control: "boolean" }, + onTabChange: { action: "onTabChange" }, + }, + parameters: { + docs: { + description: { + component: + "A segmented-control tab strip for hierarchical navigation. Sits below the primary `TabNavigation`. Uses native ARIA (`role=tablist/tab/tabpanel`).", + }, + }, + }, +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + parameters: { + docs: { + description: { + story: "Basic tab strip with three tabs and corresponding panels.", + }, + }, + }, + render: (args) => ( + + Overview + Details + Logs + +
Overview content
+
+ +
Details content
+
+ +
Logs content
+
+
+ ), +} + +export const WithIcons: Story = { + parameters: { + docs: { + description: { + story: "Tabs with icons rendered to the left of each label.", + }, + }, + }, + render: (args) => ( + + + Compute + + + Storage + + + Network + + +
Compute content
+
+ +
Storage content
+
+ +
Network content
+
+
+ ), +} + +export const AllDisabled: Story = { + parameters: { + docs: { + description: { + story: "All tabs disabled via the parent `disabled` prop.", + }, + }, + }, + render: (args) => ( + + Overview + Details + Logs + + ), +} + +export const SingleTabDisabled: Story = { + parameters: { + docs: { + description: { + story: "One tab disabled individually. Keyboard navigation skips it.", + }, + }, + }, + render: (args) => ( + + Overview + + Details + + Logs + +
Overview content
+
+ +
Logs content
+
+
+ ), +} + +export const Controlled: Story = { + parameters: { + docs: { + description: { + story: "Controlled mode: active tab driven by the `activeTab` prop.", + }, + }, + }, + args: { + activeTab: "details", + }, + render: (args) => ( + + Overview + Details + Logs + +
Overview content
+
+ +
Details content (controlled active)
+
+ +
Logs content
+
+
+ ), +} diff --git a/packages/ui-components/src/components/SecondaryTabs/SecondaryTabs.test.tsx b/packages/ui-components/src/components/SecondaryTabs/SecondaryTabs.test.tsx new file mode 100644 index 0000000000..41cd3a85e3 --- /dev/null +++ b/packages/ui-components/src/components/SecondaryTabs/SecondaryTabs.test.tsx @@ -0,0 +1,156 @@ +/* + * SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as React from "react" +import { render, screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { describe, expect, test, vi } from "vitest" +import { SecondaryTabs } from "./SecondaryTabs.component" +import { SecondaryTab } from "./SecondaryTab.component" +import { SecondaryTabPanel } from "./SecondaryTabPanel.component" + +const renderTabs = (props = {}) => + render( + + Tab A + Tab B + + Tab C + + Panel A + Panel B + + ) + +describe("SecondaryTabs", () => { + describe("Basic Rendering", () => { + test("renders a tablist", () => { + renderTabs() + expect(screen.getByRole("tablist")).toBeInTheDocument() + }) + + test("renders juno-secondary-tabs class on tablist", () => { + renderTabs() + expect(screen.getByRole("tablist")).toHaveClass("juno-secondary-tabs") + }) + + test("renders all tabs", () => { + renderTabs() + expect(screen.getAllByRole("tab")).toHaveLength(3) + }) + + test("renders tabpanels", () => { + renderTabs() + expect(screen.getAllByRole("tabpanel", { hidden: true })).toHaveLength(2) + }) + + test("applies custom className", () => { + renderTabs({ className: "my-class" }) + expect(screen.getByRole("tablist")).toHaveClass("my-class") + }) + + test("forwards additional props", () => { + renderTabs({ "data-custom": "test" }) + expect(screen.getByRole("tablist")).toHaveAttribute("data-custom", "test") + }) + }) + + describe("Uncontrolled mode", () => { + test("activates defaultTab on mount", () => { + renderTabs() + expect(screen.getByRole("tab", { name: "Tab A" })).toHaveAttribute("aria-selected", "true") + expect(screen.getByRole("tab", { name: "Tab B" })).toHaveAttribute("aria-selected", "false") + }) + + test("switches tab on click", async () => { + renderTabs() + await userEvent.click(screen.getByRole("tab", { name: "Tab B" })) + expect(screen.getByRole("tab", { name: "Tab B" })).toHaveAttribute("aria-selected", "true") + expect(screen.getByRole("tab", { name: "Tab A" })).toHaveAttribute("aria-selected", "false") + }) + + test("shows the active panel", () => { + renderTabs() + expect(screen.getByText("Panel A")).toBeVisible() + expect(screen.getByText("Panel B")).not.toBeVisible() + }) + }) + + describe("Controlled mode", () => { + test("respects activeTab prop", () => { + renderTabs({ activeTab: "b", defaultTab: undefined }) + expect(screen.getByRole("tab", { name: "Tab B" })).toHaveAttribute("aria-selected", "true") + }) + + test("calls onTabChange when tab is clicked", async () => { + const onTabChange = vi.fn() + renderTabs({ activeTab: "a", onTabChange }) + await userEvent.click(screen.getByRole("tab", { name: "Tab B" })) + expect(onTabChange).toHaveBeenCalledWith("b") + }) + }) + + describe("Disabled", () => { + test("disables all tabs when disabled prop is set", () => { + renderTabs({ disabled: true, defaultTab: undefined }) + screen.getAllByRole("tab").forEach((tab) => { + expect(tab).toHaveAttribute("aria-disabled", "true") + }) + }) + + test("does not switch tab when disabled tab is clicked", async () => { + renderTabs() + await userEvent.click(screen.getByRole("tab", { name: "Tab C" })) + expect(screen.getByRole("tab", { name: "Tab A" })).toHaveAttribute("aria-selected", "true") + }) + }) + + describe("Keyboard navigation", () => { + test("ArrowRight moves focus and activates next tab", async () => { + renderTabs() + screen.getByRole("tab", { name: "Tab A" }).focus() + await userEvent.keyboard("{ArrowRight}") + expect(screen.getByRole("tab", { name: "Tab B" })).toHaveFocus() + expect(screen.getByRole("tab", { name: "Tab B" })).toHaveAttribute("aria-selected", "true") + }) + + test("ArrowLeft moves focus and activates previous tab", async () => { + renderTabs({ defaultTab: "b" }) + screen.getByRole("tab", { name: "Tab B" }).focus() + await userEvent.keyboard("{ArrowLeft}") + expect(screen.getByRole("tab", { name: "Tab A" })).toHaveFocus() + expect(screen.getByRole("tab", { name: "Tab A" })).toHaveAttribute("aria-selected", "true") + }) + + test("ArrowRight wraps from last to first enabled tab", async () => { + renderTabs({ defaultTab: "b" }) + screen.getByRole("tab", { name: "Tab B" }).focus() + await userEvent.keyboard("{ArrowRight}") + expect(screen.getByRole("tab", { name: "Tab A" })).toHaveFocus() + }) + + test("Home moves focus to first enabled tab", async () => { + renderTabs({ defaultTab: "b" }) + screen.getByRole("tab", { name: "Tab B" }).focus() + await userEvent.keyboard("{Home}") + expect(screen.getByRole("tab", { name: "Tab A" })).toHaveFocus() + }) + + test("End moves focus to last enabled tab", async () => { + renderTabs() + screen.getByRole("tab", { name: "Tab A" }).focus() + await userEvent.keyboard("{End}") + expect(screen.getByRole("tab", { name: "Tab B" })).toHaveFocus() + }) + + test("skips disabled tabs during keyboard navigation", async () => { + renderTabs({ defaultTab: "b" }) + screen.getByRole("tab", { name: "Tab B" }).focus() + await userEvent.keyboard("{ArrowRight}") + // Tab C is disabled — wraps to Tab A + expect(screen.getByRole("tab", { name: "Tab A" })).toHaveFocus() + }) + }) +}) diff --git a/packages/ui-components/src/components/SecondaryTabs/index.ts b/packages/ui-components/src/components/SecondaryTabs/index.ts new file mode 100644 index 0000000000..be0b252ca5 --- /dev/null +++ b/packages/ui-components/src/components/SecondaryTabs/index.ts @@ -0,0 +1,13 @@ +/* + * SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { SecondaryTabs } from "./SecondaryTabs.component" +export type { SecondaryTabsProps } from "./SecondaryTabs.component" +export { useSecondaryTabsContext } from "./SecondaryTabs.component" +export type { SecondaryTabsContextType } from "./SecondaryTabs.component" +export { SecondaryTab } from "./SecondaryTab.component" +export type { SecondaryTabProps } from "./SecondaryTab.component" +export { SecondaryTabPanel } from "./SecondaryTabPanel.component" +export type { SecondaryTabPanelProps } from "./SecondaryTabPanel.component" diff --git a/packages/ui-components/src/global.css b/packages/ui-components/src/global.css index e425fe5035..8a39449876 100644 --- a/packages/ui-components/src/global.css +++ b/packages/ui-components/src/global.css @@ -385,6 +385,8 @@ --background-color-theme-switch-handle: var(--color-switch-handle-bg); --background-color-theme-switch-handle-checked: var(--color-switch-handle-checked-bg); --background-color-theme-tab-navigation-top: var(--color-tabnavigation-top-bg); + --background-color-theme-secondary-tabs: var(--color-secondary-tabs-bg); + --background-color-theme-secondary-tab-active: var(--color-secondary-tab-bg-active); --background-color-theme-textinput: var(--color-textinput-bg); --background-color-theme-textinput-autofill: var(--color-textinput-autofill-bg); --background-color-theme-topnavigation-item: var(--color-topnavigation-item-bg); @@ -427,6 +429,10 @@ --text-color-theme-sidenavigation-item-active: var(--color-sidenavigation-item-active); --text-color-theme-status-details: var(--color-status-details-text); --text-color-theme-status-code: var(--color-status-code); + --text-color-theme-secondary-tab: var(--color-secondary-tab-text); + --text-color-theme-secondary-tab-active: var(--color-secondary-tab-text-active); + --text-color-theme-secondary-tab-hover: var(--color-secondary-tab-text-hover); + --text-color-theme-secondary-tab-disabled: var(--color-secondary-tab-text-disabled); /* Component Border Colors: */ --border-color-theme-default: var(--color-border-default); @@ -465,6 +471,7 @@ --border-color-theme-tab-navigation-content-bottom: var(--color-tabnavigation-content-bottom-border); --border-color-theme-tab-active-bottom: var(--color-text-default); + --border-color-theme-secondary-tab-active: var(--color-secondary-tab-border-active); --border-color-theme-tab-content-inactive-bottom: var(--color-background-lvl-4); --border-color-theme-textinput-default: var(--color-textinput-default-border); @@ -747,6 +754,14 @@ /* LT TabNavigation */ --color-tabnavigation-top-bg: var(--color-juno-grey-light-5); --color-tabnavigation-content-bottom-border: var(--color-background-lvl-4); + /* LT SecondaryTabs */ + --color-secondary-tabs-bg: var(--color-juno-grey-light-3); + --color-secondary-tab-bg-active: var(--color-white); + --color-secondary-tab-border-active: var(--color-juno-grey-light-9); + --color-secondary-tab-text: var(--color-text-default); + --color-secondary-tab-text-active: var(--color-text-high); + --color-secondary-tab-text-hover: var(--color-text-highest); + --color-secondary-tab-text-disabled: var(--color-text-disabled); /* LT Filters */ --color-filters-bg: var(--color-background-lvl-1); --color-filter-input-bg: var(--color-background-lvl-0); @@ -1012,6 +1027,14 @@ /* DT TabNavigation */ --color-tabnavigation-top-bg: var(--color-juno-grey-blue-8); --color-tabnavigation-content-bottom-border: var(--color-background-lvl-4); + /* DT SecondaryTabs */ + --color-secondary-tabs-bg: var(--color-background-lvl-2); + --color-secondary-tab-bg-active: var(--color-background-lvl-0); + --color-secondary-tab-border-active: var(--color-border-default); + --color-secondary-tab-text: var(--color-text-default); + --color-secondary-tab-text-active: var(--color-text-high); + --color-secondary-tab-text-hover: var(--color-text-highest); + --color-secondary-tab-text-disabled: var(--color-text-disabled); /* DT Filters */ --color-filters-bg: var(--color-background-lvl-1); --color-filter-input-bg: var(--color-background-lvl-0); diff --git a/packages/ui-components/src/index.ts b/packages/ui-components/src/index.ts index 73e3431126..f43180f299 100644 --- a/packages/ui-components/src/index.ts +++ b/packages/ui-components/src/index.ts @@ -105,6 +105,10 @@ export { TabList } from "./components/TabList/TabList.component" export { TabNavigation } from "./components/TabNavigation/TabNavigation.component" export { TabNavigationItem } from "./components/TabNavigationItem/TabNavigationItem.component" export { TabPanel } from "./components/TabPanel/TabPanel.component" +export { SecondaryTabs } from "./components/SecondaryTabs/SecondaryTabs.component" +export { SecondaryTab } from "./components/SecondaryTabs/SecondaryTab.component" +export { SecondaryTabPanel } from "./components/SecondaryTabs/SecondaryTabPanel.component" +export { useSecondaryTabsContext } from "./components/SecondaryTabs/SecondaryTabs.component" export { Textarea } from "./components/Textarea/Textarea.component" export { Tabs } from "./components/Tabs/Tabs.component" export { TextInput } from "./components/TextInput/TextInput.component" @@ -235,6 +239,10 @@ export type { } from "./components/TabNavigation/TabNavigation.component" export type { TabNavigationItemProps } from "./components/TabNavigationItem/TabNavigationItem.component" export type { TabPanelProps } from "./components/TabPanel/TabPanel.component" +export type { SecondaryTabsProps } from "./components/SecondaryTabs/SecondaryTabs.component" +export type { SecondaryTabsContextType } from "./components/SecondaryTabs/SecondaryTabs.component" +export type { SecondaryTabProps } from "./components/SecondaryTabs/SecondaryTab.component" +export type { SecondaryTabPanelProps } from "./components/SecondaryTabs/SecondaryTabPanel.component" export type { TextareaProps } from "./components/Textarea/Textarea.component" export type { TabsProps, TabsContextType, TabsVariant } from "./components/Tabs/Tabs.component" export type { TextInputProps } from "./components/TextInput/TextInput.component" diff --git a/packages/ui-components/src/theme.css b/packages/ui-components/src/theme.css index d5308e0dd3..26f89d2393 100644 --- a/packages/ui-components/src/theme.css +++ b/packages/ui-components/src/theme.css @@ -378,6 +378,8 @@ --background-color-theme-switch-handle: var(--color-switch-handle-bg); --background-color-theme-switch-handle-checked: var(--color-switch-handle-checked-bg); --background-color-theme-tab-navigation-top: var(--color-tabnavigation-top-bg); + --background-color-theme-secondary-tabs: var(--color-secondary-tabs-bg); + --background-color-theme-secondary-tab-active: var(--color-secondary-tab-bg-active); --background-color-theme-textinput: var(--color-textinput-bg); --background-color-theme-textinput-autofill: var(--color-textinput-autofill-bg); --background-color-theme-topnavigation-item: var(--color-topnavigation-item-bg); @@ -410,6 +412,10 @@ --text-color-theme-sidenavigation-item-active: var(--color-sidenavigation-item-active); --text-color-theme-status-details: var(--color-status-details-text); --text-color-theme-status-code: var(--color-status-code); + --text-color-theme-secondary-tab: var(--color-secondary-tab-text); + --text-color-theme-secondary-tab-active: var(--color-secondary-tab-text-active); + --text-color-theme-secondary-tab-hover: var(--color-secondary-tab-text-hover); + --text-color-theme-secondary-tab-disabled: var(--color-secondary-tab-text-disabled); /* Component Border Colors: */ --border-color-theme-default: var(--color-border-default); @@ -453,6 +459,7 @@ --border-color-theme-tab-navigation-content-bottom: var(--color-tabnavigation-content-bottom-border); --border-color-theme-tab-active-bottom: var(--color-text-default); + --border-color-theme-secondary-tab-active: var(--color-secondary-tab-border-active); --border-color-theme-tab-content-inactive-bottom: var(--color-background-lvl-4); --border-color-theme-textinput-default: var(--color-textinput-default-border); @@ -722,6 +729,14 @@ /* LT TabNavigation */ --color-tabnavigation-top-bg: var(--color-juno-grey-light-5); --color-tabnavigation-content-bottom-border: var(--color-background-lvl-4); + /* LT SecondaryTabs */ + --color-secondary-tabs-bg: var(--color-juno-grey-light-3); + --color-secondary-tab-bg-active: var(--color-white); + --color-secondary-tab-border-active: var(--color-juno-grey-light-9); + --color-secondary-tab-text: var(--color-text-default); + --color-secondary-tab-text-active: var(--color-text-high); + --color-secondary-tab-text-hover: var(--color-text-highest); + --color-secondary-tab-text-disabled: var(--color-text-disabled); /* LT Filters */ --color-filters-bg: var(--color-background-lvl-1); --color-filter-input-bg: var(--color-background-lvl-0); @@ -1001,6 +1016,14 @@ /* DT TabNavigation */ --color-tabnavigation-top-bg: var(--color-juno-grey-blue-8); --color-tabnavigation-content-bottom-border: var(--color-background-lvl-4); + /* DT SecondaryTabs */ + --color-secondary-tabs-bg: var(--color-background-lvl-2); + --color-secondary-tab-bg-active: var(--color-background-lvl-0); + --color-secondary-tab-border-active: var(--color-border-default); + --color-secondary-tab-text: var(--color-text-default); + --color-secondary-tab-text-active: var(--color-text-high); + --color-secondary-tab-text-hover: var(--color-text-highest); + --color-secondary-tab-text-disabled: var(--color-text-disabled); /* DT Filters */ --color-filters-bg: var(--color-background-lvl-1); --color-filter-input-bg: var(--color-background-lvl-0);