From e748b127400af89ce8936311a15b128d5544bf90 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Thu, 10 Jul 2025 10:06:24 +0200 Subject: [PATCH] Updating Tabs Signed-off-by: Charles de Dreuille --- .../canon/src/components/Header/Header.tsx | 23 ++- .../src/components/Header/HeaderTabs.tsx | 103 ------------ packages/canon/src/components/Header/types.ts | 20 +-- .../src/components/HeaderPage/HeaderPage.tsx | 78 +++++---- .../{Header => Tabs}/HeaderTabsIndicators.tsx | 4 +- .../src/components/Tabs/Tabs.stories.tsx | 135 ++++++++++++--- .../canon/src/components/Tabs/Tabs.styles.css | 116 ++++++------- packages/canon/src/components/Tabs/Tabs.tsx | 157 ++++++++++-------- packages/canon/src/components/Tabs/index.ts | 4 +- packages/canon/src/components/Tabs/types.ts | 41 ++++- .../canon/src/utils/componentDefinitions.ts | 11 +- 11 files changed, 369 insertions(+), 323 deletions(-) delete mode 100644 packages/canon/src/components/Header/HeaderTabs.tsx rename packages/canon/src/components/{Header => Tabs}/HeaderTabsIndicators.tsx (98%) diff --git a/packages/canon/src/components/Header/Header.tsx b/packages/canon/src/components/Header/Header.tsx index 26a3b066d7..4f551d60b8 100644 --- a/packages/canon/src/components/Header/Header.tsx +++ b/packages/canon/src/components/Header/Header.tsx @@ -16,9 +16,9 @@ import type { HeaderProps } from './types'; import { HeaderToolbar } from './HeaderToolbar'; -import { HeaderTabs } from './HeaderTabs'; -import { RouterProvider } from 'react-aria-components'; -import { type NavigateOptions, useHref, useNavigate } from 'react-router-dom'; +import { Tabs, Tab } from '../Tabs'; +import { useStyles } from '../../hooks/useStyles'; +import { type NavigateOptions } from 'react-router-dom'; declare module 'react-aria-components' { interface RouterConfig { @@ -33,12 +33,13 @@ declare module 'react-aria-components' { */ export const Header = (props: HeaderProps) => { const { tabs, icon, title, menuItems, breadcrumbs, customActions } = props; - let navigate = useNavigate(); + + const { classNames } = useStyles('Header'); const hasTabs = tabs && tabs.length > 0; return ( - + <> { customActions={customActions} hasTabs={hasTabs} /> - - +
+ + {tabs?.map(tab => ( + + {tab.label} + + ))} + +
+ ); }; diff --git a/packages/canon/src/components/Header/HeaderTabs.tsx b/packages/canon/src/components/Header/HeaderTabs.tsx deleted file mode 100644 index 4eb6345931..0000000000 --- a/packages/canon/src/components/Header/HeaderTabs.tsx +++ /dev/null @@ -1,103 +0,0 @@ -/* - * 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 { Tabs, TabList, Tab as AriaTab } from 'react-aria-components'; -import { useStyles } from '../../hooks/useStyles'; -import { useRef, useState } from 'react'; -import { HeaderTabsIndicators } from './HeaderTabsIndicators'; -import type { HeaderProps, HeaderTabProps } from './types'; -import { useLocation } from 'react-router-dom'; - -/** - * A component that renders header tabs. - * - * @internal - */ -export const HeaderTabs = (props: HeaderProps) => { - const { tabs } = props; - const { classNames } = useStyles('Header'); - const tabsRef = useRef(null); - const tabRefs = useRef>(new Map()); - const [hoveredKey, setHoveredKey] = useState(null); - const prevHoveredKey = useRef(null); - const location = useLocation(); - - const selectedKey = tabs?.find(tab => tab.href === location.pathname)?.id; - - const setTabRef = (key: string, element: HTMLDivElement | null) => { - if (element) { - tabRefs.current.set(key, element); - } else { - tabRefs.current.delete(key); - } - }; - - if (!tabs) return null; - - return ( -
- - - {tabs.map((tab, index) => { - return ( - - ); - })} - - - -
- ); -}; - -/** - * A component that renders a toolbar tab. - * - * @internal - */ -const Tab = (props: HeaderTabProps) => { - const { tab, setTabRef, setHoveredKey, id } = props; - const { classNames } = useStyles('Header'); - - return ( - setTabRef(id, el as HTMLDivElement)} - onHoverStart={() => setHoveredKey(id)} - onHoverEnd={() => setHoveredKey(null)} - href={tab.href} - > - {tab.label} - - ); -}; diff --git a/packages/canon/src/components/Header/types.ts b/packages/canon/src/components/Header/types.ts index f4b45c87a0..d5f779dbf7 100644 --- a/packages/canon/src/components/Header/types.ts +++ b/packages/canon/src/components/Header/types.ts @@ -14,8 +14,6 @@ * limitations under the License. */ -import { MutableRefObject } from 'react'; - /** * Props for the main Header component. * @@ -63,17 +61,10 @@ export interface HeaderBreadcrumb { } /** - * Props for individual header tab components. + * Props for the HeaderToolbar component. * - * @public + * @internal */ -export interface HeaderTabProps { - id: string; - tab: HeaderTab; - setTabRef: (key: string, element: HTMLDivElement | null) => void; - setHoveredKey: (key: string | null) => void; -} - export interface HeaderToolbarProps { icon?: HeaderProps['icon']; title?: HeaderProps['title']; @@ -82,10 +73,3 @@ export interface HeaderToolbarProps { menuItems?: HeaderProps['menuItems']; hasTabs?: boolean; } - -export interface HeaderIndicatorsProps { - tabRefs: MutableRefObject>; - tabsRef: MutableRefObject; - hoveredKey: string | null; - prevHoveredKey: MutableRefObject; -} diff --git a/packages/canon/src/components/HeaderPage/HeaderPage.tsx b/packages/canon/src/components/HeaderPage/HeaderPage.tsx index 4943371843..84fa375161 100644 --- a/packages/canon/src/components/HeaderPage/HeaderPage.tsx +++ b/packages/canon/src/components/HeaderPage/HeaderPage.tsx @@ -21,6 +21,7 @@ import { Flex } from '../Flex'; import { Menu } from '../Menu'; import { ButtonIcon } from '../ButtonIcon'; import { RiMore2Line } from '@remixicon/react'; +import { Tabs } from '../Tabs'; /** * A component that renders a header page. @@ -31,41 +32,50 @@ export const HeaderPage = (props: HeaderPageProps) => { const { title, description, options } = props; return ( - - - {title} - {description} + + + + {title} + {description} + +
+ {options && ( + + ( + } + variant="tertiary" + /> + )} + /> + + + + {options.map(option => ( + option.onClick?.()} + > + {option.label} + + ))} + + + + + )} +
-
- {options && ( - - ( - } - variant="tertiary" - /> - )} - /> - - - - {options.map(option => ( - option.onClick?.()} - > - {option.label} - - ))} - - - - - )} -
+ + + Tab 1 + Tab 2 + Tab 3 With long title + +
); }; diff --git a/packages/canon/src/components/Header/HeaderTabsIndicators.tsx b/packages/canon/src/components/Tabs/HeaderTabsIndicators.tsx similarity index 98% rename from packages/canon/src/components/Header/HeaderTabsIndicators.tsx rename to packages/canon/src/components/Tabs/HeaderTabsIndicators.tsx index 9628809f3f..95fb0e870a 100644 --- a/packages/canon/src/components/Header/HeaderTabsIndicators.tsx +++ b/packages/canon/src/components/Tabs/HeaderTabsIndicators.tsx @@ -17,14 +17,14 @@ import { TabListStateContext } from 'react-aria-components'; import { useStyles } from '../../hooks/useStyles'; import { useContext, useEffect, useCallback, useRef } from 'react'; -import type { HeaderIndicatorsProps } from './types'; +import type { TabsIndicatorsProps } from './types'; /** * A component that renders the indicators for the toolbar. * * @internal */ -export const HeaderTabsIndicators = (props: HeaderIndicatorsProps) => { +export const HeaderTabsIndicators = (props: TabsIndicatorsProps) => { const { tabRefs, tabsRef, hoveredKey, prevHoveredKey } = props; const { classNames } = useStyles('Header'); const state = useContext(TabListStateContext); diff --git a/packages/canon/src/components/Tabs/Tabs.stories.tsx b/packages/canon/src/components/Tabs/Tabs.stories.tsx index 87fe32a362..a8bafaaed5 100644 --- a/packages/canon/src/components/Tabs/Tabs.stories.tsx +++ b/packages/canon/src/components/Tabs/Tabs.stories.tsx @@ -14,35 +14,130 @@ * limitations under the License. */ -import type { Meta, StoryObj } from '@storybook/react'; -import { Tabs } from './Tabs'; +import type { Meta, StoryFn, StoryObj } from '@storybook/react'; +import { Tabs, Tab } from './Tabs'; +import { MemoryRouter } from 'react-router-dom'; +import { Box } from '../Box'; +import { Text } from '../Text'; const meta = { title: 'Components/Tabs', - component: Tabs.Root, -} satisfies Meta; + component: Tabs, +} satisfies Meta; export default meta; type Story = StoryObj; -const panelStyles = { - padding: 'var(--bui-space-3)', - fontSize: 'var(--bui-font-size-2)', -}; +const withRouter = (Story: StoryFn) => ( + + + +); 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 - - ), + children: '', }, + decorators: [withRouter], + render: () => ( + + Tab 1 + Tab 2 + Tab 3 With long title + + ), +}; + +export const WithMockedURLTab2: Story = { + args: { + children: '', + }, + render: () => ( + + + + Tab 1 + + + Tab 2 + + + Tab 3 With long title + + + + + Current URL is mocked to be: /tab2 + + + Notice how the "Tab 2" tab is selected (highlighted) because it + matches the current path. + + + + ), +}; + +export const WithMockedURLTab3: Story = { + args: { + children: '', + }, + render: () => ( + + + + Tab 1 + + + Tab 2 + + + Tab 3 With long title + + + + + Current URL is mocked to be: /tab3 + + + Notice how the "Tab 3 With long title" tab is selected (highlighted) + because it matches the current path. + + + + ), +}; + +export const WithMockedURLNoMatch: Story = { + args: { + children: '', + }, + render: () => ( + + + + Tab 1 + + + Tab 2 + + + Tab 3 With long title + + + + + Current URL is mocked to be: /some-other-page + + + No tab is selected because the current path doesn't match any tab's + href. + + + Tabs without href (like "Tab 1", "Tab 2", "Tab 3 With long title") + fall back to React Aria's internal state. + + + + ), }; diff --git a/packages/canon/src/components/Tabs/Tabs.styles.css b/packages/canon/src/components/Tabs/Tabs.styles.css index 44da74b7c7..842102a892 100644 --- a/packages/canon/src/components/Tabs/Tabs.styles.css +++ b/packages/canon/src/components/Tabs/Tabs.styles.css @@ -1,76 +1,76 @@ -.bui-TabsRoot { - border: 1px solid var(--color-gray-200); - border-radius: 0.375rem; -} - -.bui-TabsList { +.bui-Tabs { display: flex; + flex-direction: row; + align-items: center; position: relative; - z-index: 0; + + /* Initialize CSS variables */ + --active-tab-left: 0px; + --active-tab-right: 0px; + --active-tab-top: 0px; + --active-tab-bottom: 0px; + --active-tab-width: 0px; + --active-tab-height: 0px; + --active-transition-duration: 0s; + + --hovered-tab-left: 0px; + --hovered-tab-right: 0px; + --hovered-tab-top: 0px; + --hovered-tab-bottom: 0px; + --hovered-tab-width: 0px; + --hovered-tab-height: 0px; + --hovered-tab-opacity: 0; + --hovered-transition-duration: 0s; } -.bui-TabsTab { +.bui-TabList { + display: flex; + flex-direction: row; +} + +.bui-Tab { + font-size: var(--bui-font-size-3); + color: var(--bui-fg-secondary); + height: 36px; display: flex; align-items: center; justify-content: center; - border: 0; - margin: 0; - outline: 0; - background: none; - appearance: none; - color: var(--bui-fg-secondary); - font-family: inherit; - font-size: 0.875rem; - line-height: 1.25rem; - font-weight: 500; - user-select: none; - padding-inline: var(--bui-space-3); - padding-block: 0; - height: 2rem; - font-size: var(--bui-font-size-2); - transition: color 200ms ease-in-out; cursor: pointer; + position: relative; + z-index: 2; + padding-inline: var(--bui-space-2); - &[data-selected] { + &[data-selected='true'] { color: var(--bui-fg-primary); } - - @media (hover: hover) { - &:hover { - color: var(--bui-fg-primary); - } - } - - &:focus-visible { - position: relative; - - &::before { - content: ''; - position: absolute; - inset: 0.25rem 0; - border-radius: 0.25rem; - outline: 1px solid var(--bui-ring); - outline-offset: -1px; - } - } } -.bui-TabsIndicator { +.bui-TabActive { + content: ''; position: absolute; - z-index: -1; - left: 0; - bottom: 0; - translate: var(--active-tab-left) -50%; - width: var(--active-tab-width); + left: calc(var(--active-tab-left) + var(--bui-space-2)); + bottom: -1px; + width: calc(var(--active-tab-width) - var(--bui-space-4)); height: 1px; - background-color: var(--bui-bg-solid); - transition-property: translate, width; - transition-duration: 200ms; - transition-timing-function: ease-in-out; + background-color: var(--bui-fg-primary); + border-radius: 4px; + transition: left var(--active-transition-duration) ease-out, + opacity 0.15s ease-out; + opacity: 1; } -.bui-TabsPanel { - &[hidden] { - display: none; - } +.bui-TabHovered { + content: ''; + position: absolute; + left: var(--hovered-tab-left); + top: calc(var(--hovered-tab-top) + 4px); + width: var(--hovered-tab-width); + height: calc(var(--hovered-tab-height) - 8px); + background-color: var(--bui-gray-2); + border-radius: 4px; + opacity: var(--hovered-tab-opacity); + transition: left var(--hovered-transition-duration) ease-out, + top var(--hovered-transition-duration) ease-out, + width var(--hovered-transition-duration) ease-out, + height var(--hovered-transition-duration) ease-out, opacity 0.15s ease-out; } diff --git a/packages/canon/src/components/Tabs/Tabs.tsx b/packages/canon/src/components/Tabs/Tabs.tsx index 1cdceba533..f3ff65f727 100644 --- a/packages/canon/src/components/Tabs/Tabs.tsx +++ b/packages/canon/src/components/Tabs/Tabs.tsx @@ -14,83 +14,108 @@ * 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'; +import { + useRef, + useState, + Children, + cloneElement, + isValidElement, + ReactNode, +} from 'react'; +import type { TabsProps, TabProps } from './types'; +import { useLocation, useNavigate, useHref } from 'react-router-dom'; +import { HeaderTabsIndicators } from './HeaderTabsIndicators'; +import { + Tabs as AriaTabs, + TabList as AriaTabList, + Tab as AriaTab, + RouterProvider, +} from 'react-aria-components'; + import { useStyles } from '../../hooks/useStyles'; -const TabsRoot = forwardRef< - React.ElementRef, - TabsRootWithoutOrientation ->(({ className, ...props }, ref) => { +export const Tabs = (props: TabsProps) => { + const { children } = props; const { classNames } = useStyles('Tabs'); + const tabsRef = useRef(null); + const tabRefs = useRef>(new Map()); + const [hoveredKey, setHoveredKey] = useState(null); + const prevHoveredKey = useRef(null); + const location = useLocation(); + let navigate = useNavigate(); + + // If selectedKey is not provided, try to determine it from the current route + const computedSelectedKey = (() => { + const childrenArray = Children.toArray(children as ReactNode); + for (const child of childrenArray) { + if (isValidElement(child) && child.props.href === location.pathname) { + return child.props.id; + } + } + return undefined; + })(); + + const setTabRef = (key: string, element: HTMLDivElement | null) => { + if (element) { + tabRefs.current.set(key, element); + } else { + tabRefs.current.delete(key); + } + }; + + const handleHover = (key: string | null) => { + setHoveredKey(key); + }; + + // Clone children with additional props for hover and ref management + const enhancedChildren = Children.map(children as ReactNode, child => { + if (isValidElement(child)) { + return cloneElement(child, { + onHover: handleHover, + onRegister: setTabRef, + } as Partial); + } + return child; + }); + + if (!children) return null; return ( - + + + + {enhancedChildren} + + + + ); -}); -TabsRoot.displayName = TabsPrimitive.Root.displayName; +}; -const TabsList = forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => { +export const Tab = (props: TabProps) => { + const { href, children, id, onHover, onRegister } = props; const { classNames } = useStyles('Tabs'); return ( - onRegister?.(id as string, el as HTMLDivElement)} + onHoverStart={() => onHover?.(id as string)} + onHoverEnd={() => onHover?.(null)} + href={href} > {children} - - + ); -}); -TabsList.displayName = TabsPrimitive.List.displayName; - -const TabsTab = forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => { - const { classNames } = useStyles('Tabs'); - - return ( - - ); -}); -TabsTab.displayName = TabsPrimitive.Tab.displayName; - -const TabsPanel = forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => { - const { classNames } = useStyles('Tabs'); - - return ( - - ); -}); -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 index ba6ca91de7..1809fb58ca 100644 --- a/packages/canon/src/components/Tabs/index.ts +++ b/packages/canon/src/components/Tabs/index.ts @@ -14,5 +14,5 @@ * limitations under the License. */ -export { Tabs } from './Tabs'; -export type { TabsRootWithoutOrientation } from './types'; +export { Tabs, Tab } from './Tabs'; +export type { TabsProps, TabProps } from './types'; diff --git a/packages/canon/src/components/Tabs/types.ts b/packages/canon/src/components/Tabs/types.ts index f3b6342761..f07c38bce5 100644 --- a/packages/canon/src/components/Tabs/types.ts +++ b/packages/canon/src/components/Tabs/types.ts @@ -13,13 +13,38 @@ * 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 +import type { + TabProps as AriaTabProps, + TabsProps as AriaTabsProps, +} from 'react-aria-components'; +import { MutableRefObject } from 'react'; -/** @public */ -export interface TabsRootWithoutOrientation - extends Omit< - React.ComponentPropsWithoutRef, - 'orientation' - > {} +/** + * Props for the Tabs component. + * + * @public + */ +export interface TabsProps extends AriaTabsProps {} + +/** + * Props for the Tab component. + * + * @public + */ +export interface TabProps extends AriaTabProps { + onHover?: (key: string | null) => void; + onRegister?: (key: string, element: HTMLDivElement | null) => void; +} + +/** + * Props for the TabsIndicators component. + * + * @internal + */ +export interface TabsIndicatorsProps { + tabRefs: MutableRefObject>; + tabsRef: MutableRefObject; + hoveredKey: string | null; + prevHoveredKey: MutableRefObject; +} diff --git a/packages/canon/src/utils/componentDefinitions.ts b/packages/canon/src/utils/componentDefinitions.ts index 188e7bce63..67431514b8 100644 --- a/packages/canon/src/utils/componentDefinitions.ts +++ b/packages/canon/src/utils/componentDefinitions.ts @@ -239,11 +239,12 @@ export const componentDefinitions = { }, Tabs: { classNames: { - root: 'bui-TabsRoot', - list: 'bui-TabsList', - indicator: 'bui-TabsIndicator', - tab: 'bui-TabsTab', - panel: 'bui-TabsPanel', + tabs: 'bui-Tabs', + tabList: 'bui-TabList', + tab: 'bui-Tab', + tabActive: 'bui-TabActive', + tabHovered: 'bui-TabHovered', + panel: 'bui-TabPanel', }, }, Text: {