Updating Tabs

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2025-07-10 10:06:24 +02:00
parent 8b2799221e
commit e748b12740
11 changed files with 369 additions and 323 deletions
@@ -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 (
<RouterProvider navigate={navigate} useHref={useHref}>
<>
<HeaderToolbar
icon={icon}
title={title}
@@ -47,7 +48,15 @@ export const Header = (props: HeaderProps) => {
customActions={customActions}
hasTabs={hasTabs}
/>
<HeaderTabs tabs={tabs} />
</RouterProvider>
<div className={classNames.tabsWrapper}>
<Tabs>
{tabs?.map(tab => (
<Tab key={tab.id} id={tab.id} href={tab.href}>
{tab.label}
</Tab>
))}
</Tabs>
</div>
</>
);
};
@@ -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<HTMLDivElement>(null);
const tabRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const [hoveredKey, setHoveredKey] = useState<string | null>(null);
const prevHoveredKey = useRef<string | null>(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 (
<div className={classNames.tabsWrapper}>
<Tabs
className={classNames.tabs}
ref={tabsRef}
keyboardActivation="manual"
selectedKey={selectedKey}
>
<TabList className={classNames.tabList} aria-label="Toolbar tabs">
{tabs.map((tab, index) => {
return (
<Tab
id={tab.id}
key={index}
tab={tab}
setTabRef={setTabRef}
setHoveredKey={setHoveredKey}
/>
);
})}
</TabList>
<HeaderTabsIndicators
tabRefs={tabRefs}
tabsRef={tabsRef}
hoveredKey={hoveredKey}
prevHoveredKey={prevHoveredKey}
/>
</Tabs>
</div>
);
};
/**
* A component that renders a toolbar tab.
*
* @internal
*/
const Tab = (props: HeaderTabProps) => {
const { tab, setTabRef, setHoveredKey, id } = props;
const { classNames } = useStyles('Header');
return (
<AriaTab
id={id}
className={classNames.tab}
ref={el => setTabRef(id, el as HTMLDivElement)}
onHoverStart={() => setHoveredKey(id)}
onHoverEnd={() => setHoveredKey(null)}
href={tab.href}
>
{tab.label}
</AriaTab>
);
};
+2 -18
View File
@@ -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<Map<string, HTMLDivElement>>;
tabsRef: MutableRefObject<HTMLDivElement | null>;
hoveredKey: string | null;
prevHoveredKey: MutableRefObject<string | null>;
}
@@ -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 (
<Flex pl="4" pr="2" mt="6" justify="between">
<Flex direction="column" gap="2">
<Heading variant="title4">{title}</Heading>
<Text color="secondary">{description}</Text>
<Flex pl="4" pr="2" direction="column" gap="4">
<Flex mt="6" justify="between">
<Flex direction="column" gap="2">
<Heading variant="title4">{title}</Heading>
<Text color="secondary">{description}</Text>
</Flex>
<div>
{options && (
<Menu.Root>
<Menu.Trigger
render={props => (
<ButtonIcon
{...props}
size="small"
icon={<RiMore2Line />}
variant="tertiary"
/>
)}
/>
<Menu.Portal>
<Menu.Positioner sideOffset={4} align="end">
<Menu.Popup>
{options.map(option => (
<Menu.Item
key={option.value}
onClick={() => option.onClick?.()}
>
{option.label}
</Menu.Item>
))}
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
)}
</div>
</Flex>
<div>
{options && (
<Menu.Root>
<Menu.Trigger
render={props => (
<ButtonIcon
{...props}
size="small"
icon={<RiMore2Line />}
variant="tertiary"
/>
)}
/>
<Menu.Portal>
<Menu.Positioner sideOffset={4} align="end">
<Menu.Popup>
{options.map(option => (
<Menu.Item
key={option.value}
onClick={() => option.onClick?.()}
>
{option.label}
</Menu.Item>
))}
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
)}
</div>
<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.Root>
</Flex>
);
};
@@ -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);
@@ -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<typeof Tabs.Root>;
component: Tabs,
} satisfies Meta<typeof Tabs>;
export default meta;
type Story = StoryObj<typeof meta>;
const panelStyles = {
padding: 'var(--bui-space-3)',
fontSize: 'var(--bui-font-size-2)',
};
const withRouter = (Story: StoryFn) => (
<MemoryRouter>
<Story />
</MemoryRouter>
);
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>
),
children: '',
},
decorators: [withRouter],
render: () => (
<Tabs>
<Tab id="tab1">Tab 1</Tab>
<Tab id="tab2">Tab 2</Tab>
<Tab id="tab3">Tab 3 With long title</Tab>
</Tabs>
),
};
export const WithMockedURLTab2: Story = {
args: {
children: '',
},
render: () => (
<MemoryRouter initialEntries={['/tab2']}>
<Tabs>
<Tab id="tab1" href="/tab1">
Tab 1
</Tab>
<Tab id="tab2" href="/tab2">
Tab 2
</Tab>
<Tab id="tab3" href="/tab3">
Tab 3 With long title
</Tab>
</Tabs>
<Box mt="6" pl="2">
<Text>
Current URL is mocked to be: <strong>/tab2</strong>
</Text>
<Text>
Notice how the "Tab 2" tab is selected (highlighted) because it
matches the current path.
</Text>
</Box>
</MemoryRouter>
),
};
export const WithMockedURLTab3: Story = {
args: {
children: '',
},
render: () => (
<MemoryRouter initialEntries={['/tab3']}>
<Tabs>
<Tab id="tab1" href="/tab1">
Tab 1
</Tab>
<Tab id="tab2" href="/tab2">
Tab 2
</Tab>
<Tab id="tab3" href="/tab3">
Tab 3 With long title
</Tab>
</Tabs>
<Box mt="6" pl="2">
<Text>
Current URL is mocked to be: <strong>/tab3</strong>
</Text>
<Text>
Notice how the "Tab 3 With long title" tab is selected (highlighted)
because it matches the current path.
</Text>
</Box>
</MemoryRouter>
),
};
export const WithMockedURLNoMatch: Story = {
args: {
children: '',
},
render: () => (
<MemoryRouter initialEntries={['/some-other-page']}>
<Tabs>
<Tab id="tab1" href="/tab1">
Tab 1
</Tab>
<Tab id="tab2" href="/tab2">
Tab 2
</Tab>
<Tab id="tab3" href="/tab3">
Tab 3 With long title
</Tab>
</Tabs>
<Box mt="6" pl="2">
<Text>
Current URL is mocked to be: <strong>/some-other-page</strong>
</Text>
<Text>
No tab is selected because the current path doesn't match any tab's
href.
</Text>
<Text>
Tabs without href (like "Tab 1", "Tab 2", "Tab 3 With long title")
fall back to React Aria's internal state.
</Text>
</Box>
</MemoryRouter>
),
};
@@ -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;
}
+91 -66
View File
@@ -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<typeof TabsPrimitive.Root>,
TabsRootWithoutOrientation
>(({ className, ...props }, ref) => {
export const Tabs = (props: TabsProps) => {
const { children } = props;
const { classNames } = useStyles('Tabs');
const tabsRef = useRef<HTMLDivElement>(null);
const tabRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const [hoveredKey, setHoveredKey] = useState<string | null>(null);
const prevHoveredKey = useRef<string | null>(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<TabProps>);
}
return child;
});
if (!children) return null;
return (
<TabsPrimitive.Root
ref={ref}
className={clsx(classNames.root, className)}
{...props}
/>
<RouterProvider navigate={navigate} useHref={useHref}>
<AriaTabs
className={classNames.tabs}
ref={tabsRef}
keyboardActivation="manual"
selectedKey={computedSelectedKey}
>
<AriaTabList className={classNames.tabList} aria-label="Toolbar tabs">
{enhancedChildren}
</AriaTabList>
<HeaderTabsIndicators
tabRefs={tabRefs}
tabsRef={tabsRef}
hoveredKey={hoveredKey}
prevHoveredKey={prevHoveredKey}
/>
</AriaTabs>
</RouterProvider>
);
});
TabsRoot.displayName = TabsPrimitive.Root.displayName;
};
const TabsList = forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, children, ...props }, ref) => {
export const Tab = (props: TabProps) => {
const { href, children, id, onHover, onRegister } = props;
const { classNames } = useStyles('Tabs');
return (
<TabsPrimitive.List
ref={ref}
className={clsx(classNames.list, className)}
{...props}
<AriaTab
id={id}
className={classNames.tab}
ref={el => onRegister?.(id as string, el as HTMLDivElement)}
onHoverStart={() => onHover?.(id as string)}
onHoverEnd={() => onHover?.(null)}
href={href}
>
{children}
<TabsPrimitive.Indicator className={classNames.indicator} />
</TabsPrimitive.List>
</AriaTab>
);
});
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTab = forwardRef<
React.ElementRef<typeof TabsPrimitive.Tab>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Tab>
>(({ className, ...props }, ref) => {
const { classNames } = useStyles('Tabs');
return (
<TabsPrimitive.Tab
ref={ref}
className={clsx(classNames.tab, className)}
{...props}
/>
);
});
TabsTab.displayName = TabsPrimitive.Tab.displayName;
const TabsPanel = forwardRef<
React.ElementRef<typeof TabsPrimitive.Panel>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Panel>
>(({ className, ...props }, ref) => {
const { classNames } = useStyles('Tabs');
return (
<TabsPrimitive.Panel
ref={ref}
className={clsx(classNames.panel, className)}
{...props}
/>
);
});
TabsPanel.displayName = TabsPrimitive.Panel.displayName;
/** @public */
export const Tabs = {
Root: TabsRoot,
List: TabsList,
Tab: TabsTab,
Panel: TabsPanel,
};
+2 -2
View File
@@ -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';
+33 -8
View File
@@ -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<typeof Tabs.Root>,
'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<Map<string, HTMLDivElement>>;
tabsRef: MutableRefObject<HTMLDivElement | null>;
hoveredKey: string | null;
prevHoveredKey: MutableRefObject<string | null>;
}
@@ -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: {