Merge branch 'master' into ui/searchfield-textfield-bg-focus

# Conflicts:
#	packages/ui/src/components/PluginHeader/PluginHeader.module.css
#	packages/ui/src/components/PluginHeader/PluginHeader.tsx
#	packages/ui/src/components/PluginHeader/PluginHeaderToolbar.tsx
This commit is contained in:
Charles de Dreuille
2026-03-03 14:16:54 +00:00
38 changed files with 2144 additions and 230 deletions
@@ -0,0 +1,25 @@
/*
* Copyright 2020 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 { cli } from 'cleye';
import { targetPaths } from '@backstage/cli-common';
import { revertProductionPack } from '../../lib/packager/productionPack';
import type { CommandContext } from '../../../../wiring/types';
export default async ({ args, info }: CommandContext) => {
cli({ help: info }, undefined, args);
await revertProductionPack(targetPaths.dir);
};
@@ -14,17 +14,17 @@
* limitations under the License.
*/
import {
productionPack,
revertProductionPack,
} from '../../lib/packager/productionPack';
import { targetPaths } from '@backstage/cli-common';
import { cli } from 'cleye';
import fs from 'fs-extra';
import { targetPaths } from '@backstage/cli-common';
import { productionPack } from '../../lib/packager/productionPack';
import { publishPreflightCheck } from '../../lib/publishing';
import { createTypeDistProject } from '../../lib/typeDistProject';
import type { CommandContext } from '../../../../wiring/types';
export default async ({ args, info }: CommandContext) => {
cli({ help: info }, undefined, args);
export const pre = async () => {
publishPreflightCheck({
dir: targetPaths.dir,
packageJson: await fs.readJson(targetPaths.resolve('package.json')),
@@ -35,7 +35,3 @@ export const pre = async () => {
featureDetectionProject: await createTypeDistProject(),
});
};
export const post = async () => {
await revertProductionPack(targetPaths.dir);
};
+4 -9
View File
@@ -14,7 +14,6 @@
* limitations under the License.
*/
import { cli } from 'cleye';
import { Command, Option } from 'commander';
import { createCliPlugin } from '../../wiring/factory';
import { lazy } from '../../wiring/lazy';
@@ -215,20 +214,16 @@ export const buildPlugin = createCliPlugin({
reg.addCommand({
path: ['package', 'prepack'],
description: 'Prepares a package for packaging before publishing',
execute: async ({ args, info }) => {
cli({ help: info }, undefined, args);
const { pre } = await import('./commands/package/pack');
await pre();
execute: {
loader: () => import('./commands/package/prepack'),
},
});
reg.addCommand({
path: ['package', 'postpack'],
description: 'Restores the changes made by the prepack command',
execute: async ({ args, info }) => {
cli({ help: info }, undefined, args);
const { post } = await import('./commands/package/pack');
await post();
execute: {
loader: () => import('./commands/package/postpack'),
},
});
+3 -2
View File
@@ -33,13 +33,14 @@ export interface Config {
* If no organization matches the first credential without an organization is used.
*
* If no credentials are specified at all, either a default credential (for Azure DevOps) or anonymous access (for Azure DevOps Server) is used.
* @deepVisibility secret
*/
credentials?: {
organizations?: string[];
clientId?: string;
/** @visibility secret */
clientSecret?: string;
tenantId?: string;
/** @visibility secret */
personalAccessToken?: string;
managedIdentityClientId?: string;
}[];
@@ -90,7 +91,6 @@ export interface Config {
endpoint?: string;
/**
* Optional credential to use for Azure Active Directory authentication.
* @deepVisibility secret
*/
aadCredential?: {
/**
@@ -105,6 +105,7 @@ export interface Config {
/**
* The client secret for the Azure AD application.
* @visibility secret
*/
clientSecret: string;
};
+7 -1
View File
@@ -1745,7 +1745,13 @@ export const PluginHeaderDefinition: {
};
readonly classNames: {
readonly root: 'bui-PluginHeader';
readonly tabsWrapper: 'bui-PluginHeaderTabsWrapper';
readonly toolbar: 'bui-PluginHeaderToolbar';
readonly toolbarWrapper: 'bui-PluginHeaderToolbarWrapper';
readonly toolbarContent: 'bui-PluginHeaderToolbarContent';
readonly toolbarControls: 'bui-PluginHeaderToolbarControls';
readonly toolbarIcon: 'bui-PluginHeaderToolbarIcon';
readonly toolbarName: 'bui-PluginHeaderToolbarName';
readonly tabs: 'bui-PluginHeaderTabsWrapper';
};
readonly propDefs: {
readonly icon: {};
@@ -17,25 +17,7 @@
@layer tokens, base, components, utilities;
@layer components {
.bui-PluginHeader {
display: block;
}
.bui-PluginHeaderToolbar {
&::before {
content: '';
position: absolute;
top: 0;
left: 0px;
right: 0px;
height: 16px;
z-index: 0;
}
}
.bui-PluginHeaderToolbarWrapper {
position: relative;
z-index: 1;
display: flex;
flex-direction: row;
align-items: center;
@@ -75,10 +57,6 @@
}
.bui-PluginHeaderToolbarControls {
position: absolute;
right: var(--bui-space-5);
top: 50%;
transform: translateY(-50%);
display: flex;
flex-direction: row;
align-items: center;
@@ -15,7 +15,6 @@
*/
import type { PluginHeaderProps } from './types';
import { PluginHeaderToolbar } from './PluginHeaderToolbar';
import { Tabs, TabList, Tab } from '../Tabs';
import { useDefinition } from '../../hooks/useDefinition';
import { PluginHeaderDefinition } from './definition';
@@ -23,6 +22,9 @@ import { type NavigateOptions } from 'react-router-dom';
import { useRef } from 'react';
import { useIsomorphicLayoutEffect } from '../../hooks/useIsomorphicLayoutEffect';
import { Box } from '../Box';
import { Link } from 'react-aria-components';
import { RiShapesLine } from '@remixicon/react';
import { Text } from '../Text';
declare module 'react-aria-components' {
interface RouterConfig {
@@ -50,6 +52,9 @@ export const PluginHeader = (props: PluginHeaderProps) => {
const hasTabs = tabs && tabs.length > 0;
const headerRef = useRef<HTMLElement>(null);
const toolbarWrapperRef = useRef<HTMLDivElement>(null);
const toolbarContentRef = useRef<HTMLDivElement>(null);
const toolbarControlsRef = useRef<HTMLDivElement>(null);
useIsomorphicLayoutEffect(() => {
const el = headerRef.current;
@@ -83,17 +88,35 @@ export const PluginHeader = (props: PluginHeaderProps) => {
};
}, []);
const titleContent = (
<>
<div className={classes.toolbarIcon}>{icon || <RiShapesLine />}</div>
<Text variant="body-medium">{title || 'Your plugin'}</Text>
</>
);
return (
<header ref={headerRef} className={classes.root}>
<PluginHeaderToolbar
icon={icon}
title={title}
titleLink={titleLink}
customActions={customActions}
hasTabs={hasTabs}
/>
<div className={classes.toolbar} data-has-tabs={hasTabs}>
<div className={classes.toolbarWrapper} ref={toolbarWrapperRef}>
<div className={classes.toolbarContent} ref={toolbarContentRef}>
<Text as="h1" variant="body-medium">
{titleLink ? (
<Link className={classes.toolbarName} href={titleLink}>
{titleContent}
</Link>
) : (
<div className={classes.toolbarName}>{titleContent}</div>
)}
</Text>
</div>
<div className={classes.toolbarControls} ref={toolbarControlsRef}>
{customActions}
</div>
</div>
</div>
{tabs && (
<Box bg="neutral" className={classes.tabsWrapper}>
<Box bg="neutral" className={classes.tabs}>
<Tabs onSelectionChange={onTabSelectionChange}>
<TabList>
{tabs?.map(tab => (
@@ -1,67 +0,0 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Link } from 'react-aria-components';
import { useDefinition } from '../../hooks/useDefinition';
import { PluginHeaderToolbarDefinition } from './definition';
import { useRef } from 'react';
import { RiShapesLine } from '@remixicon/react';
import type { PluginHeaderToolbarProps } from './types';
import { Text } from '../Text';
import { Box } from '../Box';
/**
* A component that renders the toolbar section of a plugin header.
*
* @internal
*/
export const PluginHeaderToolbar = (props: PluginHeaderToolbarProps) => {
const { ownProps } = useDefinition(PluginHeaderToolbarDefinition, props);
const { classes, icon, title, titleLink, customActions, hasTabs } = ownProps;
// Refs for collision detection
const toolbarWrapperRef = useRef<HTMLDivElement>(null);
const toolbarContentRef = useRef<HTMLDivElement>(null);
const toolbarControlsRef = useRef<HTMLDivElement>(null);
const titleContent = (
<>
<div className={classes.icon}>{icon || <RiShapesLine />}</div>
<Text variant="body-medium">{title || 'Your plugin'}</Text>
</>
);
return (
<Box bg="neutral" className={classes.root} data-has-tabs={hasTabs}>
<div className={classes.wrapper} ref={toolbarWrapperRef}>
<div className={classes.content} ref={toolbarContentRef}>
<Text as="h1" variant="body-medium">
{titleLink ? (
<Link className={classes.name} href={titleLink}>
{titleContent}
</Link>
) : (
<div className={classes.name}>{titleContent}</div>
)}
</Text>
</div>
<div className={classes.controls} ref={toolbarControlsRef}>
{customActions}
</div>
</div>
</Box>
);
};
@@ -15,10 +15,7 @@
*/
import { defineComponent } from '../../hooks/useDefinition';
import type {
PluginHeaderOwnProps,
PluginHeaderToolbarOwnProps,
} from './types';
import type { PluginHeaderOwnProps } from './types';
import styles from './PluginHeader.module.css';
/**
@@ -29,7 +26,13 @@ export const PluginHeaderDefinition = defineComponent<PluginHeaderOwnProps>()({
styles,
classNames: {
root: 'bui-PluginHeader',
tabsWrapper: 'bui-PluginHeaderTabsWrapper',
toolbar: 'bui-PluginHeaderToolbar',
toolbarWrapper: 'bui-PluginHeaderToolbarWrapper',
toolbarContent: 'bui-PluginHeaderToolbarContent',
toolbarControls: 'bui-PluginHeaderToolbarControls',
toolbarIcon: 'bui-PluginHeaderToolbarIcon',
toolbarName: 'bui-PluginHeaderToolbarName',
tabs: 'bui-PluginHeaderTabsWrapper',
},
propDefs: {
icon: {},
@@ -41,28 +44,3 @@ export const PluginHeaderDefinition = defineComponent<PluginHeaderOwnProps>()({
className: {},
},
});
/**
* Component definition for PluginHeaderToolbar
* @internal
*/
export const PluginHeaderToolbarDefinition =
defineComponent<PluginHeaderToolbarOwnProps>()({
styles,
classNames: {
root: 'bui-PluginHeaderToolbar',
wrapper: 'bui-PluginHeaderToolbarWrapper',
content: 'bui-PluginHeaderToolbarContent',
controls: 'bui-PluginHeaderToolbarControls',
icon: 'bui-PluginHeaderToolbarIcon',
name: 'bui-PluginHeaderToolbarName',
},
propDefs: {
icon: {},
title: {},
titleLink: {},
customActions: {},
hasTabs: {},
className: {},
},
});
@@ -55,24 +55,3 @@ export interface HeaderTab {
*/
matchStrategy?: TabMatchStrategy;
}
/**
* Own props for the PluginHeaderToolbar component.
*
* @internal
*/
export interface PluginHeaderToolbarOwnProps {
icon?: PluginHeaderOwnProps['icon'];
title?: PluginHeaderOwnProps['title'];
titleLink?: PluginHeaderOwnProps['titleLink'];
customActions?: PluginHeaderOwnProps['customActions'];
hasTabs?: boolean;
className?: string;
}
/**
* Props for the PluginHeaderToolbar component.
*
* @internal
*/
export interface PluginHeaderToolbarProps extends PluginHeaderToolbarOwnProps {}
@@ -125,10 +125,10 @@ export const WithMockedURLTab3 = meta.story({
</TabList>
</Tabs>
<Box mt="6" pl="2">
<Text>
<Text as="p">
Current URL is mocked to be: <strong>/tab3</strong>
</Text>
<Text>
<Text as="p">
Notice how the "Tab 3 With long title" tab is selected (highlighted)
because it matches the current path.
</Text>
@@ -157,14 +157,14 @@ export const WithMockedURLNoMatch = meta.story({
</TabList>
</Tabs>
<Box mt="6" pl="2">
<Text>
<Text as="p">
Current URL is mocked to be: <strong>/some-other-page</strong>
</Text>
<Text>
<Text as="p">
No tab is selected because the current path doesn't match any tab's
href.
</Text>
<Text>
<Text as="p">
Tabs without href (like "Tab 1", "Tab 2", "Tab 3 With long title")
fall back to React Aria's internal state.
</Text>
@@ -195,14 +195,14 @@ export const ExactMatchingDefault = meta.story({
</TabList>
</Tabs>
<Box mt="6" pl="2">
<Text>
<Text as="p">
Current URL: <strong>/mentorship/events</strong>
</Text>
<Text>
<Text as="p">
Using default exact matching, only the "Events" tab is active because
it exactly matches the URL.
</Text>
<Text>
<Text as="p">
The "Mentorship" tab is NOT active even though the URL contains
"/mentorship".
</Text>
@@ -231,18 +231,18 @@ export const PrefixMatchingForNestedRoutes = meta.story({
</TabList>
</Tabs>
<Box mt="6" pl="2">
<Text>
<Text as="p">
Current URL: <strong>/mentorship/events</strong>
</Text>
<Text>
<Text as="p">
The "Mentorship" tab uses prefix matching and IS active because
"/mentorship/events" starts with "/mentorship".
</Text>
<Text>
<Text as="p">
The "Events" tab uses exact matching and is also active because it
exactly matches.
</Text>
<Text>
<Text as="p">
The "Catalog" tab uses prefix matching but is NOT active because the
URL doesn't start with "/catalog".
</Text>
@@ -313,22 +313,22 @@ export const MixedMatchingStrategies = meta.story({
</TabList>
</Tabs>
<Box mt="6" pl="2">
<Text>
<Text as="p">
Current URL: <strong>/dashboard/analytics/reports</strong>
</Text>
<Text>
<Text as="p">
"Overview" tab: exact matching, NOT active (doesn't exactly match
"/dashboard")
</Text>
<Text>
<Text as="p">
• "Analytics" tab: prefix matching, IS active (URL starts with
"/dashboard/analytics")
</Text>
<Text>
<Text as="p">
• "Settings" tab: prefix matching, NOT active (URL doesn't start with
"/dashboard/settings")
</Text>
<Text>
<Text as="p">
"Help" tab: exact matching, NOT active (doesn't exactly match
"/help")
</Text>
@@ -357,20 +357,20 @@ export const PrefixMatchingEdgeCases = meta.story({
</TabList>
</Tabs>
<Box mt="6" pl="2">
<Text>
<Text as="p">
Current URL: <strong>/foobar</strong>
</Text>
<Text>
<Text as="p">
• "Foo" tab (prefix): NOT active - prevents "/foo" from matching
"/foobar"
</Text>
<Text>
<Text as="p">
• "Foobar" tab (exact): IS active - exactly matches "/foobar"
</Text>
<Text>
<Text as="p">
• "Foo (exact)" tab: NOT active - doesn't exactly match "/foobar"
</Text>
<Text>
<Text as="p">
This shows that prefix matching properly requires a "/" separator to
prevent false matches.
</Text>
@@ -399,20 +399,20 @@ export const PrefixMatchingWithSlash = meta.story({
</TabList>
</Tabs>
<Box mt="6" pl="2">
<Text>
<Text as="p">
Current URL: <strong>/foo/bar</strong>
</Text>
<Text>
<Text as="p">
"Foo" tab (prefix): IS active - "/foo/bar" starts with "/foo/"
</Text>
<Text>
<Text as="p">
"Foobar" tab (exact): NOT active - doesn't exactly match "/foobar"
</Text>
<Text>
<Text as="p">
• "Bar" tab (prefix): NOT active - "/foo/bar" doesn't start with
"/bar"
</Text>
<Text>
<Text as="p">
This demonstrates proper prefix matching with the "/" separator.
</Text>
</Box>
@@ -440,12 +440,14 @@ export const RootPathMatching = meta.story({
</TabList>
</Tabs>
<Box mt="6" pl="2">
<Text>
<Text as="p">
Current URL: <strong>/</strong>
</Text>
<Text> "Home" tab (exact): IS active - exactly matches "/"</Text>
<Text> "Home (prefix)" tab: IS active - "/" matches "/"</Text>
<Text>
<Text as="p">
"Home" tab (exact): IS active - exactly matches "/"
</Text>
<Text as="p"> "Home (prefix)" tab: IS active - "/" matches "/"</Text>
<Text as="p">
"Catalog" tab (prefix): NOT active - "/" doesn't start with
"/catalog"
</Text>
@@ -454,6 +456,51 @@ export const RootPathMatching = meta.story({
),
});
export const HrefWithQueryParams = meta.story({
args: {
children: '',
},
render: () => (
<MemoryRouter initialEntries={['/cost-insights/dashboard?group=bar']}>
<Tabs>
<TabList>
<Tab
id="dashboard"
href="/cost-insights/dashboard?group=foo"
matchStrategy="prefix"
>
Dashboard
</Tab>
<Tab
id="alerts"
href="/cost-insights/alerts?group=foo"
matchStrategy="prefix"
>
Alerts
</Tab>
</TabList>
</Tabs>
<Box mt="6" pl="2">
<Text as="p">
Current URL: <strong>/cost-insights/dashboard?group=bar</strong>
</Text>
<Text as="p">
Tab hrefs include query params (e.g., ?group=foo) but the current URL
has different query params (?group=bar).
</Text>
<Text as="p">
• "Dashboard" tab: IS active — matching ignores query params and
compares only the pathname.
</Text>
<Text as="p">
• "Alerts" tab: NOT active — pathname /cost-insights/alerts doesn't
match /cost-insights/dashboard.
</Text>
</Box>
</MemoryRouter>
),
});
export const AutoSelectionOfTabs = meta.story({
args: {
children: '',
@@ -488,10 +535,12 @@ export const AutoSelectionOfTabs = meta.story({
</Tabs>
{/* With hrefs */}
<Text>
{' '}
<strong>Case 2: With hrefs</strong> By default no selection is shown
because the URL doesn't match any tab's href.{' '}
<Text as="p">
<strong>Case 2: With hrefs</strong>
</Text>
<Text as="p">
By default no selection is shown because the URL doesn't match any
tab's href.
</Text>
<Tabs>
<TabList>
+12 -4
View File
@@ -79,6 +79,12 @@ const TabSelectionContext = createContext<TabSelectionContextValue | null>(
null,
);
/**
* Strips query params and hash from a href, leaving only the pathname.
* Tab matching always compares against location.pathname which never includes them.
*/
const hrefPathname = (href: string) => href.split('?')[0].split('#')[0];
/**
* Utility function to determine if a tab should be active based on the matching strategy.
* This follows the pattern used in WorkaroundNavLink from the sidebar.
@@ -88,18 +94,20 @@ const isTabActive = (
currentPathname: string,
matchStrategy: 'exact' | 'prefix',
): boolean => {
const pathname = hrefPathname(tabHref);
if (matchStrategy === 'exact') {
return tabHref === currentPathname;
return pathname === currentPathname;
}
// Prefix matching - similar to WorkaroundNavLink behavior
if (tabHref === currentPathname) {
if (pathname === currentPathname) {
return true;
}
// Check if current path starts with tab href followed by a slash
// This prevents /foo matching /foobar
return currentPathname.startsWith(`${tabHref}/`);
return currentPathname.startsWith(`${pathname}/`);
};
/**
@@ -304,7 +312,7 @@ function RoutedTabEffects({
// Register as active tab when URL matches (for tab selection)
const isActive = isTabActive(href, location.pathname, matchStrategy);
const segmentCount = href.split('/').filter(Boolean).length;
const segmentCount = hrefPathname(href).split('/').filter(Boolean).length;
useEffect(() => {
if (isActive && selectionCtx) {
+2 -2
View File
@@ -80,8 +80,8 @@
--bui-bg-app: #f8f8f8;
--bui-bg-neutral-1: #fff;
--bui-bg-neutral-1-hover: oklch(0% 0 0 / 12%);
--bui-bg-neutral-1-pressed: oklch(0% 0 0 / 16%);
--bui-bg-neutral-1-hover: oklch(0% 0 0 / 6%);
--bui-bg-neutral-1-pressed: oklch(0% 0 0 / 12%);
--bui-bg-neutral-1-disabled: oklch(0% 0 0 / 6%);
--bui-bg-neutral-2: oklch(0% 0 0 / 6%);