diff --git a/.changeset/early-trains-relax.md b/.changeset/early-trains-relax.md new file mode 100644 index 0000000000..179e224681 --- /dev/null +++ b/.changeset/early-trains-relax.md @@ -0,0 +1,5 @@ +--- +'@backstage/canon': patch +--- + +Add new Header component to Canon. diff --git a/packages/canon/css/styles.css b/packages/canon/css/styles.css index 4fbd0cf035..9a5b19bff8 100644 --- a/packages/canon/css/styles.css +++ b/packages/canon/css/styles.css @@ -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; } diff --git a/packages/canon/package.json b/packages/canon/package.json index 2def886f3b..167fca0455 100644 --- a/packages/canon/package.json +++ b/packages/canon/package.json @@ -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": { diff --git a/packages/canon/report.api.md b/packages/canon/report.api.md index 372b82436b..1070193649 100644 --- a/packages/canon/report.api.md +++ b/packages/canon/report.api.md @@ -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: ( props: HeadingProps & { diff --git a/packages/canon/src/components/Header/Header.stories.tsx b/packages/canon/src/components/Header/Header.stories.tsx new file mode 100644 index 0000000000..0554648bc9 --- /dev/null +++ b/packages/canon/src/components/Header/Header.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +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) => ( + <> +
+
+ +
+
+ + ), +]; + +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', + }, +}; diff --git a/packages/canon/src/components/Header/Header.styles.css b/packages/canon/src/components/Header/Header.styles.css new file mode 100644 index 0000000000..76b7c6bcf4 --- /dev/null +++ b/packages/canon/src/components/Header/Header.styles.css @@ -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; +} diff --git a/packages/canon/src/components/Header/Header.tsx b/packages/canon/src/components/Header/Header.tsx new file mode 100644 index 0000000000..9d7e65a6d1 --- /dev/null +++ b/packages/canon/src/components/Header/Header.tsx @@ -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 ( + <> + + + + ); +}; diff --git a/packages/canon/src/components/Header/HeaderTabs.tsx b/packages/canon/src/components/Header/HeaderTabs.tsx new file mode 100644 index 0000000000..b83e27e83d --- /dev/null +++ b/packages/canon/src/components/Header/HeaderTabs.tsx @@ -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(null); + const tabRefs = useRef>(new Map()); + const [hoveredKey, setHoveredKey] = useState(null); + const prevHoveredKey = useRef(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.map((tab, index) => { + return ( + + ); + })} + + + + ); +}; + +/** + * 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 ( + setTabRef(id, el as HTMLDivElement)} + onHoverStart={() => setHoveredKey(id)} + onHoverEnd={() => setHoveredKey(null)} + href={tab.href} + > + {tab.label} + + ); +}; diff --git a/packages/canon/src/components/Header/HeaderTabsIndicators.tsx b/packages/canon/src/components/Header/HeaderTabsIndicators.tsx new file mode 100644 index 0000000000..41de56574e --- /dev/null +++ b/packages/canon/src/components/Header/HeaderTabsIndicators.tsx @@ -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 ( + <> +
+
+ + ); +}; diff --git a/packages/canon/src/components/Header/HeaderToolbar.tsx b/packages/canon/src/components/Header/HeaderToolbar.tsx new file mode 100644 index 0000000000..95c01e1c88 --- /dev/null +++ b/packages/canon/src/components/Header/HeaderToolbar.tsx @@ -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(null); + const toolbarContentRef = useRef(null); + const toolbarOptionsRef = useRef(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 ( +
+
+
+
+
+ {icon || } +
+ {title || 'Your plugin'} +
+ {breadcrumbs && ( + + + {breadcrumbs.map((breadcrumb, index) => ( +
+ + {breadcrumb.label} + + {index < breadcrumbs.length - 1 && ( + + )} +
+ ))} +
+ )} +
+
+ {options && ( + + ( + } + variant="secondary" + /> + )} + /> + + + + {options.map(option => ( + option.onClick?.()} + > + {option.label} + + ))} + + + + + )} +
+
+
+ ); +}; diff --git a/packages/canon/src/components/Header/index.tsx b/packages/canon/src/components/Header/index.tsx new file mode 100644 index 0000000000..ac55e27fa1 --- /dev/null +++ b/packages/canon/src/components/Header/index.tsx @@ -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'; diff --git a/packages/canon/src/components/Header/types.ts b/packages/canon/src/components/Header/types.ts new file mode 100644 index 0000000000..6cdd51b7d6 --- /dev/null +++ b/packages/canon/src/components/Header/types.ts @@ -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>; + tabsRef: MutableRefObject; + hoveredKey: string | null; + prevHoveredKey: MutableRefObject; +} diff --git a/packages/canon/src/components/HeaderPage/HeaderPage.stories.tsx b/packages/canon/src/components/HeaderPage/HeaderPage.stories.tsx new file mode 100644 index 0000000000..1fafb47a44 --- /dev/null +++ b/packages/canon/src/components/HeaderPage/HeaderPage.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +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) => ( + <> +
+
+ +
+
+ + ), +]; + +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', + }, +}; diff --git a/packages/canon/src/components/HeaderPage/HeaderPage.styles.css b/packages/canon/src/components/HeaderPage/HeaderPage.styles.css new file mode 100644 index 0000000000..94adcafa53 --- /dev/null +++ b/packages/canon/src/components/HeaderPage/HeaderPage.styles.css @@ -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. + */ diff --git a/packages/canon/src/components/HeaderPage/HeaderPage.tsx b/packages/canon/src/components/HeaderPage/HeaderPage.tsx new file mode 100644 index 0000000000..36581a4b5b --- /dev/null +++ b/packages/canon/src/components/HeaderPage/HeaderPage.tsx @@ -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 ( + + + {title} + {description} + +
+ {options && ( + + ( + } + variant="secondary" + /> + )} + /> + + + + {options.map(option => ( + option.onClick?.()} + > + {option.label} + + ))} + + + + + )} +
+
+ ); +}; diff --git a/packages/canon/src/components/HeaderPage/index.tsx b/packages/canon/src/components/HeaderPage/index.tsx new file mode 100644 index 0000000000..c90a0a93f4 --- /dev/null +++ b/packages/canon/src/components/HeaderPage/index.tsx @@ -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'; diff --git a/packages/canon/src/components/HeaderPage/types.ts b/packages/canon/src/components/HeaderPage/types.ts new file mode 100644 index 0000000000..8c146b4596 --- /dev/null +++ b/packages/canon/src/components/HeaderPage/types.ts @@ -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; +} diff --git a/packages/canon/src/components/Menu/Menu.styles.css b/packages/canon/src/components/Menu/Menu.styles.css index 7ccaa7a634..37fa4731e5 100644 --- a/packages/canon/src/components/Menu/Menu.styles.css +++ b/packages/canon/src/components/Menu/Menu.styles.css @@ -1,5 +1,6 @@ .canon-MenuPositioner { outline: 0; + z-index: 100; } .canon-MenuPopup { diff --git a/packages/canon/src/css/components.css b/packages/canon/src/css/components.css index 7feb065226..98eb4ff566 100644 --- a/packages/canon/src/css/components.css +++ b/packages/canon/src/css/components.css @@ -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'; diff --git a/packages/canon/src/index.ts b/packages/canon/src/index.ts index 88063eab3c..61fdb5e8cd 100644 --- a/packages/canon/src/index.ts +++ b/packages/canon/src/index.ts @@ -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'; diff --git a/packages/canon/src/utils/componentDefinitions.ts b/packages/canon/src/utils/componentDefinitions.ts index aa941d303c..29f6ecb7d1 100644 --- a/packages/canon/src/utils/componentDefinitions.ts +++ b/packages/canon/src/utils/componentDefinitions.ts @@ -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', diff --git a/yarn.lock b/yarn.lock index df32db8abd..45962f279b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"