diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index e67522fd87..9f04a9b4a8 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -5,7 +5,7 @@ import addonLinks from '@storybook/addon-links'; import { definePreview } from '@storybook/react-vite'; import { useEffect } from 'react'; import { TestApiProvider } from '@backstage/test-utils'; -import { Content, AlertDisplay } from '@backstage/core-components'; +import { AlertDisplay } from '@backstage/core-components'; import { apis } from './support/apis'; import { useGlobals } from 'storybook/preview-api'; import { UnifiedThemeProvider, themes } from '@backstage/theme'; @@ -146,9 +146,7 @@ export default definePreview({ {/* @ts-ignore */} - - - + ); diff --git a/packages/ui/src/components/FullPage/FullPage.module.css b/packages/ui/src/components/FullPage/FullPage.module.css new file mode 100644 index 0000000000..7451d17cec --- /dev/null +++ b/packages/ui/src/components/FullPage/FullPage.module.css @@ -0,0 +1,24 @@ +/* + * 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. + */ + +@layer tokens, base, components, utilities; + +@layer components { + .bui-FullPage { + height: calc(100dvh - var(--bui-header-height, 0px)); + overflow-y: auto; + } +} diff --git a/packages/ui/src/components/FullPage/FullPage.stories.tsx b/packages/ui/src/components/FullPage/FullPage.stories.tsx new file mode 100644 index 0000000000..8ac1b731e9 --- /dev/null +++ b/packages/ui/src/components/FullPage/FullPage.stories.tsx @@ -0,0 +1,110 @@ +/* + * 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 preview from '../../../../../.storybook/preview'; +import type { StoryFn } from '@storybook/react-vite'; +import { FullPage } from './FullPage'; +import { Header } from '../Header'; +import { Container } from '../Container'; +import { Text } from '../Text'; +import type { HeaderTab } from '../Header/types'; +import { MemoryRouter } from 'react-router-dom'; + +const meta = preview.meta({ + title: 'Backstage UI/FullPage', + component: FullPage, + parameters: { + layout: 'fullscreen', + }, +}); + +const withRouter = (Story: StoryFn) => ( + + + +); + +const tabs: HeaderTab[] = [ + { id: 'overview', label: 'Overview', href: '/overview' }, + { id: 'checks', label: 'Checks', href: '/checks' }, + { id: 'tracks', label: 'Tracks', href: '/tracks' }, + { id: 'campaigns', label: 'Campaigns', href: '/campaigns' }, +]; + +const paragraphs = Array.from({ length: 20 }, (_, i) => ( + + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Pellentesque habitant morbi tristique senectus et netus et malesuada fames + ac turpis egestas. Sed do eiusmod tempor incididunt ut labore et dolore + magna aliqua. + +)); + +export const Default = meta.story({ + decorators: [withRouter], + render: () => ( + <> +
+ + + + This content fills the remaining viewport height below the Header. + + + + + ), +}); + +export const WithScrollableContent = meta.story({ + decorators: [withRouter], + render: () => ( + <> +
+ + + + Scrollable Content + + + The content below scrolls independently while the Header stays + pinned at the top. + + {paragraphs} + + + + ), +}); + +export const WithTabs = meta.story({ + decorators: [withRouter], + render: () => ( + <> +
+ + + + The FullPage height adjusts automatically when the Header includes + tabs, thanks to the ResizeObserver measuring the Header's actual + height. + + {paragraphs} + + + + ), +}); diff --git a/packages/ui/src/components/FullPage/FullPage.tsx b/packages/ui/src/components/FullPage/FullPage.tsx new file mode 100644 index 0000000000..dfe3a87948 --- /dev/null +++ b/packages/ui/src/components/FullPage/FullPage.tsx @@ -0,0 +1,45 @@ +/* + * 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 { forwardRef } from 'react'; +import type { FullPageProps } from './types'; +import { useStyles } from '../../hooks/useStyles'; +import { FullPageDefinition } from './definition'; +import styles from './FullPage.module.css'; +import clsx from 'clsx'; + +/** + * A component that fills the remaining viewport height below the Header. + * + * The FullPage component consumes the `--bui-header-height` CSS custom property + * set by the Header component to calculate its height as + * `calc(100dvh - var(--bui-header-height, 0px))`. Content inside the FullPage + * scrolls independently while the Header stays visible. + * + * @public + */ +export const FullPage = forwardRef((props, ref) => { + const { classNames, cleanedProps } = useStyles(FullPageDefinition, props); + const { className, ...rest } = cleanedProps; + + return ( +
+ ); +}); diff --git a/packages/ui/src/components/FullPage/definition.ts b/packages/ui/src/components/FullPage/definition.ts new file mode 100644 index 0000000000..db978dd7cc --- /dev/null +++ b/packages/ui/src/components/FullPage/definition.ts @@ -0,0 +1,27 @@ +/* + * 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 { ComponentDefinition } from '../../types'; + +/** + * Component definition for FullPage + * @public + */ +export const FullPageDefinition = { + classNames: { + root: 'bui-FullPage', + }, +} as const satisfies ComponentDefinition; diff --git a/packages/ui/src/components/FullPage/index.tsx b/packages/ui/src/components/FullPage/index.tsx new file mode 100644 index 0000000000..0201803126 --- /dev/null +++ b/packages/ui/src/components/FullPage/index.tsx @@ -0,0 +1,19 @@ +/* + * 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. + */ + +export { FullPage } from './FullPage'; +export { FullPageDefinition } from './definition'; +export type { FullPageProps } from './types'; diff --git a/packages/ui/src/components/FullPage/types.ts b/packages/ui/src/components/FullPage/types.ts new file mode 100644 index 0000000000..fde8a12d93 --- /dev/null +++ b/packages/ui/src/components/FullPage/types.ts @@ -0,0 +1,22 @@ +/* + * 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. + */ + +/** + * Props for the FullPage component. + * + * @public + */ +export interface FullPageProps extends React.ComponentPropsWithoutRef<'main'> {} diff --git a/packages/ui/src/components/Header/Header.module.css b/packages/ui/src/components/Header/Header.module.css index cb51d60535..ba014cfd48 100644 --- a/packages/ui/src/components/Header/Header.module.css +++ b/packages/ui/src/components/Header/Header.module.css @@ -17,9 +17,11 @@ @layer tokens, base, components, utilities; @layer components { - .bui-HeaderToolbar { - margin-bottom: var(--bui-space-6); + .bui-Header { + display: block; + } + .bui-HeaderToolbar { &::before { content: ''; position: absolute; @@ -30,10 +32,6 @@ background-color: var(--bui-bg-neutral-0); z-index: 0; } - - &[data-has-tabs='true'] { - margin-bottom: 0; - } } .bui-HeaderToolbarWrapper { diff --git a/packages/ui/src/components/Header/Header.tsx b/packages/ui/src/components/Header/Header.tsx index 56ec200e29..080ec13b6a 100644 --- a/packages/ui/src/components/Header/Header.tsx +++ b/packages/ui/src/components/Header/Header.tsx @@ -20,6 +20,7 @@ import { Tabs, TabList, Tab } from '../Tabs'; import { useStyles } from '../../hooks/useStyles'; import { HeaderDefinition } from './definition'; import { type NavigateOptions } from 'react-router-dom'; +import { useRef, useEffect } from 'react'; import styles from './Header.module.css'; import clsx from 'clsx'; @@ -47,9 +48,35 @@ export const Header = (props: HeaderProps) => { } = cleanedProps; const hasTabs = tabs && tabs.length > 0; + const headerRef = useRef(null); + + useEffect(() => { + const el = headerRef.current; + if (!el) return undefined; + + const updateHeight = () => { + const height = el.offsetHeight; + document.documentElement.style.setProperty( + '--bui-header-height', + `${height}px`, + ); + }; + + const observer = new ResizeObserver(updateHeight); + observer.observe(el); + updateHeight(); + + return () => { + observer.disconnect(); + document.documentElement.style.removeProperty('--bui-header-height'); + }; + }, []); return ( - <> +
{ )} - +
); }; diff --git a/packages/ui/src/components/Header/definition.ts b/packages/ui/src/components/Header/definition.ts index 5969d7d8fa..6937bc5347 100644 --- a/packages/ui/src/components/Header/definition.ts +++ b/packages/ui/src/components/Header/definition.ts @@ -22,6 +22,7 @@ import type { ComponentDefinition } from '../../types'; */ export const HeaderDefinition = { classNames: { + root: 'bui-Header', toolbar: 'bui-HeaderToolbar', toolbarWrapper: 'bui-HeaderToolbarWrapper', toolbarContent: 'bui-HeaderToolbarContent', diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index c9f2a3763c..40f37aba41 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -25,6 +25,7 @@ export * from './components/Box'; export * from './components/Grid'; export * from './components/Flex'; export * from './components/Container'; +export * from './components/FullPage'; // UI components export * from './components/Accordion';