diff --git a/workspaces/homepage/.changeset/red-insects-teach.md b/workspaces/homepage/.changeset/red-insects-teach.md new file mode 100644 index 0000000000..db67d654ce --- /dev/null +++ b/workspaces/homepage/.changeset/red-insects-teach.md @@ -0,0 +1,9 @@ +--- +'@red-hat-developer-hub/backstage-plugin-dynamic-home-page': patch +--- + +Enhance home page layout adaptability when QuickStart is displayed. + +Updated the homepage layout logic to utilize container width monitoring rather than relying on viewport-based Grid breakpoints. This change ensures the illustration card seamlessly switches to a vertical stack when the QuickStart drawer is open, independent of the user's screen resolution. + +Additionally, introduced scrollbars to the onboarding, software catalog, and template sections for improved navigation and usability. diff --git a/workspaces/homepage/plugins/dynamic-home-page/src/components/EntitySection/EntityCard.tsx b/workspaces/homepage/plugins/dynamic-home-page/src/components/EntitySection/EntityCard.tsx index 4c5d045b1e..dea0ecb863 100644 --- a/workspaces/homepage/plugins/dynamic-home-page/src/components/EntitySection/EntityCard.tsx +++ b/workspaces/homepage/plugins/dynamic-home-page/src/components/EntitySection/EntityCard.tsx @@ -46,13 +46,18 @@ const EntityCard: FC = ({ `1px solid ${theme.palette.grey[400]}`, overflow: 'auto', - maxHeight: '100%', + display: 'flex', + flexDirection: 'column', }} > ; @@ -72,6 +80,10 @@ jest.mock('../../utils/utils', () => ({ jest.mock('../../images/homepage-entities-1.svg', () => 'mock-image.svg'); +jest.mock('../../hooks/useContainerQuery', () => ({ + useContainerQuery: () => 'lg', +})); + const theme = createTheme(); const renderComponent = () => diff --git a/workspaces/homepage/plugins/dynamic-home-page/src/components/EntitySection/EntitySection.tsx b/workspaces/homepage/plugins/dynamic-home-page/src/components/EntitySection/EntitySection.tsx index c425b3ab50..9c8b2214b9 100644 --- a/workspaces/homepage/plugins/dynamic-home-page/src/components/EntitySection/EntitySection.tsx +++ b/workspaces/homepage/plugins/dynamic-home-page/src/components/EntitySection/EntitySection.tsx @@ -15,7 +15,7 @@ */ import type { ReactNode } from 'react'; -import { useState, useEffect, Fragment } from 'react'; +import { useState, useEffect, Fragment, useRef } from 'react'; import { CodeSnippet, @@ -34,7 +34,6 @@ import CloseIcon from '@mui/icons-material/Close'; import CircularProgress from '@mui/material/CircularProgress'; import CardContent from '@mui/material/CardContent'; import { useTheme, styled } from '@mui/material/styles'; -import useMediaQuery from '@mui/material/useMediaQuery'; import EntityCard from './EntityCard'; import { ViewMoreLink } from './ViewMoreLink'; @@ -46,6 +45,8 @@ import { } from '../../utils/utils'; import { useTranslation } from '../../hooks/useTranslation'; import { Trans } from '../Trans'; +import { containerGridItemSx } from '../../utils/GridItem'; +import { useContainerQuery } from '../../hooks/useContainerQuery'; const StyledLink = styled(BackstageLink)(({ theme }) => ({ textDecoration: 'none', @@ -63,17 +64,19 @@ export const EntitySection = () => { const [isRemoveFirstCard, setIsRemoveFirstCard] = useState(false); const [showDiscoveryCard, setShowDiscoveryCard] = useState(true); const [imgLoaded, setImgLoaded] = useState(false); - const [isMediumBreakpoint, setIsMediumBreakpoint] = useState(false); - const isMd = useMediaQuery(theme.breakpoints.only('md')); + const containerRef = useRef(null); + const containerSize = useContainerQuery(containerRef); - useEffect(() => { - if (isMd) { - setIsMediumBreakpoint(true); - } else { - setIsMediumBreakpoint(false); - } - }, [isMd]); + const entityCardCount = + containerSize === 'xs' || containerSize === 'sm' ? 2 : 4; + + const getIllustrationWidth = () => { + if (containerSize === 'md') return 180; + if (containerSize === 'lg') return 220; + return 266; + }; + const illustrationWidth = getIllustrationWidth(); useEffect(() => { const isUserDismissedEntityIllustration = @@ -120,82 +123,109 @@ export const EntitySection = () => { ); } else { - let entityCardCount = 2; - if (isMediumBreakpoint) entityCardCount = 3; - content = ( - {!isRemoveFirstCard && !profileLoading && ( - - - {!imgLoaded && ( - - )} - setImgLoaded(true)} - alt="" - height={300} + {/* hiding discovery card on small containers */} + {!isRemoveFirstCard && + !profileLoading && + containerSize !== 'xs' && + containerSize !== 'sm' && ( + + - - - - {t('entities.description')} - - - {entities?.length > 0 && ( - + + {!imgLoaded && ( + + )} + setImgLoaded(true)} + alt="" + height={300} + sx={{ + width: illustrationWidth, }} - > - - - )} - - - - )} + /> + + + + {t('entities.description')} + + + {entities?.length > 0 && ( + + + + )} + + + + + )} {entities - ?.slice(0, isRemoveFirstCard ? 4 : entityCardCount) + ?.slice( + 0, + (() => { + const isWide = + containerSize === 'xl' || + containerSize === 'lg' || + containerSize === 'md'; + if (!isWide) return entityCardCount; + return isRemoveFirstCard + ? entityCardCount + : entityCardCount - 2; + })(), + ) + .map((item: any) => ( { ))} {entities?.length === 0 && ( - + `1px solid ${muiTheme.palette.grey[400]}`, borderRadius: 3, @@ -255,7 +293,9 @@ export const EntitySection = () => { sx={{ padding: '24px', border: muitheme => `1px solid ${muitheme.palette.grey[300]}`, - overflow: 'auto', + containerType: 'inline-size', + display: 'flex', + flexDirection: 'column', }} > { alignItems: 'center', fontWeight: '500', fontSize: '1.5rem', + flexShrink: 0, }} > {t('entities.title')} - {content} - {entities?.length > 0 && ( - - - - - - )} + + {content} + {entities?.length > 0 && ( + + + + + + )} + ); }; diff --git a/workspaces/homepage/plugins/dynamic-home-page/src/components/OnboardingSection/OnboardingCard.tsx b/workspaces/homepage/plugins/dynamic-home-page/src/components/OnboardingSection/OnboardingCard.tsx index 57c3b7a4d4..581b09a335 100644 --- a/workspaces/homepage/plugins/dynamic-home-page/src/components/OnboardingSection/OnboardingCard.tsx +++ b/workspaces/homepage/plugins/dynamic-home-page/src/components/OnboardingSection/OnboardingCard.tsx @@ -44,6 +44,7 @@ const OnboardingCard: FC = ({ = ({ target={target} aria-label={ariaLabel} sx={{ + width: 220, + minWidth: 220, padding: theme => theme.spacing(1, 1.5), fontSize: '16px', '& .v5-MuiButton-endIcon': { diff --git a/workspaces/homepage/plugins/dynamic-home-page/src/components/OnboardingSection/OnboardingSection.tsx b/workspaces/homepage/plugins/dynamic-home-page/src/components/OnboardingSection/OnboardingSection.tsx index 9a5c264e80..3501149339 100644 --- a/workspaces/homepage/plugins/dynamic-home-page/src/components/OnboardingSection/OnboardingSection.tsx +++ b/workspaces/homepage/plugins/dynamic-home-page/src/components/OnboardingSection/OnboardingSection.tsx @@ -33,6 +33,7 @@ import { getLearningItems } from '../../utils/constants'; import useGreeting from '../../hooks/useGreeting'; import { LearningSectionItem } from '../../types'; import { useTranslation } from '../../hooks/useTranslation'; +import { containerGridItemSx } from '../../utils/GridItem'; export const OnboardingSection = () => { const [user, setUser] = useState(); @@ -80,16 +81,16 @@ export const OnboardingSection = () => { }; const content = ( - - + + { alt="" sx={{ width: 'clamp(200px, 20vw, 264px)', + height: 'auto', }} /> {getLearningItems(t).map((item: LearningSectionItem) => ( { sx={{ padding: '24px', border: muiTheme => `1px solid ${muiTheme.palette.grey[300]}`, - overflow: 'auto', + containerType: 'inline-size', + display: 'flex', + flexDirection: 'column', }} > {!profileLoading && ( @@ -145,12 +149,22 @@ export const OnboardingSection = () => { alignItems: 'center', fontWeight: '500', fontSize: '1.5rem', + flexShrink: 0, }} > {`${greeting}, ${profileDisplayName() || t('onboarding.guest')}!`} )} - {content} + + {content} + ); }; diff --git a/workspaces/homepage/plugins/dynamic-home-page/src/components/TemplateSection/TemplateSection.tsx b/workspaces/homepage/plugins/dynamic-home-page/src/components/TemplateSection/TemplateSection.tsx index aae0b7cfc8..433ad9f401 100644 --- a/workspaces/homepage/plugins/dynamic-home-page/src/components/TemplateSection/TemplateSection.tsx +++ b/workspaces/homepage/plugins/dynamic-home-page/src/components/TemplateSection/TemplateSection.tsx @@ -36,6 +36,7 @@ import { useEntities } from '../../hooks/useEntities'; import { ViewMoreLink } from './ViewMoreLink'; import { useTranslation } from '../../hooks/useTranslation'; import { Trans } from '../Trans'; +import { containerGridItemSx } from '../../utils/GridItem'; const StyledLink = styled(BackstageLink)(({ theme }) => ({ textDecoration: 'none', @@ -90,7 +91,15 @@ export const TemplateSection = () => { {templates?.items.map((item: any) => ( - + { ))} {templates?.items.length === 0 && ( - + `1px solid ${muiTheme.palette.grey[400]}`, borderRadius: 3, @@ -150,7 +169,9 @@ export const TemplateSection = () => { sx={{ padding: '24px', border: muiTheme => `1px solid ${muiTheme.palette.grey[300]}`, - overflow: 'auto', + containerType: 'inline-size', + display: 'flex', + flexDirection: 'column', }} > { alignItems: 'center', fontWeight: '500', fontSize: '1.5rem', + flexShrink: 0, }} > {t('templates.title')} - {content} - {templates?.items && templates?.items.length > 0 && ( - - - - - - )} + + {content} + {templates?.items && templates?.items.length > 0 && ( + + + + + + )} + ); }; diff --git a/workspaces/homepage/plugins/dynamic-home-page/src/hooks/useContainerQuery.ts b/workspaces/homepage/plugins/dynamic-home-page/src/hooks/useContainerQuery.ts new file mode 100644 index 0000000000..de26b70e07 --- /dev/null +++ b/workspaces/homepage/plugins/dynamic-home-page/src/hooks/useContainerQuery.ts @@ -0,0 +1,83 @@ +/* + * Copyright Red Hat, Inc. + * + * 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. + */ + +/* + * A React hook that observes the width of a DOM element and + * returns a responsive "container size" (`xs | sm | md | lg | xl`) + * based on predefined breakpoints. + * + * Unlike window-based media queries, this hook is **container-based**: + * the returned size depends on the element's own width, not the viewport. + * + * Internally, it: + * - Reads the element's initial layout width on mount + * - Uses ResizeObserver to track width changes + * - Maps the width to MUI-like breakpoints + * - Updates state only when the breakpoint actually changes + * */ + +import { useLayoutEffect, useState, RefObject } from 'react'; + +// Container Breakpoints (MUI-like, but container-based) +export type containerSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'; + +export const CONTAINER_BREAKPOINTS = { + xs: 0, // <600 + sm: 600, // ≥600 + md: 900, // ≥900 + lg: 1200, // ≥1200 + xl: 1536, // ≥1536 +} as const; + +// Width → containerSize mapper +const resolveContainerSize = (width: number): containerSize => { + if (width >= CONTAINER_BREAKPOINTS.xl) return 'xl'; + if (width >= CONTAINER_BREAKPOINTS.lg) return 'lg'; + if (width >= CONTAINER_BREAKPOINTS.md) return 'md'; + if (width >= CONTAINER_BREAKPOINTS.sm) return 'sm'; + return 'xs'; +}; + +// Hook: useContainerQuery + +export const useContainerQuery = ( + ref: RefObject, +): containerSize => { + const [containerSize, setContainerSize] = useState('lg'); + + useLayoutEffect(() => { + const el = ref.current; + if (!el) return undefined; + + const updateSize = (width: number) => { + const next = resolveContainerSize(width); + setContainerSize(prev => (prev === next ? prev : next)); + }; + + // Initial read + updateSize(el.getBoundingClientRect().width); + + const observer = new ResizeObserver(entries => { + if (!entries.length) return; + updateSize(entries[0].contentRect.width); + }); + + observer.observe(el); + return () => observer.disconnect(); + }, [ref]); + + return containerSize; +}; diff --git a/workspaces/homepage/plugins/dynamic-home-page/src/utils/GridItem.ts b/workspaces/homepage/plugins/dynamic-home-page/src/utils/GridItem.ts new file mode 100644 index 0000000000..1ad45fb109 --- /dev/null +++ b/workspaces/homepage/plugins/dynamic-home-page/src/utils/GridItem.ts @@ -0,0 +1,90 @@ +/* + * Copyright Red Hat, Inc. + * + * 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. + */ + +/* +* Generates an MUI `sx` object for a Grid **item** that supports +* container-based responsive columns (`xs | sm | md | lg | xl`). +* +* This utility mimics MUI Grid's breakpoint inheritance behavior, +* but uses **CSS Container Queries** instead of viewport media queries. +* +* How it works: +* - Accepts column counts (1–12) per breakpoint +* - Converts columns into percentage widths +* - Falls back to the nearest smaller breakpoint if a value is missing +* - Applies widths using `@container (min-width: ...)` rules +* +* Breakpoint mapping: +* - xs → base width +* - sm → ≥600px +* - md → ≥900px +* - lg → ≥1200px +* - xl → ≥1536px +* +* Example: +* containerGridItemSx({ xs: 12, sm: 6, md: 4 }) + +*/ + +// Breakpoint props (xs / sm / md / lg / xl) +export type ContainerGridCols = { + xs?: number; + sm?: number; + md?: number; + lg?: number; + xl?: number; +}; + +// Convert column count to percentage width +const col = (n?: number): string | undefined => { + if (!n) return undefined; + return `${(n / 12) * 100}%`; +}; + +// Resolve breakpoint value with fallback (MUI-style inheritance) +const resolveCol = ( + current?: number, + fallback?: number, +): string | undefined => { + return col(current ?? fallback); +}; + +// SX generator for MUI Grid Item using container queries +export const containerGridItemSx = ({ + xs = 12, + sm, + md, + lg, + xl, +}: ContainerGridCols) => ({ + width: col(xs), + + '@container (min-width: 600px)': { + width: resolveCol(sm, xs), + }, + + '@container (min-width: 900px)': { + width: resolveCol(md, sm ?? xs), + }, + + '@container (min-width: 1200px)': { + width: resolveCol(lg, md ?? sm ?? xs), + }, + + '@container (min-width: 1536px)': { + width: resolveCol(xl, lg ?? md ?? sm ?? xs), + }, +});