Merge pull request #30410 from backstage/canon-toolbar
Canon - New Header component ✨
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/canon': patch
|
||||
---
|
||||
|
||||
Add new Header component to Canon.
|
||||
@@ -9540,6 +9540,176 @@
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.canon-HeaderToolbar {
|
||||
z-index: 10;
|
||||
padding-top: 8px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
|
||||
&:before {
|
||||
content: "";
|
||||
background-color: var(--canon-bg);
|
||||
z-index: 0;
|
||||
height: 16px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.canon-HeaderToolbarWrapper {
|
||||
z-index: 1;
|
||||
background-color: var(--canon-bg-surface-1);
|
||||
padding-inline: var(--canon-space-3);
|
||||
border-radius: var(--canon-radius-2);
|
||||
border: 1px solid var(--canon-border);
|
||||
color: var(--canon-fg-primary);
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 52px;
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.canon-HeaderToolbarContent {
|
||||
align-items: center;
|
||||
gap: var(--canon-space-2);
|
||||
flex-direction: row;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.canon-HeaderToolbarName {
|
||||
align-items: center;
|
||||
gap: var(--canon-space-2);
|
||||
font-size: var(--canon-font-size-3);
|
||||
font-weight: var(--canon-font-weight-regular);
|
||||
flex-direction: row;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.canon-HeaderToolbarIcon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--canon-fg-primary);
|
||||
|
||||
& svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.canon-HeaderToolbarOptions {
|
||||
right: var(--canon-space-2);
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.canon-HeaderBreadcrumbs {
|
||||
align-items: center;
|
||||
gap: var(--canon-space-2);
|
||||
font-size: var(--canon-font-size-3);
|
||||
font-weight: var(--canon-font-weight-regular);
|
||||
flex-direction: row;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.canon-HeaderBreadcrumb {
|
||||
align-items: center;
|
||||
gap: var(--canon-space-2);
|
||||
flex-direction: row;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.canon-HeaderBreadcrumbLink {
|
||||
color: var(--canon-fg-secondary);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
|
||||
&[data-active="true"] {
|
||||
color: var(--canon-fg-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.canon-HeaderBreadcrumbSeparator {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--canon-fg-secondary);
|
||||
}
|
||||
|
||||
.canon-HeaderTabs {
|
||||
background-color: var(--canon-bg-surface-1);
|
||||
padding-inline: var(--canon-space-1);
|
||||
border-radius: var(--canon-radius-2);
|
||||
border: 1px solid var(--canon-border);
|
||||
--active-tab-left: 0px;
|
||||
--active-tab-right: 0px;
|
||||
--active-tab-top: 0px;
|
||||
--active-tab-bottom: 0px;
|
||||
--active-tab-width: 0px;
|
||||
--active-tab-height: 0px;
|
||||
--hovered-tab-left: 0px;
|
||||
--hovered-tab-right: 0px;
|
||||
--hovered-tab-top: 0px;
|
||||
--hovered-tab-bottom: 0px;
|
||||
--hovered-tab-width: 0px;
|
||||
--hovered-tab-height: 0px;
|
||||
--hovered-tab-opacity: 0;
|
||||
--hovered-transition-duration: 0s;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.canon-HeaderTabList {
|
||||
flex-direction: row;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.canon-HeaderTab {
|
||||
font-size: var(--canon-font-size-3);
|
||||
color: var(--canon-fg-primary);
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
height: 36px;
|
||||
padding-inline: var(--canon-space-2);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.canon-HeaderTabActive {
|
||||
content: "";
|
||||
left: calc(var(--active-tab-left) + var(--canon-space-2));
|
||||
width: calc(var(--active-tab-width) - var(--canon-space-4));
|
||||
background-color: var(--canon-fg-primary);
|
||||
opacity: 1;
|
||||
border-radius: 4px;
|
||||
height: 1px;
|
||||
transition: all .25s ease-out;
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
}
|
||||
|
||||
.canon-HeaderTabHovered {
|
||||
content: "";
|
||||
left: var(--hovered-tab-left);
|
||||
top: calc(var(--hovered-tab-top) + 4px);
|
||||
width: var(--hovered-tab-width);
|
||||
height: calc(var(--hovered-tab-height) - 8px);
|
||||
background-color: var(--canon-gray-2);
|
||||
opacity: var(--hovered-tab-opacity);
|
||||
transition: left var(--hovered-transition-duration) ease-out, top var(--hovered-transition-duration) ease-out, width var(--hovered-transition-duration) ease-out, height var(--hovered-transition-duration) ease-out, opacity .15s ease-out;
|
||||
border-radius: 4px;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.canon-Heading {
|
||||
font-family: var(--canon-font-regular);
|
||||
color: var(--canon-fg-primary);
|
||||
@@ -9644,6 +9814,7 @@
|
||||
}
|
||||
|
||||
.canon-MenuPositioner {
|
||||
z-index: 100;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"@remixicon/react": "^4.6.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"clsx": "^2.1.1",
|
||||
"motion": "^12.20.1",
|
||||
"react-aria-components": "^1.10.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -508,6 +508,26 @@ export const componentDefinitions: {
|
||||
readonly disabled: readonly [true, false];
|
||||
};
|
||||
};
|
||||
readonly Header: {
|
||||
readonly classNames: {
|
||||
readonly toolbar: 'canon-HeaderToolbar';
|
||||
readonly toolbarWrapper: 'canon-HeaderToolbarWrapper';
|
||||
readonly toolbarContent: 'canon-HeaderToolbarContent';
|
||||
readonly toolbarOptions: 'canon-HeaderToolbarOptions';
|
||||
readonly toolbarIcon: 'canon-HeaderToolbarIcon';
|
||||
readonly toolbarName: 'canon-HeaderToolbarName';
|
||||
readonly breadcrumbs: 'canon-HeaderBreadcrumbs';
|
||||
readonly breadcrumb: 'canon-HeaderBreadcrumb';
|
||||
readonly breadcrumbLink: 'canon-HeaderBreadcrumbLink';
|
||||
readonly breadcrumbSeparator: 'canon-HeaderBreadcrumbSeparator';
|
||||
readonly tabs: 'canon-HeaderTabs';
|
||||
readonly tabList: 'canon-HeaderTabList';
|
||||
readonly tab: 'canon-HeaderTab';
|
||||
readonly tabActive: 'canon-HeaderTabActive';
|
||||
readonly tabHovered: 'canon-HeaderTabHovered';
|
||||
readonly subNav: 'canon-HeaderSubNav';
|
||||
};
|
||||
};
|
||||
readonly Tooltip: {
|
||||
readonly classNames: {
|
||||
readonly trigger: 'canon-TooltipTrigger';
|
||||
@@ -916,6 +936,82 @@ export interface GridProps extends SpaceProps {
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
// @public
|
||||
export const Header: (props: HeaderProps) => JSX_2.Element;
|
||||
|
||||
// @public
|
||||
export interface HeaderBreadcrumb {
|
||||
// (undocumented)
|
||||
href: string;
|
||||
// (undocumented)
|
||||
label: string;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface HeaderOption {
|
||||
// (undocumented)
|
||||
label: string;
|
||||
// (undocumented)
|
||||
onClick?: () => void;
|
||||
// (undocumented)
|
||||
value: string;
|
||||
}
|
||||
|
||||
// @public
|
||||
export const HeaderPage: (props: HeaderPageProps) => JSX_2.Element;
|
||||
|
||||
// @public
|
||||
export interface HeaderPageOption {
|
||||
// (undocumented)
|
||||
label: string;
|
||||
// (undocumented)
|
||||
onClick?: () => void;
|
||||
// (undocumented)
|
||||
value: string;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface HeaderPageProps {
|
||||
// (undocumented)
|
||||
description?: string;
|
||||
// (undocumented)
|
||||
options?: HeaderPageOption[];
|
||||
// (undocumented)
|
||||
tabs?: HeaderPageTab[];
|
||||
// (undocumented)
|
||||
title?: string;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface HeaderPageTab {
|
||||
// (undocumented)
|
||||
href?: string;
|
||||
// (undocumented)
|
||||
label: string;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface HeaderProps {
|
||||
// (undocumented)
|
||||
breadcrumbs?: HeaderBreadcrumb[];
|
||||
// (undocumented)
|
||||
icon?: React.ReactNode;
|
||||
// (undocumented)
|
||||
options?: HeaderOption[];
|
||||
// (undocumented)
|
||||
tabs?: HeaderTab[];
|
||||
// (undocumented)
|
||||
title?: string;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface HeaderTab {
|
||||
// (undocumented)
|
||||
href?: string;
|
||||
// (undocumented)
|
||||
label: string;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export const Heading: <T extends ElementType = 'h1'>(
|
||||
props: HeadingProps<T> & {
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* Copyright 2024 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 type { Meta, StoryObj, StoryFn } from '@storybook/react';
|
||||
import { Header } from './Header';
|
||||
import { HeaderBreadcrumb, HeaderOption, HeaderTab } from './types';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Header',
|
||||
component: Header,
|
||||
} satisfies Meta<typeof Header>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const tabs: HeaderTab[] = [
|
||||
{
|
||||
label: 'Overview',
|
||||
},
|
||||
{
|
||||
label: 'Checks',
|
||||
},
|
||||
{
|
||||
label: 'Tracks',
|
||||
},
|
||||
{
|
||||
label: 'Campaigns',
|
||||
},
|
||||
{
|
||||
label: 'Integrations',
|
||||
},
|
||||
];
|
||||
|
||||
const breadcrumbs: HeaderBreadcrumb[] = [
|
||||
{
|
||||
label: 'Home',
|
||||
href: '/',
|
||||
},
|
||||
{
|
||||
label: 'Dashboard',
|
||||
href: '/dashboard',
|
||||
},
|
||||
{
|
||||
label: 'Settings',
|
||||
href: '/settings',
|
||||
},
|
||||
];
|
||||
|
||||
const options: HeaderOption[] = [
|
||||
{
|
||||
label: 'Settings',
|
||||
value: 'settings',
|
||||
},
|
||||
{
|
||||
label: 'Invite new members',
|
||||
value: 'invite-new-members',
|
||||
},
|
||||
];
|
||||
|
||||
// Extract layout decorator as a reusable constant
|
||||
const layoutDecorator = [
|
||||
(Story: StoryFn) => (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
width: '250px',
|
||||
position: 'fixed',
|
||||
left: 8,
|
||||
top: 8,
|
||||
bottom: 8,
|
||||
backgroundColor: 'var(--canon-bg-surface-1',
|
||||
borderRadius: 'var(--canon-radius-2)',
|
||||
border: '1px solid var(--canon-border)',
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
paddingInline: '266px',
|
||||
minHeight: '200vh',
|
||||
}}
|
||||
>
|
||||
<Story />
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
width: '250px',
|
||||
position: 'fixed',
|
||||
right: 8,
|
||||
top: 8,
|
||||
bottom: 8,
|
||||
backgroundColor: 'var(--canon-bg-surface-1',
|
||||
borderRadius: 'var(--canon-radius-2)',
|
||||
border: '1px solid var(--canon-border)',
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
];
|
||||
|
||||
export const Default: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const WithTabs: Story = {
|
||||
args: {
|
||||
tabs,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithOptions: Story = {
|
||||
args: {
|
||||
options,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithBreadcrumbs: Story = {
|
||||
args: {
|
||||
breadcrumbs,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithAllComponents: Story = {
|
||||
args: {
|
||||
options,
|
||||
tabs,
|
||||
breadcrumbs,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithLayout: Story = {
|
||||
args: {
|
||||
options,
|
||||
tabs,
|
||||
breadcrumbs,
|
||||
},
|
||||
decorators: layoutDecorator,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
* Copyright 2024 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.
|
||||
*/
|
||||
|
||||
.canon-HeaderToolbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
padding-top: 8px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0px;
|
||||
right: 0px;
|
||||
height: 16px;
|
||||
background-color: var(--canon-bg);
|
||||
z-index: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.canon-HeaderToolbarWrapper {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background-color: var(--canon-bg-surface-1);
|
||||
padding-inline: var(--canon-space-3);
|
||||
border-radius: var(--canon-radius-2);
|
||||
border: 1px solid var(--canon-border);
|
||||
color: var(--canon-fg-primary);
|
||||
height: 52px;
|
||||
}
|
||||
|
||||
.canon-HeaderToolbarContent {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--canon-space-2);
|
||||
}
|
||||
|
||||
.canon-HeaderToolbarName {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--canon-space-2);
|
||||
font-size: var(--canon-font-size-3);
|
||||
font-weight: var(--canon-font-weight-regular);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.canon-HeaderToolbarIcon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--canon-fg-primary);
|
||||
|
||||
& svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.canon-HeaderToolbarOptions {
|
||||
position: absolute;
|
||||
right: var(--canon-space-2);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.canon-HeaderBreadcrumbs {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--canon-space-2);
|
||||
font-size: var(--canon-font-size-3);
|
||||
font-weight: var(--canon-font-weight-regular);
|
||||
}
|
||||
|
||||
.canon-HeaderBreadcrumb {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--canon-space-2);
|
||||
}
|
||||
|
||||
.canon-HeaderBreadcrumbLink {
|
||||
color: var(--canon-fg-secondary);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
|
||||
&[data-active='true'] {
|
||||
color: var(--canon-fg-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.canon-HeaderBreadcrumbSeparator {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--canon-fg-secondary);
|
||||
}
|
||||
|
||||
.canon-HeaderTabs {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
background-color: var(--canon-bg-surface-1);
|
||||
position: relative;
|
||||
padding-inline: var(--canon-space-1);
|
||||
border-radius: var(--canon-radius-2);
|
||||
border: 1px solid var(--canon-border);
|
||||
margin-top: 8px;
|
||||
|
||||
/* Initialize CSS variables */
|
||||
--active-tab-left: 0px;
|
||||
--active-tab-right: 0px;
|
||||
--active-tab-top: 0px;
|
||||
--active-tab-bottom: 0px;
|
||||
--active-tab-width: 0px;
|
||||
--active-tab-height: 0px;
|
||||
|
||||
--hovered-tab-left: 0px;
|
||||
--hovered-tab-right: 0px;
|
||||
--hovered-tab-top: 0px;
|
||||
--hovered-tab-bottom: 0px;
|
||||
--hovered-tab-width: 0px;
|
||||
--hovered-tab-height: 0px;
|
||||
--hovered-tab-opacity: 0;
|
||||
--hovered-transition-duration: 0s;
|
||||
}
|
||||
|
||||
.canon-HeaderTabList {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.canon-HeaderTab {
|
||||
font-size: var(--canon-font-size-3);
|
||||
color: var(--canon-fg-primary);
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
padding-inline: var(--canon-space-2);
|
||||
}
|
||||
|
||||
/* Active tab indicator */
|
||||
.canon-HeaderTabActive {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: calc(var(--active-tab-left) + var(--canon-space-2));
|
||||
bottom: -1px;
|
||||
width: calc(var(--active-tab-width) - var(--canon-space-4));
|
||||
height: 1px;
|
||||
background-color: var(--canon-fg-primary);
|
||||
border-radius: 4px;
|
||||
transition: all 0.25s ease-out;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Hovered tab indicator */
|
||||
.canon-HeaderTabHovered {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: var(--hovered-tab-left);
|
||||
top: calc(var(--hovered-tab-top) + 4px);
|
||||
width: var(--hovered-tab-width);
|
||||
height: calc(var(--hovered-tab-height) - 8px);
|
||||
background-color: var(--canon-gray-2);
|
||||
border-radius: 4px;
|
||||
opacity: var(--hovered-tab-opacity);
|
||||
transition: left var(--hovered-transition-duration) ease-out,
|
||||
top var(--hovered-transition-duration) ease-out,
|
||||
width var(--hovered-transition-duration) ease-out,
|
||||
height var(--hovered-transition-duration) ease-out, opacity 0.15s ease-out;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright 2024 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 type { HeaderProps } from './types';
|
||||
import { HeaderToolbar } from './HeaderToolbar';
|
||||
import { HeaderTabs } from './HeaderTabs';
|
||||
|
||||
/**
|
||||
* A component that renders a toolbar.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const Header = (props: HeaderProps) => {
|
||||
const { tabs, icon, title, options, breadcrumbs } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeaderToolbar
|
||||
icon={icon}
|
||||
title={title}
|
||||
options={options}
|
||||
breadcrumbs={breadcrumbs}
|
||||
/>
|
||||
<HeaderTabs tabs={tabs} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* Copyright 2024 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 { Tabs, TabList, Tab as AriaTab } from 'react-aria-components';
|
||||
import { useStyles } from '../../hooks/useStyles';
|
||||
import { useId, useRef, useState } from 'react';
|
||||
import { HeaderTabsIndicators } from './HeaderTabsIndicators';
|
||||
import type { HeaderProps, HeaderTabProps } from './types';
|
||||
|
||||
/**
|
||||
* A component that renders header tabs.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export const HeaderTabs = (props: HeaderProps) => {
|
||||
const { tabs } = props;
|
||||
const { classNames } = useStyles('Header');
|
||||
const tabsRef = useRef<HTMLDivElement>(null);
|
||||
const tabRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const [hoveredKey, setHoveredKey] = useState<string | null>(null);
|
||||
const prevHoveredKey = useRef<string | null>(null);
|
||||
|
||||
const setTabRef = (key: string, element: HTMLDivElement | null) => {
|
||||
if (element) {
|
||||
tabRefs.current.set(key, element);
|
||||
} else {
|
||||
tabRefs.current.delete(key);
|
||||
}
|
||||
};
|
||||
|
||||
if (!tabs) return null;
|
||||
|
||||
return (
|
||||
<Tabs className={classNames.tabs} ref={tabsRef} keyboardActivation="manual">
|
||||
<TabList className={classNames.tabList} aria-label="Toolbar tabs">
|
||||
{tabs.map((tab, index) => {
|
||||
return (
|
||||
<Tab
|
||||
key={index}
|
||||
tab={tab}
|
||||
setTabRef={setTabRef}
|
||||
setHoveredKey={setHoveredKey}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</TabList>
|
||||
<HeaderTabsIndicators
|
||||
tabRefs={tabRefs}
|
||||
tabsRef={tabsRef}
|
||||
hoveredKey={hoveredKey}
|
||||
prevHoveredKey={prevHoveredKey}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* A component that renders a toolbar tab.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
const Tab = (props: HeaderTabProps) => {
|
||||
const { tab, setTabRef, setHoveredKey } = props;
|
||||
const id = useId();
|
||||
const { classNames } = useStyles('Header');
|
||||
|
||||
return (
|
||||
<AriaTab
|
||||
id={id}
|
||||
className={classNames.tab}
|
||||
ref={el => setTabRef(id, el as HTMLDivElement)}
|
||||
onHoverStart={() => setHoveredKey(id)}
|
||||
onHoverEnd={() => setHoveredKey(null)}
|
||||
href={tab.href}
|
||||
>
|
||||
{tab.label}
|
||||
</AriaTab>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
* Copyright 2024 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 { TabListStateContext } from 'react-aria-components';
|
||||
import { useStyles } from '../../hooks/useStyles';
|
||||
import { useContext, useEffect, useCallback } from 'react';
|
||||
import type { HeaderIndicatorsProps } from './types';
|
||||
|
||||
/**
|
||||
* A component that renders the indicators for the toolbar.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export const HeaderTabsIndicators = (props: HeaderIndicatorsProps) => {
|
||||
const { tabRefs, tabsRef, hoveredKey, prevHoveredKey } = props;
|
||||
const { classNames } = useStyles('Header');
|
||||
const state = useContext(TabListStateContext);
|
||||
|
||||
const updateCSSVariables = useCallback(() => {
|
||||
if (!tabsRef.current) return;
|
||||
|
||||
const tabsRect = tabsRef.current.getBoundingClientRect();
|
||||
|
||||
// Set active tab variables
|
||||
if (state?.selectedKey) {
|
||||
const activeTab = tabRefs.current.get(state.selectedKey.toString());
|
||||
|
||||
if (activeTab) {
|
||||
const activeRect = activeTab.getBoundingClientRect();
|
||||
const relativeLeft = activeRect.left - tabsRect.left;
|
||||
const relativeTop = activeRect.top - tabsRect.top;
|
||||
|
||||
tabsRef.current.style.setProperty(
|
||||
'--active-tab-left',
|
||||
`${relativeLeft}px`,
|
||||
);
|
||||
tabsRef.current.style.setProperty(
|
||||
'--active-tab-right',
|
||||
`${relativeLeft + activeRect.width}px`,
|
||||
);
|
||||
tabsRef.current.style.setProperty(
|
||||
'--active-tab-top',
|
||||
`${relativeTop}px`,
|
||||
);
|
||||
tabsRef.current.style.setProperty(
|
||||
'--active-tab-bottom',
|
||||
`${relativeTop + activeRect.height}px`,
|
||||
);
|
||||
tabsRef.current.style.setProperty(
|
||||
'--active-tab-width',
|
||||
`${activeRect.width}px`,
|
||||
);
|
||||
tabsRef.current.style.setProperty(
|
||||
'--active-tab-height',
|
||||
`${activeRect.height}px`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Set hovered tab variables
|
||||
if (hoveredKey) {
|
||||
const hoveredTab = tabRefs.current.get(hoveredKey);
|
||||
if (hoveredTab) {
|
||||
const hoveredRect = hoveredTab.getBoundingClientRect();
|
||||
const relativeLeft = hoveredRect.left - tabsRect.left;
|
||||
const relativeTop = hoveredRect.top - tabsRect.top;
|
||||
|
||||
tabsRef.current.style.setProperty(
|
||||
'--hovered-tab-left',
|
||||
`${relativeLeft}px`,
|
||||
);
|
||||
tabsRef.current.style.setProperty(
|
||||
'--hovered-tab-right',
|
||||
`${relativeLeft + hoveredRect.width}px`,
|
||||
);
|
||||
tabsRef.current.style.setProperty(
|
||||
'--hovered-tab-top',
|
||||
`${relativeTop}px`,
|
||||
);
|
||||
tabsRef.current.style.setProperty(
|
||||
'--hovered-tab-bottom',
|
||||
`${relativeTop + hoveredRect.height}px`,
|
||||
);
|
||||
tabsRef.current.style.setProperty(
|
||||
'--hovered-tab-width',
|
||||
`${hoveredRect.width}px`,
|
||||
);
|
||||
tabsRef.current.style.setProperty(
|
||||
'--hovered-tab-height',
|
||||
`${hoveredRect.height}px`,
|
||||
);
|
||||
// Control transition timing based on whether this is a new hover session
|
||||
const isNewHoverSession = prevHoveredKey.current === null;
|
||||
|
||||
if (isNewHoverSession) {
|
||||
// Starting new hover session: no transitions for position
|
||||
tabsRef.current.style.setProperty(
|
||||
'--hovered-transition-duration',
|
||||
'0s',
|
||||
);
|
||||
// Enable transitions on next frame for future tab switches
|
||||
requestAnimationFrame(() => {
|
||||
if (tabsRef.current) {
|
||||
tabsRef.current.style.setProperty(
|
||||
'--hovered-transition-duration',
|
||||
'0.2s',
|
||||
);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Moving between tabs in same session: full transitions
|
||||
tabsRef.current.style.setProperty(
|
||||
'--hovered-transition-duration',
|
||||
'0.2s',
|
||||
);
|
||||
}
|
||||
|
||||
// Update previous hover key for next time
|
||||
prevHoveredKey.current = hoveredKey;
|
||||
|
||||
tabsRef.current.style.setProperty('--hovered-tab-opacity', '1');
|
||||
}
|
||||
} else {
|
||||
// When not hovering, hide with opacity and reset for next hover session
|
||||
tabsRef.current.style.setProperty('--hovered-tab-opacity', '0');
|
||||
|
||||
// Reset previous hover key so next hover is treated as new session
|
||||
prevHoveredKey.current = null;
|
||||
}
|
||||
}, [state?.selectedKey, hoveredKey]);
|
||||
|
||||
useEffect(() => {
|
||||
updateCSSVariables();
|
||||
}, [updateCSSVariables]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => updateCSSVariables();
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [updateCSSVariables]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={classNames.tabActive} />
|
||||
<div className={classNames.tabHovered} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,164 @@
|
||||
/*
|
||||
* Copyright 2024 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 { useStyles } from '../../hooks/useStyles';
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import { RiArrowRightSLine, RiMore2Line, RiShapesLine } from '@remixicon/react';
|
||||
import type { HeaderProps } from './types';
|
||||
import { ButtonIcon } from '../ButtonIcon';
|
||||
import { Menu } from '../Menu';
|
||||
import { motion, useScroll, useTransform } from 'motion/react';
|
||||
|
||||
/**
|
||||
* A component that renders a toolbar.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export const HeaderToolbar = (props: HeaderProps) => {
|
||||
const { icon, title, options, breadcrumbs } = props;
|
||||
const { classNames } = useStyles('Header');
|
||||
|
||||
const { scrollY } = useScroll();
|
||||
const breadcrumbOpacity = useTransform(scrollY, [80, 120], [0, 1]);
|
||||
|
||||
// Refs for collision detection
|
||||
const toolbarWrapperRef = useRef<HTMLDivElement>(null);
|
||||
const toolbarContentRef = useRef<HTMLDivElement>(null);
|
||||
const toolbarOptionsRef = 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 = toolbarOptionsRef.current;
|
||||
|
||||
if (!wrapper || !content) return;
|
||||
|
||||
// Get dimensions
|
||||
const wrapperRect = wrapper.getBoundingClientRect();
|
||||
const wrapperWidth = wrapperRect.width;
|
||||
const wrapperPadding = 24; // 12px on each side (var(--canon-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();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={classNames.toolbar}>
|
||||
<div className={classNames.toolbarWrapper} ref={toolbarWrapperRef}>
|
||||
<div className={classNames.toolbarContent} ref={toolbarContentRef}>
|
||||
<div className={classNames.toolbarName}>
|
||||
<div className={classNames.toolbarIcon}>
|
||||
{icon || <RiShapesLine />}
|
||||
</div>
|
||||
{title || 'Your plugin'}
|
||||
</div>
|
||||
{breadcrumbs && (
|
||||
<motion.div
|
||||
className={classNames.breadcrumbs}
|
||||
style={{
|
||||
opacity: breadcrumbOpacity,
|
||||
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>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
<div className={classNames.toolbarOptions} ref={toolbarOptionsRef}>
|
||||
{options && (
|
||||
<Menu.Root>
|
||||
<Menu.Trigger
|
||||
render={props => (
|
||||
<ButtonIcon
|
||||
{...props}
|
||||
size="small"
|
||||
icon={<RiMore2Line />}
|
||||
variant="secondary"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Positioner sideOffset={4} align="end">
|
||||
<Menu.Popup>
|
||||
{options.map(option => (
|
||||
<Menu.Item
|
||||
key={option.value}
|
||||
onClick={() => option.onClick?.()}
|
||||
>
|
||||
{option.label}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Popup>
|
||||
</Menu.Positioner>
|
||||
</Menu.Portal>
|
||||
</Menu.Root>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright 2024 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.
|
||||
*/
|
||||
|
||||
export { Header } from './Header';
|
||||
export type {
|
||||
HeaderProps,
|
||||
HeaderTab,
|
||||
HeaderOption,
|
||||
HeaderBreadcrumb,
|
||||
} from './types';
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Copyright 2024 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 { MutableRefObject } from 'react';
|
||||
|
||||
/**
|
||||
* Props for the main Header component.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface HeaderProps {
|
||||
icon?: React.ReactNode;
|
||||
title?: string;
|
||||
tabs?: HeaderTab[];
|
||||
options?: HeaderOption[];
|
||||
breadcrumbs?: HeaderBreadcrumb[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a tab item in the header navigation.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface HeaderTab {
|
||||
label: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an option item in the header dropdown menu.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface HeaderOption {
|
||||
label: string;
|
||||
value: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a breadcrumb item in the header.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface HeaderBreadcrumb {
|
||||
label: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for individual header tab components.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface HeaderTabProps {
|
||||
tab: HeaderTab;
|
||||
setTabRef: (key: string, element: HTMLDivElement | null) => void;
|
||||
setHoveredKey: (key: string | null) => void;
|
||||
}
|
||||
|
||||
export interface HeaderIndicatorsProps {
|
||||
tabRefs: MutableRefObject<Map<string, HTMLDivElement>>;
|
||||
tabsRef: MutableRefObject<HTMLDivElement | null>;
|
||||
hoveredKey: string | null;
|
||||
prevHoveredKey: MutableRefObject<string | null>;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* Copyright 2024 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 type { Meta, StoryObj, StoryFn } from '@storybook/react';
|
||||
import { HeaderPage } from './HeaderPage';
|
||||
import { HeaderPageOption, HeaderPageTab } from './types';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/HeaderPage',
|
||||
component: HeaderPage,
|
||||
} satisfies Meta<typeof HeaderPage>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const tabs: HeaderPageTab[] = [
|
||||
{
|
||||
label: 'Overview',
|
||||
},
|
||||
{
|
||||
label: 'Checks',
|
||||
},
|
||||
{
|
||||
label: 'Tracks',
|
||||
},
|
||||
{
|
||||
label: 'Campaigns',
|
||||
},
|
||||
{
|
||||
label: 'Integrations',
|
||||
},
|
||||
];
|
||||
|
||||
const options: HeaderPageOption[] = [
|
||||
{
|
||||
label: 'Settings',
|
||||
value: 'settings',
|
||||
},
|
||||
{
|
||||
label: 'Invite new members',
|
||||
value: 'invite-new-members',
|
||||
},
|
||||
];
|
||||
|
||||
// Extract layout decorator as a reusable constant
|
||||
const layoutDecorator = [
|
||||
(Story: StoryFn) => (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
width: '250px',
|
||||
position: 'fixed',
|
||||
left: 8,
|
||||
top: 8,
|
||||
bottom: 8,
|
||||
backgroundColor: 'var(--canon-bg-surface-1',
|
||||
borderRadius: 'var(--canon-radius-2)',
|
||||
border: '1px solid var(--canon-border)',
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
paddingInline: '266px',
|
||||
minHeight: '200vh',
|
||||
}}
|
||||
>
|
||||
<Story />
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
width: '250px',
|
||||
position: 'fixed',
|
||||
right: 8,
|
||||
top: 8,
|
||||
bottom: 8,
|
||||
backgroundColor: 'var(--canon-bg-surface-1',
|
||||
borderRadius: 'var(--canon-radius-2)',
|
||||
border: '1px solid var(--canon-border)',
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
];
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
title: 'Header Page',
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDescription: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
description: 'Header Page Description',
|
||||
},
|
||||
};
|
||||
|
||||
export const WithTabs: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
tabs,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithOptions: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
options,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithEverything: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
description: 'Header Page Description',
|
||||
tabs,
|
||||
options,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithLayout: Story = {
|
||||
args: {
|
||||
...WithEverything.args,
|
||||
},
|
||||
decorators: layoutDecorator,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
* Copyright 2024 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.
|
||||
*/
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* 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 type { HeaderPageProps } from './types';
|
||||
import { Heading } from '../Heading';
|
||||
import { Text } from '../Text';
|
||||
import { Flex } from '../Flex';
|
||||
import { Menu } from '../Menu';
|
||||
import { ButtonIcon } from '../ButtonIcon';
|
||||
import { RiMore2Line } from '@remixicon/react';
|
||||
|
||||
/**
|
||||
* A component that renders a header page.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const HeaderPage = (props: HeaderPageProps) => {
|
||||
const { title, description, options } = props;
|
||||
|
||||
return (
|
||||
<Flex pl="4" pr="2" mt="6" justify="between">
|
||||
<Flex direction="column" gap="2">
|
||||
<Heading variant="title4">{title}</Heading>
|
||||
<Text>{description}</Text>
|
||||
</Flex>
|
||||
<div>
|
||||
{options && (
|
||||
<Menu.Root>
|
||||
<Menu.Trigger
|
||||
render={props => (
|
||||
<ButtonIcon
|
||||
{...props}
|
||||
size="small"
|
||||
icon={<RiMore2Line />}
|
||||
variant="secondary"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Positioner sideOffset={4} align="end">
|
||||
<Menu.Popup>
|
||||
{options.map(option => (
|
||||
<Menu.Item
|
||||
key={option.value}
|
||||
onClick={() => option.onClick?.()}
|
||||
>
|
||||
{option.label}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Popup>
|
||||
</Menu.Positioner>
|
||||
</Menu.Portal>
|
||||
</Menu.Root>
|
||||
)}
|
||||
</div>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2024 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.
|
||||
*/
|
||||
|
||||
export { HeaderPage } from './HeaderPage';
|
||||
export type { HeaderPageProps, HeaderPageTab, HeaderPageOption } from './types';
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright 2024 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Props for the main HeaderPage component.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface HeaderPageProps {
|
||||
title?: string;
|
||||
description?: string;
|
||||
tabs?: HeaderPageTab[];
|
||||
options?: HeaderPageOption[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a tab item in the header page.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface HeaderPageTab {
|
||||
label: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an option item in the header page.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface HeaderPageOption {
|
||||
label: string;
|
||||
value: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
.canon-MenuPositioner {
|
||||
outline: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.canon-MenuPopup {
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
@import '../components/FieldLabel/FieldLabel.styles.css';
|
||||
@import '../components/Flex/styles.css';
|
||||
@import '../components/Grid/styles.css';
|
||||
@import '../components/Header/Header.styles.css';
|
||||
@import '../components/HeaderPage/HeaderPage.styles.css';
|
||||
@import '../components/Heading/styles.css';
|
||||
@import '../components/Icon/styles.css';
|
||||
@import '../components/Link/styles.css';
|
||||
|
||||
@@ -37,6 +37,8 @@ export * from './components/Button';
|
||||
export * from './components/Collapsible';
|
||||
export * from './components/DataTable';
|
||||
export * from './components/FieldLabel';
|
||||
export * from './components/Header';
|
||||
export * from './components/HeaderPage';
|
||||
export * from './components/Icon';
|
||||
export * from './components/ButtonIcon';
|
||||
export * from './components/ButtonLink';
|
||||
|
||||
@@ -235,6 +235,26 @@ export const componentDefinitions = {
|
||||
disabled: [true, false] as const,
|
||||
},
|
||||
},
|
||||
Header: {
|
||||
classNames: {
|
||||
toolbar: 'canon-HeaderToolbar',
|
||||
toolbarWrapper: 'canon-HeaderToolbarWrapper',
|
||||
toolbarContent: 'canon-HeaderToolbarContent',
|
||||
toolbarOptions: 'canon-HeaderToolbarOptions',
|
||||
toolbarIcon: 'canon-HeaderToolbarIcon',
|
||||
toolbarName: 'canon-HeaderToolbarName',
|
||||
breadcrumbs: 'canon-HeaderBreadcrumbs',
|
||||
breadcrumb: 'canon-HeaderBreadcrumb',
|
||||
breadcrumbLink: 'canon-HeaderBreadcrumbLink',
|
||||
breadcrumbSeparator: 'canon-HeaderBreadcrumbSeparator',
|
||||
tabs: 'canon-HeaderTabs',
|
||||
tabList: 'canon-HeaderTabList',
|
||||
tab: 'canon-HeaderTab',
|
||||
tabActive: 'canon-HeaderTabActive',
|
||||
tabHovered: 'canon-HeaderTabHovered',
|
||||
subNav: 'canon-HeaderSubNav',
|
||||
},
|
||||
},
|
||||
Tooltip: {
|
||||
classNames: {
|
||||
trigger: 'canon-TooltipTrigger',
|
||||
|
||||
@@ -3836,6 +3836,7 @@ __metadata:
|
||||
globals: "npm:^15.11.0"
|
||||
lightningcss: "npm:^1.29.1"
|
||||
mini-css-extract-plugin: "npm:^2.9.2"
|
||||
motion: "npm:^12.20.1"
|
||||
react: "npm:^18.0.2"
|
||||
react-aria-components: "npm:^1.10.1"
|
||||
react-dom: "npm:^18.0.2"
|
||||
@@ -32985,6 +32986,28 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"framer-motion@npm:^12.20.1":
|
||||
version: 12.20.1
|
||||
resolution: "framer-motion@npm:12.20.1"
|
||||
dependencies:
|
||||
motion-dom: "npm:^12.20.1"
|
||||
motion-utils: "npm:^12.19.0"
|
||||
tslib: "npm:^2.4.0"
|
||||
peerDependencies:
|
||||
"@emotion/is-prop-valid": "*"
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
"@emotion/is-prop-valid":
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
react-dom:
|
||||
optional: true
|
||||
checksum: 10/ba36e76dbf66c856f0c5d3cfb80973bb0ef95ca9169fc16b22560a7bc55fbedcf63205266dba7beecac5152c4cd140097ff504c5d529beffca86cd85f97bf5e3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"framer-motion@npm:^6.5.1":
|
||||
version: 6.5.1
|
||||
resolution: "framer-motion@npm:6.5.1"
|
||||
@@ -40409,6 +40432,43 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"motion-dom@npm:^12.20.1":
|
||||
version: 12.20.1
|
||||
resolution: "motion-dom@npm:12.20.1"
|
||||
dependencies:
|
||||
motion-utils: "npm:^12.19.0"
|
||||
checksum: 10/b92d733fdfe3f66511f73c7f84f9483a0529eadf6ad2ab633cef584d161bcd1af786d257776e1fe7cd2b4e2c9c10912773f96ce9e2bd5b45c6c33ba6d9edfe86
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"motion-utils@npm:^12.19.0":
|
||||
version: 12.19.0
|
||||
resolution: "motion-utils@npm:12.19.0"
|
||||
checksum: 10/a6fe6b329bf4e2070bb95788218f36f0d7ea6cc3098e6ea2646ed68d5be88d25fa30e55284463ace78d19a5e19714875a7b70055bd86ae6e52a5fdee08438470
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"motion@npm:^12.20.1":
|
||||
version: 12.20.1
|
||||
resolution: "motion@npm:12.20.1"
|
||||
dependencies:
|
||||
framer-motion: "npm:^12.20.1"
|
||||
tslib: "npm:^2.4.0"
|
||||
peerDependencies:
|
||||
"@emotion/is-prop-valid": "*"
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
"@emotion/is-prop-valid":
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
react-dom:
|
||||
optional: true
|
||||
checksum: 10/49057c8d5812207209ca078a67ee7467d2dca7abacdcf2db486cd28c3ca105a7fe2504c34407df201c85035f6df1c806954af94914417ee89d721133fc4e92ce
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"mri@npm:1.1.4":
|
||||
version: 1.1.4
|
||||
resolution: "mri@npm:1.1.4"
|
||||
|
||||
Reference in New Issue
Block a user