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:
@@ -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.
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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';
|
||||
|
||||
+23
-3
@@ -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>
|
||||
);
|
||||
}
|
||||
+3
-9
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user