Merge pull request #30701 from backstage/bui-header
BUI - Move the breadcrumb on the HeaderPage
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/ui': patch
|
||||
---
|
||||
|
||||
**Breaking change** Move breadcrumb to fit in the `HeaderPage` instead of the `Header` in Backstage UI.
|
||||
File diff suppressed because one or more lines are too long
@@ -65,6 +65,24 @@ export const propDefs: Record<string, PropDef> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
breadcrumbs: {
|
||||
type: 'complex',
|
||||
complexType: {
|
||||
name: 'Breadcrumb[]',
|
||||
properties: {
|
||||
label: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Display text for the breadcrumb',
|
||||
},
|
||||
href: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'URL for the breadcrumb link',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
...childrenPropDefs,
|
||||
...classNamePropDefs,
|
||||
...stylePropDefs,
|
||||
|
||||
@@ -18,24 +18,6 @@ export const propDefs: Record<string, PropDef> = {
|
||||
type: 'string',
|
||||
default: '/',
|
||||
},
|
||||
breadcrumbs: {
|
||||
type: 'complex',
|
||||
complexType: {
|
||||
name: 'Breadcrumb[]',
|
||||
properties: {
|
||||
label: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Display text for the breadcrumb',
|
||||
},
|
||||
href: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'URL for the breadcrumb link',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
customActions: {
|
||||
type: 'enum',
|
||||
values: ['ReactNode'],
|
||||
|
||||
+53
-36
@@ -9593,10 +9593,7 @@
|
||||
}
|
||||
|
||||
.bui-HeaderToolbar {
|
||||
z-index: 10;
|
||||
margin-bottom: var(--bui-space-6);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
|
||||
&:before {
|
||||
content: "";
|
||||
@@ -9667,38 +9664,6 @@
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.bui-HeaderBreadcrumbs {
|
||||
align-items: center;
|
||||
gap: var(--bui-space-2);
|
||||
font-size: var(--bui-font-size-3);
|
||||
font-weight: var(--bui-font-weight-regular);
|
||||
flex-direction: row;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.bui-HeaderBreadcrumb {
|
||||
align-items: center;
|
||||
gap: var(--bui-space-2);
|
||||
flex-direction: row;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.bui-HeaderBreadcrumbLink {
|
||||
color: var(--bui-fg-secondary);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
|
||||
&[data-active="true"] {
|
||||
color: var(--bui-fg-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.bui-HeaderBreadcrumbSeparator {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--bui-fg-secondary);
|
||||
}
|
||||
|
||||
.bui-HeaderTabsWrapper {
|
||||
margin-bottom: var(--bui-space-4);
|
||||
padding-inline: var(--bui-space-3);
|
||||
@@ -9708,7 +9673,6 @@
|
||||
|
||||
.bui-HeaderPage {
|
||||
gap: var(--bui-space-1);
|
||||
padding-inline: var(--bui-space-5);
|
||||
margin-top: var(--bui-space-6);
|
||||
margin-bottom: var(--bui-space-6);
|
||||
flex-direction: column;
|
||||
@@ -9732,6 +9696,59 @@
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.bui-HeaderPageSticky {
|
||||
z-index: -1;
|
||||
height: 0;
|
||||
color: var(--bui-fg-primary);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.bui-HeaderPageStickyWrapper {
|
||||
background-color: var(--bui-bg-surface-1);
|
||||
border-bottom: 1px solid var(--bui-border);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.bui-HeaderPageStickyContent {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.bui-HeaderPageBreadcrumbs {
|
||||
align-items: center;
|
||||
gap: var(--bui-space-2);
|
||||
font-size: var(--bui-font-size-3);
|
||||
font-weight: var(--bui-font-weight-regular);
|
||||
flex-direction: row;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.bui-HeaderPageBreadcrumb {
|
||||
align-items: center;
|
||||
gap: var(--bui-space-2);
|
||||
flex-direction: row;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.bui-HeaderPageBreadcrumbLink {
|
||||
color: var(--bui-fg-secondary);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
|
||||
&[data-active="true"] {
|
||||
color: var(--bui-fg-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.bui-HeaderPageBreadcrumbSeparator {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--bui-fg-secondary);
|
||||
}
|
||||
|
||||
.bui-Icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
|
||||
+17
-10
@@ -405,6 +405,13 @@ export const componentDefinitions: {
|
||||
readonly content: 'bui-HeaderPageContent';
|
||||
readonly tabsWrapper: 'bui-HeaderPageTabsWrapper';
|
||||
readonly controls: 'bui-HeaderPageControls';
|
||||
readonly sticky: 'bui-HeaderPageSticky';
|
||||
readonly stickyWrapper: 'bui-HeaderPageStickyWrapper';
|
||||
readonly stickyContent: 'bui-HeaderPageStickyContent';
|
||||
readonly breadcrumbs: 'bui-HeaderPageBreadcrumbs';
|
||||
readonly breadcrumb: 'bui-HeaderPageBreadcrumb';
|
||||
readonly breadcrumbLink: 'bui-HeaderPageBreadcrumbLink';
|
||||
readonly breadcrumbSeparator: 'bui-HeaderPageBreadcrumbSeparator';
|
||||
};
|
||||
};
|
||||
readonly Heading: {
|
||||
@@ -980,14 +987,6 @@ export interface GridProps extends SpaceProps {
|
||||
// @public
|
||||
export const Header: (props: HeaderProps) => JSX_2.Element;
|
||||
|
||||
// @public
|
||||
export interface HeaderBreadcrumb {
|
||||
// (undocumented)
|
||||
href: string;
|
||||
// (undocumented)
|
||||
label: string;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface HeaderMenuItem {
|
||||
// (undocumented)
|
||||
@@ -1001,8 +1000,18 @@ export interface HeaderMenuItem {
|
||||
// @public
|
||||
export const HeaderPage: (props: HeaderPageProps) => JSX_2.Element;
|
||||
|
||||
// @public
|
||||
export interface HeaderPageBreadcrumb {
|
||||
// (undocumented)
|
||||
href: string;
|
||||
// (undocumented)
|
||||
label: string;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface HeaderPageProps {
|
||||
// (undocumented)
|
||||
breadcrumbs?: HeaderPageBreadcrumb[];
|
||||
// (undocumented)
|
||||
customActions?: React.ReactNode;
|
||||
// (undocumented)
|
||||
@@ -1015,8 +1024,6 @@ export interface HeaderPageProps {
|
||||
|
||||
// @public
|
||||
export interface HeaderProps {
|
||||
// (undocumented)
|
||||
breadcrumbs?: HeaderBreadcrumb[];
|
||||
// (undocumented)
|
||||
customActions?: React.ReactNode;
|
||||
// (undocumented)
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import type { Meta, StoryObj, StoryFn } from '@storybook/react';
|
||||
import { Header } from './Header';
|
||||
import { HeaderBreadcrumb, HeaderMenuItem, HeaderTab } from './types';
|
||||
import { HeaderMenuItem, HeaderTab } from './types';
|
||||
import { Button } from '../Button';
|
||||
import { HeaderPage } from '../HeaderPage';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
RiEmotionHappyLine,
|
||||
RiCloudy2Line,
|
||||
} from '@remixicon/react';
|
||||
import { HeaderPageBreadcrumb } from '../HeaderPage/types';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Header',
|
||||
@@ -86,7 +87,18 @@ const tabs2: HeaderTab[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const breadcrumbs: HeaderBreadcrumb[] = [
|
||||
const menuItems: HeaderMenuItem[] = [
|
||||
{
|
||||
label: 'Settings',
|
||||
value: 'settings',
|
||||
},
|
||||
{
|
||||
label: 'Invite new members',
|
||||
value: 'invite-new-members',
|
||||
},
|
||||
];
|
||||
|
||||
const breadcrumbs: HeaderPageBreadcrumb[] = [
|
||||
{
|
||||
label: 'Home',
|
||||
href: '/',
|
||||
@@ -101,17 +113,6 @@ const breadcrumbs: HeaderBreadcrumb[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const menuItems: HeaderMenuItem[] = [
|
||||
{
|
||||
label: 'Settings',
|
||||
value: 'settings',
|
||||
},
|
||||
{
|
||||
label: 'Invite new members',
|
||||
value: 'invite-new-members',
|
||||
},
|
||||
];
|
||||
|
||||
// Extract layout decorator as a reusable constant
|
||||
const layoutDecorator = [
|
||||
(Story: StoryFn) => (
|
||||
@@ -138,7 +139,35 @@ const layoutDecorator = [
|
||||
>
|
||||
<Story />
|
||||
<Container>
|
||||
<Text>
|
||||
<Text as="p">
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam,
|
||||
quos.
|
||||
</Text>
|
||||
<Text as="p">
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam,
|
||||
quos.
|
||||
</Text>
|
||||
<Text as="p">
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam,
|
||||
quos.
|
||||
</Text>
|
||||
<Text as="p">
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam,
|
||||
quos.
|
||||
</Text>
|
||||
<Text as="p">
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam,
|
||||
quos.
|
||||
</Text>
|
||||
<Text as="p">
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam,
|
||||
quos.
|
||||
</Text>
|
||||
<Text as="p">
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam,
|
||||
quos.
|
||||
</Text>
|
||||
<Text as="p">
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam,
|
||||
quos.
|
||||
</Text>
|
||||
@@ -195,14 +224,6 @@ export const WithAllOptions: Story = {
|
||||
render: WithCustomActions.render,
|
||||
};
|
||||
|
||||
export const WithBreadcrumbs: Story = {
|
||||
args: {
|
||||
breadcrumbs,
|
||||
tabs,
|
||||
},
|
||||
decorators: [withRouter],
|
||||
};
|
||||
|
||||
export const WithAllOptionsAndTabs: Story = {
|
||||
args: {
|
||||
...WithAllOptions.args,
|
||||
@@ -234,6 +255,7 @@ export const WithHeaderPage: Story = {
|
||||
menuItems={args.menuItems}
|
||||
tabs={tabs2}
|
||||
customActions={<Button>Custom action</Button>}
|
||||
breadcrumbs={breadcrumbs}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
@@ -242,7 +264,6 @@ export const WithHeaderPage: Story = {
|
||||
export const WithLayout: Story = {
|
||||
args: {
|
||||
menuItems,
|
||||
breadcrumbs,
|
||||
},
|
||||
decorators: layoutDecorator,
|
||||
render: args => (
|
||||
@@ -253,6 +274,7 @@ export const WithLayout: Story = {
|
||||
menuItems={args.menuItems}
|
||||
tabs={tabs2}
|
||||
customActions={<Button>Custom action</Button>}
|
||||
breadcrumbs={breadcrumbs}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
@@ -261,7 +283,6 @@ export const WithLayout: Story = {
|
||||
export const WithLayoutNoTabs: Story = {
|
||||
args: {
|
||||
menuItems,
|
||||
breadcrumbs,
|
||||
},
|
||||
decorators: layoutDecorator,
|
||||
render: args => (
|
||||
@@ -275,7 +296,6 @@ export const WithLayoutNoTabs: Story = {
|
||||
export const WithEverything: Story = {
|
||||
args: {
|
||||
menuItems,
|
||||
breadcrumbs,
|
||||
tabs,
|
||||
titleLink: '/',
|
||||
},
|
||||
|
||||
@@ -15,9 +15,6 @@
|
||||
*/
|
||||
|
||||
.bui-HeaderToolbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
margin-bottom: var(--bui-space-6);
|
||||
|
||||
&::before {
|
||||
@@ -89,38 +86,6 @@
|
||||
gap: var(--bui-space-2);
|
||||
}
|
||||
|
||||
.bui-HeaderBreadcrumbs {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--bui-space-2);
|
||||
font-size: var(--bui-font-size-3);
|
||||
font-weight: var(--bui-font-weight-regular);
|
||||
}
|
||||
|
||||
.bui-HeaderBreadcrumb {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--bui-space-2);
|
||||
}
|
||||
|
||||
.bui-HeaderBreadcrumbLink {
|
||||
color: var(--bui-fg-secondary);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
|
||||
&[data-active='true'] {
|
||||
color: var(--bui-fg-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.bui-HeaderBreadcrumbSeparator {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--bui-fg-secondary);
|
||||
}
|
||||
|
||||
.bui-HeaderTabsWrapper {
|
||||
margin-bottom: var(--bui-space-4);
|
||||
padding-inline: var(--bui-space-3);
|
||||
|
||||
@@ -38,7 +38,6 @@ export const Header = (props: HeaderProps) => {
|
||||
title,
|
||||
titleLink,
|
||||
menuItems,
|
||||
breadcrumbs,
|
||||
customActions,
|
||||
onTabSelectionChange,
|
||||
} = props;
|
||||
@@ -54,7 +53,6 @@ export const Header = (props: HeaderProps) => {
|
||||
title={title}
|
||||
titleLink={titleLink}
|
||||
menuItems={menuItems}
|
||||
breadcrumbs={breadcrumbs}
|
||||
customActions={customActions}
|
||||
hasTabs={hasTabs}
|
||||
/>
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
|
||||
import { Link, RouterProvider } from 'react-aria-components';
|
||||
import { useStyles } from '../../hooks/useStyles';
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import { RiArrowRightSLine, RiMore2Line, RiShapesLine } from '@remixicon/react';
|
||||
import { useRef } from 'react';
|
||||
import { RiMore2Line, RiShapesLine } from '@remixicon/react';
|
||||
import type { HeaderToolbarProps } from './types';
|
||||
import { ButtonIcon } from '../ButtonIcon';
|
||||
import { Menu } from '../Menu';
|
||||
@@ -25,7 +25,6 @@ import { Text } from '../Text';
|
||||
import { useNavigate, useHref } from 'react-router-dom';
|
||||
import { gsap } from 'gsap';
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||
import { useGSAP } from '@gsap/react';
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
@@ -35,15 +34,7 @@ gsap.registerPlugin(ScrollTrigger);
|
||||
* @internal
|
||||
*/
|
||||
export const HeaderToolbar = (props: HeaderToolbarProps) => {
|
||||
const {
|
||||
icon,
|
||||
title,
|
||||
titleLink,
|
||||
menuItems,
|
||||
breadcrumbs,
|
||||
customActions,
|
||||
hasTabs,
|
||||
} = props;
|
||||
const { icon, title, titleLink, menuItems, customActions, hasTabs } = props;
|
||||
const { classNames } = useStyles('Header');
|
||||
let navigate = useNavigate();
|
||||
|
||||
@@ -51,66 +42,6 @@ export const HeaderToolbar = (props: HeaderToolbarProps) => {
|
||||
const toolbarWrapperRef = useRef<HTMLDivElement>(null);
|
||||
const toolbarContentRef = useRef<HTMLDivElement>(null);
|
||||
const toolbarControlsRef = useRef<HTMLDivElement>(null);
|
||||
const breadcrumbsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// State for breadcrumb visibility
|
||||
const [showBreadcrumbs, setShowBreadcrumbs] = useState(true);
|
||||
|
||||
// Set up resize observer
|
||||
useEffect(() => {
|
||||
const wrapper = toolbarWrapperRef.current;
|
||||
if (!wrapper) return;
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
const wrapper = toolbarWrapperRef.current;
|
||||
const content = toolbarContentRef.current;
|
||||
const options = toolbarControlsRef.current;
|
||||
|
||||
if (!wrapper || !content) return;
|
||||
|
||||
// Get dimensions
|
||||
const wrapperRect = wrapper.getBoundingClientRect();
|
||||
const wrapperWidth = wrapperRect.width;
|
||||
const wrapperPadding = 24; // 12px on each side (var(--bui-space-3))
|
||||
const availableWidth = wrapperWidth - wrapperPadding;
|
||||
|
||||
// Calculate required width for content
|
||||
const contentRect = content.getBoundingClientRect();
|
||||
const contentWidth = contentRect?.width || 0;
|
||||
|
||||
// Calculate options width (if exists)
|
||||
const optionsRect = options?.getBoundingClientRect();
|
||||
const optionsWidth = optionsRect?.width || 0;
|
||||
|
||||
// Check if we need to hide breadcrumbs
|
||||
const shouldShowBreadcrumbs =
|
||||
contentWidth + optionsWidth + 32 <= availableWidth;
|
||||
|
||||
// Only update state if the value actually changed to prevent flickering
|
||||
setShowBreadcrumbs(prev =>
|
||||
prev !== shouldShowBreadcrumbs ? shouldShowBreadcrumbs : prev,
|
||||
);
|
||||
});
|
||||
|
||||
resizeObserver.observe(wrapper);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useGSAP(() => {
|
||||
gsap.to(breadcrumbsRef.current, {
|
||||
scrollTrigger: {
|
||||
start: '10% 10%',
|
||||
end: '20% 20%',
|
||||
scrub: true,
|
||||
},
|
||||
opacity: 1,
|
||||
duration: 1,
|
||||
ease: 'power2.inOut',
|
||||
});
|
||||
});
|
||||
|
||||
const titleContent = (
|
||||
<>
|
||||
@@ -133,38 +64,6 @@ export const HeaderToolbar = (props: HeaderToolbarProps) => {
|
||||
<div className={classNames.toolbarName}>{titleContent}</div>
|
||||
)}
|
||||
</Text>
|
||||
{breadcrumbs && (
|
||||
<div
|
||||
ref={breadcrumbsRef}
|
||||
className={classNames.breadcrumbs}
|
||||
style={{
|
||||
opacity: 0,
|
||||
visibility: showBreadcrumbs ? 'visible' : 'hidden',
|
||||
}}
|
||||
>
|
||||
<RiArrowRightSLine
|
||||
size={16}
|
||||
className={classNames.breadcrumbSeparator}
|
||||
/>
|
||||
{breadcrumbs.map((breadcrumb, index) => (
|
||||
<div key={breadcrumb.label} className={classNames.breadcrumb}>
|
||||
<Link
|
||||
href={breadcrumb.href}
|
||||
className={classNames.breadcrumbLink}
|
||||
data-active={index === breadcrumbs.length - 1}
|
||||
>
|
||||
{breadcrumb.label}
|
||||
</Link>
|
||||
{index < breadcrumbs.length - 1 && (
|
||||
<RiArrowRightSLine
|
||||
size={16}
|
||||
className={classNames.breadcrumbSeparator}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={classNames.toolbarControls} ref={toolbarControlsRef}>
|
||||
{customActions}
|
||||
|
||||
@@ -15,9 +15,4 @@
|
||||
*/
|
||||
|
||||
export { Header } from './Header';
|
||||
export type {
|
||||
HeaderProps,
|
||||
HeaderTab,
|
||||
HeaderMenuItem,
|
||||
HeaderBreadcrumb,
|
||||
} from './types';
|
||||
export type { HeaderProps, HeaderTab, HeaderMenuItem } from './types';
|
||||
|
||||
@@ -26,7 +26,6 @@ export interface HeaderProps {
|
||||
icon?: React.ReactNode;
|
||||
title?: string;
|
||||
titleLink?: string;
|
||||
breadcrumbs?: HeaderBreadcrumb[];
|
||||
customActions?: React.ReactNode;
|
||||
menuItems?: HeaderMenuItem[];
|
||||
tabs?: HeaderTab[];
|
||||
@@ -61,16 +60,6 @@ export interface HeaderMenuItem {
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a breadcrumb item in the header.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface HeaderBreadcrumb {
|
||||
label: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the HeaderToolbar component.
|
||||
*
|
||||
@@ -80,7 +69,6 @@ export interface HeaderToolbarProps {
|
||||
icon?: HeaderProps['icon'];
|
||||
title?: HeaderProps['title'];
|
||||
titleLink?: HeaderProps['titleLink'];
|
||||
breadcrumbs?: HeaderProps['breadcrumbs'];
|
||||
customActions?: HeaderProps['customActions'];
|
||||
menuItems?: HeaderProps['menuItems'];
|
||||
hasTabs?: boolean;
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--bui-space-1);
|
||||
padding-inline: var(--bui-space-5);
|
||||
margin-top: var(--bui-space-6);
|
||||
margin-bottom: var(--bui-space-6);
|
||||
}
|
||||
@@ -39,3 +38,56 @@
|
||||
align-items: center;
|
||||
gap: var(--bui-space-2);
|
||||
}
|
||||
|
||||
.bui-HeaderPageSticky {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: -1;
|
||||
height: 0;
|
||||
color: var(--bui-fg-primary);
|
||||
}
|
||||
|
||||
.bui-HeaderPageStickyWrapper {
|
||||
background-color: var(--bui-bg-surface-1);
|
||||
border-bottom: 1px solid var(--bui-border);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.bui-HeaderPageStickyContent {
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.bui-HeaderPageBreadcrumbs {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--bui-space-2);
|
||||
font-size: var(--bui-font-size-3);
|
||||
font-weight: var(--bui-font-weight-regular);
|
||||
}
|
||||
|
||||
.bui-HeaderPageBreadcrumb {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--bui-space-2);
|
||||
}
|
||||
|
||||
.bui-HeaderPageBreadcrumbLink {
|
||||
color: var(--bui-fg-secondary);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
|
||||
&[data-active='true'] {
|
||||
color: var(--bui-fg-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.bui-HeaderPageBreadcrumbSeparator {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--bui-fg-secondary);
|
||||
}
|
||||
|
||||
@@ -18,9 +18,17 @@ import type { HeaderPageProps } from './types';
|
||||
import { Menu } from '../Menu';
|
||||
import { Text } from '../Text';
|
||||
import { ButtonIcon } from '../ButtonIcon';
|
||||
import { RiMore2Line } from '@remixicon/react';
|
||||
import { RiArrowRightSLine, RiMore2Line } from '@remixicon/react';
|
||||
import { Tabs, TabList, Tab } from '../Tabs';
|
||||
import { useStyles } from '../../hooks/useStyles';
|
||||
import { useRef } from 'react';
|
||||
import { useGSAP } from '@gsap/react';
|
||||
import { gsap } from 'gsap';
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||
import { Container } from '../Container';
|
||||
import { Link } from '../Link';
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
/**
|
||||
* A component that renders a header page.
|
||||
@@ -28,65 +36,128 @@ import { useStyles } from '../../hooks/useStyles';
|
||||
* @public
|
||||
*/
|
||||
export const HeaderPage = (props: HeaderPageProps) => {
|
||||
const { title, menuItems, tabs, customActions } = props;
|
||||
const { title, menuItems, tabs, customActions, breadcrumbs } = props;
|
||||
const { classNames } = useStyles('HeaderPage');
|
||||
const stickyRef = useRef<HTMLDivElement>(null);
|
||||
const stickyContentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<div className={classNames.root}>
|
||||
<div className={classNames.content}>
|
||||
<Text variant="title-small" weight="bold" as="h2">
|
||||
{title}
|
||||
</Text>
|
||||
<div className={classNames.controls}>
|
||||
{customActions}
|
||||
{menuItems && (
|
||||
<Menu.Root>
|
||||
<Menu.Trigger
|
||||
render={props => (
|
||||
<ButtonIcon
|
||||
{...props}
|
||||
size="small"
|
||||
icon={<RiMore2Line />}
|
||||
variant="tertiary"
|
||||
/>
|
||||
)}
|
||||
useGSAP(() => {
|
||||
const tl = gsap.timeline({
|
||||
scrollTrigger: {
|
||||
trigger: stickyRef.current,
|
||||
start: 'top top',
|
||||
end: '120px top',
|
||||
scrub: true,
|
||||
},
|
||||
});
|
||||
|
||||
tl.to(stickyContentRef.current, {
|
||||
opacity: 1,
|
||||
ease: 'power2.inOut',
|
||||
}).to(
|
||||
stickyRef.current,
|
||||
{
|
||||
zIndex: 10,
|
||||
ease: 'none',
|
||||
},
|
||||
'<',
|
||||
);
|
||||
});
|
||||
|
||||
const controls = (
|
||||
<div className={classNames.controls}>
|
||||
{customActions}
|
||||
{menuItems && (
|
||||
<Menu.Root>
|
||||
<Menu.Trigger
|
||||
render={props => (
|
||||
<ButtonIcon
|
||||
{...props}
|
||||
size="small"
|
||||
icon={<RiMore2Line />}
|
||||
variant="tertiary"
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Positioner sideOffset={4} align="end">
|
||||
<Menu.Popup>
|
||||
{menuItems.map(menuItem => (
|
||||
<Menu.Item
|
||||
key={menuItem.value}
|
||||
onClick={() => menuItem.onClick?.()}
|
||||
>
|
||||
{menuItem.label}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Popup>
|
||||
</Menu.Positioner>
|
||||
</Menu.Portal>
|
||||
</Menu.Root>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{tabs && (
|
||||
<div className={classNames.tabsWrapper}>
|
||||
<Tabs>
|
||||
<TabList>
|
||||
{tabs.map(tab => (
|
||||
<Tab
|
||||
key={tab.id}
|
||||
id={tab.id}
|
||||
href={tab.href}
|
||||
matchStrategy={tab.matchStrategy}
|
||||
>
|
||||
{tab.label}
|
||||
</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Positioner sideOffset={4} align="end">
|
||||
<Menu.Popup>
|
||||
{menuItems.map(menuItem => (
|
||||
<Menu.Item
|
||||
key={menuItem.value}
|
||||
onClick={() => menuItem.onClick?.()}
|
||||
>
|
||||
{menuItem.label}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Popup>
|
||||
</Menu.Positioner>
|
||||
</Menu.Portal>
|
||||
</Menu.Root>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container className={classNames.root}>
|
||||
<div className={classNames.content}>
|
||||
<Text variant="title-small" weight="bold" as="h2">
|
||||
{title}
|
||||
</Text>
|
||||
{controls}
|
||||
</div>
|
||||
{tabs && (
|
||||
<div className={classNames.tabsWrapper}>
|
||||
<Tabs>
|
||||
<TabList>
|
||||
{tabs.map(tab => (
|
||||
<Tab
|
||||
key={tab.id}
|
||||
id={tab.id}
|
||||
href={tab.href}
|
||||
matchStrategy={tab.matchStrategy}
|
||||
>
|
||||
{tab.label}
|
||||
</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
<div className={classNames.sticky} ref={stickyRef}>
|
||||
<div className={classNames.stickyWrapper} ref={stickyContentRef}>
|
||||
<Container className={classNames.stickyContent}>
|
||||
{breadcrumbs ? (
|
||||
<div className={classNames.breadcrumbs}>
|
||||
{breadcrumbs.map((breadcrumb, index) => (
|
||||
<div key={breadcrumb.label} className={classNames.breadcrumb}>
|
||||
<Link
|
||||
href={breadcrumb.href}
|
||||
className={classNames.breadcrumbLink}
|
||||
data-active={index === breadcrumbs.length - 1}
|
||||
>
|
||||
{breadcrumb.label}
|
||||
</Link>
|
||||
{index < breadcrumbs.length - 1 && (
|
||||
<RiArrowRightSLine
|
||||
size={16}
|
||||
className={classNames.breadcrumbSeparator}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Text variant="body-medium" as="p">
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
{controls}
|
||||
</Container>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,4 +15,4 @@
|
||||
*/
|
||||
|
||||
export { HeaderPage } from './HeaderPage';
|
||||
export type { HeaderPageProps } from './types';
|
||||
export type { HeaderPageProps, HeaderPageBreadcrumb } from './types';
|
||||
|
||||
@@ -26,4 +26,15 @@ export interface HeaderPageProps {
|
||||
customActions?: React.ReactNode;
|
||||
menuItems?: HeaderMenuItem[];
|
||||
tabs?: HeaderTab[];
|
||||
breadcrumbs?: HeaderPageBreadcrumb[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a breadcrumb item in the header.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface HeaderPageBreadcrumb {
|
||||
label: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
@@ -125,6 +125,13 @@ export const componentDefinitions = {
|
||||
content: 'bui-HeaderPageContent',
|
||||
tabsWrapper: 'bui-HeaderPageTabsWrapper',
|
||||
controls: 'bui-HeaderPageControls',
|
||||
sticky: 'bui-HeaderPageSticky',
|
||||
stickyWrapper: 'bui-HeaderPageStickyWrapper',
|
||||
stickyContent: 'bui-HeaderPageStickyContent',
|
||||
breadcrumbs: 'bui-HeaderPageBreadcrumbs',
|
||||
breadcrumb: 'bui-HeaderPageBreadcrumb',
|
||||
breadcrumbLink: 'bui-HeaderPageBreadcrumbLink',
|
||||
breadcrumbSeparator: 'bui-HeaderPageBreadcrumbSeparator',
|
||||
},
|
||||
},
|
||||
Heading: {
|
||||
|
||||
Reference in New Issue
Block a user