feat(ui): centralize routing in BUIProvider (#33267)

* feat(ui): centralize routing in BUIProvider

BUIProvider now auto-detects React Router context and provides
client-side navigation for all BUI components. Retired
InternalLinkProvider and added BUIRouterProvider as a public
export for integration use.

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* feat(plugin-app): move BUIProvider inside app router

Moved BUIProvider from wrapping AppRouter to being a child inside
it, so it detects the React Router context and provides client-side
routing for all BUI components.

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* feat(core-app-api): add BUIRouterProvider to legacy app router

Added BUIRouterProvider inside the legacy AppRouter to provide
React Aria routing for all BUI components.

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* docs(ui): update BUIProvider documentation for routing

Updated installation docs to cover BUIProvider's routing role
and the requirement to render it inside a React Router context.

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* refactor(ui): move BUIProvider from analytics to provider directory

BUIProvider now handles both analytics and routing, so it no longer
belongs in the analytics directory.

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* fix(ui): add BUIProvider to storybook stories with MemoryRouter

Added BUIProvider inside MemoryRouter in all stories that use
routing, so client-side navigation works in Storybook.

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* fix(plugin-app): move BUIProvider inside RouterComponent

Moved BUIProvider to wrap all content inside RouterComponent
so that extraElements (like dialogs) also get BUI context.

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* refactor: replace BUIRouterProvider with BUIProvider in legacy app

Use BUIProvider directly inside the legacy AppRouter instead of a
separate BUIRouterProvider export. Removes BUIRouterProvider from
the public API of @backstage/ui.

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* refactor(ui): inline routing logic into BUIProvider

Removed the routing/ directory and inlined the RouterProvider
setup directly into BUIProvider since it's the only consumer.

Signed-off-by: Johan Persson <johanopersson@gmail.com>

---------

Signed-off-by: Johan Persson <johanopersson@gmail.com>
This commit is contained in:
Johan Persson
2026-03-16 18:56:24 +01:00
committed by GitHub
parent 1058260dc6
commit 42f8c9b2b8
33 changed files with 884 additions and 914 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-app-api': patch
---
Added `BUIProvider` inside the legacy app router to enable client-side routing for all BUI components.
+23
View File
@@ -0,0 +1,23 @@
---
'@backstage/ui': minor
---
**BREAKING**: Centralized client-side routing in `BUIProvider`. Components like Link, ButtonLink, Tabs, Menu, TagGroup, and Table now require a `BUIProvider` rendered inside a React Router context for client-side navigation to work.
**Migration:**
This change requires updating `@backstage/plugin-app` and `@backstage/core-app-api` alongside `@backstage/ui`. If you only upgrade `@backstage/ui`, BUI components will fall back to full-page navigation.
If you cannot upgrade all packages together, or if you have a custom app shell, add a `BUIProvider` inside your Router:
```diff
+ import { BUIProvider } from '@backstage/ui';
<BrowserRouter>
+ <BUIProvider>
<AppContent />
+ </BUIProvider>
</BrowserRouter>
```
**Affected components:** Link, ButtonLink, Tabs, Menu, TagGroup, Table
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-app': patch
---
Moved `BUIProvider` inside the app router to enable automatic client-side routing for all BUI components.
@@ -49,23 +49,28 @@ your plugin and import the components you need.
<CodeBlock lang="tsx" title="Let's get started 🚀" code={snippet} />
## Analytics
## BUIProvider
BUI components with navigation behavior — Link, ButtonLink, Tab, MenuItem, Tag, and Row — can fire analytics events when clicked. To enable this, you need to connect BUI's analytics layer to Backstage's analytics system.
`BUIProvider` provides routing and analytics integration for all BUI components. It must be rendered inside a React Router context for client-side navigation to work in components like Link, ButtonLink, Tabs, Menu, TagGroup, and Table.
### Setup
If you're using the **new frontend system**, analytics is wired automatically via `@backstage/plugin-app` — no setup required.
If you're using the **new frontend system**, the provider is wired automatically via `@backstage/plugin-app` — no setup required.
For the **old frontend system**, the `BUIProvider` is included in the app shell from `@backstage/core-app-api` and works out of the box.
If you need to set up the provider manually (e.g. in a custom app shell), wrap your app content with the `BUIProvider` and pass in Backstage's `useAnalytics` hook:
If you need to set up the provider manually (e.g. in a custom app shell), wrap your app content with the `BUIProvider` inside your Router and pass in Backstage's `useAnalytics` hook:
<CodeBlock lang="tsx" code={analyticsSetupSnippet} />
### How it works
<Banner
text="BUIProvider must be rendered inside a React Router context. If placed outside, components will fall back to full-page navigation instead of client-side routing."
variant="warning"
/>
Once configured, BUI components fire a `click` event through Backstage's analytics system when a user navigates. Events include the link text as the subject and the destination URL in the attributes, along with any `AnalyticsContext` metadata (such as `pluginId`) from the component's position in the tree.
### Analytics
Once configured, BUI components with navigation behavior — Link, ButtonLink, Tab, MenuItem, Tag, and Row — fire a `click` event through Backstage's analytics system when a user navigates. Events include the link text as the subject and the destination URL in the attributes, along with any `AnalyticsContext` metadata (such as `pluginId`) from the component's position in the tree.
To suppress tracking on an individual component, use the `noTrack` prop:
@@ -7,11 +7,14 @@ export const snippet = `import { Flex, Button, Text } from '@backstage/ui';
export const analyticsSetupSnippet = `import { BUIProvider } from '@backstage/ui';
import { useAnalytics } from '@backstage/core-plugin-api';
import { BrowserRouter } from 'react-router-dom';
// Wrap your app content with the provider
<BUIProvider useAnalytics={useAnalytics}>
<AppContent />
</BUIProvider>`;
// BUIProvider must be inside a Router for client-side navigation
<BrowserRouter>
<BUIProvider useAnalytics={useAnalytics}>
<AppContent />
</BUIProvider>
</BrowserRouter>`;
export const analyticsNoTrackSnippet = `// Suppress analytics for a specific link
<Link href="/internal" noTrack>
+32 -22
View File
@@ -23,7 +23,9 @@ import {
SignInPageProps,
useApi,
useApp,
useAnalytics,
} from '@backstage/core-plugin-api';
import { BUIProvider } from '@backstage/ui';
import { InternalAppContext } from './InternalAppContext';
import { isReactRouterBeta } from './isReactRouterBeta';
import { RouteTracker } from '../routing/RouteTracker';
@@ -143,18 +145,22 @@ export function AppRouter(props: AppRouterProps) {
if (isReactRouterBeta()) {
return (
<RouterComponent>
<RouteTracker routeObjects={routeObjects} />
<Routes>
<Route path={mountPath} element={<>{props.children}</>} />
</Routes>
<BUIProvider useAnalytics={useAnalytics}>
<RouteTracker routeObjects={routeObjects} />
<Routes>
<Route path={mountPath} element={<>{props.children}</>} />
</Routes>
</BUIProvider>
</RouterComponent>
);
}
return (
<RouterComponent basename={basePath}>
<RouteTracker routeObjects={routeObjects} />
{props.children}
<BUIProvider useAnalytics={useAnalytics}>
<RouteTracker routeObjects={routeObjects} />
{props.children}
</BUIProvider>
</RouterComponent>
);
}
@@ -162,28 +168,32 @@ export function AppRouter(props: AppRouterProps) {
if (isReactRouterBeta()) {
return (
<RouterComponent>
<RouteTracker routeObjects={routeObjects} />
<SignInPageWrapper
component={SignInPageComponent}
appIdentityProxy={appIdentityProxy}
>
<Routes>
<Route path={mountPath} element={<>{props.children}</>} />
</Routes>
</SignInPageWrapper>
<BUIProvider useAnalytics={useAnalytics}>
<RouteTracker routeObjects={routeObjects} />
<SignInPageWrapper
component={SignInPageComponent}
appIdentityProxy={appIdentityProxy}
>
<Routes>
<Route path={mountPath} element={<>{props.children}</>} />
</Routes>
</SignInPageWrapper>
</BUIProvider>
</RouterComponent>
);
}
return (
<RouterComponent basename={basePath}>
<RouteTracker routeObjects={routeObjects} />
<SignInPageWrapper
component={SignInPageComponent}
appIdentityProxy={appIdentityProxy}
>
{props.children}
</SignInPageWrapper>
<BUIProvider useAnalytics={useAnalytics}>
<RouteTracker routeObjects={routeObjects} />
<SignInPageWrapper
component={SignInPageComponent}
appIdentityProxy={appIdentityProxy}
>
{props.children}
</SignInPageWrapper>
</BUIProvider>
</RouterComponent>
);
}
-2
View File
@@ -15,8 +15,6 @@
*/
export { useAnalytics } from './useAnalytics';
export { BUIProvider } from './BUIProvider';
export type { BUIProviderProps } from './BUIProvider';
export { getNodeText } from './getNodeText';
export type {
AnalyticsTracker,
@@ -19,6 +19,7 @@ import type { StoryFn } from '@storybook/react-vite';
import { ButtonLink } from './ButtonLink';
import { Flex } from '../Flex';
import { MemoryRouter } from 'react-router-dom';
import { BUIProvider } from '../../provider';
import { RiArrowRightSLine, RiCloudLine } from '@remixicon/react';
const meta = preview.meta({
@@ -27,7 +28,9 @@ const meta = preview.meta({
decorators: [
(Story: StoryFn) => (
<MemoryRouter>
<Story />
<BUIProvider>
<Story />
</BUIProvider>
</MemoryRouter>
),
],
@@ -19,7 +19,6 @@ import { Link as RALink } from 'react-aria-components';
import type { ButtonLinkProps } from './types';
import { useDefinition } from '../../hooks/useDefinition';
import { ButtonLinkDefinition } from './definition';
import { InternalLinkProvider } from '../InternalLinkProvider';
import { getNodeText } from '../../analytics/getNodeText';
/** @public */
@@ -43,21 +42,19 @@ export const ButtonLink = forwardRef(
};
return (
<InternalLinkProvider href={restProps.href}>
<RALink
className={classes.root}
ref={ref}
{...dataAttributes}
{...restProps}
onPress={handlePress}
>
<span className={classes.content}>
{iconStart}
{children}
{iconEnd}
</span>
</RALink>
</InternalLinkProvider>
<RALink
className={classes.root}
ref={ref}
{...dataAttributes}
{...restProps}
onPress={handlePress}
>
<span className={classes.content}>
{iconStart}
{children}
{iconEnd}
</span>
</RALink>
);
},
);
@@ -22,6 +22,7 @@ import { Container } from '../Container';
import { Text } from '../Text';
import type { HeaderTab } from '../PluginHeader/types';
import { MemoryRouter } from 'react-router-dom';
import { BUIProvider } from '../../provider';
const meta = preview.meta({
title: 'Backstage UI/FullPage',
@@ -33,7 +34,9 @@ const meta = preview.meta({
const withRouter = (Story: StoryFn) => (
<MemoryRouter>
<Story />
<BUIProvider>
<Story />
</BUIProvider>
</MemoryRouter>
);
@@ -19,6 +19,7 @@ import type { StoryFn } from '@storybook/react-vite';
import { Header } from './Header';
import type { HeaderTab } from '../PluginHeader/types';
import { MemoryRouter } from 'react-router-dom';
import { BUIProvider } from '../../provider';
import {
Button,
Container,
@@ -88,7 +89,9 @@ const menuItems = [
const withRouter = (Story: StoryFn) => (
<MemoryRouter>
<Story />
<BUIProvider>
<Story />
</BUIProvider>
</MemoryRouter>
);
@@ -239,32 +242,34 @@ export const WithTabsMatchingStrategies = meta.story({
},
render: args => (
<MemoryRouter initialEntries={['/mentorship/events']}>
<Header {...args} />
<Container>
<Text>
<strong>Current URL:</strong> /mentorship/events
</Text>
<br />
<Text>
Notice how the "Mentorship" tab is active even though we're on a
nested route. This is because it uses{' '}
<code>matchStrategy="prefix"</code>.
</Text>
<br />
<Text>
• <strong>Home</strong>: exact matching (default) - not active
</Text>
<Text>
• <strong>Mentorship</strong>: prefix matching - IS active (URL starts
with /mentorship)
</Text>
<Text>
• <strong>Catalog</strong>: prefix matching - not active
</Text>
<Text>
• <strong>Settings</strong>: exact matching (default) - not active
</Text>
</Container>
<BUIProvider>
<Header {...args} />
<Container>
<Text>
<strong>Current URL:</strong> /mentorship/events
</Text>
<br />
<Text>
Notice how the "Mentorship" tab is active even though we're on a
nested route. This is because it uses{' '}
<code>matchStrategy="prefix"</code>.
</Text>
<br />
<Text>
• <strong>Home</strong>: exact matching (default) - not active
</Text>
<Text>
• <strong>Mentorship</strong>: prefix matching - IS active (URL
starts with /mentorship)
</Text>
<Text>
• <strong>Catalog</strong>: prefix matching - not active
</Text>
<Text>
• <strong>Settings</strong>: exact matching (default) - not active
</Text>
</Container>
</BUIProvider>
</MemoryRouter>
),
});
@@ -292,18 +297,20 @@ export const WithTabsExactMatching = meta.story({
},
render: args => (
<MemoryRouter initialEntries={['/mentorship/events']}>
<Header {...args} />
<Container>
<Text>
<strong>Current URL:</strong> /mentorship/events
</Text>
<br />
<Text>
With default exact matching, only the "Events" tab is active because
it exactly matches the current URL. The "Mentorship" tab is not active
even though the URL is under /mentorship.
</Text>
</Container>
<BUIProvider>
<Header {...args} />
<Container>
<Text>
<strong>Current URL:</strong> /mentorship/events
</Text>
<br />
<Text>
With default exact matching, only the "Events" tab is active because
it exactly matches the current URL. The "Mentorship" tab is not
active even though the URL is under /mentorship.
</Text>
</Container>
</BUIProvider>
</MemoryRouter>
),
});
@@ -334,33 +341,36 @@ export const WithTabsPrefixMatchingDeep = meta.story({
},
render: args => (
<MemoryRouter initialEntries={['/catalog/users/john/details']}>
<Header {...args} />
<Container>
<Text as="p">
<strong>Current URL:</strong> /catalog/users/john/details
</Text>
<br />
<Text as="p">
Active tab is <strong>Users</strong> because:
</Text>
<ul>
<li>
<strong>Catalog</strong>: Matches since URL starts with /catalog
</li>
<li>
<strong>Users</strong>: Is active since URL starts with
/catalog/users, and is more specific (has more url segments) than
"Catalog"
</li>
<li>
<strong>Components</strong>: not active (URL doesn't start with
/catalog/components)
</li>
</ul>
<Text as="p">
This demonstrates how prefix matching works with deeply nested routes.
</Text>
</Container>
<BUIProvider>
<Header {...args} />
<Container>
<Text as="p">
<strong>Current URL:</strong> /catalog/users/john/details
</Text>
<br />
<Text as="p">
Active tab is <strong>Users</strong> because:
</Text>
<ul>
<li>
<strong>Catalog</strong>: Matches since URL starts with /catalog
</li>
<li>
<strong>Users</strong>: Is active since URL starts with
/catalog/users, and is more specific (has more url segments) than
"Catalog"
</li>
<li>
<strong>Components</strong>: not active (URL doesn't start with
/catalog/components)
</li>
</ul>
<Text as="p">
This demonstrates how prefix matching works with deeply nested
routes.
</Text>
</Container>
</BUIProvider>
</MemoryRouter>
),
});
@@ -1,157 +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 {
ReactNode,
createContext,
useCallback,
useContext,
useEffect,
useState,
} from 'react';
import { RouterProvider } from 'react-aria-components';
import { useNavigate, useHref } from 'react-router-dom';
import { isExternalLink } from '../../utils/isExternalLink';
/**
* Checks if an href is an internal link (not external and not empty).
*
* @internal
*/
export function isInternalLink(href: string | undefined): href is string {
return !!href && !isExternalLink(href);
}
/**
* Context value type for routing registration.
* Used by container components to track children that need RouterProvider.
*
* @internal
*/
export type RoutingContextValue = {
register: () => () => void;
};
/**
* Wraps children in a RouterProvider for client-side navigation.
* Must be rendered within a React Router context.
*
* @internal
*/
export function RoutedContainer({ children }: { children: ReactNode }) {
const navigate = useNavigate();
return (
<RouterProvider navigate={navigate} useHref={useHref}>
{children}
</RouterProvider>
);
}
/**
* Hook for container components that need to conditionally provide routing.
*
* Usage:
* 1. Call this hook in the container component
* 2. Pass `contextValue` to a RoutingContextValue context provider
* 3. Children call `register()` via context when they have internal hrefs
* 4. If `hasRoutedChildren` is true, wrap content in RoutedContainer
*
* @internal
*/
export function useRoutingRegistration(): {
hasRoutedChildren: boolean;
contextValue: RoutingContextValue;
} {
const [count, setCount] = useState(0);
const register = useCallback(() => {
setCount(c => c + 1);
return () => setCount(c => c - 1);
}, []);
return { hasRoutedChildren: count > 0, contextValue: { register } };
}
/**
* Creates a routing registration context and provider for container components.
*
* Usage:
* ```tsx
* // At module level
* const { RoutingProvider, useRoutingRegistrationEffect } = createRoutingRegistration();
*
* // Container component wraps content with provider
* <RoutingProvider>{content}</RoutingProvider>
*
* // Child items register when they have internal hrefs
* useRoutingRegistrationEffect(href);
* ```
*
* @internal
*/
export function createRoutingRegistration() {
const RoutingContext = createContext<RoutingContextValue | null>(null);
function RoutingProvider({ children }: { children: ReactNode }) {
const { hasRoutedChildren, contextValue } = useRoutingRegistration();
const content = (
<RoutingContext.Provider value={contextValue}>
{children}
</RoutingContext.Provider>
);
if (hasRoutedChildren) {
return <RoutedContainer>{content}</RoutedContainer>;
}
return content;
}
function useRoutingRegistrationEffect(href: string | undefined) {
const routingCtx = useContext(RoutingContext);
const hasInternalHref = isInternalLink(href);
useEffect(() => {
if (hasInternalHref && routingCtx) {
return routingCtx.register();
}
return undefined;
}, [hasInternalHref, routingCtx]);
}
return { RoutingContext, RoutingProvider, useRoutingRegistrationEffect };
}
/**
* Conditionally wraps children in a RouterProvider for internal link navigation.
* Only mounts the router hooks when `href` is an internal link, avoiding the
* requirement for a Router context when rendering components without internal hrefs.
*
* @internal
*/
export function InternalLinkProvider({
href,
children,
}: {
href: string | undefined;
children: ReactNode;
}) {
if (!isInternalLink(href)) {
return <>{children}</>;
}
return <RoutedContainer>{children}</RoutedContainer>;
}
@@ -20,6 +20,7 @@ import { Link } from './Link';
import { Flex } from '../Flex';
import { Text } from '../Text';
import { MemoryRouter } from 'react-router-dom';
import { BUIProvider } from '../../provider';
const meta = preview.meta({
title: 'Backstage UI/Link',
@@ -30,7 +31,9 @@ const meta = preview.meta({
decorators: [
(Story: StoryFn) => (
<MemoryRouter>
<Story />
<BUIProvider>
<Story />
</BUIProvider>
</MemoryRouter>
),
],
+1 -6
View File
@@ -19,7 +19,6 @@ import { useLink } from 'react-aria';
import type { LinkProps } from './types';
import { useDefinition } from '../../hooks/useDefinition';
import { LinkDefinition } from './definition';
import { InternalLinkProvider } from '../InternalLinkProvider';
import { getNodeText } from '../../analytics/getNodeText';
const LinkInternal = forwardRef<HTMLAnchorElement, LinkProps>((props, ref) => {
@@ -64,11 +63,7 @@ LinkInternal.displayName = 'LinkInternal';
/** @public */
export const Link = forwardRef<HTMLAnchorElement, LinkProps>((props, ref) => {
return (
<InternalLinkProvider href={props.href}>
<LinkInternal {...props} ref={ref} />
</InternalLinkProvider>
);
return <LinkInternal {...props} ref={ref} />;
});
Link.displayName = 'Link';
@@ -36,6 +36,7 @@ import {
RiShareBoxLine,
} from '@remixicon/react';
import { MemoryRouter } from 'react-router-dom';
import { BUIProvider } from '../../provider';
import { useEffect, useState } from 'react';
const meta = preview.meta({
@@ -44,7 +45,9 @@ const meta = preview.meta({
decorators: [
Story => (
<MemoryRouter>
<Story />
<BUIProvider>
<Story />
</BUIProvider>
</MemoryRouter>
),
],
+50 -62
View File
@@ -62,17 +62,11 @@ import {
RiCheckLine,
RiCloseCircleLine,
} from '@remixicon/react';
import {
isInternalLink,
createRoutingRegistration,
} from '../InternalLinkProvider';
import { isInternalLink } from '../../utils/linkUtils';
import { getNodeText } from '../../analytics/getNodeText';
import { Box } from '../Box';
import { BgReset } from '../../hooks/useBg';
const { RoutingProvider, useRoutingRegistrationEffect } =
createRoutingRegistration();
// The height will be used for virtualized menus. It should match the size set in CSS for each menu item.
const rowHeight = 32;
@@ -110,26 +104,24 @@ export const Menu = (props: MenuProps<object>) => {
);
return (
<RoutingProvider>
<RAPopover className={classes.root} placement={placement}>
<BgReset>
<Box bg="neutral" className={classes.inner}>
{virtualized ? (
<Virtualizer
layout={ListLayout}
layoutOptions={{
rowHeight,
}}
>
{menuContent}
</Virtualizer>
) : (
menuContent
)}
</Box>
</BgReset>
</RAPopover>
</RoutingProvider>
<RAPopover className={classes.root} placement={placement}>
<BgReset>
<Box bg="neutral" className={classes.inner}>
{virtualized ? (
<Virtualizer
layout={ListLayout}
layoutOptions={{
rowHeight,
}}
>
{menuContent}
</Virtualizer>
) : (
menuContent
)}
</Box>
</BgReset>
</RAPopover>
);
};
@@ -206,40 +198,38 @@ export const MenuAutocomplete = (props: MenuAutocompleteProps<object>) => {
);
return (
<RoutingProvider>
<RAPopover className={classes.root} placement={placement}>
<BgReset>
<Box bg="neutral" className={classes.inner}>
<RAAutocomplete filter={contains}>
<RASearchField
className={classes.searchField}
aria-label={placeholder || 'Search'}
<RAPopover className={classes.root} placement={placement}>
<BgReset>
<Box bg="neutral" className={classes.inner}>
<RAAutocomplete filter={contains}>
<RASearchField
className={classes.searchField}
aria-label={placeholder || 'Search'}
>
<RAInput
className={classes.searchFieldInput}
placeholder={placeholder || 'Search...'}
/>
<RAButton className={classes.searchFieldClear}>
<RiCloseCircleLine />
</RAButton>
</RASearchField>
{virtualized ? (
<Virtualizer
layout={ListLayout}
layoutOptions={{
rowHeight,
}}
>
<RAInput
className={classes.searchFieldInput}
placeholder={placeholder || 'Search...'}
/>
<RAButton className={classes.searchFieldClear}>
<RiCloseCircleLine />
</RAButton>
</RASearchField>
{virtualized ? (
<Virtualizer
layout={ListLayout}
layoutOptions={{
rowHeight,
}}
>
{menuContent}
</Virtualizer>
) : (
menuContent
)}
</RAAutocomplete>
</Box>
</BgReset>
</RAPopover>
</RoutingProvider>
{menuContent}
</Virtualizer>
) : (
menuContent
)}
</RAAutocomplete>
</Box>
</BgReset>
</RAPopover>
);
};
@@ -318,8 +308,6 @@ export const MenuItem = (props: MenuItemProps) => {
);
const { classes, iconStart, children, href } = ownProps;
useRoutingRegistrationEffect(href);
const handleAction = () => {
if (href) {
const text =
@@ -25,6 +25,7 @@ import {
import { Button } from '../..';
import { useState, useEffect } from 'react';
import { MemoryRouter } from 'react-router-dom';
import { BUIProvider } from '../../provider';
const meta = preview.meta({
title: 'Backstage UI/MenuAutocomplete',
@@ -32,7 +33,9 @@ const meta = preview.meta({
decorators: [
Story => (
<MemoryRouter>
<Story />
<BUIProvider>
<Story />
</BUIProvider>
</MemoryRouter>
),
],
@@ -27,6 +27,7 @@ import { Button, Flex, Text } from '../..';
import { useEffect, useState } from 'react';
import { Selection } from 'react-aria-components';
import { MemoryRouter } from 'react-router-dom';
import { BUIProvider } from '../../provider';
const meta = preview.meta({
title: 'Backstage UI/MenuAutocompleteListBox',
@@ -34,7 +35,9 @@ const meta = preview.meta({
decorators: [
Story => (
<MemoryRouter>
<Story />
<BUIProvider>
<Story />
</BUIProvider>
</MemoryRouter>
),
],
@@ -20,6 +20,7 @@ import { Button, Flex, Text } from '../..';
import { useEffect, useState } from 'react';
import { Selection } from 'react-aria-components';
import { MemoryRouter } from 'react-router-dom';
import { BUIProvider } from '../../provider';
const meta = preview.meta({
title: 'Backstage UI/MenuListBox',
@@ -27,7 +28,9 @@ const meta = preview.meta({
decorators: [
Story => (
<MemoryRouter>
<Story />
<BUIProvider>
<Story />
</BUIProvider>
</MemoryRouter>
),
],
@@ -29,6 +29,7 @@ import {
MenuItem,
} from '../../';
import { MemoryRouter } from 'react-router-dom';
import { BUIProvider } from '../../provider';
import {
RiHeartLine,
RiEmotionHappyLine,
@@ -47,7 +48,9 @@ const meta = preview.meta({
const withRouter = (Story: StoryFn) => (
<MemoryRouter>
<Story />
<BUIProvider>
<Story />
</BUIProvider>
</MemoryRouter>
);
@@ -336,16 +339,18 @@ export const WithMockedURLCampaigns = meta.story({
},
render: args => (
<MemoryRouter initialEntries={['/campaigns']}>
<PluginHeader {...args} />
<Container>
<Text as="p">
Current URL is mocked to be: <strong>/campaigns</strong>
</Text>
<Text as="p">
Notice how the "Campaigns" tab is selected (highlighted) because it
matches the current path.
</Text>
</Container>
<BUIProvider>
<PluginHeader {...args} />
<Container>
<Text as="p">
Current URL is mocked to be: <strong>/campaigns</strong>
</Text>
<Text as="p">
Notice how the "Campaigns" tab is selected (highlighted) because it
matches the current path.
</Text>
</Container>
</BUIProvider>
</MemoryRouter>
),
});
@@ -356,16 +361,18 @@ export const WithMockedURLIntegrations = meta.story({
},
render: args => (
<MemoryRouter initialEntries={['/integrations']}>
<PluginHeader {...args} />
<Container>
<Text as="p">
Current URL is mocked to be: <strong>/integrations</strong>
</Text>
<Text as="p">
Notice how the "Integrations" tab is selected (highlighted) because it
matches the current path.
</Text>
</Container>
<BUIProvider>
<PluginHeader {...args} />
<Container>
<Text as="p">
Current URL is mocked to be: <strong>/integrations</strong>
</Text>
<Text as="p">
Notice how the "Integrations" tab is selected (highlighted) because
it matches the current path.
</Text>
</Container>
</BUIProvider>
</MemoryRouter>
),
});
@@ -376,20 +383,22 @@ export const WithMockedURLNoMatch = meta.story({
},
render: args => (
<MemoryRouter initialEntries={['/some-other-page']}>
<PluginHeader {...args} />
<Container>
<Text as="p">
Current URL is mocked to be: <strong>/some-other-page</strong>
</Text>
<Text as="p">
No tab is selected because the current path doesn't match any tab's
href.
</Text>
<Text as="p">
Tabs without href (like "Overview", "Checks", "Tracks") fall back to
React Aria's internal state.
</Text>
</Container>
<BUIProvider>
<PluginHeader {...args} />
<Container>
<Text as="p">
Current URL is mocked to be: <strong>/some-other-page</strong>
</Text>
<Text as="p">
No tab is selected because the current path doesn't match any tab's
href.
</Text>
<Text as="p">
Tabs without href (like "Overview", "Checks", "Tracks") fall back to
React Aria's internal state.
</Text>
</Container>
</BUIProvider>
</MemoryRouter>
),
});
@@ -424,32 +433,34 @@ export const WithTabsMatchingStrategies = meta.story({
},
render: args => (
<MemoryRouter initialEntries={['/mentorship/events']}>
<PluginHeader {...args} />
<Container>
<Text>
<strong>Current URL:</strong> /mentorship/events
</Text>
<br />
<Text>
Notice how the "Mentorship" tab is active even though we're on a
nested route. This is because it uses{' '}
<code>matchStrategy="prefix"</code>.
</Text>
<br />
<Text>
<strong>Home</strong>: exact matching (default) - not active
</Text>
<Text>
<strong>Mentorship</strong>: prefix matching - IS active (URL starts
with /mentorship)
</Text>
<Text>
<strong>Catalog</strong>: prefix matching - not active
</Text>
<Text>
<strong>Settings</strong>: exact matching (default) - not active
</Text>
</Container>
<BUIProvider>
<PluginHeader {...args} />
<Container>
<Text>
<strong>Current URL:</strong> /mentorship/events
</Text>
<br />
<Text>
Notice how the "Mentorship" tab is active even though we're on a
nested route. This is because it uses{' '}
<code>matchStrategy="prefix"</code>.
</Text>
<br />
<Text>
<strong>Home</strong>: exact matching (default) - not active
</Text>
<Text>
<strong>Mentorship</strong>: prefix matching - IS active (URL
starts with /mentorship)
</Text>
<Text>
<strong>Catalog</strong>: prefix matching - not active
</Text>
<Text>
<strong>Settings</strong>: exact matching (default) - not active
</Text>
</Container>
</BUIProvider>
</MemoryRouter>
),
});
@@ -477,18 +488,20 @@ export const WithTabsExactMatching = meta.story({
},
render: args => (
<MemoryRouter initialEntries={['/mentorship/events']}>
<PluginHeader {...args} />
<Container>
<Text>
<strong>Current URL:</strong> /mentorship/events
</Text>
<br />
<Text>
With default exact matching, only the "Events" tab is active because
it exactly matches the current URL. The "Mentorship" tab is not active
even though the URL is under /mentorship.
</Text>
</Container>
<BUIProvider>
<PluginHeader {...args} />
<Container>
<Text>
<strong>Current URL:</strong> /mentorship/events
</Text>
<br />
<Text>
With default exact matching, only the "Events" tab is active because
it exactly matches the current URL. The "Mentorship" tab is not
active even though the URL is under /mentorship.
</Text>
</Container>
</BUIProvider>
</MemoryRouter>
),
});
@@ -519,33 +532,36 @@ export const WithTabsPrefixMatchingDeep = meta.story({
},
render: args => (
<MemoryRouter initialEntries={['/catalog/users/john/details']}>
<PluginHeader {...args} />
<Container>
<Text as="p">
<strong>Current URL:</strong> /catalog/users/john/details
</Text>
<br />
<Text as="p">
Active tab is <strong>Users</strong> because:
</Text>
<ul>
<li>
<strong>Catalog</strong>: Matches since URL starts with /catalog
</li>
<li>
<strong>Users</strong>: Is active since URL starts with
/catalog/users, and is more specific (has more url segments) than
"Catalog"
</li>
<li>
<strong>Components</strong>: not active (URL doesn't start with
/catalog/components)
</li>
</ul>
<Text as="p">
This demonstrates how prefix matching works with deeply nested routes.
</Text>
</Container>
<BUIProvider>
<PluginHeader {...args} />
<Container>
<Text as="p">
<strong>Current URL:</strong> /catalog/users/john/details
</Text>
<br />
<Text as="p">
Active tab is <strong>Users</strong> because:
</Text>
<ul>
<li>
<strong>Catalog</strong>: Matches since URL starts with /catalog
</li>
<li>
<strong>Users</strong>: Is active since URL starts with
/catalog/users, and is more specific (has more url segments) than
"Catalog"
</li>
<li>
<strong>Components</strong>: not active (URL doesn't start with
/catalog/components)
</li>
</ul>
<Text as="p">
This demonstrates how prefix matching works with deeply nested
routes.
</Text>
</Container>
</BUIProvider>
</MemoryRouter>
),
});
@@ -27,6 +27,7 @@ import { RiCactusLine, RiEBike2Line } from '@remixicon/react';
import { Button } from '../Button';
import { PluginHeader } from '../PluginHeader';
import { MemoryRouter } from 'react-router-dom';
import { BUIProvider } from '../../provider';
const meta = preview.meta({
title: 'Backstage UI/SearchField',
@@ -188,7 +189,9 @@ export const InHeader = meta.story({
decorators: [
Story => (
<MemoryRouter>
<Story />
<BUIProvider>
<Story />
</BUIProvider>
</MemoryRouter>
),
],
@@ -225,7 +228,9 @@ export const StartCollapsedInHeader = meta.story({
decorators: [
Story => (
<MemoryRouter>
<Story />
<BUIProvider>
<Story />
</BUIProvider>
</MemoryRouter>
),
],
@@ -24,8 +24,7 @@ import { Checkbox } from '../../Checkbox';
import { useDefinition } from '../../../hooks/useDefinition';
import { RowDefinition } from '../definition';
import type { RowProps } from '../types';
import { isExternalLink } from '../../../utils/isExternalLink';
import { InternalLinkProvider } from '../../InternalLinkProvider';
import { isExternalLink } from '../../../utils/linkUtils';
import clsx from 'clsx';
import { Flex } from '../../Flex';
@@ -85,18 +84,16 @@ export function Row<T extends object>(props: RowProps<T>) {
);
return (
<InternalLinkProvider href={href}>
<ReactAriaRow
href={href}
{...restProps}
target={effectiveTarget}
rel={effectiveRel}
className={clsx(classes.root, restProps.className)}
data-react-aria-pressable={hasInternalHref ? 'true' : undefined}
onAction={handlePress}
>
{content}
</ReactAriaRow>
</InternalLinkProvider>
<ReactAriaRow
href={href}
{...restProps}
target={effectiveTarget}
rel={effectiveRel}
className={clsx(classes.root, restProps.className)}
data-react-aria-pressable={hasInternalHref ? 'true' : undefined}
onAction={handlePress}
>
{content}
</ReactAriaRow>
);
}
@@ -17,6 +17,7 @@
import type { Meta } from '@storybook/react-vite';
import { MemoryRouter } from 'react-router-dom';
import { BUIProvider } from '../../../provider';
import { CellText, type ColumnConfig } from '..';
// Selection demo data
@@ -47,7 +48,9 @@ export const tableStoriesMeta = {
decorators: [
(Story: () => JSX.Element) => (
<MemoryRouter>
<Story />
<BUIProvider>
<Story />
</BUIProvider>
</MemoryRouter>
),
],
+395 -364
View File
@@ -18,6 +18,7 @@ import preview from '../../../../../.storybook/preview';
import type { StoryFn } from '@storybook/react-vite';
import { Tabs, TabList, Tab, TabPanel } from './Tabs';
import { MemoryRouter } from 'react-router-dom';
import { BUIProvider } from '../../provider';
import { Box } from '../Box';
import { Text } from '../Text';
@@ -28,7 +29,9 @@ const meta = preview.meta({
const withRouter = (Story: StoryFn) => (
<MemoryRouter>
<Story />
<BUIProvider>
<Story />
</BUIProvider>
</MemoryRouter>
);
@@ -79,28 +82,30 @@ export const WithMockedURLTab2 = meta.story({
},
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 as="p">
Current URL is mocked to be: <strong>/tab2</strong>
</Text>
<Text as="p">
Notice how the "Tab 2" tab is selected (highlighted) because it
matches the current path.
</Text>
</Box>
<BUIProvider>
<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 as="p">
Current URL is mocked to be: <strong>/tab2</strong>
</Text>
<Text as="p">
Notice how the "Tab 2" tab is selected (highlighted) because it
matches the current path.
</Text>
</Box>
</BUIProvider>
</MemoryRouter>
),
});
@@ -111,28 +116,30 @@ export const WithMockedURLTab3 = meta.story({
},
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 as="p">
Current URL is mocked to be: <strong>/tab3</strong>
</Text>
<Text as="p">
Notice how the "Tab 3 With long title" tab is selected (highlighted)
because it matches the current path.
</Text>
</Box>
<BUIProvider>
<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 as="p">
Current URL is mocked to be: <strong>/tab3</strong>
</Text>
<Text as="p">
Notice how the "Tab 3 With long title" tab is selected (highlighted)
because it matches the current path.
</Text>
</Box>
</BUIProvider>
</MemoryRouter>
),
});
@@ -143,32 +150,34 @@ export const WithMockedURLNoMatch = meta.story({
},
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 as="p">
Current URL is mocked to be: <strong>/some-other-page</strong>
</Text>
<Text as="p">
No tab is selected because the current path doesn't match any tab's
href.
</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>
</Box>
<BUIProvider>
<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 as="p">
Current URL is mocked to be: <strong>/some-other-page</strong>
</Text>
<Text as="p">
No tab is selected because the current path doesn't match any tab's
href.
</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>
</Box>
</BUIProvider>
</MemoryRouter>
),
});
@@ -181,32 +190,34 @@ export const ExactMatchingDefault = meta.story({
},
render: () => (
<MemoryRouter initialEntries={['/mentorship/events']}>
<Tabs>
<TabList>
<Tab id="mentorship" href="/mentorship">
Mentorship
</Tab>
<Tab id="events" href="/mentorship/events">
Events
</Tab>
<Tab id="catalog" href="/catalog">
Catalog
</Tab>
</TabList>
</Tabs>
<Box mt="6" pl="2">
<Text as="p">
Current URL: <strong>/mentorship/events</strong>
</Text>
<Text as="p">
Using default exact matching, only the "Events" tab is active because
it exactly matches the URL.
</Text>
<Text as="p">
The "Mentorship" tab is NOT active even though the URL contains
"/mentorship".
</Text>
</Box>
<BUIProvider>
<Tabs>
<TabList>
<Tab id="mentorship" href="/mentorship">
Mentorship
</Tab>
<Tab id="events" href="/mentorship/events">
Events
</Tab>
<Tab id="catalog" href="/catalog">
Catalog
</Tab>
</TabList>
</Tabs>
<Box mt="6" pl="2">
<Text as="p">
Current URL: <strong>/mentorship/events</strong>
</Text>
<Text as="p">
Using default exact matching, only the "Events" tab is active
because it exactly matches the URL.
</Text>
<Text as="p">
The "Mentorship" tab is NOT active even though the URL contains
"/mentorship".
</Text>
</Box>
</BUIProvider>
</MemoryRouter>
),
});
@@ -217,36 +228,38 @@ export const PrefixMatchingForNestedRoutes = meta.story({
},
render: () => (
<MemoryRouter initialEntries={['/mentorship/events']}>
<Tabs>
<TabList>
<Tab id="mentorship" href="/mentorship" matchStrategy="prefix">
Mentorship
</Tab>
<Tab id="events" href="/mentorship/events">
Events
</Tab>
<Tab id="catalog" href="/catalog" matchStrategy="prefix">
Catalog
</Tab>
</TabList>
</Tabs>
<Box mt="6" pl="2">
<Text as="p">
Current URL: <strong>/mentorship/events</strong>
</Text>
<Text as="p">
The "Mentorship" tab uses prefix matching and IS active because
"/mentorship/events" starts with "/mentorship".
</Text>
<Text as="p">
The "Events" tab uses exact matching and is also active because it
exactly matches.
</Text>
<Text as="p">
The "Catalog" tab uses prefix matching but is NOT active because the
URL doesn't start with "/catalog".
</Text>
</Box>
<BUIProvider>
<Tabs>
<TabList>
<Tab id="mentorship" href="/mentorship" matchStrategy="prefix">
Mentorship
</Tab>
<Tab id="events" href="/mentorship/events">
Events
</Tab>
<Tab id="catalog" href="/catalog" matchStrategy="prefix">
Catalog
</Tab>
</TabList>
</Tabs>
<Box mt="6" pl="2">
<Text as="p">
Current URL: <strong>/mentorship/events</strong>
</Text>
<Text as="p">
The "Mentorship" tab uses prefix matching and IS active because
"/mentorship/events" starts with "/mentorship".
</Text>
<Text as="p">
The "Events" tab uses exact matching and is also active because it
exactly matches.
</Text>
<Text as="p">
The "Catalog" tab uses prefix matching but is NOT active because the
URL doesn't start with "/catalog".
</Text>
</Box>
</BUIProvider>
</MemoryRouter>
),
});
@@ -257,31 +270,33 @@ export const PrefixMatchingDeepNesting = meta.story({
},
render: () => (
<MemoryRouter initialEntries={['/catalog/users/john/details']}>
<Tabs>
<TabList>
<Tab id="home" href="/home">
Home
</Tab>
<Tab id="catalog" href="/catalog" matchStrategy="prefix">
Catalog
</Tab>
<Tab id="mentorship" href="/mentorship" matchStrategy="prefix">
Mentorship
</Tab>
</TabList>
</Tabs>
<Box mt="6" pl="2">
<Text as="p">
Current URL: <strong>/catalog/users/john/details</strong>
</Text>
<Text as="p">
The "Catalog" tab is active because it uses prefix matching and the
URL starts with "/catalog".
</Text>
<Text as="p">
This works for any level of nesting under "/catalog".
</Text>
</Box>
<BUIProvider>
<Tabs>
<TabList>
<Tab id="home" href="/home">
Home
</Tab>
<Tab id="catalog" href="/catalog" matchStrategy="prefix">
Catalog
</Tab>
<Tab id="mentorship" href="/mentorship" matchStrategy="prefix">
Mentorship
</Tab>
</TabList>
</Tabs>
<Box mt="6" pl="2">
<Text as="p">
Current URL: <strong>/catalog/users/john/details</strong>
</Text>
<Text as="p">
The "Catalog" tab is active because it uses prefix matching and the
URL starts with "/catalog".
</Text>
<Text as="p">
This works for any level of nesting under "/catalog".
</Text>
</Box>
</BUIProvider>
</MemoryRouter>
),
});
@@ -292,47 +307,53 @@ export const MixedMatchingStrategies = meta.story({
},
render: () => (
<MemoryRouter initialEntries={['/dashboard/analytics/reports']}>
<Tabs>
<TabList>
<Tab id="overview" href="/dashboard">
Overview
</Tab>
<Tab
id="analytics"
href="/dashboard/analytics"
matchStrategy="prefix"
>
Analytics
</Tab>
<Tab id="settings" href="/dashboard/settings" matchStrategy="prefix">
Settings
</Tab>
<Tab id="help" href="/help">
Help
</Tab>
</TabList>
</Tabs>
<Box mt="6" pl="2">
<Text as="p">
Current URL: <strong>/dashboard/analytics/reports</strong>
</Text>
<Text as="p">
"Overview" tab: exact matching, NOT active (doesn't exactly match
"/dashboard")
</Text>
<Text as="p">
"Analytics" tab: prefix matching, IS active (URL starts with
"/dashboard/analytics")
</Text>
<Text as="p">
"Settings" tab: prefix matching, NOT active (URL doesn't start with
"/dashboard/settings")
</Text>
<Text as="p">
"Help" tab: exact matching, NOT active (doesn't exactly match
"/help")
</Text>
</Box>
<BUIProvider>
<Tabs>
<TabList>
<Tab id="overview" href="/dashboard">
Overview
</Tab>
<Tab
id="analytics"
href="/dashboard/analytics"
matchStrategy="prefix"
>
Analytics
</Tab>
<Tab
id="settings"
href="/dashboard/settings"
matchStrategy="prefix"
>
Settings
</Tab>
<Tab id="help" href="/help">
Help
</Tab>
</TabList>
</Tabs>
<Box mt="6" pl="2">
<Text as="p">
Current URL: <strong>/dashboard/analytics/reports</strong>
</Text>
<Text as="p">
"Overview" tab: exact matching, NOT active (doesn't exactly match
"/dashboard")
</Text>
<Text as="p">
"Analytics" tab: prefix matching, IS active (URL starts with
"/dashboard/analytics")
</Text>
<Text as="p">
"Settings" tab: prefix matching, NOT active (URL doesn't start
with "/dashboard/settings")
</Text>
<Text as="p">
"Help" tab: exact matching, NOT active (doesn't exactly match
"/help")
</Text>
</Box>
</BUIProvider>
</MemoryRouter>
),
});
@@ -343,38 +364,40 @@ export const PrefixMatchingEdgeCases = meta.story({
},
render: () => (
<MemoryRouter initialEntries={['/foobar']}>
<Tabs>
<TabList>
<Tab id="foo" href="/foo" matchStrategy="prefix">
Foo
</Tab>
<Tab id="foobar" href="/foobar">
Foobar
</Tab>
<Tab id="foo-exact" href="/foo">
Foo (exact)
</Tab>
</TabList>
</Tabs>
<Box mt="6" pl="2">
<Text as="p">
Current URL: <strong>/foobar</strong>
</Text>
<Text as="p">
"Foo" tab (prefix): NOT active - prevents "/foo" from matching
"/foobar"
</Text>
<Text as="p">
"Foobar" tab (exact): IS active - exactly matches "/foobar"
</Text>
<Text as="p">
"Foo (exact)" tab: NOT active - doesn't exactly match "/foobar"
</Text>
<Text as="p">
This shows that prefix matching properly requires a "/" separator to
prevent false matches.
</Text>
</Box>
<BUIProvider>
<Tabs>
<TabList>
<Tab id="foo" href="/foo" matchStrategy="prefix">
Foo
</Tab>
<Tab id="foobar" href="/foobar">
Foobar
</Tab>
<Tab id="foo-exact" href="/foo">
Foo (exact)
</Tab>
</TabList>
</Tabs>
<Box mt="6" pl="2">
<Text as="p">
Current URL: <strong>/foobar</strong>
</Text>
<Text as="p">
"Foo" tab (prefix): NOT active - prevents "/foo" from matching
"/foobar"
</Text>
<Text as="p">
"Foobar" tab (exact): IS active - exactly matches "/foobar"
</Text>
<Text as="p">
"Foo (exact)" tab: NOT active - doesn't exactly match "/foobar"
</Text>
<Text as="p">
This shows that prefix matching properly requires a "/" separator to
prevent false matches.
</Text>
</Box>
</BUIProvider>
</MemoryRouter>
),
});
@@ -385,37 +408,39 @@ export const PrefixMatchingWithSlash = meta.story({
},
render: () => (
<MemoryRouter initialEntries={['/foo/bar']}>
<Tabs>
<TabList>
<Tab id="foo" href="/foo" matchStrategy="prefix">
Foo
</Tab>
<Tab id="foobar" href="/foobar">
Foobar
</Tab>
<Tab id="bar" href="/bar" matchStrategy="prefix">
Bar
</Tab>
</TabList>
</Tabs>
<Box mt="6" pl="2">
<Text as="p">
Current URL: <strong>/foo/bar</strong>
</Text>
<Text as="p">
"Foo" tab (prefix): IS active - "/foo/bar" starts with "/foo/"
</Text>
<Text as="p">
"Foobar" tab (exact): NOT active - doesn't exactly match "/foobar"
</Text>
<Text as="p">
"Bar" tab (prefix): NOT active - "/foo/bar" doesn't start with
"/bar"
</Text>
<Text as="p">
This demonstrates proper prefix matching with the "/" separator.
</Text>
</Box>
<BUIProvider>
<Tabs>
<TabList>
<Tab id="foo" href="/foo" matchStrategy="prefix">
Foo
</Tab>
<Tab id="foobar" href="/foobar">
Foobar
</Tab>
<Tab id="bar" href="/bar" matchStrategy="prefix">
Bar
</Tab>
</TabList>
</Tabs>
<Box mt="6" pl="2">
<Text as="p">
Current URL: <strong>/foo/bar</strong>
</Text>
<Text as="p">
"Foo" tab (prefix): IS active - "/foo/bar" starts with "/foo/"
</Text>
<Text as="p">
"Foobar" tab (exact): NOT active - doesn't exactly match "/foobar"
</Text>
<Text as="p">
"Bar" tab (prefix): NOT active - "/foo/bar" doesn't start with
"/bar"
</Text>
<Text as="p">
This demonstrates proper prefix matching with the "/" separator.
</Text>
</Box>
</BUIProvider>
</MemoryRouter>
),
});
@@ -426,32 +451,34 @@ export const RootPathMatching = meta.story({
},
render: () => (
<MemoryRouter initialEntries={['/']}>
<Tabs>
<TabList>
<Tab id="home" href="/">
Home
</Tab>
<Tab id="home-prefix" href="/" matchStrategy="prefix">
Home (prefix)
</Tab>
<Tab id="catalog" href="/catalog" matchStrategy="prefix">
Catalog
</Tab>
</TabList>
</Tabs>
<Box mt="6" pl="2">
<Text as="p">
Current URL: <strong>/</strong>
</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>
</Box>
<BUIProvider>
<Tabs>
<TabList>
<Tab id="home" href="/">
Home
</Tab>
<Tab id="home-prefix" href="/" matchStrategy="prefix">
Home (prefix)
</Tab>
<Tab id="catalog" href="/catalog" matchStrategy="prefix">
Catalog
</Tab>
</TabList>
</Tabs>
<Box mt="6" pl="2">
<Text as="p">
Current URL: <strong>/</strong>
</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>
</Box>
</BUIProvider>
</MemoryRouter>
),
});
@@ -462,41 +489,43 @@ export const HrefWithQueryParams = meta.story({
},
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>
<BUIProvider>
<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>
</BUIProvider>
</MemoryRouter>
),
});
@@ -507,55 +536,57 @@ export const AutoSelectionOfTabs = meta.story({
},
render: () => (
<MemoryRouter initialEntries={['/random-page']}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
<Text style={{ fontSize: '16px', color: '#666' }}>
Current URL: <strong>/random-page</strong>
</Text>
<BUIProvider>
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
<Text style={{ fontSize: '16px', color: '#666' }}>
Current URL: <strong>/random-page</strong>
</Text>
{/* Without hrefs */}
<Text>
{' '}
<strong>Case 1: Without hrefs</strong>
</Text>
<Tabs>
<TabList>
<Tab id="settings">Settings</Tab>
<Tab id="preferences">Preferences</Tab>
<Tab id="advanced">Advanced</Tab>
</TabList>
<TabPanel id="settings">
<Text>Settings content - React Aria manages this selection</Text>
</TabPanel>
<TabPanel id="preferences">
<Text>Preferences content - works normally</Text>
</TabPanel>
<TabPanel id="advanced">
<Text>Advanced content - local state only</Text>
</TabPanel>
</Tabs>
{/* Without hrefs */}
<Text>
{' '}
<strong>Case 1: Without hrefs</strong>
</Text>
<Tabs>
<TabList>
<Tab id="settings">Settings</Tab>
<Tab id="preferences">Preferences</Tab>
<Tab id="advanced">Advanced</Tab>
</TabList>
<TabPanel id="settings">
<Text>Settings content - React Aria manages this selection</Text>
</TabPanel>
<TabPanel id="preferences">
<Text>Preferences content - works normally</Text>
</TabPanel>
<TabPanel id="advanced">
<Text>Advanced content - local state only</Text>
</TabPanel>
</Tabs>
{/* With hrefs */}
<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>
<Tab id="catalog" href="/catalog">
Catalog
</Tab>
<Tab id="create" href="/create">
Create
</Tab>
<Tab id="docs" href="/docs">
Docs
</Tab>
</TabList>
</Tabs>
</div>
{/* With hrefs */}
<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>
<Tab id="catalog" href="/catalog">
Catalog
</Tab>
<Tab id="create" href="/create">
Create
</Tab>
<Tab id="docs" href="/docs">
Docs
</Tab>
</TabList>
</Tabs>
</div>
</BUIProvider>
</MemoryRouter>
),
});
+14 -25
View File
@@ -50,15 +50,9 @@ import {
TabDefinition,
TabPanelDefinition,
} from './definition';
import {
isInternalLink,
createRoutingRegistration,
} from '../InternalLinkProvider';
import { isInternalLink } from '../../utils/linkUtils';
import { getNodeText } from '../../analytics/getNodeText';
const { RoutingProvider, useRoutingRegistrationEffect } =
createRoutingRegistration();
const TabsContext = createContext<TabsContextValue | undefined>(undefined);
const useTabsContext = () => {
@@ -218,21 +212,19 @@ export const Tabs = (props: TabsProps) => {
);
return (
<RoutingProvider>
<TabsContext.Provider value={tabsContextValue}>
<TabSelectionContext.Provider value={selectionContextValue}>
<AriaTabs
className={classes.root}
keyboardActivation="manual"
selectedKey={selectedTabId}
ref={tabsRef}
{...restProps}
>
{children as ReactNode}
</AriaTabs>
</TabSelectionContext.Provider>
</TabsContext.Provider>
</RoutingProvider>
<TabsContext.Provider value={tabsContextValue}>
<TabSelectionContext.Provider value={selectionContextValue}>
<AriaTabs
className={classes.root}
keyboardActivation="manual"
selectedKey={selectedTabId}
ref={tabsRef}
{...restProps}
>
{children as ReactNode}
</AriaTabs>
</TabSelectionContext.Provider>
</TabsContext.Provider>
);
};
@@ -299,9 +291,6 @@ function RoutedTabEffects({
const selectionCtx = useContext(TabSelectionContext);
const location = useLocation();
// Register with RoutingProvider for conditional RouterProvider wrapping
useRoutingRegistrationEffect(href);
// Register as a routed tab (for controlled vs uncontrolled mode)
useEffect(() => {
if (selectionCtx) {
@@ -21,6 +21,7 @@ import type { Selection } from 'react-aria-components';
import { Flex } from '../../';
import { useListData } from 'react-stately';
import { MemoryRouter } from 'react-router-dom';
import { BUIProvider } from '../../provider';
import {
RiAccountCircleLine,
RiBugLine,
@@ -50,7 +51,9 @@ const meta = preview.meta({
decorators: [
Story => (
<MemoryRouter>
<Story />
<BUIProvider>
<Story />
</BUIProvider>
</MemoryRouter>
),
],
@@ -25,12 +25,8 @@ import { forwardRef, type ReactNode } from 'react';
import { RiCloseCircleLine } from '@remixicon/react';
import { useDefinition } from '../../hooks/useDefinition';
import { TagGroupDefinition, TagDefinition } from './definition';
import { createRoutingRegistration } from '../InternalLinkProvider';
import { getNodeText } from '../../analytics/getNodeText';
const { RoutingProvider, useRoutingRegistrationEffect } =
createRoutingRegistration();
/**
* A component that renders a list of tags.
*
@@ -41,17 +37,15 @@ export const TagGroup = <T extends object>(props: TagGroupProps<T>) => {
const { classes, items, children, renderEmptyState } = ownProps;
return (
<RoutingProvider>
<ReactAriaTagGroup className={classes.root} {...restProps}>
<ReactAriaTagList
className={classes.list}
items={items}
renderEmptyState={renderEmptyState}
>
{children}
</ReactAriaTagList>
</ReactAriaTagGroup>
</RoutingProvider>
<ReactAriaTagGroup className={classes.root} {...restProps}>
<ReactAriaTagList
className={classes.list}
items={items}
renderEmptyState={renderEmptyState}
>
{children}
</ReactAriaTagList>
</ReactAriaTagGroup>
);
};
@@ -68,8 +62,6 @@ export const Tag = forwardRef<HTMLDivElement, TagProps>((props, ref) => {
const { classes, children, icon, href } = ownProps;
const textValue = typeof children === 'string' ? children : undefined;
useRoutingRegistrationEffect(href);
const handlePress = () => {
if (href) {
const text =
@@ -18,6 +18,7 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { MemoryRouter } from 'react-router-dom';
import { BUIProvider } from '../provider';
import {
Card,
CardHeader,
@@ -265,7 +266,9 @@ const meta = {
decorators: [
(Story: () => JSX.Element) => (
<MemoryRouter>
<Story />
<BUIProvider>
<Story />
</BUIProvider>
</MemoryRouter>
),
],
+5 -2
View File
@@ -69,11 +69,14 @@ export { useBreakpoint } from './hooks/useBreakpoint';
export { useBgProvider, useBgConsumer, BgProvider } from './hooks/useBg';
export type { BgContextValue, BgProviderProps } from './hooks/useBg';
// Provider
export { BUIProvider } from './provider';
export type { BUIProviderProps } from './provider';
// Analytics
export { useAnalytics, BUIProvider, getNodeText } from './analytics';
export { useAnalytics, getNodeText } from './analytics';
export type {
AnalyticsTracker,
AnalyticsEventAttributes,
UseAnalyticsFn,
BUIProviderProps,
} from './analytics';
@@ -15,9 +15,11 @@
*/
import { useMemo, type ReactNode } from 'react';
import { RouterProvider } from 'react-aria-components';
import { useInRouterContext, useNavigate, useHref } from 'react-router-dom';
import { createVersionedValueMap } from '@backstage/version-bridge';
import { BUIContext } from './useAnalytics';
import type { UseAnalyticsFn } from './types';
import { BUIContext } from '../analytics/useAnalytics';
import type { UseAnalyticsFn } from '../analytics/types';
/** @public */
export type BUIProviderProps = {
@@ -53,5 +55,23 @@ export function BUIProvider(props: BUIProviderProps) {
}),
[useAnalytics],
);
return <BUIContext.Provider value={value}>{children}</BUIContext.Provider>;
const content = (
<BUIContext.Provider value={value}>{children}</BUIContext.Provider>
);
if (useInRouterContext()) {
return <RoutedContent>{content}</RoutedContent>;
}
return content;
}
function RoutedContent({ children }: { children: ReactNode }) {
const navigate = useNavigate();
return (
<RouterProvider navigate={navigate} useHref={useHref}>
{children}
</RouterProvider>
);
}
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 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.
@@ -14,11 +14,5 @@
* limitations under the License.
*/
export {
InternalLinkProvider,
RoutedContainer,
useRoutingRegistration,
isInternalLink,
createRoutingRegistration,
} from './InternalLinkProvider';
export type { RoutingContextValue } from './InternalLinkProvider';
export { BUIProvider } from './BUIProvider';
export type { BUIProviderProps } from './BUIProvider';
@@ -40,3 +40,12 @@ export function isExternalLink(href?: string): boolean {
return false;
}
/**
* Checks if an href is an internal link (not external and not empty).
*
* @internal
*/
export function isInternalLink(href: string | undefined): href is string {
return !!href && !isExternalLink(href);
}
+28 -26
View File
@@ -124,21 +124,19 @@ export const AppRoot = createExtension({
return [
coreExtensionData.reactElement(
<BUIProvider useAnalytics={useAnalytics}>
<AppRouter
SignInPageComponent={inputs.signInPage?.get(
SignInPageBlueprint.dataRefs.component,
)}
RouterComponent={inputs.router?.get(
RouterBlueprint.dataRefs.component,
)}
extraElements={inputs.elements?.map(el =>
el.get(coreExtensionData.reactElement),
)}
>
{content}
</AppRouter>
</BUIProvider>,
<AppRouter
SignInPageComponent={inputs.signInPage?.get(
SignInPageBlueprint.dataRefs.component,
)}
RouterComponent={inputs.router?.get(
RouterBlueprint.dataRefs.component,
)}
extraElements={inputs.elements?.map(el =>
el.get(coreExtensionData.reactElement),
)}
>
{content}
</AppRouter>,
),
];
},
@@ -280,23 +278,27 @@ export function AppRouter(props: AppRouterProps) {
return (
<RouterComponent>
{...extraElements}
<RouteTracker routeObjects={routeObjects} />
{children}
<BUIProvider useAnalytics={useAnalytics}>
{...extraElements}
<RouteTracker routeObjects={routeObjects} />
{children}
</BUIProvider>
</RouterComponent>
);
}
return (
<RouterComponent>
{...extraElements}
<RouteTracker routeObjects={routeObjects} />
<SignInPageWrapper
component={SignInPageComponent}
appIdentityProxy={appIdentityProxy}
>
{children}
</SignInPageWrapper>
<BUIProvider useAnalytics={useAnalytics}>
{...extraElements}
<RouteTracker routeObjects={routeObjects} />
<SignInPageWrapper
component={SignInPageComponent}
appIdentityProxy={appIdentityProxy}
>
{children}
</SignInPageWrapper>
</BUIProvider>
</RouterComponent>
);
}