diff --git a/.changeset/afraid-rats-invent.md b/.changeset/afraid-rats-invent.md new file mode 100644 index 0000000000..0caa57d81c --- /dev/null +++ b/.changeset/afraid-rats-invent.md @@ -0,0 +1,14 @@ +--- +'@backstage/ui': patch +--- + +Added a new `FullPage` component that fills the remaining viewport height below the `Header`. + +```tsx +
+ + {/* content fills remaining height */} + +``` + +**Affected components:** FullPage diff --git a/.github/vale/config/vocabularies/Backstage/accept.txt b/.github/vale/config/vocabularies/Backstage/accept.txt index 68c0fb0d52..779cc00cf7 100644 --- a/.github/vale/config/vocabularies/Backstage/accept.txt +++ b/.github/vale/config/vocabularies/Backstage/accept.txt @@ -547,6 +547,7 @@ validator validators Valkey varchar +viewport vite VMware Vodafone diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index e67522fd87..dff52c85d6 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'; @@ -57,8 +57,6 @@ export default definePreview({ }, parameters: { - layout: 'fullscreen', - backgrounds: { disable: true, }, @@ -146,9 +144,7 @@ export default definePreview({ {/* @ts-ignore */} - - - + ); diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index 4d09023b69..b25b3c9b27 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -979,6 +979,21 @@ export interface FlexProps extends SpaceProps { // @public (undocumented) export type FlexWrap = 'wrap' | 'nowrap' | 'wrap-reverse'; +// @public +export const FullPage: ForwardRefExoticComponent< + FullPageProps & RefAttributes +>; + +// @public +export const FullPageDefinition: { + readonly classNames: { + readonly root: 'bui-FullPage'; + }; +}; + +// @public +export interface FullPageProps extends React.ComponentPropsWithoutRef<'main'> {} + // @public (undocumented) export const Grid: { Root: ForwardRefExoticComponent>; @@ -1082,6 +1097,7 @@ export const Header: (props: HeaderProps) => JSX_2.Element; // @public export const HeaderDefinition: { readonly classNames: { + readonly root: 'bui-Header'; readonly toolbar: 'bui-HeaderToolbar'; readonly toolbarWrapper: 'bui-HeaderToolbarWrapper'; readonly toolbarContent: 'bui-HeaderToolbarContent'; 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..4cd94d30ea 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 { @@ -90,7 +88,6 @@ } .bui-HeaderTabsWrapper { - margin-bottom: var(--bui-space-4); padding-inline: var(--bui-space-3); border-bottom: 1px solid var(--bui-border); background-color: var(--bui-bg-neutral-1); diff --git a/packages/ui/src/components/Header/Header.tsx b/packages/ui/src/components/Header/Header.tsx index 56ec200e29..09f29b95c5 100644 --- a/packages/ui/src/components/Header/Header.tsx +++ b/packages/ui/src/components/Header/Header.tsx @@ -20,6 +20,8 @@ import { Tabs, TabList, Tab } from '../Tabs'; import { useStyles } from '../../hooks/useStyles'; import { HeaderDefinition } from './definition'; import { type NavigateOptions } from 'react-router-dom'; +import { useRef } from 'react'; +import { useIsomorphicLayoutEffect } from '../../hooks/useIsomorphicLayoutEffect'; import styles from './Header.module.css'; import clsx from 'clsx'; @@ -47,9 +49,45 @@ export const Header = (props: HeaderProps) => { } = cleanedProps; const hasTabs = tabs && tabs.length > 0; + const headerRef = useRef(null); + + useIsomorphicLayoutEffect(() => { + const el = headerRef.current; + if (!el) return undefined; + + const updateHeight = () => { + const height = el.offsetHeight; + document.documentElement.style.setProperty( + '--bui-header-height', + `${height}px`, + ); + }; + + // Set height once immediately + updateHeight(); + + // Observe for resize changes if ResizeObserver is available + // (not present in Jest/jsdom by default) + if (typeof ResizeObserver === 'undefined') { + return () => { + document.documentElement.style.removeProperty('--bui-header-height'); + }; + } + + const observer = new ResizeObserver(updateHeight); + observer.observe(el); + + return () => { + observer.disconnect(); + document.documentElement.style.removeProperty('--bui-header-height'); + }; + }, []); return ( - <> +
{ className={clsx( classNames.tabsWrapper, styles[classNames.tabsWrapper], - className, )} > @@ -81,6 +118,6 @@ export const Header = (props: HeaderProps) => { )} - +
); }; 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';