From f9974de6fdf62fbc304f5acad1d772620b2900d2 Mon Sep 17 00:00:00 2001 From: rohitratannagar Date: Wed, 18 Feb 2026 12:08:42 +0530 Subject: [PATCH 1/4] fixed homepage layout when quick-access is on/visible Signed-off-by: rohitratannagar --- .../components/EntitySection/EntityCard.tsx | 7 +- .../EntitySection/EntitySection.tsx | 155 ++++++++++++------ .../OnboardingSection/OnboardingCard.tsx | 3 + .../OnboardingSection/OnboardingSection.tsx | 62 +++++-- .../TemplateSection/TemplateSection.tsx | 73 +++++++-- 5 files changed, 218 insertions(+), 82 deletions(-) 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', }} > { const isMd = useMediaQuery(theme.breakpoints.only('md')); + const responsiveGridItem = { + width: '100%', + + '@container (min-width: 600px)': { + width: '50%', + }, + + '@container (min-width: 900px)': { + width: '33.3%', + }, + + '@container (min-width: 1200px)': { + width: '25%', + }, + }; + useEffect(() => { if (isMd) { setIsMediumBreakpoint(true); @@ -128,10 +144,17 @@ export const EntitySection = () => { {!isRemoveFirstCard && !profileLoading && ( - + { : 'translateX(-50px)', }} > - {!imgLoaded && ( - + {!imgLoaded && ( + + )} + setImgLoaded(true)} + alt="" height={300} sx={{ - borderRadius: 3, width: 'clamp(140px, 14vw, 266px)', }} /> - )} - setImgLoaded(true)} - alt="" - height={300} - sx={{ - width: 'clamp(140px, 14vw, 266px)', - }} - /> - - - - {t('entities.description')} - + + + + {t('entities.description')} + + + {entities?.length > 0 && ( + + + + )} - {entities?.length > 0 && ( - - - - )} @@ -193,9 +226,9 @@ export const EntitySection = () => { .map((item: any) => ( { ))} {entities?.length === 0 && ( - + `1px solid ${muiTheme.palette.grey[400]}`, borderRadius: 3, @@ -255,7 +296,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..628df1b7a6 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 @@ -79,17 +79,34 @@ export const OnboardingSection = () => { return name; }; + // Logic to handle column widths based on the PARENT CARD size + const responsiveGridItem = { + width: '100%', + + '@container (min-width: 550px)': { + width: '50%', + }, + + '@container (min-width: 950px)': { + width: '25%', + }, + + '@container (min-width: 1300px)': { + width: '20%', + }, + }; + 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 +165,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..82bd4057ff 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 @@ -46,6 +46,38 @@ const StyledLink = styled(BackstageLink)(({ theme }) => ({ borderRadius: 4, })); +const responsiveGridItem1 = { + width: '100%', + + '@container (min-width: 600px)': { + width: '50%', + }, + + '@container (min-width: 900px)': { + width: '25%', + }, + + '@container (min-width: 1200px)': { + width: '20%', + }, +}; + +const responsiveGridItem2 = { + width: '100%', + + '@container (min-width: 600px)': { + width: '50%', + }, + + '@container (min-width: 900px)': { + width: '33.3%', + }, + + '@container (min-width: 1200px)': { + width: '25%', + }, +}; + export const TemplateSection = () => { const { t } = useTranslation(); const { @@ -90,7 +122,7 @@ export const TemplateSection = () => { {templates?.items.map((item: any) => ( - + { ))} {templates?.items.length === 0 && ( - + { 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 && ( + + + + + + )} + ); }; From 08117db71b7104a12a0ca2718f38c3ecc7a01b60 Mon Sep 17 00:00:00 2001 From: rohitratannagar Date: Wed, 18 Feb 2026 13:35:52 +0530 Subject: [PATCH 2/4] adding changeset Signed-off-by: rohitratannagar --- workspaces/homepage/.changeset/red-insects-teach.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 workspaces/homepage/.changeset/red-insects-teach.md 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. From 6d115ca0be394a41b68b3a682c942ee28257f3cc Mon Sep 17 00:00:00 2001 From: rohitratannagar Date: Wed, 18 Feb 2026 13:37:11 +0530 Subject: [PATCH 3/4] made empty-cards identical Signed-off-by: rohitratannagar --- .../EntitySection/EntitySection.tsx | 39 ++++++++++++++++--- .../TemplateSection/TemplateSection.tsx | 8 +--- 2 files changed, 36 insertions(+), 11 deletions(-) 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 b0607735d8..989f4ace73 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 @@ -67,7 +67,7 @@ export const EntitySection = () => { const isMd = useMediaQuery(theme.breakpoints.only('md')); - const responsiveGridItem = { + const responsiveGridItem1 = { width: '100%', '@container (min-width: 600px)': { @@ -83,6 +83,33 @@ export const EntitySection = () => { }, }; + const responsiveGridItem2 = { + width: '100%', + + '@container (min-width: 600px)': { + width: '50%', + }, + + '@container (min-width: 900px)': { + width: '25%', + }, + + '@container (min-width: 1200px)': { + width: '20%', + }, + }; + + const responsiveGridItem3 = { + width: '100%', + + '@container (min-width: 600px)': { + width: '50%', + }, + '@container (min-width: 1200px)': { + width: '40%', + }, + }; + useEffect(() => { if (isMd) { setIsMediumBreakpoint(true); @@ -147,7 +174,7 @@ export const EntitySection = () => { @@ -227,7 +254,9 @@ export const EntitySection = () => { @@ -246,8 +275,8 @@ export const EntitySection = () => { item sx={{ ...(isRemoveFirstCard - ? { width: '100%' } - : responsiveGridItem), + ? responsiveGridItem3 + : responsiveGridItem1), }} > { display: 'flex', alignItems: 'center', justifyContent: 'center', + minHeight: 300, border: muiTheme => `1px solid ${muiTheme.palette.grey[400]}`, borderRadius: 3, From e69e01db09897beec904ba453e97b3f71a9655a1 Mon Sep 17 00:00:00 2001 From: rohitratannagar Date: Mon, 23 Feb 2026 13:45:33 +0530 Subject: [PATCH 4/4] Add useContainerQuery hook and GridItem utility; update EntitySection, OnboardingSection, and TemplateSection to use container queries instead of viewport media queries. Signed-off-by: rohitratannagar --- .../EntitySection/EntitySection.test.tsx | 12 + .../EntitySection/EntitySection.tsx | 231 ++++++++---------- .../OnboardingSection/OnboardingSection.tsx | 22 +- .../TemplateSection/TemplateSection.tsx | 48 ++-- .../src/hooks/useContainerQuery.ts | 83 +++++++ .../dynamic-home-page/src/utils/GridItem.ts | 90 +++++++ 6 files changed, 307 insertions(+), 179 deletions(-) create mode 100644 workspaces/homepage/plugins/dynamic-home-page/src/hooks/useContainerQuery.ts create mode 100644 workspaces/homepage/plugins/dynamic-home-page/src/utils/GridItem.ts diff --git a/workspaces/homepage/plugins/dynamic-home-page/src/components/EntitySection/EntitySection.test.tsx b/workspaces/homepage/plugins/dynamic-home-page/src/components/EntitySection/EntitySection.test.tsx index 73094f2996..5572b57872 100644 --- a/workspaces/homepage/plugins/dynamic-home-page/src/components/EntitySection/EntitySection.test.tsx +++ b/workspaces/homepage/plugins/dynamic-home-page/src/components/EntitySection/EntitySection.test.tsx @@ -22,6 +22,14 @@ import { ThemeProvider, createTheme } from '@mui/material/styles'; import { EntitySection } from './EntitySection'; import { useEntities } from '../../hooks/useEntities'; +// jsdom does not provide ResizeObserver; required by useContainerQuery in EntitySection +class ResizeObserverMock { + observe = jest.fn(); + disconnect = jest.fn(); + unobserve = jest.fn(); +} +window.ResizeObserver = ResizeObserverMock as unknown as typeof ResizeObserver; + jest.mock('../../hooks/useEntities'); const mockUseEntities = useEntities as jest.MockedFunction; @@ -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 989f4ace73..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,60 +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); - const responsiveGridItem1 = { - width: '100%', + const entityCardCount = + containerSize === 'xs' || containerSize === 'sm' ? 2 : 4; - '@container (min-width: 600px)': { - width: '50%', - }, - - '@container (min-width: 900px)': { - width: '33.3%', - }, - - '@container (min-width: 1200px)': { - width: '25%', - }, - }; - - const responsiveGridItem2 = { - width: '100%', - - '@container (min-width: 600px)': { - width: '50%', - }, - - '@container (min-width: 900px)': { - width: '25%', - }, - - '@container (min-width: 1200px)': { - width: '20%', - }, - }; - - const responsiveGridItem3 = { - width: '100%', - - '@container (min-width: 600px)': { - width: '50%', - }, - '@container (min-width: 1200px)': { - width: '40%', - }, + const getIllustrationWidth = () => { + if (containerSize === 'md') return 180; + if (containerSize === 'lg') return 220; + return 266; }; - - useEffect(() => { - if (isMd) { - setIsMediumBreakpoint(true); - } else { - setIsMediumBreakpoint(false); - } - }, [isMd]); + const illustrationWidth = getIllustrationWidth(); useEffect(() => { const isUserDismissedEntityIllustration = @@ -163,101 +123,109 @@ export const EntitySection = () => { ); } else { - let entityCardCount = 2; - if (isMediumBreakpoint) entityCardCount = 3; - content = ( - {!isRemoveFirstCard && !profileLoading && ( - - - + - {!imgLoaded && ( - + {!imgLoaded && ( + + )} + setImgLoaded(true)} + alt="" height={300} sx={{ - borderRadius: 3, - width: 'clamp(140px, 14vw, 266px)', + width: illustrationWidth, }} /> - )} - setImgLoaded(true)} - alt="" - height={300} - sx={{ - width: 'clamp(140px, 14vw, 266px)', - }} - /> - - - {t('entities.description')} - + + + {t('entities.description')} + + + {entities?.length > 0 && ( + + + + )} - {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 && ( { {t('entities.title')} { const [user, setUser] = useState(); @@ -79,30 +80,13 @@ export const OnboardingSection = () => { return name; }; - // Logic to handle column widths based on the PARENT CARD size - const responsiveGridItem = { - width: '100%', - - '@container (min-width: 550px)': { - width: '50%', - }, - - '@container (min-width: 950px)': { - width: '25%', - }, - - '@container (min-width: 1300px)': { - width: '20%', - }, - }; - const content = ( { item key={item.title} sx={{ - ...responsiveGridItem, + ...containerGridItemSx({ xs: 12, sm: 6, md: 3 }), display: 'flex', justifyContent: 'left', alignItems: 'center', 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 bb792d4f54..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', @@ -46,33 +47,6 @@ const StyledLink = styled(BackstageLink)(({ theme }) => ({ borderRadius: 4, })); -const responsiveGridItem1 = { - width: '100%', - - '@container (min-width: 600px)': { - width: '50%', - }, - - '@container (min-width: 900px)': { - width: '25%', - }, - - '@container (min-width: 1200px)': { - width: '20%', - }, -}; - -const responsiveGridItem2 = { - width: '100%', - - '@container (min-width: 600px)': { - width: '50%', - }, - '@container (min-width: 1200px)': { - width: '40%', - }, -}; - export const TemplateSection = () => { const { t } = useTranslation(); const { @@ -117,7 +91,15 @@ export const TemplateSection = () => { {templates?.items.map((item: any) => ( - + { ))} {templates?.items.length === 0 && ( - + { + 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), + }, +});