diff --git a/.changeset/header-sticky-prop.md b/.changeset/header-sticky-prop.md new file mode 100644 index 0000000000..b74af33ae0 --- /dev/null +++ b/.changeset/header-sticky-prop.md @@ -0,0 +1,9 @@ +--- +'@backstage/ui': minor +--- + +Added a `sticky` prop to the `Header` component. When `true`, the title-and-actions bar stays fixed to the top of its scroll container while the rest of the header (tags, description, metadata) scrolls away. The sticky bar background color automatically matches the container surface using the bg-consumer system. + +**BREAKING**: Removed the main header class from the `Header` component. Custom styles that target this class should be updated to target the component sections that remain. + +**Affected components:** Header diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 916c48951d..eb0b111bc9 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -50,26 +50,11 @@ export default definePreview({ dynamicTitle: true, }, }, - background: { - name: 'Background', - description: 'Global background for components', - defaultValue: 'app', - toolbar: { - icon: 'contrast', - items: [ - { value: 'app', title: 'App Background' }, - { value: 'neutral-1', title: 'Neutral 1 Background' }, - { value: 'neutral-2', title: 'Neutral 2 Background' }, - { value: 'neutral-3', title: 'Neutral 3 Background' }, - ], - }, - }, }, initialGlobals: { themeMode: 'light', themeName: 'backstage', - background: 'app', }, parameters: { @@ -143,7 +128,6 @@ export default definePreview({ globals.themeMode === 'light' ? themes.light : themes.dark; const selectedThemeMode = globals.themeMode || 'light'; const selectedThemeName = globals.themeName || 'backstage'; - const selectedBackground = globals.background || 'app'; const isFullscreen = context.parameters.layout === 'fullscreen'; useEffect(() => { @@ -155,15 +139,13 @@ export default definePreview({ document.body.removeAttribute('data-theme-mode'); document.body.removeAttribute('data-theme-name'); }; - }, [selectedTheme, selectedThemeName]); + }, [selectedThemeMode, selectedThemeName]); useEffect(() => { appThemeApi.setActiveThemeId(selectedThemeMode); }, [selectedThemeMode]); document.body.style.backgroundColor = 'var(--bui-bg-app)'; - document.body.style.padding = - isFullscreen && selectedBackground !== 'app' ? '1rem' : ''; const docsStoryElements = document.getElementsByClassName('docs-story'); Array.from(docsStoryElements).forEach(element => { (element as HTMLElement).style.backgroundColor = 'var(--bui-bg-app)'; @@ -174,18 +156,23 @@ export default definePreview({ {/* @ts-ignore */} - {Array.from({ - length: - selectedBackground === 'app' - ? 0 - : parseInt(selectedBackground.split('-')[1], 10), - }).reduce( - children => ( - - {children} - - ), - , + {selectedThemeName === 'spotify' ? ( + + + + ) : ( + )} diff --git a/.storybook/themes/spotify.css b/.storybook/themes/spotify.css index 30aa0274d0..f91eb62e02 100644 --- a/.storybook/themes/spotify.css +++ b/.storybook/themes/spotify.css @@ -190,10 +190,6 @@ .bui-Tag { border-radius: var(--bui-radius-full); } - - .bui-Container { - padding-inline: 0; - } } [data-theme-mode='light'][data-theme-name='spotify'] { @@ -250,17 +246,20 @@ */ [data-theme-name='spotify'] { .bui-PluginHeaderToolbar { - padding: 0; height: 32px; border: none; background: none; - margin-bottom: var(--bui-space-2); + margin-top: var(--bui-space-4); } .bui-PluginHeaderTabsWrapper { - padding: 0; border: none; background: none; - margin-left: -8px; + margin-left: 0; + margin-top: var(--bui-space-2); + } + + .bui-HeaderTop { + padding-top: var(--bui-space-4); } } diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index 1e549de36d..c90408ef23 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -1579,10 +1579,20 @@ export const HeaderDefinition: { readonly styles: { readonly [key: string]: string; }; + readonly bg: 'consumer'; readonly classNames: { - readonly root: 'bui-Header'; + readonly headerTop: 'bui-HeaderTop'; + readonly stickySentinel: 'bui-HeaderStickySentinel'; readonly content: 'bui-HeaderContent'; + readonly headerBottom: 'bui-HeaderBottom'; readonly breadcrumbs: 'bui-HeaderBreadcrumbs'; + readonly breadcrumbsSmall: 'bui-HeaderBreadcrumbsSmall'; + readonly breadcrumbLink: 'bui-HeaderBreadcrumbLink'; + readonly breadcrumbLinkSmall: 'bui-HeaderBreadcrumbLinkSmall'; + readonly breadcrumbSeparator: 'bui-HeaderBreadcrumbSeparator'; + readonly titleStack: 'bui-HeaderTitleStack'; + readonly title: 'bui-HeaderTitle'; + readonly titleSmall: 'bui-HeaderTitleSmall'; readonly tabsWrapper: 'bui-HeaderTabsWrapper'; readonly controls: 'bui-HeaderControls'; readonly tags: 'bui-HeaderTags'; @@ -1601,6 +1611,7 @@ export const HeaderDefinition: { readonly tags: {}; readonly metadata: {}; readonly className: {}; + readonly sticky: {}; }; }; @@ -1731,6 +1742,7 @@ export interface HeaderOwnProps { description?: string; // (undocumented) metadata?: HeaderMetadataItem[]; + sticky?: boolean; // (undocumented) tabs?: HeaderNavTabItem[]; // (undocumented) @@ -1750,10 +1762,20 @@ export const HeaderPageDefinition: { readonly styles: { readonly [key: string]: string; }; + readonly bg: 'consumer'; readonly classNames: { - readonly root: 'bui-Header'; + readonly headerTop: 'bui-HeaderTop'; + readonly stickySentinel: 'bui-HeaderStickySentinel'; readonly content: 'bui-HeaderContent'; + readonly headerBottom: 'bui-HeaderBottom'; readonly breadcrumbs: 'bui-HeaderBreadcrumbs'; + readonly breadcrumbsSmall: 'bui-HeaderBreadcrumbsSmall'; + readonly breadcrumbLink: 'bui-HeaderBreadcrumbLink'; + readonly breadcrumbLinkSmall: 'bui-HeaderBreadcrumbLinkSmall'; + readonly breadcrumbSeparator: 'bui-HeaderBreadcrumbSeparator'; + readonly titleStack: 'bui-HeaderTitleStack'; + readonly title: 'bui-HeaderTitle'; + readonly titleSmall: 'bui-HeaderTitleSmall'; readonly tabsWrapper: 'bui-HeaderTabsWrapper'; readonly controls: 'bui-HeaderControls'; readonly tags: 'bui-HeaderTags'; @@ -1772,6 +1794,7 @@ export const HeaderPageDefinition: { readonly tags: {}; readonly metadata: {}; readonly className: {}; + readonly sticky: {}; }; }; diff --git a/packages/ui/src/components/Container/Container.module.css b/packages/ui/src/components/Container/Container.module.css index 1ebf52ccf3..9381126ee4 100644 --- a/packages/ui/src/components/Container/Container.module.css +++ b/packages/ui/src/components/Container/Container.module.css @@ -18,6 +18,8 @@ @layer components { .bui-Container { + flex: 1; + width: 100%; max-width: 120rem; padding-inline: var(--bui-space-4); margin-inline: auto; diff --git a/packages/ui/src/components/Header/Header.module.css b/packages/ui/src/components/Header/Header.module.css index 2e3ca28d3b..58bcf2699d 100644 --- a/packages/ui/src/components/Header/Header.module.css +++ b/packages/ui/src/components/Header/Header.module.css @@ -17,17 +17,77 @@ @layer tokens, base, components, utilities; @layer components { - .bui-Header { + .bui-HeaderBottom { display: flex; flex-direction: column; gap: var(--bui-space-3); - margin-top: var(--bui-space-6); + margin-bottom: var(--bui-space-5); + } + + .bui-HeaderTop, + .bui-HeaderContent, + .bui-HeaderBottom { + width: 100%; + padding-inline: var(--bui-space-5); + box-sizing: border-box; + } + + .bui-HeaderTop { + padding-top: var(--bui-space-6); + } + + .bui-HeaderStickySentinel { + height: 1px; + margin-bottom: -1px; + pointer-events: none; } .bui-HeaderContent { display: flex; flex-direction: row; justify-content: space-between; + gap: var(--bui-space-3); + padding-top: var(--bui-space-3); + padding-bottom: var(--bui-space-3); + } + + .bui-HeaderContent[data-has-tags] { + padding-top: var(--bui-space-2); + } + + .bui-HeaderContent[data-sticky] { + position: sticky; + top: 0; + z-index: 10; + background-color: var(--bui-bg-app); + + &[data-on-bg='neutral-1'] { + background-color: var(--bui-bg-neutral-1); + } + + &[data-on-bg='neutral-2'] { + background-color: var(--bui-bg-neutral-2); + } + + &[data-on-bg='neutral-3'] { + background-color: var(--bui-bg-neutral-3); + } + } + + .bui-HeaderContent[data-sticky]::after { + position: absolute; + right: 0; + bottom: 0; + left: 0; + height: 1px; + content: ''; + background-color: var(--bui-border-1); + opacity: 0; + transition: opacity 200ms ease; + } + + .bui-HeaderContent[data-stuck]::after { + opacity: 1; } .bui-HeaderTabsWrapper { @@ -39,13 +99,79 @@ flex-direction: row; align-items: center; gap: var(--bui-space-2); + flex-shrink: 0; } - .bui-HeaderBreadcrumbs { + .bui-HeaderTitleStack { + position: relative; + flex: 1 1 auto; + height: 1lh; + min-width: 0; + font-size: var(--bui-font-size-6); + line-height: 140%; + overflow: hidden; + } + + .bui-HeaderBreadcrumbs, + .bui-HeaderBreadcrumbsSmall { + position: absolute; + inset-inline: 0; + top: 50%; display: flex; flex-direction: row; align-items: center; gap: var(--bui-space-2); + min-width: 0; + transform: translateY(-50%); + } + + .bui-HeaderBreadcrumbsSmall { + gap: var(--bui-space-1); + } + + .bui-HeaderBreadcrumbs .bui-HeaderBreadcrumbLink, + .bui-HeaderBreadcrumbsSmall .bui-HeaderBreadcrumbLinkSmall { + max-width: 240px; + overflow: hidden; + font-family: var(--bui-font-regular); + font-weight: var(--bui-font-weight-bold); + line-height: inherit; + text-overflow: ellipsis; + white-space: nowrap; + } + + .bui-HeaderBreadcrumbs .bui-HeaderBreadcrumbLink { + font-size: inherit; + } + + .bui-HeaderBreadcrumbsSmall .bui-HeaderBreadcrumbLinkSmall { + font-size: var(--bui-font-size-4); + } + + .bui-HeaderBreadcrumbSeparator { + flex-shrink: 0; + } + + .bui-HeaderTitle, + .bui-HeaderTitleSmall { + margin: 0; + padding: 0; + flex: 1 1 auto; + min-width: 0; + font-family: var(--bui-font-regular); + font-weight: var(--bui-font-weight-bold); + line-height: inherit; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .bui-HeaderTitle { + font-size: inherit; + } + + .bui-HeaderTitleSmall { + font-size: var(--bui-font-size-4); } .bui-HeaderTags { diff --git a/packages/ui/src/components/Header/Header.stories.tsx b/packages/ui/src/components/Header/Header.stories.tsx index 59e5cf254d..9cc715e471 100644 --- a/packages/ui/src/components/Header/Header.stories.tsx +++ b/packages/ui/src/components/Header/Header.stories.tsx @@ -24,6 +24,7 @@ import { MemoryRouter } from 'react-router-dom'; import { BUIProvider } from '../../provider'; import { Button, ButtonIcon, MenuTrigger, Menu, MenuItem } from '../../'; import { RiMore2Line } from '@remixicon/react'; +import { Container } from '../Container'; const meta = preview.meta({ title: 'Backstage UI/Header', @@ -393,3 +394,127 @@ export const WithExplicitActiveTab = meta.story({ activeTabId: 'campaigns', }, }); + +export const NonSticky = meta.story({ + decorators: [withRouter], + render: () => ( + <> +
Custom action} + /> + + {Array.from({ length: 60 }, (_, i) => ( +

+ Scroll down to see the entire header scroll away with the rest of + the page content. Line {i + 1}. +

+ ))} +
+ + ), +}); + +export const Sticky = meta.story({ + decorators: [withRouter], + render: () => ( + <> +
Custom action} + /> + + {Array.from({ length: 60 }, (_, i) => ( +

+ Scroll down to see the title bar stick to the top while the tags, + description, and metadata scroll away. Line {i + 1}. +

+ ))} +
+ + ), +}); + +export const StickyWithLongTitle = meta.story({ + decorators: [withRouter], + render: () => ( + <> +
Custom action} + /> + + {Array.from({ length: 60 }, (_, i) => ( +

+ Scroll down to see the long title bar stick to the top while the + tags, description, and metadata scroll away. Line {i + 1}. +

+ ))} +
+ + ), +}); + +export const StickyWithBreadcrumbsAndLongTitle = meta.story({ + decorators: [withRouter], + render: () => ( + <> +
Custom action} + /> + + {Array.from({ length: 60 }, (_, i) => ( +

+ Scroll down to see the breadcrumb and long title bar stick to the + top while the tags, description, and metadata scroll away. Line{' '} + {i + 1}. +

+ ))} +
+ + ), +}); diff --git a/packages/ui/src/components/Header/Header.tsx b/packages/ui/src/components/Header/Header.tsx index 3f17e35732..cb83dead13 100644 --- a/packages/ui/src/components/Header/Header.tsx +++ b/packages/ui/src/components/Header/Header.tsx @@ -21,10 +21,37 @@ import { HeaderNav } from './HeaderNav'; import { useDefinition } from '../../hooks/useDefinition'; import { HeaderDefinition } from './definition'; import { sanitizeUrl } from '@braintree/sanitize-url'; -import { Container } from '../Container'; import { Lexer } from 'marked'; import { Link } from '../Link'; -import { Fragment, useMemo } from 'react'; +import { Fragment, useEffect, useMemo, useRef, useState } from 'react'; + +const getScrollParent = (element: HTMLElement | null): Element | null => { + let parent = element?.parentElement; + + while (parent) { + const { overflow, overflowX, overflowY } = window.getComputedStyle(parent); + + if ( + /(auto|scroll|overlay|hidden)/.test(`${overflow}${overflowX}${overflowY}`) + ) { + return parent; + } + + parent = parent.parentElement; + } + + return null; +}; + +const isStickySentinelOutOfView = ( + sentinel: HTMLElement, + root: Element | null, +) => { + const sentinelRect = sentinel.getBoundingClientRect(); + const rootTop = root ? root.getBoundingClientRect().top : 0; + + return sentinelRect.bottom <= rootTop; +}; /** * Parses inline Markdown links in a string and returns an array of React nodes. @@ -52,7 +79,9 @@ function renderInlineMarkdown(text: string): React.ReactNode[] { * @public */ export const Header = (props: HeaderProps) => { - const { ownProps } = useDefinition(HeaderDefinition, props); + const { ownProps, dataAttributes } = useDefinition(HeaderDefinition, props, { + classNameTarget: 'content', + }); const { classes, title, @@ -63,6 +92,7 @@ export const Header = (props: HeaderProps) => { description, tags, metadata, + sticky, } = ownProps; const descriptionNodes = useMemo( @@ -70,93 +100,186 @@ export const Header = (props: HeaderProps) => { [description], ); + // The sentinel sits directly before the sticky content and leaves the + // viewport when the content becomes stuck, letting us toggle stuck styling. + const stickySentinelRef = useRef(null); + const [isStuck, setIsStuck] = useState(false); + + useEffect(() => { + if (!sticky) { + setIsStuck(false); + return; + } + + const sentinel = stickySentinelRef.current; + if (!sentinel) { + return; + } + + const root = getScrollParent(sentinel); + + if (typeof IntersectionObserver === 'undefined') { + const updateStuckState = () => { + setIsStuck(isStickySentinelOutOfView(sentinel, root)); + }; + const scrollTarget = root ?? window; + + updateStuckState(); + scrollTarget.addEventListener('scroll', updateStuckState, { + passive: true, + }); + window.addEventListener('resize', updateStuckState); + + return () => { + scrollTarget.removeEventListener('scroll', updateStuckState); + window.removeEventListener('resize', updateStuckState); + }; + } + + const observer = new IntersectionObserver( + ([entry]) => { + setIsStuck(!entry.isIntersecting); + }, + { root, threshold: 0 }, + ); + + observer.observe(sentinel); + + return () => { + observer.disconnect(); + }; + }, [sticky]); + return ( - - {tags && tags.length > 0 && ( -
    - {tags.map((tag, i) => ( -
  • - {tag.href ? ( - - {tag.label} - - ) : ( - - {tag.label} - - )} -
  • - ))} -
- )} -
-
- {breadcrumbs && - breadcrumbs.map(breadcrumb => ( - - - {breadcrumb.label} - - - + <> +
+ {tags && tags.length > 0 && ( +
    + {tags.map((tag, i) => ( +
  • + {tag.href ? ( + + {tag.label} + + ) : ( + + {tag.label} + + )} +
  • ))} - - {title} - +
+ )} +
+ {sticky && ( +