Merge pull request #29996 from backstage/canon-tabs

Canon - Add new Tabs component
This commit is contained in:
Charles de Dreuille
2025-05-19 17:34:48 +01:00
committed by GitHub
16 changed files with 676 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/canon': patch
---
Add new Tabs component to Canon
@@ -0,0 +1,92 @@
import { PropsTable } from '@/components/PropsTable';
import { TabsSnippet } from '@/snippets/stories-snippets';
import { Snippet } from '@/components/Snippet';
import { Tabs } from '@/components/Tabs';
import { CodeBlock } from '@/components/CodeBlock';
import {
tabsRootPropDefs,
tabsListPropDefs,
tabsTabPropDefs,
tabsPanelPropDefs,
} from './props';
import { BaseUI } from '@/components/HeadlessBanners/BaseUI';
# Tabs
A component for toggling between related panels on the same page.
<Snippet
py={4}
preview={<TabsSnippet story="Default" />}
code={`<Tabs.Root>
<Tabs.List>
<Tabs.Tab>Tab 1</Tabs.Tab>
<Tabs.Tab>Tab 2</Tabs.Tab>
<Tabs.Tab>Tab 3 With long title</Tabs.Tab>
</Tabs.List>
<Tabs.Panel>Content for Tab 1</Tabs.Panel>
<Tabs.Panel>Content for Tab 2</Tabs.Panel>
<Tabs.Panel>Content for Tab 3</Tabs.Panel>
</Tabs.Root>`}
/>
<Tabs.Root>
<Tabs.List>
<Tabs.Tab>Usage</Tabs.Tab>
<Tabs.Tab>Theming</Tabs.Tab>
</Tabs.List>
<Tabs.Panel>
<CodeBlock
code={`import { Tabs } from '@backstage/canon';
<Tabs.Root>
<Tabs.List>
<Tabs.Tab>Tab 1</Tabs.Tab>
<Tabs.Tab>Tab 2</Tabs.Tab>
<Tabs.Tab>Tab 3</Tabs.Tab>
</Tabs.List>
<Tabs.Panel>Content for Tab 1</Tabs.Panel>
<Tabs.Panel>Content for Tab 2</Tabs.Panel>
<Tabs.Panel>Content for Tab 3</Tabs.Panel>
</Tabs.Root>
`}
/>
</Tabs.Panel>
<Tabs.Panel>
We recommend starting with our [global tokens](/theme/theming) to customize the library and align it with
your brand. For additional flexibility, you can use the provided class names for each element listed below.
- `canon-TabsRoot`
- `canon-TabsList`
- `canon-TabsTab`
- `canon-TabsPanel`
- `canon-TabsIndicator`
</Tabs.Panel>
</Tabs.Root>
## API reference
<BaseUI href="https://base-ui.com/react/components/menu" />
### Tabs.Root
Groups the tabs and the corresponding panels. Renders a `<div>` element.
<PropsTable data={tabsRootPropDefs} />
### Tabs.List
Groups the individual tab buttons. Renders a `<div>` element.
<PropsTable data={tabsListPropDefs} />
### Tabs.Tab
An individual interactive tab button that toggles the corresponding panel. Renders a `<button>` element.
<PropsTable data={tabsTabPropDefs} />
### Tabs.Panel
A panel displayed when the corresponding tab is active. Renders a `<div>` element.
<PropsTable data={tabsPanelPropDefs} />
@@ -0,0 +1,61 @@
import {
childrenPropDefs,
classNamePropDefs,
stylePropDefs,
} from '../../../../utils/propDefs';
import type { PropDef } from '../../../../utils/propDefs';
export const tabsRootPropDefs: Record<string, PropDef> = {
defaultValue: {
type: 'enum',
values: ['any'],
default: '0',
},
value: {
type: 'enum',
values: ['any'],
},
onValueChange: {
type: 'enum',
values: [`((value) => void)`],
},
...childrenPropDefs,
...classNamePropDefs,
...stylePropDefs,
};
export const tabsListPropDefs: Record<string, PropDef> = {
activateOnFocus: {
type: 'boolean',
default: 'false',
},
loop: {
type: 'boolean',
default: 'false',
},
...childrenPropDefs,
...classNamePropDefs,
...stylePropDefs,
};
export const tabsTabPropDefs: Record<string, PropDef> = {
value: {
type: 'string',
},
...childrenPropDefs,
...classNamePropDefs,
...stylePropDefs,
};
export const tabsPanelPropDefs: Record<string, PropDef> = {
value: {
type: 'string',
},
keepMounted: {
type: 'boolean',
default: 'false',
},
...childrenPropDefs,
...classNamePropDefs,
...stylePropDefs,
};
@@ -17,6 +17,7 @@ import * as MenuStories from '../../../packages/canon/src/components/Menu/Menu.s
import * as LinkStories from '../../../packages/canon/src/components/Link/Link.stories';
import * as AvatarStories from '../../../packages/canon/src/components/Avatar/Avatar.stories';
import * as CollapsibleStories from '../../../packages/canon/src/components/Collapsible/Collapsible.stories';
import * as TabsStories from '../../../packages/canon/src/components/Tabs/Tabs.stories';
export const BoxSnippet = ({ story }: { story: keyof typeof BoxStories }) => {
const stories = composeStories(BoxStories);
@@ -165,3 +166,10 @@ export const CollapsibleSnippet = ({
return StoryComponent ? <StoryComponent /> : null;
};
export const TabsSnippet = ({ story }: { story: keyof typeof TabsStories }) => {
const stories = composeStories(TabsStories);
const StoryComponent = stories[story as keyof typeof stories];
return StoryComponent ? <StoryComponent /> : null;
};
+5
View File
@@ -121,6 +121,11 @@ export const components: Page[] = [
slug: 'table',
status: 'inProgress',
},
{
title: 'Tabs',
slug: 'tabs',
status: 'alpha',
},
{
title: 'Text',
slug: 'text',
+77
View File
@@ -349,6 +349,83 @@
display: flex;
}
.canon-TabsRoot {
border: 1px solid var(--color-gray-200);
border-radius: .375rem;
}
.canon-TabsList {
z-index: 0;
display: flex;
position: relative;
}
.canon-TabsTab {
appearance: none;
color: var(--canon-fg-secondary);
user-select: none;
padding-inline: var(--canon-space-3);
height: 2rem;
font-family: inherit;
font-size: .875rem;
font-weight: 500;
line-height: 1.25rem;
font-size: var(--canon-font-size-2);
cursor: pointer;
background: none;
border: 0;
outline: 0;
justify-content: center;
align-items: center;
margin: 0;
padding-block: 0;
transition: color .2s ease-in-out;
display: flex;
&[data-selected] {
color: var(--canon-fg-primary);
}
@media (hover: hover) {
&:hover {
color: var(--canon-fg-primary);
}
}
&:focus-visible {
position: relative;
&:before {
content: "";
outline: 1px solid var(--canon-ring);
outline-offset: -1px;
border-radius: .25rem;
position: absolute;
inset: .25rem 0;
}
}
}
.canon-TabsIndicator {
z-index: -1;
translate: var(--active-tab-left) -50%;
width: var(--active-tab-width);
background-color: var(--canon-bg-solid);
height: 1px;
transition-property: translate, width;
transition-duration: .2s;
transition-timing-function: ease-in-out;
position: absolute;
bottom: 0;
left: 0;
}
.canon-TabsPanel {
&[hidden] {
display: none;
}
}
.canon-Text {
font-family: var(--canon-font-regular);
margin: 0;
+77
View File
@@ -9573,6 +9573,83 @@
display: flex;
}
.canon-TabsRoot {
border: 1px solid var(--color-gray-200);
border-radius: .375rem;
}
.canon-TabsList {
z-index: 0;
display: flex;
position: relative;
}
.canon-TabsTab {
appearance: none;
color: var(--canon-fg-secondary);
user-select: none;
padding-inline: var(--canon-space-3);
height: 2rem;
font-family: inherit;
font-size: .875rem;
font-weight: 500;
line-height: 1.25rem;
font-size: var(--canon-font-size-2);
cursor: pointer;
background: none;
border: 0;
outline: 0;
justify-content: center;
align-items: center;
margin: 0;
padding-block: 0;
transition: color .2s ease-in-out;
display: flex;
&[data-selected] {
color: var(--canon-fg-primary);
}
@media (hover: hover) {
&:hover {
color: var(--canon-fg-primary);
}
}
&:focus-visible {
position: relative;
&:before {
content: "";
outline: 1px solid var(--canon-ring);
outline-offset: -1px;
border-radius: .25rem;
position: absolute;
inset: .25rem 0;
}
}
}
.canon-TabsIndicator {
z-index: -1;
translate: var(--active-tab-left) -50%;
width: var(--active-tab-width);
background-color: var(--canon-bg-solid);
height: 1px;
transition-property: translate, width;
transition-duration: .2s;
transition-timing-function: ease-in-out;
position: absolute;
bottom: 0;
left: 0;
}
.canon-TabsPanel {
&[hidden] {
display: none;
}
}
.canon-Text {
font-family: var(--canon-font-regular);
margin: 0;
+76
View File
@@ -0,0 +1,76 @@
.canon-TabsRoot {
border: 1px solid var(--color-gray-200);
border-radius: .375rem;
}
.canon-TabsList {
z-index: 0;
display: flex;
position: relative;
}
.canon-TabsTab {
appearance: none;
color: var(--canon-fg-secondary);
user-select: none;
padding-inline: var(--canon-space-3);
height: 2rem;
font-family: inherit;
font-size: .875rem;
font-weight: 500;
line-height: 1.25rem;
font-size: var(--canon-font-size-2);
cursor: pointer;
background: none;
border: 0;
outline: 0;
justify-content: center;
align-items: center;
margin: 0;
padding-block: 0;
transition: color .2s ease-in-out;
display: flex;
&[data-selected] {
color: var(--canon-fg-primary);
}
@media (hover: hover) {
&:hover {
color: var(--canon-fg-primary);
}
}
&:focus-visible {
position: relative;
&:before {
content: "";
outline: 1px solid var(--canon-ring);
outline-offset: -1px;
border-radius: .25rem;
position: absolute;
inset: .25rem 0;
}
}
}
.canon-TabsIndicator {
z-index: -1;
translate: var(--active-tab-left) -50%;
width: var(--active-tab-width);
background-color: var(--canon-bg-solid);
height: 1px;
transition-property: translate, width;
transition-duration: .2s;
transition-timing-function: ease-in-out;
position: absolute;
bottom: 0;
left: 0;
}
.canon-TabsPanel {
&[hidden] {
display: none;
}
}
+27
View File
@@ -23,6 +23,7 @@ import { RefAttributes } from 'react';
import type { RemixiconComponentType } from '@remixicon/react';
import { ScrollArea as ScrollArea_2 } from '@base-ui-components/react/scroll-area';
import { Table as Table_2 } from '@tanstack/react-table';
import { Tabs as Tabs_2 } from '@base-ui-components/react/tabs';
import { TdHTMLAttributes } from 'react';
import { ThHTMLAttributes } from 'react';
import { Tooltip as Tooltip_2 } from '@base-ui-components/react/tooltip';
@@ -1231,6 +1232,32 @@ export interface TableCellTextProps
title: string;
}
// @public (undocumented)
export const Tabs: {
Root: ForwardRefExoticComponent<
TabsRootWithoutOrientation & RefAttributes<HTMLDivElement>
>;
List: ForwardRefExoticComponent<
Omit<Tabs_2.List.Props & RefAttributes<HTMLDivElement>, 'ref'> &
RefAttributes<HTMLDivElement>
>;
Tab: ForwardRefExoticComponent<
Omit<Tabs_2.Tab.Props & RefAttributes<Element>, 'ref'> &
RefAttributes<Element>
>;
Panel: ForwardRefExoticComponent<
Omit<Tabs_2.Panel.Props & RefAttributes<HTMLDivElement>, 'ref'> &
RefAttributes<HTMLDivElement>
>;
};
// @public (undocumented)
export interface TabsRootWithoutOrientation
extends Omit<
React.ComponentPropsWithoutRef<typeof Tabs_2.Root>,
'orientation'
> {}
// @public (undocumented)
const Text_2: ForwardRefExoticComponent<
TextProps & RefAttributes<HTMLParagraphElement>
@@ -0,0 +1,48 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Meta, StoryObj } from '@storybook/react';
import { Tabs } from './Tabs';
const meta = {
title: 'Components/Tabs',
component: Tabs.Root,
} satisfies Meta<typeof Tabs.Root>;
export default meta;
type Story = StoryObj<typeof meta>;
const panelStyles = {
padding: 'var(--canon-space-3)',
fontSize: 'var(--canon-font-size-2)',
};
export const Default: Story = {
args: {
children: (
<Tabs.Root>
<Tabs.List>
<Tabs.Tab>Tab 1</Tabs.Tab>
<Tabs.Tab>Tab 2</Tabs.Tab>
<Tabs.Tab>Tab 3 With long title</Tabs.Tab>
</Tabs.List>
<Tabs.Panel style={panelStyles}>Content for Tab 1</Tabs.Panel>
<Tabs.Panel style={panelStyles}>Content for Tab 2</Tabs.Panel>
<Tabs.Panel style={panelStyles}>Content for Tab 3</Tabs.Panel>
</Tabs.Root>
),
},
};
@@ -0,0 +1,76 @@
.canon-TabsRoot {
border: 1px solid var(--color-gray-200);
border-radius: 0.375rem;
}
.canon-TabsList {
display: flex;
position: relative;
z-index: 0;
}
.canon-TabsTab {
display: flex;
align-items: center;
justify-content: center;
border: 0;
margin: 0;
outline: 0;
background: none;
appearance: none;
color: var(--canon-fg-secondary);
font-family: inherit;
font-size: 0.875rem;
line-height: 1.25rem;
font-weight: 500;
user-select: none;
padding-inline: var(--canon-space-3);
padding-block: 0;
height: 2rem;
font-size: var(--canon-font-size-2);
transition: color 200ms ease-in-out;
cursor: pointer;
&[data-selected] {
color: var(--canon-fg-primary);
}
@media (hover: hover) {
&:hover {
color: var(--canon-fg-primary);
}
}
&:focus-visible {
position: relative;
&::before {
content: '';
position: absolute;
inset: 0.25rem 0;
border-radius: 0.25rem;
outline: 1px solid var(--canon-ring);
outline-offset: -1px;
}
}
}
.canon-TabsIndicator {
position: absolute;
z-index: -1;
left: 0;
bottom: 0;
translate: var(--active-tab-left) -50%;
width: var(--active-tab-width);
height: 1px;
background-color: var(--canon-bg-solid);
transition-property: translate, width;
transition-duration: 200ms;
transition-timing-function: ease-in-out;
}
.canon-TabsPanel {
&[hidden] {
display: none;
}
}
@@ -0,0 +1,79 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { forwardRef } from 'react';
import { Tabs as TabsPrimitive } from '@base-ui-components/react/tabs';
import type { TabsRootWithoutOrientation } from './types';
import clsx from 'clsx';
const TabsRoot = forwardRef<
React.ElementRef<typeof TabsPrimitive.Root>,
TabsRootWithoutOrientation
>(({ className, ...props }, ref) => (
<TabsPrimitive.Root
ref={ref}
className={clsx('canon-TabsRoot', className)}
{...props}
/>
));
TabsRoot.displayName = TabsPrimitive.Root.displayName;
const TabsList = forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, children, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={clsx('canon-TabsList', className)}
{...props}
>
{children}
<TabsPrimitive.Indicator className="canon-TabsIndicator" />
</TabsPrimitive.List>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTab = forwardRef<
React.ElementRef<typeof TabsPrimitive.Tab>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Tab>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Tab
ref={ref}
className={clsx('canon-TabsTab', className)}
{...props}
/>
));
TabsTab.displayName = TabsPrimitive.Tab.displayName;
const TabsPanel = forwardRef<
React.ElementRef<typeof TabsPrimitive.Panel>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Panel>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Panel
ref={ref}
className={clsx('canon-TabsPanel', className)}
{...props}
/>
));
TabsPanel.displayName = TabsPrimitive.Panel.displayName;
/** @public */
export const Tabs = {
Root: TabsRoot,
List: TabsList,
Tab: TabsTab,
Panel: TabsPanel,
};
@@ -0,0 +1,18 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { Tabs } from './Tabs';
export type { TabsRootWithoutOrientation } from './types';
@@ -0,0 +1,25 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Tabs } from '@base-ui-components/react/tabs';
// We are not supporting the orientation prop, so we need to omit it
/** @public */
export interface TabsRootWithoutOrientation
extends Omit<
React.ComponentPropsWithoutRef<typeof Tabs.Root>,
'orientation'
> {}
+1
View File
@@ -30,6 +30,7 @@
@import '../components/Table/TableCellText/TableCellText.styles.css';
@import '../components/Table/TableCellLink/TableCellLink.styles.css';
@import '../components/Table/TableCellProfile/TableCellProfile.styles.css';
@import '../components/Tabs/Tabs.styles.css';
@import '../components/Text/styles.css';
@import '../components/Heading/styles.css';
@import '../components/IconButton/styles.css';
+1
View File
@@ -40,6 +40,7 @@ export * from './components/Icon';
export * from './components/IconButton';
export * from './components/Checkbox';
export * from './components/Table';
export * from './components/Tabs';
export * from './components/TextField';
export * from './components/Tooltip';
export * from './components/Menu';