Merge branch 'master' into bui-select-react-aria

This commit is contained in:
Charles de Dreuille
2025-07-14 16:37:35 +02:00
35 changed files with 1055 additions and 724 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-react': patch
---
Fixed a but in the new alpha entity predicates where the `$in` operator mistakenly case sensitive.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/canon': minor
---
**Breaking changes** The Tabs components has been updates to use React Aria under the hood and to work with react-router-dom directly.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-org': patch
---
Updated card extensions for the new frontend system to use the new entity predicates, and switch the card type to `info` for the user and group profile cards.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog': patch
---
Updated card extensions for the new frontend system to use the new entity predicates, and to not show the about card on User and Group pages.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
View File
@@ -4,6 +4,8 @@ title: Proxying
description: Documentation on Proxying
---
This page describes how to configure and use the built-in HTTP proxy functionality in your Backstage backend.
## Overview
The Backstage backend comes packaged with a basic HTTP proxy, that can aid in
+12 -17
View File
@@ -183,31 +183,27 @@
font-weight: var(--bui-font-weight-regular);
}
.bui-TabsIndicator {
background-color: var(--bui-bg-underline);
}
.bui-TabsTab[data-selected] {
background: radial-gradient(
ellipse at bottom,
rgba(209, 110, 203, 0.25) 0%,
transparent 95%
);
}
.bui-HeaderToolbar {
padding-top: var(--bui-space-2);
padding-inline: var(--bui-space-2);
}
.bui-HeaderToolbarWrapper {
border-radius: var(--bui-radius-3);
padding-inline: var(--bui-space-3);
border: none;
}
.bui-HeaderTabs {
border-radius: var(--bui-radius-3);
border: none;
.bui-HeaderToolbarControls {
right: calc(var(--bui-space-3) - 1px);
}
.bui-HeaderTabsWrapper {
margin-top: var(--bui-space-2);
margin-inline: var(--bui-space-2);
border-radius: var(--bui-radius-3);
padding-inline: var(--bui-space-1);
border: none;
}
}
@@ -238,7 +234,7 @@
border: 1px solid var(--bui-border);
}
.bui-HeaderTabs {
.bui-HeaderTabsWrapper {
border: 1px solid var(--bui-border);
}
}
@@ -258,7 +254,6 @@
--bui-bg-danger: #3b1219;
--bui-bg-warning: #302008;
--bui-bg-success: #132d21;
--bui-bg-underline: #d16ecb;
--bui-fg-primary: var(--bui-white);
--bui-fg-secondary: var(--bui-gray-7);
+87 -122
View File
@@ -9617,7 +9617,7 @@
.bui-HeaderToolbarWrapper {
z-index: 1;
background-color: var(--bui-bg-surface-1);
padding-inline: var(--bui-space-3);
padding-inline: var(--bui-space-5);
border-bottom: 1px solid var(--bui-border);
color: var(--bui-fg-primary);
flex-direction: row;
@@ -9657,7 +9657,11 @@
}
.bui-HeaderToolbarControls {
right: var(--bui-space-3);
right: var(--bui-space-5);
align-items: center;
gap: var(--bui-space-2);
flex-direction: row;
display: flex;
position: absolute;
top: 50%;
transform: translateY(-50%);
@@ -9695,78 +9699,39 @@
color: var(--bui-fg-secondary);
}
.bui-HeaderTabs {
background-color: var(--bui-bg-surface-1);
padding-inline: var(--bui-space-1);
.bui-HeaderTabsWrapper {
margin-bottom: var(--bui-space-4);
padding-inline: var(--bui-space-3);
border-bottom: 1px solid var(--bui-border);
--active-tab-left: 0px;
--active-tab-right: 0px;
--active-tab-top: 0px;
--active-tab-bottom: 0px;
--active-tab-width: 0px;
--active-tab-height: 0px;
--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;
flex-direction: row;
align-items: center;
display: flex;
position: relative;
background-color: var(--bui-bg-surface-1);
}
.bui-HeaderTabList {
.bui-HeaderPage {
gap: var(--bui-space-1);
padding-inline: var(--bui-space-5);
margin-top: var(--bui-space-6);
margin-bottom: var(--bui-space-6);
flex-direction: column;
display: flex;
}
.bui-HeaderPageContent {
flex-direction: row;
justify-content: space-between;
display: flex;
}
.bui-HeaderPageTabsWrapper {
margin-left: -8px;
}
.bui-HeaderPageControls {
align-items: center;
gap: var(--bui-space-2);
flex-direction: row;
display: flex;
}
.bui-HeaderTab {
font-size: var(--bui-font-size-3);
color: var(--bui-fg-secondary);
cursor: pointer;
z-index: 2;
height: 36px;
padding-inline: var(--bui-space-2);
justify-content: center;
align-items: center;
display: flex;
position: relative;
&[data-selected="true"] {
color: var(--bui-fg-primary);
}
}
.bui-HeaderTabActive {
content: "";
left: calc(var(--active-tab-left) + var(--bui-space-2));
width: calc(var(--active-tab-width) - var(--bui-space-4));
background-color: var(--bui-fg-primary);
opacity: 1;
border-radius: 4px;
height: 1px;
transition: all .25s ease-out;
position: absolute;
bottom: -1px;
}
.bui-HeaderTabHovered {
content: "";
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);
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 .15s ease-out;
border-radius: 4px;
position: absolute;
}
.bui-Heading {
font-family: var(--bui-font-regular);
color: var(--bui-fg-primary);
@@ -10229,81 +10194,81 @@
display: flex;
}
.bui-TabsRoot {
border: 1px solid var(--color-gray-200);
border-radius: .375rem;
.bui-Tabs {
--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-TabsList {
z-index: 0;
.bui-TabList {
flex-direction: row;
display: flex;
}
.bui-TabListWrapper {
position: relative;
}
.bui-TabsTab {
appearance: none;
.bui-Tab {
font-size: var(--bui-font-size-3);
font-family: var(--bui-font-regular);
font-weight: var(--bui-font-weight-regular);
color: var(--bui-fg-secondary);
user-select: none;
padding-inline: var(--bui-space-3);
height: 2rem;
font-family: inherit;
font-size: .875rem;
font-weight: 500;
line-height: 1.25rem;
font-size: var(--bui-font-size-2);
cursor: pointer;
background: none;
border: 0;
outline: 0;
z-index: 2;
height: 36px;
padding-inline: var(--bui-space-2);
justify-content: center;
align-items: center;
margin: 0;
padding-block: 0;
transition: color .2s ease-in-out;
display: flex;
position: relative;
&[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: "";
outline: 1px solid var(--bui-ring);
outline-offset: -1px;
border-radius: .25rem;
position: absolute;
inset: .25rem 0;
}
}
}
.bui-TabsIndicator {
z-index: -1;
translate: var(--active-tab-left) -50%;
width: var(--active-tab-width);
background-color: var(--bui-bg-solid);
.bui-TabActive {
content: "";
left: calc(var(--active-tab-left) + var(--bui-space-2));
width: calc(var(--active-tab-width) - var(--bui-space-4));
background-color: var(--bui-fg-primary);
height: 1px;
transition-property: translate, width;
transition-duration: .2s;
transition-timing-function: ease-in-out;
transition: left var(--active-transition-duration) ease-out, opacity .15s ease-out, width var(--active-transition-duration) ease-out;
opacity: 1;
border-radius: 4px;
position: absolute;
bottom: 0;
left: 0;
bottom: -1px;
}
.bui-TabsPanel {
&[hidden] {
display: none;
}
.bui-TabHovered {
content: "";
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);
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 .15s ease-out;
border-radius: 4px;
position: absolute;
}
.bui-TabPanel {
padding-inline: var(--bui-space-2);
padding-top: var(--bui-space-4);
}
.bui-Text {
+60 -73
View File
@@ -27,7 +27,10 @@ import type { SearchFieldProps as SearchFieldProps_2 } from 'react-aria-componen
import type { SelectProps as SelectProps_2 } from 'react-aria-components';
import type { SwitchProps as SwitchProps_2 } from 'react-aria-components';
import { Table as Table_2 } from '@tanstack/react-table';
import { Tabs as Tabs_2 } from '@base-ui-components/react/tabs';
import type { TabListProps as TabListProps_2 } from 'react-aria-components';
import type { TabPanelProps as TabPanelProps_2 } from 'react-aria-components';
import { TabProps } from 'react-aria-components';
import type { TabsProps as TabsProps_2 } from 'react-aria-components';
import { TdHTMLAttributes } from 'react';
import type { TextFieldProps as TextFieldProps_2 } from 'react-aria-components';
import { ThHTMLAttributes } from 'react';
@@ -401,6 +404,29 @@ export const componentDefinitions: {
readonly item: 'bui-GridItem';
};
};
readonly Header: {
readonly classNames: {
readonly toolbar: 'bui-HeaderToolbar';
readonly toolbarWrapper: 'bui-HeaderToolbarWrapper';
readonly toolbarContent: 'bui-HeaderToolbarContent';
readonly toolbarControls: 'bui-HeaderToolbarControls';
readonly toolbarIcon: 'bui-HeaderToolbarIcon';
readonly toolbarName: 'bui-HeaderToolbarName';
readonly breadcrumbs: 'bui-HeaderBreadcrumbs';
readonly breadcrumb: 'bui-HeaderBreadcrumb';
readonly breadcrumbLink: 'bui-HeaderBreadcrumbLink';
readonly breadcrumbSeparator: 'bui-HeaderBreadcrumbSeparator';
readonly tabsWrapper: 'bui-HeaderTabsWrapper';
};
};
readonly HeaderPage: {
readonly classNames: {
readonly root: 'bui-HeaderPage';
readonly content: 'bui-HeaderPageContent';
readonly tabsWrapper: 'bui-HeaderPageTabsWrapper';
readonly controls: 'bui-HeaderPageControls';
};
};
readonly Heading: {
readonly classNames: {
readonly root: 'bui-Heading';
@@ -524,11 +550,13 @@ export const componentDefinitions: {
};
readonly Tabs: {
readonly classNames: {
readonly root: 'bui-TabsRoot';
readonly list: 'bui-TabsList';
readonly indicator: 'bui-TabsIndicator';
readonly tab: 'bui-TabsTab';
readonly panel: 'bui-TabsPanel';
readonly tabs: 'bui-Tabs';
readonly tabList: 'bui-TabList';
readonly tabListWrapper: 'bui-TabListWrapper';
readonly tab: 'bui-Tab';
readonly tabActive: 'bui-TabActive';
readonly tabHovered: 'bui-TabHovered';
readonly panel: 'bui-TabPanel';
};
};
readonly Text: {
@@ -560,26 +588,6 @@ export const componentDefinitions: {
readonly disabled: readonly [true, false];
};
};
readonly Header: {
readonly classNames: {
readonly toolbar: 'bui-HeaderToolbar';
readonly toolbarWrapper: 'bui-HeaderToolbarWrapper';
readonly toolbarContent: 'bui-HeaderToolbarContent';
readonly toolbarControls: 'bui-HeaderToolbarControls';
readonly toolbarIcon: 'bui-HeaderToolbarIcon';
readonly toolbarName: 'bui-HeaderToolbarName';
readonly breadcrumbs: 'bui-HeaderBreadcrumbs';
readonly breadcrumb: 'bui-HeaderBreadcrumb';
readonly breadcrumbLink: 'bui-HeaderBreadcrumbLink';
readonly breadcrumbSeparator: 'bui-HeaderBreadcrumbSeparator';
readonly tabs: 'bui-HeaderTabs';
readonly tabList: 'bui-HeaderTabList';
readonly tab: 'bui-HeaderTab';
readonly tabActive: 'bui-HeaderTabActive';
readonly tabHovered: 'bui-HeaderTabHovered';
readonly subNav: 'bui-HeaderSubNav';
};
};
readonly Tooltip: {
readonly classNames: {
readonly tooltip: 'bui-Tooltip';
@@ -998,7 +1006,7 @@ export interface HeaderBreadcrumb {
}
// @public
export interface HeaderOption {
export interface HeaderMenuItem {
// (undocumented)
label: string;
// (undocumented)
@@ -1010,36 +1018,18 @@ export interface HeaderOption {
// @public
export const HeaderPage: (props: HeaderPageProps) => JSX_2.Element;
// @public
export interface HeaderPageOption {
// (undocumented)
label: string;
// (undocumented)
onClick?: () => void;
// (undocumented)
value: string;
}
// @public
export interface HeaderPageProps {
// (undocumented)
description?: string;
customActions?: React.ReactNode;
// (undocumented)
options?: HeaderPageOption[];
menuItems?: HeaderMenuItem[];
// (undocumented)
tabs?: HeaderPageTab[];
tabs?: HeaderTab[];
// (undocumented)
title?: string;
}
// @public
export interface HeaderPageTab {
// (undocumented)
href?: string;
// (undocumented)
label: string;
}
// @public
export interface HeaderProps {
// (undocumented)
@@ -1049,7 +1039,7 @@ export interface HeaderProps {
// (undocumented)
icon?: React.ReactNode;
// (undocumented)
menuItems?: HeaderOption[];
menuItems?: HeaderMenuItem[];
// (undocumented)
tabs?: HeaderTab[];
// (undocumented)
@@ -1061,6 +1051,8 @@ export interface HeaderTab {
// (undocumented)
href?: string;
// (undocumented)
id: string;
// (undocumented)
label: string;
}
@@ -1632,6 +1624,9 @@ export interface SwitchProps extends SwitchProps_2 {
label?: string;
}
// @public
export const Tab: (props: TabProps) => JSX_2.Element;
// @public
export const Table: {
Root: ForwardRefExoticComponent<
@@ -1704,31 +1699,23 @@ 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
export const TabList: (props: TabListProps) => JSX_2.Element;
// @public (undocumented)
export interface TabsRootWithoutOrientation
extends Omit<
React.ComponentPropsWithoutRef<typeof Tabs_2.Root>,
'orientation'
> {}
// @public
export interface TabListProps extends Omit<TabListProps_2<object>, 'items'> {}
// @public
export const TabPanel: (props: TabPanelProps) => JSX_2.Element;
// @public
export interface TabPanelProps extends TabPanelProps_2 {}
// @public
export const Tabs: (props: TabsProps) => JSX_2.Element | null;
// @public
export interface TabsProps extends TabsProps_2 {}
// @public (undocumented)
const Text_2: {
@@ -16,33 +16,73 @@
import type { Meta, StoryObj, StoryFn } from '@storybook/react';
import { Header } from './Header';
import { HeaderBreadcrumb, HeaderOption, HeaderTab } from './types';
import { HeaderBreadcrumb, HeaderMenuItem, HeaderTab } from './types';
import { Button } from '../Button';
import { HeaderPage } from '../HeaderPage';
import { MemoryRouter } from 'react-router-dom';
import { Container } from '../Container';
import { Text } from '../Text';
import { ButtonIcon } from '../ButtonIcon';
import {
RiHeartLine,
RiEmotionHappyLine,
RiCloudy2Line,
} from '@remixicon/react';
const meta = {
title: 'Components/Header',
component: Header,
parameters: {
layout: 'fullscreen',
},
} satisfies Meta<typeof Header>;
export default meta;
type Story = StoryObj<typeof meta>;
const withRouter = (Story: StoryFn) => (
<MemoryRouter>
<Story />
</MemoryRouter>
);
const tabs: HeaderTab[] = [
{
id: 'overview',
label: 'Overview',
},
{
id: 'checks',
label: 'Checks',
},
{
id: 'tracks',
label: 'Tracks',
},
{
id: 'campaigns',
label: 'Campaigns',
href: '/campaigns',
},
{
id: 'integrations',
label: 'Integrations',
href: '/integrations',
},
];
const tabs2: HeaderTab[] = [
{
id: 'Banana',
label: 'Banana',
},
{
id: 'Apple',
label: 'Apple',
},
{
id: 'Orange',
label: 'Orange',
},
];
@@ -61,7 +101,7 @@ const breadcrumbs: HeaderBreadcrumb[] = [
},
];
const menuItems: HeaderOption[] = [
const menuItems: HeaderMenuItem[] = [
{
label: 'Settings',
value: 'settings',
@@ -97,6 +137,12 @@ const layoutDecorator = [
}}
>
<Story />
<Container>
<Text>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam,
quos.
</Text>
</Container>
</div>
<div
style={{
@@ -114,34 +160,41 @@ const layoutDecorator = [
/>
</>
),
withRouter,
];
export const Default: Story = {
args: {},
decorators: [withRouter],
};
export const WithTabs: Story = {
args: {
tabs,
},
decorators: [withRouter],
};
export const WithOptions: Story = {
args: {
menuItems,
},
decorators: [withRouter],
};
export const WithCustomActions: Story = {
args: {
customActions: <Button>Custom action</Button>,
menuItems,
},
decorators: [withRouter],
};
export const WithBreadcrumbs: Story = {
args: {
breadcrumbs,
},
decorators: [withRouter],
};
export const WithAllComponents: Story = {
@@ -150,59 +203,136 @@ export const WithAllComponents: Story = {
tabs,
breadcrumbs,
},
decorators: [withRouter],
};
export const WithLayout: Story = {
args: {
menuItems,
tabs,
breadcrumbs,
},
decorators: layoutDecorator,
parameters: {
layout: 'fullscreen',
},
};
export const WithLayoutAndHeaderPage: Story = {
args: {
menuItems,
tabs,
breadcrumbs,
},
decorators: layoutDecorator,
parameters: {
layout: 'fullscreen',
},
render: args => (
<>
<Header {...args} />
<Header {...args} tabs={tabs} />
<HeaderPage
title="Page title"
description="Page description"
options={args.menuItems}
menuItems={args.menuItems}
tabs={tabs2}
customActions={<Button>Custom action</Button>}
/>
</>
),
};
export const WithLayoutAndHeaderPageNoTabs: Story = {
export const WithLayoutNoTabs: Story = {
args: {
menuItems,
breadcrumbs,
},
decorators: layoutDecorator,
parameters: {
layout: 'fullscreen',
},
render: args => (
<>
<Header {...args} />
<HeaderPage title="Page title" menuItems={args.menuItems} tabs={tabs2} />
</>
),
};
export const WithEverything: Story = {
args: {
menuItems,
breadcrumbs,
tabs,
},
decorators: layoutDecorator,
render: args => (
<>
<Header
{...args}
customActions={
<>
<ButtonIcon variant="tertiary" icon={<RiCloudy2Line />} />
<ButtonIcon variant="tertiary" icon={<RiEmotionHappyLine />} />
<ButtonIcon variant="tertiary" icon={<RiHeartLine />} />
</>
}
/>
<HeaderPage
title="Page title"
description="Page description"
options={args.menuItems}
menuItems={args.menuItems}
tabs={tabs2}
customActions={
<>
<Button variant="secondary">Secondary</Button>
<Button variant="primary">Primary</Button>
</>
}
/>
</>
),
};
export const WithMockedURLCampaigns: Story = {
args: {
tabs,
},
render: args => (
<MemoryRouter initialEntries={['/campaigns']}>
<Header {...args} />
<Container>
<Text>
Current URL is mocked to be: <strong>/campaigns</strong>
</Text>
<Text>
Notice how the "Campaigns" tab is selected (highlighted) because it
matches the current path.
</Text>
</Container>
</MemoryRouter>
),
};
export const WithMockedURLIntegrations: Story = {
args: {
tabs,
},
render: args => (
<MemoryRouter initialEntries={['/integrations']}>
<Header {...args} />
<Container>
<Text>
Current URL is mocked to be: <strong>/integrations</strong>
</Text>
<Text>
Notice how the "Integrations" tab is selected (highlighted) because it
matches the current path.
</Text>
</Container>
</MemoryRouter>
),
};
export const WithMockedURLNoMatch: Story = {
args: {
tabs,
},
render: args => (
<MemoryRouter initialEntries={['/some-other-page']}>
<Header {...args} />
<Container>
<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 "Overview", "Checks", "Tracks") fall back to
React Aria's internal state.
</Text>
</Container>
</MemoryRouter>
),
};
@@ -44,7 +44,7 @@
align-items: center;
justify-content: space-between;
background-color: var(--bui-bg-surface-1);
padding-inline: var(--bui-space-3);
padding-inline: var(--bui-space-5);
border-bottom: 1px solid var(--bui-border);
color: var(--bui-fg-primary);
height: 52px;
@@ -80,9 +80,13 @@
.bui-HeaderToolbarControls {
position: absolute;
right: var(--bui-space-3);
right: var(--bui-space-5);
top: 50%;
transform: translateY(-50%);
display: flex;
flex-direction: row;
align-items: center;
gap: var(--bui-space-2);
}
.bui-HeaderBreadcrumbs {
@@ -117,80 +121,9 @@
color: var(--bui-fg-secondary);
}
.bui-HeaderTabs {
display: flex;
flex-direction: row;
align-items: center;
background-color: var(--bui-bg-surface-1);
position: relative;
padding-inline: var(--bui-space-1);
.bui-HeaderTabsWrapper {
margin-bottom: var(--bui-space-4);
padding-inline: var(--bui-space-3);
border-bottom: 1px solid var(--bui-border);
/* 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;
--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-HeaderTabList {
display: flex;
flex-direction: row;
}
.bui-HeaderTab {
font-size: var(--bui-font-size-3);
color: var(--bui-fg-secondary);
height: 36px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
position: relative;
z-index: 2;
padding-inline: var(--bui-space-2);
&[data-selected='true'] {
color: var(--bui-fg-primary);
}
}
.bui-HeaderTabActive {
content: '';
position: absolute;
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-fg-primary);
border-radius: 4px;
transition: all 0.25s ease-out;
opacity: 1;
}
.bui-HeaderTabHovered {
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;
background-color: var(--bui-bg-surface-1);
}
@@ -16,7 +16,15 @@
import type { HeaderProps } from './types';
import { HeaderToolbar } from './HeaderToolbar';
import { HeaderTabs } from './HeaderTabs';
import { Tabs, TabList, Tab } from '../Tabs';
import { useStyles } from '../../hooks/useStyles';
import { type NavigateOptions } from 'react-router-dom';
declare module 'react-aria-components' {
interface RouterConfig {
routerOptions: NavigateOptions;
}
}
/**
* A component that renders a toolbar.
@@ -26,6 +34,8 @@ import { HeaderTabs } from './HeaderTabs';
export const Header = (props: HeaderProps) => {
const { tabs, icon, title, menuItems, breadcrumbs, customActions } = props;
const { classNames } = useStyles('Header');
const hasTabs = tabs && tabs.length > 0;
return (
@@ -38,7 +48,19 @@ export const Header = (props: HeaderProps) => {
customActions={customActions}
hasTabs={hasTabs}
/>
<HeaderTabs tabs={tabs} />
{tabs && (
<div className={classNames.tabsWrapper}>
<Tabs>
<TabList>
{tabs?.map(tab => (
<Tab key={tab.id} id={tab.id} href={tab.href}>
{tab.label}
</Tab>
))}
</TabList>
</Tabs>
</div>
)}
</>
);
};
@@ -1,92 +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 { useId, useRef, useState } from 'react';
import { HeaderTabsIndicators } from './HeaderTabsIndicators';
import type { HeaderProps, HeaderTabProps } from './types';
/**
* 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 setTabRef = (key: string, element: HTMLDivElement | null) => {
if (element) {
tabRefs.current.set(key, element);
} else {
tabRefs.current.delete(key);
}
};
if (!tabs) return null;
return (
<Tabs className={classNames.tabs} ref={tabsRef} keyboardActivation="manual">
<TabList className={classNames.tabList} aria-label="Toolbar tabs">
{tabs.map((tab, index) => {
return (
<Tab
key={index}
tab={tab}
setTabRef={setTabRef}
setHoveredKey={setHoveredKey}
/>
);
})}
</TabList>
<HeaderTabsIndicators
tabRefs={tabRefs}
tabsRef={tabsRef}
hoveredKey={hoveredKey}
prevHoveredKey={prevHoveredKey}
/>
</Tabs>
);
};
/**
* A component that renders a toolbar tab.
*
* @internal
*/
const Tab = (props: HeaderTabProps) => {
const { tab, setTabRef, setHoveredKey } = props;
const id = useId();
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>
);
};
@@ -21,6 +21,7 @@ import { RiArrowRightSLine, RiMore2Line, RiShapesLine } from '@remixicon/react';
import type { HeaderToolbarProps } from './types';
import { ButtonIcon } from '../ButtonIcon';
import { Menu } from '../Menu';
import { Text } from '../Text';
import { motion, useScroll, useTransform } from 'motion/react';
/**
@@ -94,7 +95,7 @@ export const HeaderToolbar = (props: HeaderToolbarProps) => {
<div className={classNames.toolbarIcon}>
{icon || <RiShapesLine />}
</div>
{title || 'Your plugin'}
<Text variant="body">{title || 'Your plugin'}</Text>
</div>
{breadcrumbs && (
<motion.div
@@ -18,6 +18,6 @@ export { Header } from './Header';
export type {
HeaderProps,
HeaderTab,
HeaderOption,
HeaderMenuItem,
HeaderBreadcrumb,
} from './types';
+5 -19
View File
@@ -14,8 +14,6 @@
* limitations under the License.
*/
import { MutableRefObject } from 'react';
/**
* Props for the main Header component.
*
@@ -26,7 +24,7 @@ export interface HeaderProps {
title?: string;
breadcrumbs?: HeaderBreadcrumb[];
customActions?: React.ReactNode;
menuItems?: HeaderOption[];
menuItems?: HeaderMenuItem[];
tabs?: HeaderTab[];
}
@@ -36,6 +34,7 @@ export interface HeaderProps {
* @public
*/
export interface HeaderTab {
id: string;
label: string;
href?: string;
}
@@ -45,7 +44,7 @@ export interface HeaderTab {
*
* @public
*/
export interface HeaderOption {
export interface HeaderMenuItem {
label: string;
value: string;
onClick?: () => void;
@@ -62,16 +61,10 @@ export interface HeaderBreadcrumb {
}
/**
* Props for individual header tab components.
* Props for the HeaderToolbar component.
*
* @public
* @internal
*/
export interface HeaderTabProps {
tab: HeaderTab;
setTabRef: (key: string, element: HTMLDivElement | null) => void;
setHoveredKey: (key: string | null) => void;
}
export interface HeaderToolbarProps {
icon?: HeaderProps['icon'];
title?: HeaderProps['title'];
@@ -80,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>;
}
@@ -16,35 +16,52 @@
import type { Meta, StoryObj, StoryFn } from '@storybook/react';
import { HeaderPage } from './HeaderPage';
import { HeaderPageOption, HeaderPageTab } from './types';
import type { HeaderTab, HeaderMenuItem } from '../Header/types';
import { MemoryRouter } from 'react-router-dom';
import { Button } from '../Button';
const meta = {
title: 'Components/HeaderPage',
component: HeaderPage,
parameters: {
layout: 'fullscreen',
},
decorators: [
(Story: StoryFn) => (
<MemoryRouter>
<Story />
</MemoryRouter>
),
],
} satisfies Meta<typeof HeaderPage>;
export default meta;
type Story = StoryObj<typeof meta>;
const tabs: HeaderPageTab[] = [
const tabs: HeaderTab[] = [
{
id: 'overview',
label: 'Overview',
},
{
id: 'checks',
label: 'Checks',
},
{
id: 'tracks',
label: 'Tracks',
},
{
id: 'campaigns',
label: 'Campaigns',
},
{
id: 'integrations',
label: 'Integrations',
},
];
const options: HeaderPageOption[] = [
const menuItems: HeaderMenuItem[] = [
{
label: 'Settings',
value: 'settings',
@@ -75,7 +92,6 @@ const layoutDecorator = [
<div
style={{
paddingInline: '266px',
minHeight: '200vh',
}}
>
<Story />
@@ -103,13 +119,6 @@ export const Default: Story = {
},
};
export const WithDescription: Story = {
args: {
...Default.args,
description: 'Header Page Description',
},
};
export const WithTabs: Story = {
args: {
...Default.args,
@@ -117,20 +126,32 @@ export const WithTabs: Story = {
},
};
export const WithOptions: Story = {
export const WithMenuItems: Story = {
args: {
...Default.args,
options,
menuItems,
},
};
export const WithCustomActions: Story = {
render: () => (
<HeaderPage
{...Default.args}
menuItems={menuItems}
customActions={<Button>Custom action</Button>}
/>
),
};
export const WithEverything: Story = {
args: {
...Default.args,
description: 'Header Page Description',
tabs,
options,
},
render: () => (
<HeaderPage
{...Default.args}
menuItems={menuItems}
tabs={tabs}
customActions={<Button>Custom action</Button>}
/>
),
};
export const WithLayout: Story = {
@@ -138,7 +159,4 @@ export const WithLayout: Story = {
...WithEverything.args,
},
decorators: layoutDecorator,
parameters: {
layout: 'fullscreen',
},
};
@@ -13,3 +13,29 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
.bui-HeaderPage {
display: flex;
flex-direction: column;
gap: var(--bui-space-1);
padding-inline: var(--bui-space-5);
margin-top: var(--bui-space-6);
margin-bottom: var(--bui-space-6);
}
.bui-HeaderPageContent {
display: flex;
flex-direction: row;
justify-content: space-between;
}
.bui-HeaderPageTabsWrapper {
margin-left: -8px;
}
.bui-HeaderPageControls {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--bui-space-2);
}
@@ -16,11 +16,11 @@
import type { HeaderPageProps } from './types';
import { Heading } from '../Heading';
import { Text } from '../Text';
import { Flex } from '../Flex';
import { Menu } from '../Menu';
import { ButtonIcon } from '../ButtonIcon';
import { RiMore2Line } from '@remixicon/react';
import { Tabs, TabList, Tab } from '../Tabs';
import { useStyles } from '../../hooks/useStyles';
/**
* A component that renders a header page.
@@ -28,44 +28,58 @@ import { RiMore2Line } from '@remixicon/react';
* @public
*/
export const HeaderPage = (props: HeaderPageProps) => {
const { title, description, options } = props;
const { title, menuItems, tabs, customActions } = props;
const { classNames } = useStyles('HeaderPage');
return (
<Flex pl="4" pr="2" mt="6" justify="between">
<Flex direction="column" gap="2">
<div className={classNames.root}>
<div className={classNames.content}>
<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 className={classNames.controls}>
{customActions}
{menuItems && (
<Menu.Root>
<Menu.Trigger
render={props => (
<ButtonIcon
{...props}
size="small"
icon={<RiMore2Line />}
variant="tertiary"
/>
)}
/>
<Menu.Portal>
<Menu.Positioner sideOffset={4} align="end">
<Menu.Popup>
{menuItems.map(menuItem => (
<Menu.Item
key={menuItem.value}
onClick={() => menuItem.onClick?.()}
>
{menuItem.label}
</Menu.Item>
))}
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
)}
</div>
</div>
</Flex>
{tabs && (
<div className={classNames.tabsWrapper}>
<Tabs>
<TabList>
{tabs.map(tab => (
<Tab key={tab.id} id={tab.id} href={tab.href}>
{tab.label}
</Tab>
))}
</TabList>
</Tabs>
</div>
)}
</div>
);
};
@@ -15,4 +15,4 @@
*/
export { HeaderPage } from './HeaderPage';
export type { HeaderPageProps, HeaderPageTab, HeaderPageOption } from './types';
export type { HeaderPageProps } from './types';
@@ -14,6 +14,8 @@
* limitations under the License.
*/
import type { HeaderTab, HeaderMenuItem } from '../Header/types';
/**
* Props for the main HeaderPage component.
*
@@ -21,28 +23,7 @@
*/
export interface HeaderPageProps {
title?: string;
description?: string;
tabs?: HeaderPageTab[];
options?: HeaderPageOption[];
}
/**
* Represents a tab item in the header page.
*
* @public
*/
export interface HeaderPageTab {
label: string;
href?: string;
}
/**
* Represents an option item in the header page.
*
* @public
*/
export interface HeaderPageOption {
label: string;
value: string;
onClick?: () => void;
customActions?: React.ReactNode;
menuItems?: HeaderMenuItem[];
tabs?: HeaderTab[];
}
@@ -14,35 +14,163 @@
* 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, TabList, Tab, TabPanel } 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>
<TabList>
<Tab id="tab1">Tab 1</Tab>
<Tab id="tab2">Tab 2</Tab>
<Tab id="tab3">Tab 3 With long title</Tab>
</TabList>
</Tabs>
),
};
export const WithTabPanels: Story = {
args: {
children: '',
},
decorators: [withRouter],
render: () => (
<Tabs>
<TabList>
<Tab id="tab1">Settings</Tab>
<Tab id="tab2">Profile</Tab>
<Tab id="tab3">Preferences</Tab>
</TabList>
<TabPanel id="tab1">
<Text>Settings panel content goes here</Text>
</TabPanel>
<TabPanel id="tab2">
<Text>Profile panel content goes here</Text>
</TabPanel>
<TabPanel id="tab3">
<Text>Preferences panel content goes here</Text>
</TabPanel>
</Tabs>
),
};
export const WithMockedURLTab2: Story = {
args: {
children: '',
},
render: () => (
<MemoryRouter initialEntries={['/tab2']}>
<Tabs>
<TabList>
<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>
</TabList>
</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>
<TabList>
<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>
</TabList>
</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>
<TabList>
<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>
</TabList>
</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,82 @@
.bui-TabsRoot {
border: 1px solid var(--color-gray-200);
border-radius: 0.375rem;
.bui-Tabs {
/* 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-TabsList {
.bui-TabList {
display: flex;
position: relative;
z-index: 0;
flex-direction: row;
}
.bui-TabsTab {
.bui-TabListWrapper {
position: relative;
}
.bui-Tab {
font-size: var(--bui-font-size-3);
font-family: var(--bui-font-regular);
font-weight: var(--bui-font-weight-regular);
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, width var(--active-transition-duration) 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;
}
.bui-TabPanel {
padding-inline: var(--bui-space-2);
padding-top: var(--bui-space-4);
}
+178 -66
View File
@@ -14,83 +14,195 @@
* 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,
createContext,
useContext,
} from 'react';
import type {
TabsProps,
TabListProps,
TabPanelProps,
TabsContextValue,
} from './types';
import { useLocation, useNavigate, useHref } from 'react-router-dom';
import { TabsIndicators } from './TabsIndicators';
import {
Tabs as AriaTabs,
TabList as AriaTabList,
Tab as AriaTab,
TabPanel as AriaTabPanel,
RouterProvider,
TabProps as AriaTabProps,
} from 'react-aria-components';
import { useStyles } from '../../hooks/useStyles';
const TabsRoot = forwardRef<
React.ElementRef<typeof TabsPrimitive.Root>,
TabsRootWithoutOrientation
>(({ className, ...props }, ref) => {
const TabsContext = createContext<TabsContextValue | undefined>(undefined);
const useTabsContext = () => {
const context = useContext(TabsContext);
if (!context) {
throw new Error('Tab components must be used within a Tabs component');
}
return context;
};
/**
* A component that renders a list of tabs.
*
* @public
*/
export const Tabs = (props: TabsProps) => {
const { children, ...rest } = 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);
let navigate = useNavigate();
const location = useLocation();
const setTabRef = (key: string, element: HTMLDivElement | null) => {
if (element) {
tabRefs.current.set(key, element);
} else {
tabRefs.current.delete(key);
}
};
// 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.type === TabList) {
const tabListChildren = Children.toArray(child.props.children);
for (const tabChild of tabListChildren) {
if (
isValidElement(tabChild) &&
tabChild.props.href === location.pathname
) {
return tabChild.props.id;
}
}
}
}
return undefined;
})();
if (!children) return null;
const contextValue: TabsContextValue = {
tabsRef,
tabRefs,
hoveredKey,
prevHoveredKey,
setHoveredKey,
setTabRef,
};
return (
<TabsPrimitive.Root
ref={ref}
className={clsx(classNames.root, className)}
{...props}
/>
<TabsContext.Provider value={contextValue}>
<RouterProvider navigate={navigate} useHref={useHref}>
<AriaTabs
className={classNames.tabs}
keyboardActivation="manual"
selectedKey={computedSelectedKey}
ref={tabsRef}
{...rest}
>
{children as ReactNode}
</AriaTabs>
</RouterProvider>
</TabsContext.Provider>
);
});
TabsRoot.displayName = TabsPrimitive.Root.displayName;
};
const TabsList = forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, children, ...props }, ref) => {
/**
* A component that renders a list of tabs.
*
* @public
*/
export const TabList = (props: TabListProps) => {
const { children, ...rest } = props;
const { classNames } = useStyles('Tabs');
const { setHoveredKey, tabRefs, tabsRef, hoveredKey, prevHoveredKey } =
useTabsContext();
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, {
onHoverStart: () => handleHover(child.props.id as string),
onHoverEnd: () => handleHover(null),
} as Partial<AriaTabProps>);
}
return child;
});
return (
<TabsPrimitive.List
ref={ref}
className={clsx(classNames.list, className)}
{...props}
<div className={classNames.tabListWrapper}>
<AriaTabList
className={classNames.tabList}
aria-label="Toolbar tabs"
{...rest}
>
{enhancedChildren}
</AriaTabList>
<TabsIndicators
tabRefs={tabRefs}
tabsRef={tabsRef}
hoveredKey={hoveredKey}
prevHoveredKey={prevHoveredKey}
/>
</div>
);
};
/**
* A component that renders a tab.
*
* @public
*/
export const Tab = (props: AriaTabProps) => {
const { href, children, id, ...rest } = props;
const { classNames } = useStyles('Tabs');
const { setTabRef } = useTabsContext();
return (
<AriaTab
id={id}
className={classNames.tab}
ref={el => setTabRef(id as string, el as HTMLDivElement)}
href={href}
{...rest}
>
{children}
<TabsPrimitive.Indicator className={classNames.indicator} />
</TabsPrimitive.List>
</AriaTab>
);
};
/**
* A component that renders the content of a tab.
*
* @public
*/
export const TabPanel = (props: TabPanelProps) => {
const { children, ...rest } = props;
const { classNames } = useStyles('Tabs');
return (
<AriaTabPanel className={classNames.panel} {...rest}>
{children}
</AriaTabPanel>
);
});
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,
};
@@ -16,18 +16,19 @@
import { TabListStateContext } from 'react-aria-components';
import { useStyles } from '../../hooks/useStyles';
import { useContext, useEffect, useCallback } from 'react';
import type { HeaderIndicatorsProps } from './types';
import { useContext, useEffect, useCallback, useRef } from 'react';
import type { TabsIndicatorsProps } from './types';
/**
* A component that renders the indicators for the toolbar.
*
* @internal
*/
export const HeaderTabsIndicators = (props: HeaderIndicatorsProps) => {
export const TabsIndicators = (props: TabsIndicatorsProps) => {
const { tabRefs, tabsRef, hoveredKey, prevHoveredKey } = props;
const { classNames } = useStyles('Header');
const { classNames } = useStyles('Tabs');
const state = useContext(TabListStateContext);
const prevSelectedKey = useRef<string | null>(null);
const updateCSSVariables = useCallback(() => {
if (!tabsRef.current) return;
@@ -43,6 +44,35 @@ export const HeaderTabsIndicators = (props: HeaderIndicatorsProps) => {
const relativeLeft = activeRect.left - tabsRect.left;
const relativeTop = activeRect.top - tabsRect.top;
// Control transition timing based on whether this is the first time setting active tab
const isFirstActiveTab = prevSelectedKey.current === null;
if (isFirstActiveTab) {
// First time setting active tab: no transitions for position
tabsRef.current.style.setProperty(
'--active-transition-duration',
'0s',
);
// Enable transitions on next frame for future tab switches
requestAnimationFrame(() => {
if (tabsRef.current) {
tabsRef.current.style.setProperty(
'--active-transition-duration',
'0.25s',
);
}
});
} else {
// Switching between tabs: full transitions
tabsRef.current.style.setProperty(
'--active-transition-duration',
'0.25s',
);
}
// Update previous selected key for next time
prevSelectedKey.current = state.selectedKey.toString();
tabsRef.current.style.setProperty(
'--active-tab-left',
`${relativeLeft}px`,
@@ -140,11 +170,11 @@ export const HeaderTabsIndicators = (props: HeaderIndicatorsProps) => {
// Reset previous hover key so next hover is treated as new session
prevHoveredKey.current = null;
}
}, [state?.selectedKey, hoveredKey]);
}, [state?.selectedKey, hoveredKey, tabRefs.current]);
useEffect(() => {
updateCSSVariables();
}, [updateCSSVariables]);
}, [updateCSSVariables, tabRefs.current.size]);
useEffect(() => {
const handleResize = () => updateCSSVariables();
+2 -2
View File
@@ -14,5 +14,5 @@
* limitations under the License.
*/
export { Tabs } from './Tabs';
export type { TabsRootWithoutOrientation } from './types';
export { Tabs, TabList, Tab, TabPanel } from './Tabs';
export type { TabsProps, TabListProps, TabPanelProps } from './types';
+65 -8
View File
@@ -13,13 +13,70 @@
* 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 {
TabsProps as AriaTabsProps,
TabListProps as AriaTabListProps,
TabPanelProps as AriaTabPanelProps,
} 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 TabList component.
*
* @public
*/
export interface TabListProps extends Omit<AriaTabListProps<object>, 'items'> {}
/** Context for sharing refs between Tabs and TabList
*
* @internal
*/
export interface TabsContextValue {
tabsRef: React.RefObject<HTMLDivElement>;
tabRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
hoveredKey: string | null;
prevHoveredKey: React.MutableRefObject<string | null>;
setHoveredKey: (key: string | null) => void;
setTabRef: (key: string, element: HTMLDivElement | null) => void;
}
/**
* Props for the TabPanel component.
*
* @public
*/
export interface TabPanelProps extends AriaTabPanelProps {}
/**
* 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>;
}
/**
* Context value for sharing refs and state between Tabs and TabList components.
*
* @internal
*/
export interface TabsContextValue {
tabsRef: React.RefObject<HTMLDivElement>;
tabRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
hoveredKey: string | null;
prevHoveredKey: React.MutableRefObject<string | null>;
setHoveredKey: (key: string | null) => void;
setTabRef: (key: string, element: HTMLDivElement | null) => void;
}
+1 -1
View File
@@ -21,7 +21,7 @@
--sb-sidebar-border-right: none;
--sb-options-border: none;
--sb-options-border-left: none;
--sb-content-padding-inline: 266px;
--sb-content-padding-inline: 258px;
}
[data-theme-name='spotify'][data-theme='light'] {
@@ -104,6 +104,29 @@ export const componentDefinitions = {
item: 'bui-GridItem',
},
},
Header: {
classNames: {
toolbar: 'bui-HeaderToolbar',
toolbarWrapper: 'bui-HeaderToolbarWrapper',
toolbarContent: 'bui-HeaderToolbarContent',
toolbarControls: 'bui-HeaderToolbarControls',
toolbarIcon: 'bui-HeaderToolbarIcon',
toolbarName: 'bui-HeaderToolbarName',
breadcrumbs: 'bui-HeaderBreadcrumbs',
breadcrumb: 'bui-HeaderBreadcrumb',
breadcrumbLink: 'bui-HeaderBreadcrumbLink',
breadcrumbSeparator: 'bui-HeaderBreadcrumbSeparator',
tabsWrapper: 'bui-HeaderTabsWrapper',
},
},
HeaderPage: {
classNames: {
root: 'bui-HeaderPage',
content: 'bui-HeaderPageContent',
tabsWrapper: 'bui-HeaderPageTabsWrapper',
controls: 'bui-HeaderPageControls',
},
},
Heading: {
classNames: {
root: 'bui-Heading',
@@ -227,11 +250,13 @@ 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',
tabListWrapper: 'bui-TabListWrapper',
tab: 'bui-Tab',
tabActive: 'bui-TabActive',
tabHovered: 'bui-TabHovered',
panel: 'bui-TabPanel',
},
},
Text: {
@@ -257,26 +282,6 @@ export const componentDefinitions = {
disabled: [true, false] as const,
},
},
Header: {
classNames: {
toolbar: 'bui-HeaderToolbar',
toolbarWrapper: 'bui-HeaderToolbarWrapper',
toolbarContent: 'bui-HeaderToolbarContent',
toolbarControls: 'bui-HeaderToolbarControls',
toolbarIcon: 'bui-HeaderToolbarIcon',
toolbarName: 'bui-HeaderToolbarName',
breadcrumbs: 'bui-HeaderBreadcrumbs',
breadcrumb: 'bui-HeaderBreadcrumb',
breadcrumbLink: 'bui-HeaderBreadcrumbLink',
breadcrumbSeparator: 'bui-HeaderBreadcrumbSeparator',
tabs: 'bui-HeaderTabs',
tabList: 'bui-HeaderTabList',
tab: 'bui-HeaderTab',
tabActive: 'bui-HeaderTabActive',
tabHovered: 'bui-HeaderTabHovered',
subNav: 'bui-HeaderSubNav',
},
},
Tooltip: {
classNames: {
tooltip: 'bui-Tooltip',
@@ -149,6 +149,8 @@ describe('entityPredicateToFilterFunction', () => {
['s,w,g,a', Object.create({ kind: 'component' })],
['', { 'metadata.tags': { $contains: 'go' } }],
['', { 'metadata.tags.0': 'java' }],
['s,w,g', { kind: { $in: ['component', 'group'] } }],
['a', { $not: { kind: { $in: ['component', 'group'] } } }],
['w,g,a', { $not: { 'metadata.tags': { $contains: 'java' } } }],
[
's,g',
@@ -15,11 +15,7 @@
*/
import { JsonValue } from '@backstage/types';
import {
EntityPredicate,
EntityPredicatePrimitive,
EntityPredicateValue,
} from './types';
import { EntityPredicate, EntityPredicateValue } from './types';
import { valueAtPath } from './valueAtPath';
/**
@@ -92,7 +88,7 @@ function evaluatePredicateValue(
return value.some(v => evaluateEntityPredicate(filter.$contains, v));
}
if ('$in' in filter) {
return filter.$in.includes(value as EntityPredicatePrimitive);
return filter.$in.some(search => valuesAreEqual(value, search));
}
if ('$exists' in filter) {
if (filter.$exists === true) {
+14 -9
View File
@@ -56,6 +56,11 @@ export const catalogAboutEntityCard = EntityCardBlueprint.makeWithOverrides({
}
return originalFactory({
type: 'info',
filter: {
$not: {
kind: { $in: ['user', 'group'] },
},
},
async loader() {
const { InternalAboutCard } = await import(
'../components/AboutCard/AboutCard'
@@ -72,7 +77,7 @@ export const catalogLinksEntityCard = EntityCardBlueprint.make({
name: 'links',
params: {
type: 'info',
filter: 'has:links',
filter: { 'metadata.links': { $exists: true } },
loader: async () =>
import('../components/EntityLinksCard').then(m =>
compatWrapper(<m.EntityLinksCard variant="gridItem" />),
@@ -84,7 +89,7 @@ export const catalogLabelsEntityCard = EntityCardBlueprint.make({
name: 'labels',
params: {
type: 'info',
filter: 'has:labels',
filter: { 'metadata.labels': { $exists: true } },
loader: async () =>
import('../components/EntityLabelsCard').then(m =>
compatWrapper(<m.EntityLabelsCard variant="gridItem" />),
@@ -95,7 +100,7 @@ export const catalogLabelsEntityCard = EntityCardBlueprint.make({
export const catalogDependsOnComponentsEntityCard = EntityCardBlueprint.make({
name: 'depends-on-components',
params: {
filter: 'kind:component',
filter: { kind: 'component' },
loader: async () =>
import('../components/DependsOnComponentsCard').then(m =>
compatWrapper(<m.DependsOnComponentsCard variant="gridItem" />),
@@ -106,7 +111,7 @@ export const catalogDependsOnComponentsEntityCard = EntityCardBlueprint.make({
export const catalogDependsOnResourcesEntityCard = EntityCardBlueprint.make({
name: 'depends-on-resources',
params: {
filter: 'kind:component',
filter: { kind: 'component' },
loader: async () =>
import('../components/DependsOnResourcesCard').then(m =>
compatWrapper(<m.DependsOnResourcesCard variant="gridItem" />),
@@ -117,7 +122,7 @@ export const catalogDependsOnResourcesEntityCard = EntityCardBlueprint.make({
export const catalogHasComponentsEntityCard = EntityCardBlueprint.make({
name: 'has-components',
params: {
filter: 'kind:system',
filter: { kind: 'system' },
loader: async () =>
import('../components/HasComponentsCard').then(m =>
compatWrapper(<m.HasComponentsCard variant="gridItem" />),
@@ -128,7 +133,7 @@ export const catalogHasComponentsEntityCard = EntityCardBlueprint.make({
export const catalogHasResourcesEntityCard = EntityCardBlueprint.make({
name: 'has-resources',
params: {
filter: 'kind:system',
filter: { kind: 'system' },
loader: async () =>
import('../components/HasResourcesCard').then(m =>
compatWrapper(<m.HasResourcesCard variant="gridItem" />),
@@ -139,7 +144,7 @@ export const catalogHasResourcesEntityCard = EntityCardBlueprint.make({
export const catalogHasSubcomponentsEntityCard = EntityCardBlueprint.make({
name: 'has-subcomponents',
params: {
filter: 'kind:component',
filter: { kind: 'component' },
loader: async () =>
import('../components/HasSubcomponentsCard').then(m =>
compatWrapper(<m.HasSubcomponentsCard variant="gridItem" />),
@@ -150,7 +155,7 @@ export const catalogHasSubcomponentsEntityCard = EntityCardBlueprint.make({
export const catalogHasSubdomainsEntityCard = EntityCardBlueprint.make({
name: 'has-subdomains',
params: {
filter: 'kind:domain',
filter: { kind: 'domain' },
loader: async () =>
import('../components/HasSubdomainsCard').then(m =>
compatWrapper(<m.HasSubdomainsCard variant="gridItem" />),
@@ -161,7 +166,7 @@ export const catalogHasSubdomainsEntityCard = EntityCardBlueprint.make({
export const catalogHasSystemsEntityCard = EntityCardBlueprint.make({
name: 'has-systems',
params: {
filter: 'kind:domain',
filter: { kind: 'domain' },
loader: async () =>
import('../components/HasSystemsCard').then(m =>
compatWrapper(<m.HasSystemsCard variant="gridItem" />),
+6 -4
View File
@@ -26,7 +26,8 @@ import { EntityCardBlueprint } from '@backstage/plugin-catalog-react/alpha';
const EntityGroupProfileCard = EntityCardBlueprint.make({
name: 'group-profile',
params: {
filter: 'kind:group',
type: 'info',
filter: { kind: 'group' },
loader: async () =>
import('./components/Cards/Group/GroupProfile/GroupProfileCard').then(m =>
compatWrapper(<m.GroupProfileCard />),
@@ -38,7 +39,7 @@ const EntityGroupProfileCard = EntityCardBlueprint.make({
const EntityMembersListCard = EntityCardBlueprint.make({
name: 'members-list',
params: {
filter: 'kind:group',
filter: { kind: 'group' },
loader: async () =>
import('./components/Cards/Group/MembersList/MembersListCard').then(m =>
compatWrapper(<m.MembersListCard />),
@@ -50,7 +51,7 @@ const EntityMembersListCard = EntityCardBlueprint.make({
const EntityOwnershipCard = EntityCardBlueprint.make({
name: 'ownership',
params: {
filter: 'kind:group,user',
filter: { kind: { $in: ['group', 'user'] } },
loader: async () =>
import('./components/Cards/OwnershipCard/OwnershipCard').then(m =>
compatWrapper(<m.OwnershipCard />),
@@ -69,7 +70,8 @@ const EntityUserProfileCard = EntityCardBlueprint.makeWithOverrides({
},
factory(originalFactory, { config }) {
return originalFactory({
filter: 'kind:user',
type: 'info',
filter: { kind: 'user' },
loader: async () =>
import('./components/Cards/User/UserProfileCard/UserProfileCard').then(
m =>
+3 -3
View File
@@ -46981,9 +46981,9 @@ __metadata:
linkType: hard
"set-cookie-parser@npm:^2.4.6":
version: 2.6.0
resolution: "set-cookie-parser@npm:2.6.0"
checksum: 10/8d451ebadb760989f93b634942c79de3c925ca7a986d133d08a80c40b5ae713ce12e354f0d5245c49f288c52daa7bd6554d5dc52f8a4eecaaf5e192881cf2b1f
version: 2.7.1
resolution: "set-cookie-parser@npm:2.7.1"
checksum: 10/c92b1130032693342bca13ea1b1bc93967ab37deec4387fcd8c2a843c0ef2fd9a9f3df25aea5bb3976cd05a91c2cf4632dd6164d6e1814208fb7d7e14edd42b4
languageName: node
linkType: hard