diff --git a/.changeset/tabbar-appearance.md b/.changeset/tabbar-appearance.md new file mode 100644 index 0000000000..462e88283a --- /dev/null +++ b/.changeset/tabbar-appearance.md @@ -0,0 +1,10 @@ +--- +"@cloudoperators/juno-ui-components": minor +--- + +feat(ui): add TabBar `appearance="buttons"` and rename `appearance="main"` to `appearance="tabs"` + +- Add `appearance="buttons"` to `TabBar` for a pill-style segmented control, useful for pre-defined filters. +- Rename `appearance="main"` to `appearance="tabs"`. `appearance="tabs"` is now the default. +- `appearance="main"` keeps working as a deprecated alias and emits a deprecation warning. Switch to `appearance="tabs"`. +- `appearance="content"` remains a distinct style variant with a shared bottom border (unchanged). diff --git a/packages/ui-components/src/components/TabBar/TabBar.component.tsx b/packages/ui-components/src/components/TabBar/TabBar.component.tsx index d0ca709222..7d5f1ef29d 100644 --- a/packages/ui-components/src/components/TabBar/TabBar.component.tsx +++ b/packages/ui-components/src/components/TabBar/TabBar.component.tsx @@ -3,14 +3,24 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { createContext, HTMLAttributes, ReactNode } from "react" +import React, { createContext, HTMLAttributes, ReactNode, useEffect, useRef } from "react" import { Navigation } from "../Navigation/Navigation.component" const tabBarStyles = ` jn:flex ` -export type TabBarAppearance = "main" | "content" +const tabBarButtonsStyles = ` + jn:w-fit + jn:bg-theme-tab-buttons + jn:rounded-sm + jn:p-0.5 +` + +/** @deprecated Use `"tabs"` instead. `"main"` remains functional but will be removed in a future major release. */ +export type DeprecatedTabBarAppearance = "main" + +export type TabBarAppearance = "tabs" | "buttons" | "content" | DeprecatedTabBarAppearance /** @deprecated Use TabBarAppearance instead. Can be removed when TabNavigation is removed. */ export type TabStyle = TabBarAppearance @@ -46,7 +56,16 @@ export const TabBar = ({ tabStyle, // Can be removed when TabNavigation is removed ...props }: TabBarProps): ReactNode => { - const resolvedAppearance = appearance || tabStyle || "main" // `|| tabStyle` can be removed when TabNavigation is removed + const resolvedAppearance = appearance || tabStyle || "tabs" // `|| tabStyle` can be removed when TabNavigation is removed + + const warnedDeprecatedMain = useRef(false) + useEffect(() => { + if (appearance === "main" && !warnedDeprecatedMain.current) { + warnedDeprecatedMain.current = true + console.warn('TabBar: appearance="main" is deprecated. Use appearance="tabs" instead.') + } + }, [appearance]) + return ( { /** A handler to execute when the active tab changes */ // eslint-disable-next-line no-unused-vars onActiveItemChange?: (activeItem: ReactNode) => void - /** The visual appearance of the TabBar. Use `main` as the first child in an AppShell. Use `content` for tabs inside page content — adds a darkened bottom border on inactive tabs. */ + /** The visual appearance of the TabBar. Defaults to `tabs`. Use `tabs` for tabs inside an AppShell or page content. Use `buttons` for a pill-style segmented control. `content` is a distinct style variant with a shared bottom border. `main` is deprecated — use `tabs` instead. */ appearance?: TabBarAppearance /** @deprecated Use appearance instead. Can be removed when TabNavigation is removed. */ tabStyle?: TabBarAppearance diff --git a/packages/ui-components/src/components/TabBar/TabBar.stories.tsx b/packages/ui-components/src/components/TabBar/TabBar.stories.tsx index 336c8da503..26c2b24a63 100644 --- a/packages/ui-components/src/components/TabBar/TabBar.stories.tsx +++ b/packages/ui-components/src/components/TabBar/TabBar.stories.tsx @@ -19,9 +19,12 @@ const meta: Meta = { control: false, }, appearance: { - options: ["main", "content"], + options: ["tabs", "content", "buttons"], control: { type: "radio" }, }, + tabStyle: { + table: { disable: true }, + }, }, } @@ -105,12 +108,72 @@ export const WithChildren: Story = { }, } +export const MainAppearance: Story = { + parameters: { + docs: { + description: { + story: + '**Deprecated.** Use `appearance="tabs"` instead. `appearance="main"` keeps working but will be removed in a future major release.', + }, + }, + }, + args: { + appearance: "main", + children: [ + , + , + , + , + ], + }, +} + +export const TabsAppearance: Story = { + parameters: { + docs: { + description: { + story: + 'Use `appearance="tabs"` as the default appearance for the primary navigation bar, e.g. as the first child in an AppShell. Each item carries its own bottom border, highlighted on the active item.', + }, + }, + }, + args: { + appearance: "tabs", + children: [ + , + , + , + , + ], + }, +} + +export const ButtonsAppearance: Story = { + parameters: { + docs: { + description: { + story: + 'Use `appearance="buttons"` for a pill-style segmented control. Items are displayed as buttons with a shared background container.', + }, + }, + }, + args: { + appearance: "buttons", + children: [ + , + , + , + , + ], + }, +} + export const ContentAppearance: Story = { parameters: { docs: { description: { story: - 'Use `appearance="content"` for a TabBar inside page content. Inactive items show a darkened bottom border to visually distinguish them from the active item.', + 'Use `appearance="content"` for tabs inside page content. Inactive items share a darkened bottom border to visually distinguish them from the active item.', }, }, }, diff --git a/packages/ui-components/src/components/TabBar/TabBar.test.tsx b/packages/ui-components/src/components/TabBar/TabBar.test.tsx index a4afd0dbb4..d1542aebba 100644 --- a/packages/ui-components/src/components/TabBar/TabBar.test.tsx +++ b/packages/ui-components/src/components/TabBar/TabBar.test.tsx @@ -166,7 +166,7 @@ describe("TabBar", () => { expect(mockOnActiveItemChange).toHaveBeenCalled() }) - test("renders main appearance by default", async () => { + test("renders tabs appearance by default", async () => { await waitFor(() => render( @@ -174,9 +174,42 @@ describe("TabBar", () => { ) ) + expect(screen.getByRole("navigation")).toHaveClass("juno-tabbar-tabs") + }) + + test("renders main appearance as passed via appearance prop", async () => { + await waitFor(() => + render( + + + + ) + ) expect(screen.getByRole("navigation")).toHaveClass("juno-tabbar-main") }) + test("renders tabs appearance as passed via appearance prop", async () => { + await waitFor(() => + render( + + + + ) + ) + expect(screen.getByRole("navigation")).toHaveClass("juno-tabbar-tabs") + }) + + test("renders buttons appearance as passed via appearance prop", async () => { + await waitFor(() => + render( + + + + ) + ) + expect(screen.getByRole("navigation")).toHaveClass("juno-tabbar-buttons") + }) + test("renders content appearance as passed via appearance prop", async () => { await waitFor(() => render( @@ -188,6 +221,20 @@ describe("TabBar", () => { expect(screen.getByRole("navigation")).toHaveClass("juno-tabbar-content") }) + // Can be removed when the deprecated "main" appearance is removed: + test("emits a deprecation warning when the main appearance is used", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + await waitFor(() => + render( + + + + ) + ) + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('appearance="main" is deprecated')) + warnSpy.mockRestore() + }) + // Can be removed when TabNavigation is removed: test("renders content appearance as passed via deprecated tabStyle prop", async () => { await waitFor(() => @@ -220,11 +267,11 @@ describe("TabBar", () => { return null } render( - + ) - expect(ctx?.appearance).toBe("content") + expect(ctx?.appearance).toBe("tabs") }) // Can be removed when TabNavigation is removed: diff --git a/packages/ui-components/src/components/TabBar/TabNavigation.deprecated.tsx b/packages/ui-components/src/components/TabBar/TabNavigation.deprecated.tsx index 92f4f1ced4..0b3e480b10 100644 --- a/packages/ui-components/src/components/TabBar/TabNavigation.deprecated.tsx +++ b/packages/ui-components/src/components/TabBar/TabNavigation.deprecated.tsx @@ -10,12 +10,12 @@ import { TabBar, TabBarProps } from "./TabBar.component" import { withDeprecationWarning } from "../withDeprecationWarning/index" const TabNavigationBackwardsCompat = ({ appearance, tabStyle, className = "", ...props }: TabBarProps) => { - const a = appearance || tabStyle || "main" + // Preserve the historic TabNavigation default of "main" rather than TabBar's new "tabs" default. + const resolvedAppearance = appearance || tabStyle || "main" return ( ) diff --git a/packages/ui-components/src/components/TabBarItem/TabBarItem.component.tsx b/packages/ui-components/src/components/TabBarItem/TabBarItem.component.tsx index 6ae58164ab..7a2873129c 100644 --- a/packages/ui-components/src/components/TabBarItem/TabBarItem.component.tsx +++ b/packages/ui-components/src/components/TabBarItem/TabBarItem.component.tsx @@ -13,8 +13,8 @@ const tabBarItemStyles = ` jn:items-center jn:text-theme-default jn:font-bold - jn:py-[0.875rem] - jn:px-[1.5625rem] + jn:py-3.5 + jn:px-6.25 jn:border-b-[3px] jn:focus-visible:outline-hidden jn:focus-visible:ring-2 @@ -28,6 +28,58 @@ const tabBarActiveItemStyles = ` jn:border-theme-tab-active-bottom ` +const tabBarItemButtonStyles = ` + jn:rounded-[0.1875rem] + jn:px-2.5 + jn:py-1.75 + jn:text-sm + jn:font-bold + jn:leading-[1.4] + jn:border + jn:cursor-pointer + jn:transition-colors + jn:duration-150 + jn:focus-visible:outline-hidden + jn:focus-visible:ring-2 + jn:focus-visible:ring-theme-focus + jn:focus-visible:ring-offset-1 +` + +const tabBarItemButtonActiveStyles = ` + jn:bg-theme-tab-button-active + jn:text-theme-tab-button-active + jn:border-theme-tab-button-active +` + +const tabBarItemButtonInactiveStyles = ` + jn:text-theme-tab-button + jn:border-transparent + jn:not-[[aria-disabled=true]]:hover:text-theme-tab-button-hover +` + +const tabBarItemContentStyles = ` + jn:flex + jn:items-center + jn:text-theme-default + jn:font-bold + jn:py-3.5 + jn:px-6.25 + jn:focus-visible:outline-hidden + jn:focus-visible:ring-2 + jn:focus-visible:ring-theme-focus +` + +const tabBarActiveContentItemStyles = ` + jn:text-theme-high + jn:font-bold +` + +const tabBarItemContentWrapperStyles = ` + jn:border-b-[3px] + jn:border-theme-tab-content-inactive-bottom + jn:has-[.juno-navigation-item-active]:border-theme-tab-active-bottom +` + /** * An individual TabBar item. Use wrapped in a `` parent component. * @see https://cloudoperators.github.io/juno/?path=/docs/navigation-tabbar-tabbaritem--docs @@ -46,27 +98,34 @@ export const TabBarItem = ({ value = "", ...props }: TabBarItemProps): ReactNode => { - const tabBarContext = useContext(TabBarContext) - const { appearance } = tabBarContext || {} - const resolvedAppearance = appearance + const { appearance } = useContext(TabBarContext) || {} + const isButtons = appearance === "buttons" + const isContentLike = appearance === "content" // "content" is a distinct style variant with a shared bottom border return ( {children} diff --git a/packages/ui-components/src/components/TabBarItem/TabBarItem.test.tsx b/packages/ui-components/src/components/TabBarItem/TabBarItem.test.tsx index 7c67dcea84..5bd3f71160 100644 --- a/packages/ui-components/src/components/TabBarItem/TabBarItem.test.tsx +++ b/packages/ui-components/src/components/TabBarItem/TabBarItem.test.tsx @@ -78,13 +78,42 @@ describe("TabBarItem", () => { expect(mockOnClick).toHaveBeenCalled() }) - test("renders main appearance items by default inside a TabBar", () => { + test("renders tabs appearance items by default inside a TabBar", () => { render( ) + expect(screen.getByRole("button", { name: "Item 1" })).toHaveClass("juno-tabbar-tabs-item") + }) + + test("renders main appearance items as passed to the parent TabBar via appearance prop", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + render( + + + + ) expect(screen.getByRole("button", { name: "Item 1" })).toHaveClass("juno-tabbar-main-item") + warnSpy.mockRestore() + }) + + test("renders tabs appearance items as passed to the parent TabBar via appearance prop", () => { + render( + + + + ) + expect(screen.getByRole("button", { name: "Item 1" })).toHaveClass("juno-tabbar-tabs-item") + }) + + test("renders buttons appearance items as passed to the parent TabBar via appearance prop", () => { + render( + + + + ) + expect(screen.getByRole("button", { name: "Item 1" })).toHaveClass("juno-tabbar-buttons-item") }) test("renders content appearance items as passed to the parent TabBar via appearance prop", () => { @@ -96,6 +125,18 @@ describe("TabBarItem", () => { expect(screen.getByRole("button", { name: "Item 1" })).toHaveClass("juno-tabbar-content-item") }) + // Can be removed when the deprecated "main" appearance is removed: + test("still renders the deprecated main appearance items via appearance prop", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + render( + + + + ) + expect(screen.getByRole("button", { name: "Item 1" })).toHaveClass("juno-tabbar-main-item") + warnSpy.mockRestore() + }) + // Can be removed when TabNavigation is removed: test("renders content appearance items as passed to the parent TabBar via deprecated tabStyle prop", () => { render( diff --git a/packages/ui-components/src/global.css b/packages/ui-components/src/global.css index 10f804dc53..eb069a88e0 100644 --- a/packages/ui-components/src/global.css +++ b/packages/ui-components/src/global.css @@ -386,6 +386,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-tab-buttons: var(--color-tab-buttons-bg); + --background-color-theme-tab-button-active: var(--color-tab-button-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); @@ -428,6 +430,9 @@ --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-tab-button: var(--color-tab-button-text); + --text-color-theme-tab-button-active: var(--color-tab-button-text-active); + --text-color-theme-tab-button-hover: var(--color-tab-button-text-hover); /* Component Border Colors: */ --border-color-theme-default: var(--color-border-default); @@ -470,6 +475,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-tab-button-active: var(--color-tab-button-border-active); --border-color-theme-tab-content-inactive-bottom: var(--color-background-lvl-4); --border-color-theme-textinput-default: var(--color-textinput-default-border); @@ -755,6 +761,13 @@ /* LT TabNavigation */ --color-tabnavigation-top-bg: var(--color-juno-grey-light-5); --color-tabnavigation-content-bottom-border: var(--color-background-lvl-4); + /* LT TabBar Buttons */ + --color-tab-buttons-bg: var(--color-juno-grey-light-3); + --color-tab-button-bg-active: var(--color-white); + --color-tab-button-border-active: var(--color-juno-grey-light-9); + --color-tab-button-text: var(--color-text-default); + --color-tab-button-text-active: var(--color-text-high); + --color-tab-button-text-hover: var(--color-text-highest); /* LT Filters */ --color-filters-bg: var(--color-background-lvl-1); --color-filter-input-bg: var(--color-background-lvl-0); @@ -1023,6 +1036,13 @@ /* DT TabNavigation */ --color-tabnavigation-top-bg: var(--color-juno-grey-blue-8); --color-tabnavigation-content-bottom-border: var(--color-background-lvl-4); + /* DT TabBar Buttons */ + --color-tab-buttons-bg: var(--color-background-lvl-2); + --color-tab-button-bg-active: var(--color-background-lvl-0); + --color-tab-button-border-active: var(--color-border-default); + --color-tab-button-text: var(--color-text-default); + --color-tab-button-text-active: var(--color-text-high); + --color-tab-button-text-hover: var(--color-text-highest); /* 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 6e9c85e1f3..6202c0ec63 100644 --- a/packages/ui-components/src/index.ts +++ b/packages/ui-components/src/index.ts @@ -237,6 +237,7 @@ export type { TabListProps } from "./components/TabList/TabList.component" export type { TabBarProps, TabBarAppearance, + DeprecatedTabBarAppearance, TabBarContextType, TabNavigationProps, // Can be removed when TabNavigation is removed TabNavigationContextType, // Can be removed when TabNavigation is removed diff --git a/packages/ui-components/src/theme.css b/packages/ui-components/src/theme.css index 5862d61c03..496cd58dc7 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-tab-buttons: var(--color-tab-buttons-bg); + --background-color-theme-tab-button-active: var(--color-tab-button-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,9 @@ --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-tab-button: var(--color-tab-button-text); + --text-color-theme-tab-button-active: var(--color-tab-button-text-active); + --text-color-theme-tab-button-hover: var(--color-tab-button-text-hover); /* Component Border Colors: */ --border-color-theme-default: var(--color-border-default); @@ -457,6 +462,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-tab-button-active: var(--color-tab-button-border-active); --border-color-theme-tab-content-inactive-bottom: var(--color-background-lvl-4); --border-color-theme-textinput-default: var(--color-textinput-default-border); @@ -729,6 +735,13 @@ /* LT TabNavigation */ --color-tabnavigation-top-bg: var(--color-juno-grey-light-5); --color-tabnavigation-content-bottom-border: var(--color-background-lvl-4); + /* LT TabBar Buttons */ + --color-tab-buttons-bg: var(--color-juno-grey-light-3); + --color-tab-button-bg-active: var(--color-white); + --color-tab-button-border-active: var(--color-juno-grey-light-9); + --color-tab-button-text: var(--color-text-default); + --color-tab-button-text-active: var(--color-text-high); + --color-tab-button-text-hover: var(--color-text-highest); /* LT Filters */ --color-filters-bg: var(--color-background-lvl-1); --color-filter-input-bg: var(--color-background-lvl-0); @@ -1011,6 +1024,13 @@ /* DT TabNavigation */ --color-tabnavigation-top-bg: var(--color-juno-grey-blue-8); --color-tabnavigation-content-bottom-border: var(--color-background-lvl-4); + /* DT TabBar Buttons */ + --color-tab-buttons-bg: var(--color-background-lvl-2); + --color-tab-button-bg-active: var(--color-background-lvl-0); + --color-tab-button-border-active: var(--color-border-default); + --color-tab-button-text: var(--color-text-default); + --color-tab-button-text-active: var(--color-text-high); + --color-tab-button-text-hover: var(--color-text-highest); /* DT Filters */ --color-filters-bg: var(--color-background-lvl-1); --color-filter-input-bg: var(--color-background-lvl-0);