From 9510105a4935c9600159886efe3ca3ba70f30e64 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sun, 18 May 2025 20:53:59 +0100 Subject: [PATCH 1/2] Add new Tabs component Signed-off-by: Charles de Dreuille --- .changeset/long-ends-shop.md | 5 ++ packages/canon/css/components.css | 77 ++++++++++++++++++ packages/canon/css/styles.css | 77 ++++++++++++++++++ packages/canon/css/tabs.css | 76 ++++++++++++++++++ packages/canon/report.api.md | 21 +++++ .../src/components/Tabs/Tabs.stories.tsx | 50 ++++++++++++ .../canon/src/components/Tabs/Tabs.styles.css | 76 ++++++++++++++++++ packages/canon/src/components/Tabs/Tabs.tsx | 78 +++++++++++++++++++ packages/canon/src/components/Tabs/index.ts | 16 ++++ packages/canon/src/css/components.css | 1 + packages/canon/src/index.ts | 1 + 11 files changed, 478 insertions(+) create mode 100644 .changeset/long-ends-shop.md create mode 100644 packages/canon/css/tabs.css create mode 100644 packages/canon/src/components/Tabs/Tabs.stories.tsx create mode 100644 packages/canon/src/components/Tabs/Tabs.styles.css create mode 100644 packages/canon/src/components/Tabs/Tabs.tsx create mode 100644 packages/canon/src/components/Tabs/index.ts diff --git a/.changeset/long-ends-shop.md b/.changeset/long-ends-shop.md new file mode 100644 index 0000000000..f57c3ccdbd --- /dev/null +++ b/.changeset/long-ends-shop.md @@ -0,0 +1,5 @@ +--- +'@backstage/canon': patch +--- + +Add new Tabs component to Canon diff --git a/packages/canon/css/components.css b/packages/canon/css/components.css index 124d575a96..6e9ffa6cff 100644 --- a/packages/canon/css/components.css +++ b/packages/canon/css/components.css @@ -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; diff --git a/packages/canon/css/styles.css b/packages/canon/css/styles.css index 084aef2855..436c9a8db7 100644 --- a/packages/canon/css/styles.css +++ b/packages/canon/css/styles.css @@ -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; diff --git a/packages/canon/css/tabs.css b/packages/canon/css/tabs.css new file mode 100644 index 0000000000..a9bf036c3e --- /dev/null +++ b/packages/canon/css/tabs.css @@ -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; + } +} diff --git a/packages/canon/report.api.md b/packages/canon/report.api.md index ff8c36e5ec..59a2e2bffd 100644 --- a/packages/canon/report.api.md +++ b/packages/canon/report.api.md @@ -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,26 @@ export interface TableCellTextProps title: string; } +// @public (undocumented) +export const Tabs: { + Root: ForwardRefExoticComponent< + Omit, 'ref'> & + RefAttributes + >; + List: ForwardRefExoticComponent< + Omit, 'ref'> & + RefAttributes + >; + Tab: ForwardRefExoticComponent< + Omit, 'ref'> & + RefAttributes + >; + Panel: ForwardRefExoticComponent< + Omit, 'ref'> & + RefAttributes + >; +}; + // @public (undocumented) const Text_2: ForwardRefExoticComponent< TextProps & RefAttributes diff --git a/packages/canon/src/components/Tabs/Tabs.stories.tsx b/packages/canon/src/components/Tabs/Tabs.stories.tsx new file mode 100644 index 0000000000..ec8526e9b1 --- /dev/null +++ b/packages/canon/src/components/Tabs/Tabs.stories.tsx @@ -0,0 +1,50 @@ +/* + * 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'; +import { Text } from '../../index'; + +const meta = { + title: 'Components/Tabs', + component: Tabs.Root, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + children: ( + + + Tab 1 + Tab 2 + Tab 3 With long title + + + Content for Tab 1 + + + Content for Tab 2 + + + Content for Tab 3 + + + ), + }, +}; diff --git a/packages/canon/src/components/Tabs/Tabs.styles.css b/packages/canon/src/components/Tabs/Tabs.styles.css new file mode 100644 index 0000000000..84a4d378c1 --- /dev/null +++ b/packages/canon/src/components/Tabs/Tabs.styles.css @@ -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; + } +} diff --git a/packages/canon/src/components/Tabs/Tabs.tsx b/packages/canon/src/components/Tabs/Tabs.tsx new file mode 100644 index 0000000000..116ef94fb9 --- /dev/null +++ b/packages/canon/src/components/Tabs/Tabs.tsx @@ -0,0 +1,78 @@ +/* + * 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 clsx from 'clsx'; + +const TabsRoot = forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsRoot.displayName = TabsPrimitive.Root.displayName; + +const TabsList = forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + {children} + + +)); +TabsList.displayName = TabsPrimitive.List.displayName; + +const TabsTab = forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsTab.displayName = TabsPrimitive.Tab.displayName; + +const TabsPanel = forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsPanel.displayName = TabsPrimitive.Panel.displayName; + +/** @public */ +export const Tabs = { + Root: TabsRoot, + List: TabsList, + Tab: TabsTab, + Panel: TabsPanel, +}; diff --git a/packages/canon/src/components/Tabs/index.ts b/packages/canon/src/components/Tabs/index.ts new file mode 100644 index 0000000000..e9bc130d25 --- /dev/null +++ b/packages/canon/src/components/Tabs/index.ts @@ -0,0 +1,16 @@ +/* + * 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'; diff --git a/packages/canon/src/css/components.css b/packages/canon/src/css/components.css index 53c2882302..7cee946e3b 100644 --- a/packages/canon/src/css/components.css +++ b/packages/canon/src/css/components.css @@ -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'; diff --git a/packages/canon/src/index.ts b/packages/canon/src/index.ts index 7677a539c7..cab4cc4acb 100644 --- a/packages/canon/src/index.ts +++ b/packages/canon/src/index.ts @@ -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'; From eb54d726d73a79323a3017d6edd1c67b8a08cc35 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sun, 18 May 2025 22:34:19 +0100 Subject: [PATCH 2/2] Add docs for tabs Signed-off-by: Charles de Dreuille --- .../src/app/(docs)/components/tabs/page.mdx | 92 +++++++++++++++++++ .../src/app/(docs)/components/tabs/props.ts | 61 ++++++++++++ canon-docs/src/snippets/stories-snippets.tsx | 8 ++ canon-docs/src/utils/data.ts | 5 + packages/canon/report.api.md | 10 +- .../src/components/Tabs/Tabs.stories.tsx | 18 ++-- packages/canon/src/components/Tabs/Tabs.tsx | 3 +- packages/canon/src/components/Tabs/index.ts | 2 + packages/canon/src/components/Tabs/types.ts | 25 +++++ 9 files changed, 211 insertions(+), 13 deletions(-) create mode 100644 canon-docs/src/app/(docs)/components/tabs/page.mdx create mode 100644 canon-docs/src/app/(docs)/components/tabs/props.ts create mode 100644 packages/canon/src/components/Tabs/types.ts diff --git a/canon-docs/src/app/(docs)/components/tabs/page.mdx b/canon-docs/src/app/(docs)/components/tabs/page.mdx new file mode 100644 index 0000000000..478608d718 --- /dev/null +++ b/canon-docs/src/app/(docs)/components/tabs/page.mdx @@ -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. + +} + code={` + + Tab 1 + Tab 2 + Tab 3 With long title + + Content for Tab 1 + Content for Tab 2 + Content for Tab 3 + `} +/> + + + + Usage + Theming + + + + + Tab 1 + Tab 2 + Tab 3 + + Content for Tab 1 + Content for Tab 2 + Content for Tab 3 + +`} + /> + + + 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` + + + +## API reference + + + +### Tabs.Root + +Groups the tabs and the corresponding panels. Renders a `
` element. + + + +### Tabs.List + +Groups the individual tab buttons. Renders a `
` element. + + + +### Tabs.Tab + +An individual interactive tab button that toggles the corresponding panel. Renders a `