From 1203e26b93ea4d149ff9fad3b8d7e1e247945985 Mon Sep 17 00:00:00 2001 From: MartinS-git Date: Thu, 24 Sep 2026 08:25:23 +0200 Subject: [PATCH 1/4] feat(ui): add SecondaryTabs component family MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add SecondaryTabs, SecondaryTab, and SecondaryTabPanel as a segmented-control tab strip for hierarchical navigation below primary TabNavigation. - Controlled and uncontrolled mode (activeTab / defaultTab / onTabChange) - Per-tab and global disabled state - Optional icons left and right of label - Full keyboard navigation per ARIA APG Tabs pattern: ArrowRight/Left with wrap-around, Home, End, skips disabled tabs - useId() for unique per-instance IDs — no collisions on pages with multiple SecondaryTabs instances - aria-controls / aria-labelledby relationship between tabs and panels - State marker classes: juno-secondary-tab-active, juno-secondary-tab-disabled - forwardRef + displayName on all three components - 14 CSS design tokens added to theme.css and global.css (LT + DT) - 36 tests covering rendering, controlled/uncontrolled, disabled, keyboard nav - Storybook stories under WIP/SecondaryTabs Signed-off-by: MartinS-git --- .changeset/secondary-tabs-component.md | 7 + .../SecondaryTab/SecondaryTab.component.tsx | 154 +++++++++++++++++ .../SecondaryTab/SecondaryTab.stories.tsx | 60 +++++++ .../SecondaryTab/SecondaryTab.test.tsx | 85 ++++++++++ .../src/components/SecondaryTab/index.ts | 7 + .../SecondaryTabPanel.component.tsx | 42 +++++ .../SecondaryTabPanel.stories.tsx | 36 ++++ .../SecondaryTabPanel.test.tsx | 80 +++++++++ .../src/components/SecondaryTabPanel/index.ts | 7 + .../SecondaryTabs/SecondaryTabs.component.tsx | 92 +++++++++++ .../SecondaryTabs/SecondaryTabs.stories.tsx | 120 ++++++++++++++ .../SecondaryTabs/SecondaryTabs.test.tsx | 156 ++++++++++++++++++ .../src/components/SecondaryTabs/index.ts | 7 + packages/ui-components/src/global.css | 23 +++ packages/ui-components/src/index.ts | 6 + packages/ui-components/src/theme.css | 23 +++ 16 files changed, 905 insertions(+) create mode 100644 .changeset/secondary-tabs-component.md create mode 100644 packages/ui-components/src/components/SecondaryTab/SecondaryTab.component.tsx create mode 100644 packages/ui-components/src/components/SecondaryTab/SecondaryTab.stories.tsx create mode 100644 packages/ui-components/src/components/SecondaryTab/SecondaryTab.test.tsx create mode 100644 packages/ui-components/src/components/SecondaryTab/index.ts create mode 100644 packages/ui-components/src/components/SecondaryTabPanel/SecondaryTabPanel.component.tsx create mode 100644 packages/ui-components/src/components/SecondaryTabPanel/SecondaryTabPanel.stories.tsx create mode 100644 packages/ui-components/src/components/SecondaryTabPanel/SecondaryTabPanel.test.tsx create mode 100644 packages/ui-components/src/components/SecondaryTabPanel/index.ts create mode 100644 packages/ui-components/src/components/SecondaryTabs/SecondaryTabs.component.tsx create mode 100644 packages/ui-components/src/components/SecondaryTabs/SecondaryTabs.stories.tsx create mode 100644 packages/ui-components/src/components/SecondaryTabs/SecondaryTabs.test.tsx create mode 100644 packages/ui-components/src/components/SecondaryTabs/index.ts diff --git a/.changeset/secondary-tabs-component.md b/.changeset/secondary-tabs-component.md new file mode 100644 index 0000000000..3d9c99d6ca --- /dev/null +++ b/.changeset/secondary-tabs-component.md @@ -0,0 +1,7 @@ +--- +"@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`) 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). diff --git a/packages/ui-components/src/components/SecondaryTab/SecondaryTab.component.tsx b/packages/ui-components/src/components/SecondaryTab/SecondaryTab.component.tsx new file mode 100644 index 0000000000..f6170c7a7b --- /dev/null +++ b/packages/ui-components/src/components/SecondaryTab/SecondaryTab.component.tsx @@ -0,0 +1,154 @@ +/* + * 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/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. */ + icon?: KnownIcons + /** Icon rendered to the right of the label. */ + iconRight?: KnownIcons + children?: ReactNode + /** @default false */ + disabled?: boolean + /** @default "" */ + className?: string +} + +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 + 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 +` + +/** + * A single selectable tab inside `SecondaryTabs`. + * @see {@link SecondaryTabProps} + */ +export const SecondaryTab = forwardRef( + ( + { + value, + icon, + 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) => { + 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([disabled])') + ) + 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/SecondaryTab/SecondaryTab.stories.tsx b/packages/ui-components/src/components/SecondaryTab/SecondaryTab.stories.tsx new file mode 100644 index 0000000000..2fe6b34bc5 --- /dev/null +++ b/packages/ui-components/src/components/SecondaryTab/SecondaryTab.stories.tsx @@ -0,0 +1,60 @@ +/* + * 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/SecondaryTabs.component" +import { SecondaryTab } from "./SecondaryTab.component" + +const meta: Meta = { + title: "WIP/SecondaryTab", + component: SecondaryTab, + decorators: [ + (Story) => ( + + + + ), + ], + argTypes: { + value: { control: "text" }, + disabled: { control: "boolean" }, + icon: { control: "text" }, + iconRight: { control: "text" }, + }, +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { + value: "demo", + children: "Tab Label", + }, +} + +export const Active: Story = { + args: { + value: "demo", + children: "Active Tab", + }, +} + +export const Disabled: Story = { + args: { + value: "demo", + disabled: true, + children: "Disabled Tab", + }, +} + +export const WithIcon: Story = { + args: { + value: "demo", + icon: "openInNew", + children: "With Icon", + }, +} diff --git a/packages/ui-components/src/components/SecondaryTab/SecondaryTab.test.tsx b/packages/ui-components/src/components/SecondaryTab/SecondaryTab.test.tsx new file mode 100644 index 0000000000..402916e740 --- /dev/null +++ b/packages/ui-components/src/components/SecondaryTab/SecondaryTab.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/SecondaryTabs.component" +import { SecondaryTab } from "./SecondaryTab.component" +import { SecondaryTabPanel } from "../SecondaryTabPanel/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 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("is disabled when disabled prop is passed", () => { + wrap({ disabled: true }) + expect(screen.getByRole("tab")).toBeDisabled() + }) + }) +}) diff --git a/packages/ui-components/src/components/SecondaryTab/index.ts b/packages/ui-components/src/components/SecondaryTab/index.ts new file mode 100644 index 0000000000..11750894fa --- /dev/null +++ b/packages/ui-components/src/components/SecondaryTab/index.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { SecondaryTab } from "./SecondaryTab.component" +export type { SecondaryTabProps } from "./SecondaryTab.component" diff --git a/packages/ui-components/src/components/SecondaryTabPanel/SecondaryTabPanel.component.tsx b/packages/ui-components/src/components/SecondaryTabPanel/SecondaryTabPanel.component.tsx new file mode 100644 index 0000000000..6f794f40fa --- /dev/null +++ b/packages/ui-components/src/components/SecondaryTabPanel/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/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/SecondaryTabPanel/SecondaryTabPanel.stories.tsx b/packages/ui-components/src/components/SecondaryTabPanel/SecondaryTabPanel.stories.tsx new file mode 100644 index 0000000000..a9a0cc941e --- /dev/null +++ b/packages/ui-components/src/components/SecondaryTabPanel/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/SecondaryTabs.component" +import { SecondaryTab } from "../SecondaryTab/SecondaryTab.component" +import { SecondaryTabPanel } from "./SecondaryTabPanel.component" + +const meta: Meta = { + title: "WIP/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/SecondaryTabPanel/SecondaryTabPanel.test.tsx b/packages/ui-components/src/components/SecondaryTabPanel/SecondaryTabPanel.test.tsx new file mode 100644 index 0000000000..034e9ac9c1 --- /dev/null +++ b/packages/ui-components/src/components/SecondaryTabPanel/SecondaryTabPanel.test.tsx @@ -0,0 +1,80 @@ +/* + * 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/SecondaryTabs.component" +import { SecondaryTab } from "../SecondaryTab/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 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/SecondaryTabPanel/index.ts b/packages/ui-components/src/components/SecondaryTabPanel/index.ts new file mode 100644 index 0000000000..b1d8426ac8 --- /dev/null +++ b/packages/ui-components/src/components/SecondaryTabPanel/index.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { SecondaryTabPanel } from "./SecondaryTabPanel.component" +export type { SecondaryTabPanelProps } from "./SecondaryTabPanel.component" 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..d600fb82e3 --- /dev/null +++ b/packages/ui-components/src/components/SecondaryTabs/SecondaryTabs.component.tsx @@ -0,0 +1,92 @@ +/* + * 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 } 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 = ({ + activeTab: activeTabProp, + defaultTab, + onTabChange, + disabled = false, + children, + className = "", + ...props +}: SecondaryTabsProps) => { + 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..c1650b6b66 --- /dev/null +++ b/packages/ui-components/src/components/SecondaryTabs/SecondaryTabs.stories.tsx @@ -0,0 +1,120 @@ +/* + * 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/SecondaryTab.component" +import { SecondaryTabPanel } from "../SecondaryTabPanel/SecondaryTabPanel.component" + +const meta: Meta = { + title: "WIP/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 = { + render: (args) => ( + + Overview + Details + Logs + +
Overview content
+
+ +
Details content
+
+ +
Logs content
+
+
+ ), +} + +export const WithIcons: Story = { + render: (args) => ( + + Compute + Storage + Network + +
Compute content
+
+ +
Storage content
+
+ +
Network content
+
+
+ ), +} + +export const AllDisabled: Story = { + render: (args) => ( + + Overview + Details + Logs + + ), +} + +export const SingleTabDisabled: Story = { + render: (args) => ( + + Overview + + Details + + Logs + +
Overview content
+
+ +
Logs content
+
+
+ ), +} + +export const Controlled: Story = { + 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..ed892e97e7 --- /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/SecondaryTab.component" +import { SecondaryTabPanel } from "../SecondaryTabPanel/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).toBeDisabled() + }) + }) + + 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..aaf8b0e957 --- /dev/null +++ b/packages/ui-components/src/components/SecondaryTabs/index.ts @@ -0,0 +1,7 @@ +/* + * 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" 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..11cc70774f 100644 --- a/packages/ui-components/src/index.ts +++ b/packages/ui-components/src/index.ts @@ -105,6 +105,9 @@ 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/SecondaryTab/SecondaryTab.component" +export { SecondaryTabPanel } from "./components/SecondaryTabPanel/SecondaryTabPanel.component" export { Textarea } from "./components/Textarea/Textarea.component" export { Tabs } from "./components/Tabs/Tabs.component" export { TextInput } from "./components/TextInput/TextInput.component" @@ -235,6 +238,9 @@ 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 { SecondaryTabProps } from "./components/SecondaryTab/SecondaryTab.component" +export type { SecondaryTabPanelProps } from "./components/SecondaryTabPanel/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); From 83ed76000ffdcaba4e7a76b96d46c0bdeeb28b08 Mon Sep 17 00:00:00 2001 From: MartinS-git Date: Thu, 24 Sep 2026 11:16:36 +0200 Subject: [PATCH 2/4] feat(ui): consolidate SecondaryTabs sub-components into single folder Move SecondaryTab and SecondaryTabPanel from separate folders into SecondaryTabs/, matching the established single-folder convention. Rename SecondaryTab `icon` prop to `iconLeft` for consistency with `iconRight`. Add KnownIconsEnum-based select controls for iconLeft and iconRight in Storybook. Update all imports and re-exports in src/index.ts accordingly. Signed-off-by: MartinS-git --- .../src/components/SecondaryTab/index.ts | 7 ---- .../src/components/SecondaryTabPanel/index.ts | 7 ---- .../SecondaryTab.component.tsx | 12 +++--- .../SecondaryTab.stories.tsx | 38 ++++++++++++++++--- .../SecondaryTab.test.tsx | 4 +- .../SecondaryTabPanel.component.tsx | 2 +- .../SecondaryTabPanel.stories.tsx | 4 +- .../SecondaryTabPanel.test.tsx | 4 +- .../SecondaryTabs/SecondaryTabs.component.tsx | 11 +++++- .../SecondaryTabs/SecondaryTabs.stories.tsx | 16 +++++--- .../SecondaryTabs/SecondaryTabs.test.tsx | 4 +- .../src/components/SecondaryTabs/index.ts | 4 ++ packages/ui-components/src/index.ts | 8 ++-- 13 files changed, 75 insertions(+), 46 deletions(-) delete mode 100644 packages/ui-components/src/components/SecondaryTab/index.ts delete mode 100644 packages/ui-components/src/components/SecondaryTabPanel/index.ts rename packages/ui-components/src/components/{SecondaryTab => SecondaryTabs}/SecondaryTab.component.tsx (93%) rename packages/ui-components/src/components/{SecondaryTab => SecondaryTabs}/SecondaryTab.stories.tsx (57%) rename packages/ui-components/src/components/{SecondaryTab => SecondaryTabs}/SecondaryTab.test.tsx (93%) rename packages/ui-components/src/components/{SecondaryTabPanel => SecondaryTabs}/SecondaryTabPanel.component.tsx (93%) rename packages/ui-components/src/components/{SecondaryTabPanel => SecondaryTabs}/SecondaryTabPanel.stories.tsx (85%) rename packages/ui-components/src/components/{SecondaryTabPanel => SecondaryTabs}/SecondaryTabPanel.test.tsx (94%) diff --git a/packages/ui-components/src/components/SecondaryTab/index.ts b/packages/ui-components/src/components/SecondaryTab/index.ts deleted file mode 100644 index 11750894fa..0000000000 --- a/packages/ui-components/src/components/SecondaryTab/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Juno contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -export { SecondaryTab } from "./SecondaryTab.component" -export type { SecondaryTabProps } from "./SecondaryTab.component" diff --git a/packages/ui-components/src/components/SecondaryTabPanel/index.ts b/packages/ui-components/src/components/SecondaryTabPanel/index.ts deleted file mode 100644 index b1d8426ac8..0000000000 --- a/packages/ui-components/src/components/SecondaryTabPanel/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Juno contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -export { SecondaryTabPanel } from "./SecondaryTabPanel.component" -export type { SecondaryTabPanelProps } from "./SecondaryTabPanel.component" diff --git a/packages/ui-components/src/components/SecondaryTab/SecondaryTab.component.tsx b/packages/ui-components/src/components/SecondaryTabs/SecondaryTab.component.tsx similarity index 93% rename from packages/ui-components/src/components/SecondaryTab/SecondaryTab.component.tsx rename to packages/ui-components/src/components/SecondaryTabs/SecondaryTab.component.tsx index f6170c7a7b..1602f0a54a 100644 --- a/packages/ui-components/src/components/SecondaryTab/SecondaryTab.component.tsx +++ b/packages/ui-components/src/components/SecondaryTabs/SecondaryTab.component.tsx @@ -6,13 +6,13 @@ import React, { forwardRef, ButtonHTMLAttributes, ReactNode } from "react" import { Icon } from "../Icon/Icon.component" import { KnownIcons } from "../Icon/Icon.component.js" -import { useSecondaryTabsContext } from "../SecondaryTabs/SecondaryTabs.component" +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. */ - icon?: KnownIcons + iconLeft?: KnownIcons /** Icon rendered to the right of the label. */ iconRight?: KnownIcons children?: ReactNode @@ -72,7 +72,7 @@ export const SecondaryTab = forwardRef( ( { value, - icon, + iconLeft, iconRight, disabled: disabledProp, onClick, @@ -97,9 +97,7 @@ export const SecondaryTab = forwardRef( const handleKeyDown = (e: React.KeyboardEvent) => { const tablist = e.currentTarget.closest('[role="tablist"]') if (tablist) { - const enabledTabs = Array.from( - tablist.querySelectorAll('[role="tab"]:not([disabled])') - ) + const enabledTabs = Array.from(tablist.querySelectorAll('[role="tab"]:not([disabled])')) const currentIndex = enabledTabs.indexOf(e.currentTarget) let nextTab: HTMLButtonElement | null = null @@ -143,7 +141,7 @@ export const SecondaryTab = forwardRef( onKeyDown={handleKeyDown} className={`juno-secondary-tab ${isActive ? "juno-secondary-tab-active" : ""} ${isDisabled ? "juno-secondary-tab-disabled" : ""} ${tabBaseStyles} ${isActive ? tabActiveStyles : tabDefaultStyles} ${isDisabled ? tabDisabledStyles : ""} ${className}`} > - {icon && } + {iconLeft && } {children} {iconRight && } diff --git a/packages/ui-components/src/components/SecondaryTab/SecondaryTab.stories.tsx b/packages/ui-components/src/components/SecondaryTabs/SecondaryTab.stories.tsx similarity index 57% rename from packages/ui-components/src/components/SecondaryTab/SecondaryTab.stories.tsx rename to packages/ui-components/src/components/SecondaryTabs/SecondaryTab.stories.tsx index 2fe6b34bc5..b0ff5b278f 100644 --- a/packages/ui-components/src/components/SecondaryTab/SecondaryTab.stories.tsx +++ b/packages/ui-components/src/components/SecondaryTabs/SecondaryTab.stories.tsx @@ -5,9 +5,12 @@ import React from "react" import { Meta, StoryObj } from "@storybook/react-vite" -import { SecondaryTabs } from "../SecondaryTabs/SecondaryTabs.component" +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/SecondaryTab", component: SecondaryTab, @@ -21,8 +24,14 @@ const meta: Meta = { argTypes: { value: { control: "text" }, disabled: { control: "boolean" }, - icon: { control: "text" }, - iconRight: { control: "text" }, + iconLeft: { + options: iconOptions, + control: { type: "select" }, + }, + iconRight: { + options: iconOptions, + control: { type: "select" }, + }, }, } @@ -51,10 +60,27 @@ export const Disabled: Story = { }, } -export const WithIcon: Story = { +export const WithIconLeft: Story = { + args: { + value: "demo", + iconLeft: "openInNew", + children: "With Icon Left", + }, +} + +export const WithIconRight: Story = { + args: { + value: "demo", + iconRight: "info", + children: "With Icon Right", + }, +} + +export const WithBothIcons: Story = { args: { value: "demo", - icon: "openInNew", - children: "With Icon", + iconLeft: "openInNew", + iconRight: "info", + children: "Both Icons", }, } diff --git a/packages/ui-components/src/components/SecondaryTab/SecondaryTab.test.tsx b/packages/ui-components/src/components/SecondaryTabs/SecondaryTab.test.tsx similarity index 93% rename from packages/ui-components/src/components/SecondaryTab/SecondaryTab.test.tsx rename to packages/ui-components/src/components/SecondaryTabs/SecondaryTab.test.tsx index 402916e740..96add02209 100644 --- a/packages/ui-components/src/components/SecondaryTab/SecondaryTab.test.tsx +++ b/packages/ui-components/src/components/SecondaryTabs/SecondaryTab.test.tsx @@ -6,9 +6,9 @@ import * as React from "react" import { render, screen } from "@testing-library/react" import { describe, expect, test } from "vitest" -import { SecondaryTabs } from "../SecondaryTabs/SecondaryTabs.component" +import { SecondaryTabs } from "./SecondaryTabs.component" import { SecondaryTab } from "./SecondaryTab.component" -import { SecondaryTabPanel } from "../SecondaryTabPanel/SecondaryTabPanel.component" +import { SecondaryTabPanel } from "./SecondaryTabPanel.component" const wrap = (props = {}) => render( diff --git a/packages/ui-components/src/components/SecondaryTabPanel/SecondaryTabPanel.component.tsx b/packages/ui-components/src/components/SecondaryTabs/SecondaryTabPanel.component.tsx similarity index 93% rename from packages/ui-components/src/components/SecondaryTabPanel/SecondaryTabPanel.component.tsx rename to packages/ui-components/src/components/SecondaryTabs/SecondaryTabPanel.component.tsx index 6f794f40fa..753256aae0 100644 --- a/packages/ui-components/src/components/SecondaryTabPanel/SecondaryTabPanel.component.tsx +++ b/packages/ui-components/src/components/SecondaryTabs/SecondaryTabPanel.component.tsx @@ -4,7 +4,7 @@ */ import React, { forwardRef, HTMLAttributes, ReactNode } from "react" -import { useSecondaryTabsContext } from "../SecondaryTabs/SecondaryTabs.component" +import { useSecondaryTabsContext } from "./SecondaryTabs.component" export interface SecondaryTabPanelProps extends HTMLAttributes { /** Must match the `value` of the corresponding `SecondaryTab`. */ diff --git a/packages/ui-components/src/components/SecondaryTabPanel/SecondaryTabPanel.stories.tsx b/packages/ui-components/src/components/SecondaryTabs/SecondaryTabPanel.stories.tsx similarity index 85% rename from packages/ui-components/src/components/SecondaryTabPanel/SecondaryTabPanel.stories.tsx rename to packages/ui-components/src/components/SecondaryTabs/SecondaryTabPanel.stories.tsx index a9a0cc941e..cf99801960 100644 --- a/packages/ui-components/src/components/SecondaryTabPanel/SecondaryTabPanel.stories.tsx +++ b/packages/ui-components/src/components/SecondaryTabs/SecondaryTabPanel.stories.tsx @@ -5,8 +5,8 @@ import React from "react" import { Meta, StoryObj } from "@storybook/react-vite" -import { SecondaryTabs } from "../SecondaryTabs/SecondaryTabs.component" -import { SecondaryTab } from "../SecondaryTab/SecondaryTab.component" +import { SecondaryTabs } from "./SecondaryTabs.component" +import { SecondaryTab } from "./SecondaryTab.component" import { SecondaryTabPanel } from "./SecondaryTabPanel.component" const meta: Meta = { diff --git a/packages/ui-components/src/components/SecondaryTabPanel/SecondaryTabPanel.test.tsx b/packages/ui-components/src/components/SecondaryTabs/SecondaryTabPanel.test.tsx similarity index 94% rename from packages/ui-components/src/components/SecondaryTabPanel/SecondaryTabPanel.test.tsx rename to packages/ui-components/src/components/SecondaryTabs/SecondaryTabPanel.test.tsx index 034e9ac9c1..855434de34 100644 --- a/packages/ui-components/src/components/SecondaryTabPanel/SecondaryTabPanel.test.tsx +++ b/packages/ui-components/src/components/SecondaryTabs/SecondaryTabPanel.test.tsx @@ -6,8 +6,8 @@ import * as React from "react" import { render, screen } from "@testing-library/react" import { describe, expect, test } from "vitest" -import { SecondaryTabs } from "../SecondaryTabs/SecondaryTabs.component" -import { SecondaryTab } from "../SecondaryTab/SecondaryTab.component" +import { SecondaryTabs } from "./SecondaryTabs.component" +import { SecondaryTab } from "./SecondaryTab.component" import { SecondaryTabPanel } from "./SecondaryTabPanel.component" const wrap = (props = {}) => diff --git a/packages/ui-components/src/components/SecondaryTabs/SecondaryTabs.component.tsx b/packages/ui-components/src/components/SecondaryTabs/SecondaryTabs.component.tsx index d600fb82e3..93c7c4e3da 100644 --- a/packages/ui-components/src/components/SecondaryTabs/SecondaryTabs.component.tsx +++ b/packages/ui-components/src/components/SecondaryTabs/SecondaryTabs.component.tsx @@ -3,7 +3,16 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { createContext, useContext, useState, useId, Children, isValidElement, HTMLAttributes, ReactNode } from "react" +import React, { + createContext, + useContext, + useState, + useId, + Children, + isValidElement, + HTMLAttributes, + ReactNode, +} from "react" export interface SecondaryTabsContextType { activeTab: string | undefined diff --git a/packages/ui-components/src/components/SecondaryTabs/SecondaryTabs.stories.tsx b/packages/ui-components/src/components/SecondaryTabs/SecondaryTabs.stories.tsx index c1650b6b66..9a626407ab 100644 --- a/packages/ui-components/src/components/SecondaryTabs/SecondaryTabs.stories.tsx +++ b/packages/ui-components/src/components/SecondaryTabs/SecondaryTabs.stories.tsx @@ -6,8 +6,8 @@ import React from "react" import { Meta, StoryObj } from "@storybook/react-vite" import { SecondaryTabs } from "./SecondaryTabs.component" -import { SecondaryTab } from "../SecondaryTab/SecondaryTab.component" -import { SecondaryTabPanel } from "../SecondaryTabPanel/SecondaryTabPanel.component" +import { SecondaryTab } from "./SecondaryTab.component" +import { SecondaryTabPanel } from "./SecondaryTabPanel.component" const meta: Meta = { title: "WIP/SecondaryTabs", @@ -53,9 +53,15 @@ export const Default: Story = { export const WithIcons: Story = { render: (args) => ( - Compute - Storage - Network + + Compute + + + Storage + + + Network +
Compute content
diff --git a/packages/ui-components/src/components/SecondaryTabs/SecondaryTabs.test.tsx b/packages/ui-components/src/components/SecondaryTabs/SecondaryTabs.test.tsx index ed892e97e7..8cea83fda5 100644 --- a/packages/ui-components/src/components/SecondaryTabs/SecondaryTabs.test.tsx +++ b/packages/ui-components/src/components/SecondaryTabs/SecondaryTabs.test.tsx @@ -8,8 +8,8 @@ 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/SecondaryTab.component" -import { SecondaryTabPanel } from "../SecondaryTabPanel/SecondaryTabPanel.component" +import { SecondaryTab } from "./SecondaryTab.component" +import { SecondaryTabPanel } from "./SecondaryTabPanel.component" const renderTabs = (props = {}) => render( diff --git a/packages/ui-components/src/components/SecondaryTabs/index.ts b/packages/ui-components/src/components/SecondaryTabs/index.ts index aaf8b0e957..6bae51c39c 100644 --- a/packages/ui-components/src/components/SecondaryTabs/index.ts +++ b/packages/ui-components/src/components/SecondaryTabs/index.ts @@ -5,3 +5,7 @@ export { SecondaryTabs } from "./SecondaryTabs.component" export type { SecondaryTabsProps } 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/index.ts b/packages/ui-components/src/index.ts index 11cc70774f..35fc6284b1 100644 --- a/packages/ui-components/src/index.ts +++ b/packages/ui-components/src/index.ts @@ -106,8 +106,8 @@ export { TabNavigation } from "./components/TabNavigation/TabNavigation.componen 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/SecondaryTab/SecondaryTab.component" -export { SecondaryTabPanel } from "./components/SecondaryTabPanel/SecondaryTabPanel.component" +export { SecondaryTab } from "./components/SecondaryTabs/SecondaryTab.component" +export { SecondaryTabPanel } from "./components/SecondaryTabs/SecondaryTabPanel.component" export { Textarea } from "./components/Textarea/Textarea.component" export { Tabs } from "./components/Tabs/Tabs.component" export { TextInput } from "./components/TextInput/TextInput.component" @@ -239,8 +239,8 @@ export type { 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 { SecondaryTabProps } from "./components/SecondaryTab/SecondaryTab.component" -export type { SecondaryTabPanelProps } from "./components/SecondaryTabPanel/SecondaryTabPanel.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" From d14a8c99685c8ee01fe3d653b3988f836589f263 Mon Sep 17 00:00:00 2001 From: MartinS-git Date: Tue, 29 Sep 2026 11:02:54 +0200 Subject: [PATCH 3/4] fix(ui): apply all A11y and review findings to SecondaryTabs family - Replace native disabled with aria-disabled on SecondaryTab so disabled tabs remain in the AT tree (NVDA/JAWS remove natively-disabled buttons) - Add handleClick guard that returns early when aria-disabled is true - Update keyboard-nav selector from [disabled] to [aria-disabled="true"] - Apply roving tabindex: active non-disabled tab gets tabIndex=0, all others -1 - Add aria-orientation="horizontal" on the tablist per ARIA APG - Wrap SecondaryTabs in forwardRef (SecondaryTab already had it) - Apply jn:text-theme-secondary-tab-disabled token on disabled tabs - Fix hover styles: only apply when not active AND not disabled - Add juno-secondary-tab-active class for active-state targeting in tests/CSS - Export useSecondaryTabsContext and SecondaryTabsContextType from package root - Update SecondaryTab.test: replace toBeDisabled() with aria-disabled and tabIndex checks - Update SecondaryTabs.test: replace toBeDisabled() with toHaveAttribute aria-disabled - Add props-forwarding test to SecondaryTabPanel.test - Remove dead Active story from SecondaryTab.stories, add Inactive story - Add docs descriptions to all stories across all three story files - Group all SecondaryTabs stories under WIP/SecondaryTabs/* sidebar folder - Update changeset with full A11y rationale Signed-off-by: MartinS-git --- .changeset/secondary-tabs-component.md | 4 +- .../SecondaryTabs/SecondaryTab.component.tsx | 17 ++-- .../SecondaryTabs/SecondaryTab.stories.tsx | 50 ++++++++++- .../SecondaryTabs/SecondaryTab.test.tsx | 14 ++- .../SecondaryTabPanel.stories.tsx | 2 +- .../SecondaryTabs/SecondaryTabPanel.test.tsx | 5 ++ .../SecondaryTabs/SecondaryTabs.component.tsx | 86 +++++++++++-------- .../SecondaryTabs/SecondaryTabs.stories.tsx | 37 +++++++- .../SecondaryTabs/SecondaryTabs.test.tsx | 2 +- .../src/components/SecondaryTabs/index.ts | 2 + packages/ui-components/src/index.ts | 2 + 11 files changed, 169 insertions(+), 52 deletions(-) diff --git a/.changeset/secondary-tabs-component.md b/.changeset/secondary-tabs-component.md index 3d9c99d6ca..c419294130 100644 --- a/.changeset/secondary-tabs-component.md +++ b/.changeset/secondary-tabs-component.md @@ -4,4 +4,6 @@ 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`) 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). +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 index 1602f0a54a..70dd17fd8e 100644 --- a/packages/ui-components/src/components/SecondaryTabs/SecondaryTab.component.tsx +++ b/packages/ui-components/src/components/SecondaryTabs/SecondaryTab.component.tsx @@ -20,6 +20,8 @@ export interface SecondaryTabProps extends ButtonHTMLAttributes( const isDisabled = disabledProp ?? contextDisabled const handleClick = (e: React.MouseEvent) => { + if (isDisabled) return setActiveTab(value) onClick?.(e) } @@ -97,7 +102,9 @@ export const SecondaryTab = forwardRef( const handleKeyDown = (e: React.KeyboardEvent) => { const tablist = e.currentTarget.closest('[role="tablist"]') if (tablist) { - const enabledTabs = Array.from(tablist.querySelectorAll('[role="tab"]:not([disabled])')) + const enabledTabs = Array.from( + tablist.querySelectorAll('[role="tab"]:not([aria-disabled="true"])') + ) const currentIndex = enabledTabs.indexOf(e.currentTarget) let nextTab: HTMLButtonElement | null = null @@ -132,14 +139,14 @@ export const SecondaryTab = forwardRef( {...rest} role="tab" aria-selected={isActive} + aria-disabled={isDisabled ? true : undefined} aria-controls={`${tabsId}-tabpanel-${value}`} id={`${tabsId}-tab-${value}`} data-value={value} - disabled={isDisabled} - tabIndex={isActive ? 0 : -1} + tabIndex={isActive && !isDisabled ? 0 : -1} onClick={handleClick} onKeyDown={handleKeyDown} - className={`juno-secondary-tab ${isActive ? "juno-secondary-tab-active" : ""} ${isDisabled ? "juno-secondary-tab-disabled" : ""} ${tabBaseStyles} ${isActive ? tabActiveStyles : tabDefaultStyles} ${isDisabled ? tabDisabledStyles : ""} ${className}`} + className={`juno-secondary-tab ${isActive ? "juno-secondary-tab-active" : ""} ${isDisabled ? "juno-secondary-tab-disabled" : ""} ${tabBaseStyles} ${isActive ? tabActiveStyles : `${tabDefaultStyles} ${!isDisabled ? tabHoverStyles : ""}`} ${isDisabled ? tabDisabledStyles : ""} ${className}`} > {iconLeft && } {children} diff --git a/packages/ui-components/src/components/SecondaryTabs/SecondaryTab.stories.tsx b/packages/ui-components/src/components/SecondaryTabs/SecondaryTab.stories.tsx index b0ff5b278f..cfe986330f 100644 --- a/packages/ui-components/src/components/SecondaryTabs/SecondaryTab.stories.tsx +++ b/packages/ui-components/src/components/SecondaryTabs/SecondaryTab.stories.tsx @@ -12,7 +12,7 @@ import { SecondaryTab } from "./SecondaryTab.component" const iconOptions = [undefined, ...Object.values(KnownIconsEnum)] as const const meta: Meta = { - title: "WIP/SecondaryTab", + title: "WIP/SecondaryTabs/SecondaryTab", component: SecondaryTab, decorators: [ (Story) => ( @@ -39,20 +39,41 @@ 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 Active: Story = { +export const Inactive: Story = { + parameters: { + docs: { + description: { + story: "A tab that is not the currently active tab.", + }, + }, + }, args: { - value: "demo", - children: "Active Tab", + 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, @@ -61,6 +82,13 @@ export const Disabled: Story = { } export const WithIconLeft: Story = { + parameters: { + docs: { + description: { + story: "Tab with an icon rendered to the left of the label.", + }, + }, + }, args: { value: "demo", iconLeft: "openInNew", @@ -69,6 +97,13 @@ export const WithIconLeft: Story = { } export const WithIconRight: Story = { + parameters: { + docs: { + description: { + story: "Tab with an icon rendered to the right of the label.", + }, + }, + }, args: { value: "demo", iconRight: "info", @@ -77,6 +112,13 @@ export const WithIconRight: Story = { } export const WithBothIcons: Story = { + parameters: { + docs: { + description: { + story: "Tab with icons on both sides.", + }, + }, + }, args: { value: "demo", iconLeft: "openInNew", diff --git a/packages/ui-components/src/components/SecondaryTabs/SecondaryTab.test.tsx b/packages/ui-components/src/components/SecondaryTabs/SecondaryTab.test.tsx index 96add02209..61ae08c7cb 100644 --- a/packages/ui-components/src/components/SecondaryTabs/SecondaryTab.test.tsx +++ b/packages/ui-components/src/components/SecondaryTabs/SecondaryTab.test.tsx @@ -31,6 +31,11 @@ describe("SecondaryTab", () => { 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") @@ -77,9 +82,14 @@ describe("SecondaryTab", () => { }) describe("Disabled state", () => { - test("is disabled when disabled prop is passed", () => { + 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")).toBeDisabled() + expect(screen.getByRole("tab")).toHaveAttribute("tabindex", "-1") }) }) }) diff --git a/packages/ui-components/src/components/SecondaryTabs/SecondaryTabPanel.stories.tsx b/packages/ui-components/src/components/SecondaryTabs/SecondaryTabPanel.stories.tsx index cf99801960..aea5373f21 100644 --- a/packages/ui-components/src/components/SecondaryTabs/SecondaryTabPanel.stories.tsx +++ b/packages/ui-components/src/components/SecondaryTabs/SecondaryTabPanel.stories.tsx @@ -10,7 +10,7 @@ import { SecondaryTab } from "./SecondaryTab.component" import { SecondaryTabPanel } from "./SecondaryTabPanel.component" const meta: Meta = { - title: "WIP/SecondaryTabPanel", + title: "WIP/SecondaryTabs/SecondaryTabPanel", component: SecondaryTabPanel, decorators: [ (Story) => ( diff --git a/packages/ui-components/src/components/SecondaryTabs/SecondaryTabPanel.test.tsx b/packages/ui-components/src/components/SecondaryTabs/SecondaryTabPanel.test.tsx index 855434de34..7a9fd5ade6 100644 --- a/packages/ui-components/src/components/SecondaryTabs/SecondaryTabPanel.test.tsx +++ b/packages/ui-components/src/components/SecondaryTabs/SecondaryTabPanel.test.tsx @@ -64,6 +64,11 @@ describe("SecondaryTabPanel", () => { 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( diff --git a/packages/ui-components/src/components/SecondaryTabs/SecondaryTabs.component.tsx b/packages/ui-components/src/components/SecondaryTabs/SecondaryTabs.component.tsx index 93c7c4e3da..6f7d85ef2f 100644 --- a/packages/ui-components/src/components/SecondaryTabs/SecondaryTabs.component.tsx +++ b/packages/ui-components/src/components/SecondaryTabs/SecondaryTabs.component.tsx @@ -12,6 +12,7 @@ import React, { isValidElement, HTMLAttributes, ReactNode, + forwardRef, } from "react" export interface SecondaryTabsContextType { @@ -55,47 +56,58 @@ const tablistStyles = ` * children are rendered after the pill container but stay within the same context. * @see {@link SecondaryTabsProps} */ -export const SecondaryTabs = ({ - activeTab: activeTabProp, - defaultTab, - onTabChange, - disabled = false, - children, - className = "", - ...props -}: SecondaryTabsProps) => { - const tabsId = useId() - const isControlled = activeTabProp !== undefined - const [internalTab, setInternalTab] = useState(defaultTab) +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 activeTab = isControlled ? activeTabProp : internalTab - const setActiveTab = (value: string) => { - if (!isControlled) setInternalTab(value) - onTabChange?.(value) - } + const setActiveTab = (value: string) => { + if (!isControlled) setInternalTab(value) + onTabChange?.(value) + } - const tabs: ReactNode[] = [] - const panels: ReactNode[] = [] + 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) - } - }) + 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} -
- ) -} + 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 index 9a626407ab..11aa9cc509 100644 --- a/packages/ui-components/src/components/SecondaryTabs/SecondaryTabs.stories.tsx +++ b/packages/ui-components/src/components/SecondaryTabs/SecondaryTabs.stories.tsx @@ -10,7 +10,7 @@ import { SecondaryTab } from "./SecondaryTab.component" import { SecondaryTabPanel } from "./SecondaryTabPanel.component" const meta: Meta = { - title: "WIP/SecondaryTabs", + title: "WIP/SecondaryTabs/SecondaryTabs", component: SecondaryTabs, argTypes: { activeTab: { control: "text" }, @@ -32,6 +32,13 @@ 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 @@ -51,6 +58,13 @@ export const Default: Story = { } export const WithIcons: Story = { + parameters: { + docs: { + description: { + story: "Tabs with icons rendered to the left of each label.", + }, + }, + }, render: (args) => ( @@ -76,6 +90,13 @@ export const WithIcons: Story = { } export const AllDisabled: Story = { + parameters: { + docs: { + description: { + story: "All tabs disabled via the parent `disabled` prop.", + }, + }, + }, render: (args) => ( Overview @@ -86,6 +107,13 @@ export const AllDisabled: Story = { } export const SingleTabDisabled: Story = { + parameters: { + docs: { + description: { + story: "One tab disabled individually. Keyboard navigation skips it.", + }, + }, + }, render: (args) => ( Overview @@ -104,6 +132,13 @@ export const SingleTabDisabled: Story = { } export const Controlled: Story = { + parameters: { + docs: { + description: { + story: "Controlled mode: active tab driven by the `activeTab` prop.", + }, + }, + }, args: { activeTab: "details", }, diff --git a/packages/ui-components/src/components/SecondaryTabs/SecondaryTabs.test.tsx b/packages/ui-components/src/components/SecondaryTabs/SecondaryTabs.test.tsx index 8cea83fda5..41cd3a85e3 100644 --- a/packages/ui-components/src/components/SecondaryTabs/SecondaryTabs.test.tsx +++ b/packages/ui-components/src/components/SecondaryTabs/SecondaryTabs.test.tsx @@ -96,7 +96,7 @@ describe("SecondaryTabs", () => { test("disables all tabs when disabled prop is set", () => { renderTabs({ disabled: true, defaultTab: undefined }) screen.getAllByRole("tab").forEach((tab) => { - expect(tab).toBeDisabled() + expect(tab).toHaveAttribute("aria-disabled", "true") }) }) diff --git a/packages/ui-components/src/components/SecondaryTabs/index.ts b/packages/ui-components/src/components/SecondaryTabs/index.ts index 6bae51c39c..be0b252ca5 100644 --- a/packages/ui-components/src/components/SecondaryTabs/index.ts +++ b/packages/ui-components/src/components/SecondaryTabs/index.ts @@ -5,6 +5,8 @@ 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" diff --git a/packages/ui-components/src/index.ts b/packages/ui-components/src/index.ts index 35fc6284b1..f43180f299 100644 --- a/packages/ui-components/src/index.ts +++ b/packages/ui-components/src/index.ts @@ -108,6 +108,7 @@ 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" @@ -239,6 +240,7 @@ export type { 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" From 8ad6d867ebe162e5cd7a04babfa2f339e42bc2b6 Mon Sep 17 00:00:00 2001 From: MartinS-git Date: Tue, 29 Sep 2026 11:07:14 +0200 Subject: [PATCH 4/4] style(ui): format SecondaryTabs props destructuring Signed-off-by: MartinS-git --- .../SecondaryTabs/SecondaryTabs.component.tsx | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/ui-components/src/components/SecondaryTabs/SecondaryTabs.component.tsx b/packages/ui-components/src/components/SecondaryTabs/SecondaryTabs.component.tsx index 6f7d85ef2f..00922b5d77 100644 --- a/packages/ui-components/src/components/SecondaryTabs/SecondaryTabs.component.tsx +++ b/packages/ui-components/src/components/SecondaryTabs/SecondaryTabs.component.tsx @@ -58,15 +58,7 @@ const tablistStyles = ` */ export const SecondaryTabs = forwardRef( ( - { - activeTab: activeTabProp, - defaultTab, - onTabChange, - disabled = false, - children, - className = "", - ...props - }, + { activeTab: activeTabProp, defaultTab, onTabChange, disabled = false, children, className = "", ...props }, ref ) => { const tabsId = useId()