From 42f8c9b2b8e8df353fdd0ae042461094cea80572 Mon Sep 17 00:00:00 2001 From: Johan Persson Date: Mon, 16 Mar 2026 18:56:24 +0100 Subject: [PATCH] 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 --------- Signed-off-by: Johan Persson --- .changeset/ninety-onions-ask.md | 5 + .changeset/polite-trains-crash.md | 23 + .changeset/small-feet-arrive.md | 5 + .../src/app/get-started/installation/page.mdx | 17 +- .../app/get-started/installation/snippets.ts | 11 +- packages/core-app-api/src/app/AppRouter.tsx | 54 +- packages/ui/src/analytics/index.ts | 2 - .../ButtonLink/ButtonLink.stories.tsx | 5 +- .../src/components/ButtonLink/ButtonLink.tsx | 29 +- .../components/FullPage/FullPage.stories.tsx | 5 +- .../src/components/Header/Header.stories.tsx | 142 ++-- .../InternalLinkProvider.tsx | 157 ---- .../ui/src/components/Link/Link.stories.tsx | 5 +- packages/ui/src/components/Link/Link.tsx | 7 +- .../ui/src/components/Menu/Menu.stories.tsx | 5 +- packages/ui/src/components/Menu/Menu.tsx | 112 ++- .../Menu/MenuAutocomplete.stories.tsx | 5 +- .../Menu/MenuAutocompleteListBox.stories.tsx | 5 +- .../components/Menu/MenuListBox.stories.tsx | 5 +- .../PluginHeader/PluginHeader.stories.tsx | 216 ++--- .../SearchField/SearchField.stories.tsx | 9 +- .../src/components/Table/components/Row.tsx | 27 +- .../ui/src/components/Table/stories/utils.tsx | 5 +- .../ui/src/components/Tabs/Tabs.stories.tsx | 759 +++++++++--------- packages/ui/src/components/Tabs/Tabs.tsx | 39 +- .../components/TagGroup/TagGroup.stories.tsx | 5 +- .../ui/src/components/TagGroup/TagGroup.tsx | 26 +- .../src/guidelines/CardsWithTable.stories.tsx | 5 +- packages/ui/src/index.ts | 7 +- .../{analytics => provider}/BUIProvider.tsx | 26 +- .../index.ts | 12 +- .../utils/{isExternalLink.ts => linkUtils.ts} | 9 + plugins/app/src/extensions/AppRoot.tsx | 54 +- 33 files changed, 884 insertions(+), 914 deletions(-) create mode 100644 .changeset/ninety-onions-ask.md create mode 100644 .changeset/polite-trains-crash.md create mode 100644 .changeset/small-feet-arrive.md delete mode 100644 packages/ui/src/components/InternalLinkProvider/InternalLinkProvider.tsx rename packages/ui/src/{analytics => provider}/BUIProvider.tsx (68%) rename packages/ui/src/{components/InternalLinkProvider => provider}/index.ts (68%) rename packages/ui/src/utils/{isExternalLink.ts => linkUtils.ts} (85%) diff --git a/.changeset/ninety-onions-ask.md b/.changeset/ninety-onions-ask.md new file mode 100644 index 0000000000..95798043c9 --- /dev/null +++ b/.changeset/ninety-onions-ask.md @@ -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. diff --git a/.changeset/polite-trains-crash.md b/.changeset/polite-trains-crash.md new file mode 100644 index 0000000000..8926f11e94 --- /dev/null +++ b/.changeset/polite-trains-crash.md @@ -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'; + + ++ + ++ + +``` + +**Affected components:** Link, ButtonLink, Tabs, Menu, TagGroup, Table diff --git a/.changeset/small-feet-arrive.md b/.changeset/small-feet-arrive.md new file mode 100644 index 0000000000..a0ed7b1b01 --- /dev/null +++ b/.changeset/small-feet-arrive.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-app': patch +--- + +Moved `BUIProvider` inside the app router to enable automatic client-side routing for all BUI components. diff --git a/docs-ui/src/app/get-started/installation/page.mdx b/docs-ui/src/app/get-started/installation/page.mdx index c5421ae9d7..d87c2bcba4 100644 --- a/docs-ui/src/app/get-started/installation/page.mdx +++ b/docs-ui/src/app/get-started/installation/page.mdx @@ -49,23 +49,28 @@ your plugin and import the components you need. -## 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: -### How it works + -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: diff --git a/docs-ui/src/app/get-started/installation/snippets.ts b/docs-ui/src/app/get-started/installation/snippets.ts index 12dfff4f44..61edb266b3 100644 --- a/docs-ui/src/app/get-started/installation/snippets.ts +++ b/docs-ui/src/app/get-started/installation/snippets.ts @@ -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 must be inside a Router for client-side navigation + + + + +`; export const analyticsNoTrackSnippet = `// Suppress analytics for a specific link diff --git a/packages/core-app-api/src/app/AppRouter.tsx b/packages/core-app-api/src/app/AppRouter.tsx index afc87d6753..337792d71b 100644 --- a/packages/core-app-api/src/app/AppRouter.tsx +++ b/packages/core-app-api/src/app/AppRouter.tsx @@ -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 ( - - - {props.children}} /> - + + + + {props.children}} /> + + ); } return ( - - {props.children} + + + {props.children} + ); } @@ -162,28 +168,32 @@ export function AppRouter(props: AppRouterProps) { if (isReactRouterBeta()) { return ( - - - - {props.children}} /> - - + + + + + {props.children}} /> + + + ); } return ( - - - {props.children} - + + + + {props.children} + + ); } diff --git a/packages/ui/src/analytics/index.ts b/packages/ui/src/analytics/index.ts index a9b073afa7..de42ed3181 100644 --- a/packages/ui/src/analytics/index.ts +++ b/packages/ui/src/analytics/index.ts @@ -15,8 +15,6 @@ */ export { useAnalytics } from './useAnalytics'; -export { BUIProvider } from './BUIProvider'; -export type { BUIProviderProps } from './BUIProvider'; export { getNodeText } from './getNodeText'; export type { AnalyticsTracker, diff --git a/packages/ui/src/components/ButtonLink/ButtonLink.stories.tsx b/packages/ui/src/components/ButtonLink/ButtonLink.stories.tsx index 4b9b9b747c..5be8a7edca 100644 --- a/packages/ui/src/components/ButtonLink/ButtonLink.stories.tsx +++ b/packages/ui/src/components/ButtonLink/ButtonLink.stories.tsx @@ -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) => ( - + + + ), ], diff --git a/packages/ui/src/components/ButtonLink/ButtonLink.tsx b/packages/ui/src/components/ButtonLink/ButtonLink.tsx index 96f477a4e8..4d6906f32f 100644 --- a/packages/ui/src/components/ButtonLink/ButtonLink.tsx +++ b/packages/ui/src/components/ButtonLink/ButtonLink.tsx @@ -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 ( - - - - {iconStart} - {children} - {iconEnd} - - - + + + {iconStart} + {children} + {iconEnd} + + ); }, ); diff --git a/packages/ui/src/components/FullPage/FullPage.stories.tsx b/packages/ui/src/components/FullPage/FullPage.stories.tsx index 05169ca8ea..6adfaa7efb 100644 --- a/packages/ui/src/components/FullPage/FullPage.stories.tsx +++ b/packages/ui/src/components/FullPage/FullPage.stories.tsx @@ -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) => ( - + + + ); diff --git a/packages/ui/src/components/Header/Header.stories.tsx b/packages/ui/src/components/Header/Header.stories.tsx index e605f2ed58..0adfb43d55 100644 --- a/packages/ui/src/components/Header/Header.stories.tsx +++ b/packages/ui/src/components/Header/Header.stories.tsx @@ -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) => ( - + + + ); @@ -239,32 +242,34 @@ export const WithTabsMatchingStrategies = meta.story({ }, render: args => ( -
- - - Current URL: /mentorship/events - -
- - Notice how the "Mentorship" tab is active even though we're on a - nested route. This is because it uses{' '} - matchStrategy="prefix". - -
- - • Home: exact matching (default) - not active - - - • Mentorship: prefix matching - IS active (URL starts - with /mentorship) - - - • Catalog: prefix matching - not active - - - • Settings: exact matching (default) - not active - -
+ +
+ + + Current URL: /mentorship/events + +
+ + Notice how the "Mentorship" tab is active even though we're on a + nested route. This is because it uses{' '} + matchStrategy="prefix". + +
+ + • Home: exact matching (default) - not active + + + • Mentorship: prefix matching - IS active (URL + starts with /mentorship) + + + • Catalog: prefix matching - not active + + + • Settings: exact matching (default) - not active + +
+ ), }); @@ -292,18 +297,20 @@ export const WithTabsExactMatching = meta.story({ }, render: args => ( -
- - - Current URL: /mentorship/events - -
- - 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. - -
+ +
+ + + Current URL: /mentorship/events + +
+ + 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. + +
+ ), }); @@ -334,33 +341,36 @@ export const WithTabsPrefixMatchingDeep = meta.story({ }, render: args => ( -
- - - Current URL: /catalog/users/john/details - -
- - Active tab is Users because: - -
    -
  • - Catalog: Matches since URL starts with /catalog -
  • -
  • - Users: Is active since URL starts with - /catalog/users, and is more specific (has more url segments) than - "Catalog" -
  • -
  • - Components: not active (URL doesn't start with - /catalog/components) -
  • -
- - This demonstrates how prefix matching works with deeply nested routes. - -
+ +
+ + + Current URL: /catalog/users/john/details + +
+ + Active tab is Users because: + +
    +
  • + Catalog: Matches since URL starts with /catalog +
  • +
  • + Users: Is active since URL starts with + /catalog/users, and is more specific (has more url segments) than + "Catalog" +
  • +
  • + Components: not active (URL doesn't start with + /catalog/components) +
  • +
+ + This demonstrates how prefix matching works with deeply nested + routes. + +
+ ), }); diff --git a/packages/ui/src/components/InternalLinkProvider/InternalLinkProvider.tsx b/packages/ui/src/components/InternalLinkProvider/InternalLinkProvider.tsx deleted file mode 100644 index 3c8a2ac456..0000000000 --- a/packages/ui/src/components/InternalLinkProvider/InternalLinkProvider.tsx +++ /dev/null @@ -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 ( - - {children} - - ); -} - -/** - * 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 - * {content} - * - * // Child items register when they have internal hrefs - * useRoutingRegistrationEffect(href); - * ``` - * - * @internal - */ -export function createRoutingRegistration() { - const RoutingContext = createContext(null); - - function RoutingProvider({ children }: { children: ReactNode }) { - const { hasRoutedChildren, contextValue } = useRoutingRegistration(); - - const content = ( - - {children} - - ); - - if (hasRoutedChildren) { - return {content}; - } - - 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 {children}; -} diff --git a/packages/ui/src/components/Link/Link.stories.tsx b/packages/ui/src/components/Link/Link.stories.tsx index fafb5948d9..cf52cdde23 100644 --- a/packages/ui/src/components/Link/Link.stories.tsx +++ b/packages/ui/src/components/Link/Link.stories.tsx @@ -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) => ( - + + + ), ], diff --git a/packages/ui/src/components/Link/Link.tsx b/packages/ui/src/components/Link/Link.tsx index c0de9c3087..a4509576c9 100644 --- a/packages/ui/src/components/Link/Link.tsx +++ b/packages/ui/src/components/Link/Link.tsx @@ -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((props, ref) => { @@ -64,11 +63,7 @@ LinkInternal.displayName = 'LinkInternal'; /** @public */ export const Link = forwardRef((props, ref) => { - return ( - - - - ); + return ; }); Link.displayName = 'Link'; diff --git a/packages/ui/src/components/Menu/Menu.stories.tsx b/packages/ui/src/components/Menu/Menu.stories.tsx index 982055ade1..26164f3752 100644 --- a/packages/ui/src/components/Menu/Menu.stories.tsx +++ b/packages/ui/src/components/Menu/Menu.stories.tsx @@ -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 => ( - + + + ), ], diff --git a/packages/ui/src/components/Menu/Menu.tsx b/packages/ui/src/components/Menu/Menu.tsx index 5ab55cf7d8..cb6028c45a 100644 --- a/packages/ui/src/components/Menu/Menu.tsx +++ b/packages/ui/src/components/Menu/Menu.tsx @@ -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) => { ); return ( - - - - - {virtualized ? ( - - {menuContent} - - ) : ( - menuContent - )} - - - - + + + + {virtualized ? ( + + {menuContent} + + ) : ( + menuContent + )} + + + ); }; @@ -206,40 +198,38 @@ export const MenuAutocomplete = (props: MenuAutocompleteProps) => { ); return ( - - - - - - + + + + + + + + + + {virtualized ? ( + - - - - - - {virtualized ? ( - - {menuContent} - - ) : ( - menuContent - )} - - - - - + {menuContent} + + ) : ( + menuContent + )} + + + + ); }; @@ -318,8 +308,6 @@ export const MenuItem = (props: MenuItemProps) => { ); const { classes, iconStart, children, href } = ownProps; - useRoutingRegistrationEffect(href); - const handleAction = () => { if (href) { const text = diff --git a/packages/ui/src/components/Menu/MenuAutocomplete.stories.tsx b/packages/ui/src/components/Menu/MenuAutocomplete.stories.tsx index af68ec9415..7ebd25a063 100644 --- a/packages/ui/src/components/Menu/MenuAutocomplete.stories.tsx +++ b/packages/ui/src/components/Menu/MenuAutocomplete.stories.tsx @@ -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 => ( - + + + ), ], diff --git a/packages/ui/src/components/Menu/MenuAutocompleteListBox.stories.tsx b/packages/ui/src/components/Menu/MenuAutocompleteListBox.stories.tsx index e1c22fbd9e..f3e001c165 100644 --- a/packages/ui/src/components/Menu/MenuAutocompleteListBox.stories.tsx +++ b/packages/ui/src/components/Menu/MenuAutocompleteListBox.stories.tsx @@ -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 => ( - + + + ), ], diff --git a/packages/ui/src/components/Menu/MenuListBox.stories.tsx b/packages/ui/src/components/Menu/MenuListBox.stories.tsx index 2297598d0c..469a106280 100644 --- a/packages/ui/src/components/Menu/MenuListBox.stories.tsx +++ b/packages/ui/src/components/Menu/MenuListBox.stories.tsx @@ -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 => ( - + + + ), ], diff --git a/packages/ui/src/components/PluginHeader/PluginHeader.stories.tsx b/packages/ui/src/components/PluginHeader/PluginHeader.stories.tsx index 151c48aab6..d0d3475dbb 100644 --- a/packages/ui/src/components/PluginHeader/PluginHeader.stories.tsx +++ b/packages/ui/src/components/PluginHeader/PluginHeader.stories.tsx @@ -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) => ( - + + + ); @@ -336,16 +339,18 @@ export const WithMockedURLCampaigns = meta.story({ }, render: args => ( - - - - Current URL is mocked to be: /campaigns - - - Notice how the "Campaigns" tab is selected (highlighted) because it - matches the current path. - - + + + + + Current URL is mocked to be: /campaigns + + + Notice how the "Campaigns" tab is selected (highlighted) because it + matches the current path. + + + ), }); @@ -356,16 +361,18 @@ export const WithMockedURLIntegrations = meta.story({ }, render: args => ( - - - - Current URL is mocked to be: /integrations - - - Notice how the "Integrations" tab is selected (highlighted) because it - matches the current path. - - + + + + + Current URL is mocked to be: /integrations + + + Notice how the "Integrations" tab is selected (highlighted) because + it matches the current path. + + + ), }); @@ -376,20 +383,22 @@ export const WithMockedURLNoMatch = meta.story({ }, render: args => ( - - - - Current URL is mocked to be: /some-other-page - - - No tab is selected because the current path doesn't match any tab's - href. - - - Tabs without href (like "Overview", "Checks", "Tracks") fall back to - React Aria's internal state. - - + + + + + Current URL is mocked to be: /some-other-page + + + No tab is selected because the current path doesn't match any tab's + href. + + + Tabs without href (like "Overview", "Checks", "Tracks") fall back to + React Aria's internal state. + + + ), }); @@ -424,32 +433,34 @@ export const WithTabsMatchingStrategies = meta.story({ }, render: args => ( - - - - Current URL: /mentorship/events - -
- - Notice how the "Mentorship" tab is active even though we're on a - nested route. This is because it uses{' '} - matchStrategy="prefix". - -
- - • Home: exact matching (default) - not active - - - • Mentorship: prefix matching - IS active (URL starts - with /mentorship) - - - • Catalog: prefix matching - not active - - - • Settings: exact matching (default) - not active - -
+ + + + + Current URL: /mentorship/events + +
+ + Notice how the "Mentorship" tab is active even though we're on a + nested route. This is because it uses{' '} + matchStrategy="prefix". + +
+ + • Home: exact matching (default) - not active + + + • Mentorship: prefix matching - IS active (URL + starts with /mentorship) + + + • Catalog: prefix matching - not active + + + • Settings: exact matching (default) - not active + +
+
), }); @@ -477,18 +488,20 @@ export const WithTabsExactMatching = meta.story({ }, render: args => ( - - - - Current URL: /mentorship/events - -
- - 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. - -
+ + + + + Current URL: /mentorship/events + +
+ + 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. + +
+
), }); @@ -519,33 +532,36 @@ export const WithTabsPrefixMatchingDeep = meta.story({ }, render: args => ( - - - - Current URL: /catalog/users/john/details - -
- - Active tab is Users because: - -
    -
  • - Catalog: Matches since URL starts with /catalog -
  • -
  • - Users: Is active since URL starts with - /catalog/users, and is more specific (has more url segments) than - "Catalog" -
  • -
  • - Components: not active (URL doesn't start with - /catalog/components) -
  • -
- - This demonstrates how prefix matching works with deeply nested routes. - -
+ + + + + Current URL: /catalog/users/john/details + +
+ + Active tab is Users because: + +
    +
  • + Catalog: Matches since URL starts with /catalog +
  • +
  • + Users: Is active since URL starts with + /catalog/users, and is more specific (has more url segments) than + "Catalog" +
  • +
  • + Components: not active (URL doesn't start with + /catalog/components) +
  • +
+ + This demonstrates how prefix matching works with deeply nested + routes. + +
+
), }); diff --git a/packages/ui/src/components/SearchField/SearchField.stories.tsx b/packages/ui/src/components/SearchField/SearchField.stories.tsx index 70784f3206..380b55e580 100644 --- a/packages/ui/src/components/SearchField/SearchField.stories.tsx +++ b/packages/ui/src/components/SearchField/SearchField.stories.tsx @@ -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 => ( - + + + ), ], @@ -225,7 +228,9 @@ export const StartCollapsedInHeader = meta.story({ decorators: [ Story => ( - + + + ), ], diff --git a/packages/ui/src/components/Table/components/Row.tsx b/packages/ui/src/components/Table/components/Row.tsx index 75b715f7f7..9d4a15f601 100644 --- a/packages/ui/src/components/Table/components/Row.tsx +++ b/packages/ui/src/components/Table/components/Row.tsx @@ -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(props: RowProps) { ); return ( - - - {content} - - + + {content} + ); } diff --git a/packages/ui/src/components/Table/stories/utils.tsx b/packages/ui/src/components/Table/stories/utils.tsx index 45bc6341ad..130469e5d7 100644 --- a/packages/ui/src/components/Table/stories/utils.tsx +++ b/packages/ui/src/components/Table/stories/utils.tsx @@ -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) => ( - + + + ), ], diff --git a/packages/ui/src/components/Tabs/Tabs.stories.tsx b/packages/ui/src/components/Tabs/Tabs.stories.tsx index 55cdf7c446..99bf75a072 100644 --- a/packages/ui/src/components/Tabs/Tabs.stories.tsx +++ b/packages/ui/src/components/Tabs/Tabs.stories.tsx @@ -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) => ( - + + + ); @@ -79,28 +82,30 @@ export const WithMockedURLTab2 = meta.story({ }, render: () => ( - - - - Tab 1 - - - Tab 2 - - - Tab 3 With long title - - - - - - Current URL is mocked to be: /tab2 - - - Notice how the "Tab 2" tab is selected (highlighted) because it - matches the current path. - - + + + + + Tab 1 + + + Tab 2 + + + Tab 3 With long title + + + + + + Current URL is mocked to be: /tab2 + + + Notice how the "Tab 2" tab is selected (highlighted) because it + matches the current path. + + + ), }); @@ -111,28 +116,30 @@ export const WithMockedURLTab3 = meta.story({ }, render: () => ( - - - - Tab 1 - - - Tab 2 - - - Tab 3 With long title - - - - - - Current URL is mocked to be: /tab3 - - - Notice how the "Tab 3 With long title" tab is selected (highlighted) - because it matches the current path. - - + + + + + Tab 1 + + + Tab 2 + + + Tab 3 With long title + + + + + + Current URL is mocked to be: /tab3 + + + Notice how the "Tab 3 With long title" tab is selected (highlighted) + because it matches the current path. + + + ), }); @@ -143,32 +150,34 @@ export const WithMockedURLNoMatch = meta.story({ }, render: () => ( - - - - Tab 1 - - - Tab 2 - - - Tab 3 With long title - - - - - - Current URL is mocked to be: /some-other-page - - - No tab is selected because the current path doesn't match any tab's - href. - - - Tabs without href (like "Tab 1", "Tab 2", "Tab 3 With long title") - fall back to React Aria's internal state. - - + + + + + Tab 1 + + + Tab 2 + + + Tab 3 With long title + + + + + + Current URL is mocked to be: /some-other-page + + + No tab is selected because the current path doesn't match any tab's + href. + + + Tabs without href (like "Tab 1", "Tab 2", "Tab 3 With long title") + fall back to React Aria's internal state. + + + ), }); @@ -181,32 +190,34 @@ export const ExactMatchingDefault = meta.story({ }, render: () => ( - - - - Mentorship - - - Events - - - Catalog - - - - - - Current URL: /mentorship/events - - - Using default exact matching, only the "Events" tab is active because - it exactly matches the URL. - - - The "Mentorship" tab is NOT active even though the URL contains - "/mentorship". - - + + + + + Mentorship + + + Events + + + Catalog + + + + + + Current URL: /mentorship/events + + + Using default exact matching, only the "Events" tab is active + because it exactly matches the URL. + + + The "Mentorship" tab is NOT active even though the URL contains + "/mentorship". + + + ), }); @@ -217,36 +228,38 @@ export const PrefixMatchingForNestedRoutes = meta.story({ }, render: () => ( - - - - Mentorship - - - Events - - - Catalog - - - - - - Current URL: /mentorship/events - - - The "Mentorship" tab uses prefix matching and IS active because - "/mentorship/events" starts with "/mentorship". - - - The "Events" tab uses exact matching and is also active because it - exactly matches. - - - The "Catalog" tab uses prefix matching but is NOT active because the - URL doesn't start with "/catalog". - - + + + + + Mentorship + + + Events + + + Catalog + + + + + + Current URL: /mentorship/events + + + The "Mentorship" tab uses prefix matching and IS active because + "/mentorship/events" starts with "/mentorship". + + + The "Events" tab uses exact matching and is also active because it + exactly matches. + + + The "Catalog" tab uses prefix matching but is NOT active because the + URL doesn't start with "/catalog". + + + ), }); @@ -257,31 +270,33 @@ export const PrefixMatchingDeepNesting = meta.story({ }, render: () => ( - - - - Home - - - Catalog - - - Mentorship - - - - - - Current URL: /catalog/users/john/details - - - The "Catalog" tab is active because it uses prefix matching and the - URL starts with "/catalog". - - - This works for any level of nesting under "/catalog". - - + + + + + Home + + + Catalog + + + Mentorship + + + + + + Current URL: /catalog/users/john/details + + + The "Catalog" tab is active because it uses prefix matching and the + URL starts with "/catalog". + + + This works for any level of nesting under "/catalog". + + + ), }); @@ -292,47 +307,53 @@ export const MixedMatchingStrategies = meta.story({ }, render: () => ( - - - - Overview - - - Analytics - - - Settings - - - Help - - - - - - Current URL: /dashboard/analytics/reports - - - • "Overview" tab: exact matching, NOT active (doesn't exactly match - "/dashboard") - - - • "Analytics" tab: prefix matching, IS active (URL starts with - "/dashboard/analytics") - - - • "Settings" tab: prefix matching, NOT active (URL doesn't start with - "/dashboard/settings") - - - • "Help" tab: exact matching, NOT active (doesn't exactly match - "/help") - - + + + + + Overview + + + Analytics + + + Settings + + + Help + + + + + + Current URL: /dashboard/analytics/reports + + + • "Overview" tab: exact matching, NOT active (doesn't exactly match + "/dashboard") + + + • "Analytics" tab: prefix matching, IS active (URL starts with + "/dashboard/analytics") + + + • "Settings" tab: prefix matching, NOT active (URL doesn't start + with "/dashboard/settings") + + + • "Help" tab: exact matching, NOT active (doesn't exactly match + "/help") + + + ), }); @@ -343,38 +364,40 @@ export const PrefixMatchingEdgeCases = meta.story({ }, render: () => ( - - - - Foo - - - Foobar - - - Foo (exact) - - - - - - Current URL: /foobar - - - • "Foo" tab (prefix): NOT active - prevents "/foo" from matching - "/foobar" - - - • "Foobar" tab (exact): IS active - exactly matches "/foobar" - - - • "Foo (exact)" tab: NOT active - doesn't exactly match "/foobar" - - - This shows that prefix matching properly requires a "/" separator to - prevent false matches. - - + + + + + Foo + + + Foobar + + + Foo (exact) + + + + + + Current URL: /foobar + + + • "Foo" tab (prefix): NOT active - prevents "/foo" from matching + "/foobar" + + + • "Foobar" tab (exact): IS active - exactly matches "/foobar" + + + • "Foo (exact)" tab: NOT active - doesn't exactly match "/foobar" + + + This shows that prefix matching properly requires a "/" separator to + prevent false matches. + + + ), }); @@ -385,37 +408,39 @@ export const PrefixMatchingWithSlash = meta.story({ }, render: () => ( - - - - Foo - - - Foobar - - - Bar - - - - - - Current URL: /foo/bar - - - • "Foo" tab (prefix): IS active - "/foo/bar" starts with "/foo/" - - - • "Foobar" tab (exact): NOT active - doesn't exactly match "/foobar" - - - • "Bar" tab (prefix): NOT active - "/foo/bar" doesn't start with - "/bar" - - - This demonstrates proper prefix matching with the "/" separator. - - + + + + + Foo + + + Foobar + + + Bar + + + + + + Current URL: /foo/bar + + + • "Foo" tab (prefix): IS active - "/foo/bar" starts with "/foo/" + + + • "Foobar" tab (exact): NOT active - doesn't exactly match "/foobar" + + + • "Bar" tab (prefix): NOT active - "/foo/bar" doesn't start with + "/bar" + + + This demonstrates proper prefix matching with the "/" separator. + + + ), }); @@ -426,32 +451,34 @@ export const RootPathMatching = meta.story({ }, render: () => ( - - - - Home - - - Home (prefix) - - - Catalog - - - - - - Current URL: / - - - • "Home" tab (exact): IS active - exactly matches "/" - - • "Home (prefix)" tab: IS active - "/" matches "/" - - • "Catalog" tab (prefix): NOT active - "/" doesn't start with - "/catalog" - - + + + + + Home + + + Home (prefix) + + + Catalog + + + + + + Current URL: / + + + • "Home" tab (exact): IS active - exactly matches "/" + + • "Home (prefix)" tab: IS active - "/" matches "/" + + • "Catalog" tab (prefix): NOT active - "/" doesn't start with + "/catalog" + + + ), }); @@ -462,41 +489,43 @@ export const HrefWithQueryParams = meta.story({ }, render: () => ( - - - - Dashboard - - - Alerts - - - - - - Current URL: /cost-insights/dashboard?group=bar - - - Tab hrefs include query params (e.g., ?group=foo) but the current URL - has different query params (?group=bar). - - - • "Dashboard" tab: IS active — matching ignores query params and - compares only the pathname. - - - • "Alerts" tab: NOT active — pathname /cost-insights/alerts doesn't - match /cost-insights/dashboard. - - + + + + + Dashboard + + + Alerts + + + + + + Current URL: /cost-insights/dashboard?group=bar + + + Tab hrefs include query params (e.g., ?group=foo) but the current + URL has different query params (?group=bar). + + + • "Dashboard" tab: IS active — matching ignores query params and + compares only the pathname. + + + • "Alerts" tab: NOT active — pathname /cost-insights/alerts doesn't + match /cost-insights/dashboard. + + + ), }); @@ -507,55 +536,57 @@ export const AutoSelectionOfTabs = meta.story({ }, render: () => ( -
- - Current URL: /random-page - + +
+ + Current URL: /random-page + - {/* Without hrefs */} - - {' '} - Case 1: Without hrefs - - - - Settings - Preferences - Advanced - - - Settings content - React Aria manages this selection - - - Preferences content - works normally - - - Advanced content - local state only - - + {/* Without hrefs */} + + {' '} + Case 1: Without hrefs + + + + Settings + Preferences + Advanced + + + Settings content - React Aria manages this selection + + + Preferences content - works normally + + + Advanced content - local state only + + - {/* With hrefs */} - - Case 2: With hrefs - - - By default no selection is shown because the URL doesn't match any - tab's href. - - - - - Catalog - - - Create - - - Docs - - - -
+ {/* With hrefs */} + + Case 2: With hrefs + + + By default no selection is shown because the URL doesn't match any + tab's href. + + + + + Catalog + + + Create + + + Docs + + + +
+
), }); diff --git a/packages/ui/src/components/Tabs/Tabs.tsx b/packages/ui/src/components/Tabs/Tabs.tsx index fcab2c71b9..c489a2ab67 100644 --- a/packages/ui/src/components/Tabs/Tabs.tsx +++ b/packages/ui/src/components/Tabs/Tabs.tsx @@ -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(undefined); const useTabsContext = () => { @@ -218,21 +212,19 @@ export const Tabs = (props: TabsProps) => { ); return ( - - - - - {children as ReactNode} - - - - + + + + {children as ReactNode} + + + ); }; @@ -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) { diff --git a/packages/ui/src/components/TagGroup/TagGroup.stories.tsx b/packages/ui/src/components/TagGroup/TagGroup.stories.tsx index 7d4ee4173e..00c778757f 100644 --- a/packages/ui/src/components/TagGroup/TagGroup.stories.tsx +++ b/packages/ui/src/components/TagGroup/TagGroup.stories.tsx @@ -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 => ( - + + + ), ], diff --git a/packages/ui/src/components/TagGroup/TagGroup.tsx b/packages/ui/src/components/TagGroup/TagGroup.tsx index 8258ab8554..9fd17c6d5c 100644 --- a/packages/ui/src/components/TagGroup/TagGroup.tsx +++ b/packages/ui/src/components/TagGroup/TagGroup.tsx @@ -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 = (props: TagGroupProps) => { const { classes, items, children, renderEmptyState } = ownProps; return ( - - - - {children} - - - + + + {children} + + ); }; @@ -68,8 +62,6 @@ export const Tag = forwardRef((props, ref) => { const { classes, children, icon, href } = ownProps; const textValue = typeof children === 'string' ? children : undefined; - useRoutingRegistrationEffect(href); - const handlePress = () => { if (href) { const text = diff --git a/packages/ui/src/guidelines/CardsWithTable.stories.tsx b/packages/ui/src/guidelines/CardsWithTable.stories.tsx index a8ecaf0bef..23e5923327 100644 --- a/packages/ui/src/guidelines/CardsWithTable.stories.tsx +++ b/packages/ui/src/guidelines/CardsWithTable.stories.tsx @@ -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) => ( - + + + ), ], diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 2bf478f5fb..8122fd5fde 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -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'; diff --git a/packages/ui/src/analytics/BUIProvider.tsx b/packages/ui/src/provider/BUIProvider.tsx similarity index 68% rename from packages/ui/src/analytics/BUIProvider.tsx rename to packages/ui/src/provider/BUIProvider.tsx index 3fcbfc7698..6576a9eabb 100644 --- a/packages/ui/src/analytics/BUIProvider.tsx +++ b/packages/ui/src/provider/BUIProvider.tsx @@ -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 {children}; + + const content = ( + {children} + ); + + if (useInRouterContext()) { + return {content}; + } + + return content; +} + +function RoutedContent({ children }: { children: ReactNode }) { + const navigate = useNavigate(); + return ( + + {children} + + ); } diff --git a/packages/ui/src/components/InternalLinkProvider/index.ts b/packages/ui/src/provider/index.ts similarity index 68% rename from packages/ui/src/components/InternalLinkProvider/index.ts rename to packages/ui/src/provider/index.ts index d0facb962f..7a99794d47 100644 --- a/packages/ui/src/components/InternalLinkProvider/index.ts +++ b/packages/ui/src/provider/index.ts @@ -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'; diff --git a/packages/ui/src/utils/isExternalLink.ts b/packages/ui/src/utils/linkUtils.ts similarity index 85% rename from packages/ui/src/utils/isExternalLink.ts rename to packages/ui/src/utils/linkUtils.ts index a874579ab5..8b2ded55c4 100644 --- a/packages/ui/src/utils/isExternalLink.ts +++ b/packages/ui/src/utils/linkUtils.ts @@ -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); +} diff --git a/plugins/app/src/extensions/AppRoot.tsx b/plugins/app/src/extensions/AppRoot.tsx index 2e49127a61..9ae564e06d 100644 --- a/plugins/app/src/extensions/AppRoot.tsx +++ b/plugins/app/src/extensions/AppRoot.tsx @@ -124,21 +124,19 @@ export const AppRoot = createExtension({ return [ coreExtensionData.reactElement( - - - el.get(coreExtensionData.reactElement), - )} - > - {content} - - , + + el.get(coreExtensionData.reactElement), + )} + > + {content} + , ), ]; }, @@ -280,23 +278,27 @@ export function AppRouter(props: AppRouterProps) { return ( - {...extraElements} - - {children} + + {...extraElements} + + {children} + ); } return ( - {...extraElements} - - - {children} - + + {...extraElements} + + + {children} + + ); }