Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/tabbar-appearance.md
Original file line number Diff line number Diff line change
@@ -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).
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 (
<TabBarContext.Provider
value={{
Expand All @@ -57,7 +76,7 @@ export const TabBar = ({
<Navigation
activeItem={activeItem}
ariaLabel={ariaLabel}
className={`juno-tabbar juno-tabbar-${resolvedAppearance} ${disabled ? "juno-tabbar-disabled" : ""} ${tabBarStyles} ${className}`}
className={`juno-tabbar juno-tabbar-${resolvedAppearance} ${disabled ? "juno-tabbar-disabled" : ""} ${tabBarStyles} ${resolvedAppearance === "buttons" ? tabBarButtonsStyles : ""} ${className}`}
disabled={disabled}
onActiveItemChange={onActiveItemChange}
{...props}
Expand All @@ -82,7 +101,7 @@ export interface TabBarProps extends HTMLAttributes<HTMLElement> {
/** 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. */
Comment thread
MartinS-git marked this conversation as resolved.
appearance?: TabBarAppearance
/** @deprecated Use appearance instead. Can be removed when TabNavigation is removed. */
tabStyle?: TabBarAppearance
Expand Down
67 changes: 65 additions & 2 deletions packages/ui-components/src/components/TabBar/TabBar.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,12 @@ const meta: Meta<typeof TabBar> = {
control: false,
},
appearance: {
options: ["main", "content"],
options: ["tabs", "content", "buttons"],
control: { type: "radio" },
},
tabStyle: {
table: { disable: true },
},
},
}

Expand Down Expand Up @@ -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: [
<TabBarItem label="Item 1" key="item-1" active></TabBarItem>,
<TabBarItem label="Item 2" key="item-2"></TabBarItem>,
<TabBarItem label="Item 3" key="item-3"></TabBarItem>,
<TabBarItem label="Disabled Item" key="item-4" disabled></TabBarItem>,
],
},
}

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: [
<TabBarItem label="Item 1" key="item-1" active></TabBarItem>,
<TabBarItem label="Item 2" key="item-2"></TabBarItem>,
<TabBarItem label="Item 3" key="item-3"></TabBarItem>,
<TabBarItem label="Disabled Item" key="item-4" disabled></TabBarItem>,
],
},
}

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: [
<TabBarItem label="Item 1" key="item-1" active></TabBarItem>,
<TabBarItem label="Item 2" key="item-2"></TabBarItem>,
<TabBarItem label="Item 3" key="item-3"></TabBarItem>,
<TabBarItem label="Disabled Item" key="item-4" disabled></TabBarItem>,
],
},
}

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.',
},
},
},
Expand Down
53 changes: 50 additions & 3 deletions packages/ui-components/src/components/TabBar/TabBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,17 +166,50 @@ describe("TabBar", () => {
expect(mockOnActiveItemChange).toHaveBeenCalled()
})

test("renders main appearance by default", async () => {
test("renders tabs appearance by default", async () => {
await waitFor(() =>
render(
<TabBar>
<TabBarItem label="Item 1" />
</TabBar>
)
)
expect(screen.getByRole("navigation")).toHaveClass("juno-tabbar-tabs")
})

test("renders main appearance as passed via appearance prop", async () => {
await waitFor(() =>
render(
<TabBar appearance="main">
<TabBarItem label="Item 1" />
</TabBar>
)
)
expect(screen.getByRole("navigation")).toHaveClass("juno-tabbar-main")
})

test("renders tabs appearance as passed via appearance prop", async () => {
await waitFor(() =>
render(
<TabBar appearance="tabs">
<TabBarItem label="Item 1" />
</TabBar>
)
)
expect(screen.getByRole("navigation")).toHaveClass("juno-tabbar-tabs")
})

test("renders buttons appearance as passed via appearance prop", async () => {
await waitFor(() =>
render(
<TabBar appearance="buttons">
<TabBarItem label="Item 1" />
</TabBar>
)
)
expect(screen.getByRole("navigation")).toHaveClass("juno-tabbar-buttons")
})
Comment thread
MartinS-git marked this conversation as resolved.

test("renders content appearance as passed via appearance prop", async () => {
await waitFor(() =>
render(
Expand All @@ -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(
<TabBar appearance="main">
<TabBarItem label="Item 1" />
</TabBar>
)
)
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(() =>
Expand Down Expand Up @@ -220,11 +267,11 @@ describe("TabBar", () => {
return null
}
render(
<TabBar appearance="content">
<TabBar appearance="tabs">
<Consumer />
</TabBar>
)
expect(ctx?.appearance).toBe("content")
expect(ctx?.appearance).toBe("tabs")
})

// Can be removed when TabNavigation is removed:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Comment thread
MartinS-git marked this conversation as resolved.
// Preserve the historic TabNavigation default of "main" rather than TabBar's new "tabs" default.
const resolvedAppearance = appearance || tabStyle || "main"
return (
<TabBar
appearance={appearance}
tabStyle={tabStyle}
className={`juno-tabnavigation juno-tabnavigation-${a} ${className}`.trim()}
appearance={resolvedAppearance}
className={`juno-tabnavigation juno-tabnavigation-${resolvedAppearance} ${className}`.trim()}
{...props}
/>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 `<TabBar>` parent component.
* @see https://cloudoperators.github.io/juno/?path=/docs/navigation-tabbar-tabbaritem--docs
Expand All @@ -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 (
<NavigationItem
active={active}
activeItemStyles={tabBarActiveItemStyles}
activeItemStyles={
isButtons
? tabBarItemButtonActiveStyles
: isContentLike
? tabBarActiveContentItemStyles
: tabBarActiveItemStyles
}
ariaLabel={ariaLabel}
className={`
juno-tabbar-item
${resolvedAppearance ? "juno-tabbar-" + resolvedAppearance + "-item" : ""}
${tabBarItemStyles}
${appearance ? "juno-tabbar-" + appearance + "-item" : ""}
${isButtons ? tabBarItemButtonStyles : isContentLike ? tabBarItemContentStyles : tabBarItemStyles}
${className}
`}
disabled={disabled}
href={href}
icon={icon}
inactiveItemStyles={`${resolvedAppearance === "content" ? "jn:border-theme-tab-content-inactive-bottom" : "jn:border-transparent"}`}
inactiveItemStyles={isButtons ? tabBarItemButtonInactiveStyles : isContentLike ? "" : "jn:border-transparent"}
label={label}
onClick={onClick}
value={value}
wrapperClassName={isContentLike ? tabBarItemContentWrapperStyles : ""}
{...props}
>
{children}
Expand Down
Loading
Loading