diff --git a/web/src/layout/package/Banner.module.css b/web/src/layout/package/Banner.module.css index 733496b0f..1b51b4d74 100644 --- a/web/src/layout/package/Banner.module.css +++ b/web/src/layout/package/Banner.module.css @@ -11,8 +11,30 @@ max-height: 1500px; } +.fadeInOnLoad { + opacity: 0; + transform: translateY(6px); + transition: + opacity 0.25s ease-in-out, + transform 0.25s ease-in-out; +} + +.visible { + opacity: 1; + transform: translateY(0); +} + @media (prefers-reduced-motion: reduce) { .bannerWrapper { transition: max-height 3s ease-in-out; } + + .fadeInOnLoad { + transition: opacity 0.01s linear; + transform: none; + } + + .visible { + transform: none; + } } diff --git a/web/src/layout/package/Banner.test.tsx b/web/src/layout/package/Banner.test.tsx new file mode 100644 index 000000000..f5ae7d234 --- /dev/null +++ b/web/src/layout/package/Banner.test.tsx @@ -0,0 +1,52 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; + +import { AppCtx } from '../../context/AppCtx'; +import { Banner as BannerData } from '../../types'; +import { hasClassContaining } from '../../utils/testUtils'; +import Banner from './Banner'; + +const mockCtx = { + user: null, + prefs: { + controlPanel: {}, + search: { limit: 60 }, + theme: { + configured: 'light', + effective: 'light', + }, + notifications: { + lastDisplayedTime: null, + enabled: true, + displayed: [], + }, + }, +}; + +const defaultBanner: BannerData = { + name: 'Artifact Hub banner', + images: { + 'light-theme': 'https://example.com/banner-light.png', + 'dark-theme': 'https://example.com/banner-dark.png', + }, +}; + +describe('Banner', () => { + it('reveals the banner after the image loads', async () => { + render( + + + + ); + + const image = screen.getByAltText(defaultBanner.name as string); + const wrapper = image.parentElement?.parentElement?.parentElement; + + expect(wrapper).not.toBeNull(); + + fireEvent.load(image); + + await waitFor(() => { + expect(hasClassContaining(wrapper as Element, 'loaded')).toBe(true); + }); + }); +}); diff --git a/web/src/layout/package/Banner.tsx b/web/src/layout/package/Banner.tsx index 20ed3681b..ce78e8c11 100644 --- a/web/src/layout/package/Banner.tsx +++ b/web/src/layout/package/Banner.tsx @@ -13,59 +13,76 @@ interface Props { banner: IBanner; removeBanner: () => void; maxEqualRatio: boolean; + revealMode?: 'height' | 'fade' | 'none'; } const Banner = (props: Props) => { const { ctx } = useContext(AppCtx); const { effective } = ctx.prefs.theme; const img = useRef(null); + const bannerTimeout = useRef(null); const [isLoaded, setIsLoaded] = useState(false); const [visibleBanner, setVisibleBanner] = useState(props.banner); - const [bannerTimeout, setBannerTimeout] = useState(null); + + const updateLoadedState = () => { + if (props.maxEqualRatio && img.current && img.current.naturalHeight > img.current.naturalWidth) { + setIsLoaded(false); + } else { + setIsLoaded(true); + } + }; useEffect(() => { if (visibleBanner !== props.banner) { + if (bannerTimeout.current !== null) { + clearTimeout(bannerTimeout.current); + } setVisibleBanner(null); setIsLoaded(false); - setBannerTimeout( - setTimeout(() => { - setVisibleBanner(props.banner); - }, 100) - ); + bannerTimeout.current = window.setTimeout(() => { + setVisibleBanner(props.banner); + bannerTimeout.current = null; + }, 100); } }, [props.banner]); useEffect(() => { return () => { - if (bannerTimeout) { - clearTimeout(bannerTimeout); + if (bannerTimeout.current !== null) { + clearTimeout(bannerTimeout.current); } }; }, []); if (isNull(visibleBanner)) return null; + const imageSource = effective === 'light' ? visibleBanner.images['light-theme'] : visibleBanner.images['dark-theme']; + const revealMode = props.revealMode ?? 'height'; + const getCardImage = () => ( { - if (props.maxEqualRatio && img && img.current && img.current.naturalHeight > img.current.naturalWidth) { - setIsLoaded(false); - } else { - setIsLoaded(true); - } - }} + onLoad={updateLoadedState} /> ); return ( - + {visibleBanner.link ? ( { banner={banner} removeBanner={() => setBanner(null)} maxEqualRatio={false} - revealOnLoad={false} - fadeInOnLoad={true} + revealMode="fade" /> )}