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