Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions web/src/layout/package/Banner.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
52 changes: 52 additions & 0 deletions web/src/layout/package/Banner.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<AppCtx.Provider value={{ ctx: mockCtx, dispatch: jest.fn() }}>
<Banner banner={defaultBanner} removeBanner={jest.fn()} maxEqualRatio={false} />
</AppCtx.Provider>
);

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);
});
});
});
51 changes: 34 additions & 17 deletions web/src/layout/package/Banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLImageElement>(null);
const bannerTimeout = useRef<number | null>(null);
const [isLoaded, setIsLoaded] = useState<boolean>(false);
const [visibleBanner, setVisibleBanner] = useState<IBanner | null>(props.banner);
const [bannerTimeout, setBannerTimeout] = useState<NodeJS.Timeout | null>(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 = () => (
<div className={`card flex-row shadow-sm mw-100 overflow-hidden ${styles.card} ${props.className}`}>
<img
key={imageSource}
ref={img}
src={effective === 'light' ? visibleBanner.images['light-theme'] : visibleBanner.images['dark-theme']}
src={imageSource}
alt={visibleBanner.name || 'Banner'}
className="mw-100 h-auto mx-auto"
onError={props.removeBanner}
onLoad={() => {
if (props.maxEqualRatio && img && img.current && img.current.naturalHeight > img.current.naturalWidth) {
setIsLoaded(false);
} else {
setIsLoaded(true);
}
}}
onLoad={updateLoadedState}
/>
</div>
);

return (
<div className={classNames('overflow-hidden', styles.bannerWrapper, { [styles.loaded]: isLoaded })}>
<div
className={classNames({
'overflow-hidden': revealMode === 'height',
[styles.bannerWrapper]: revealMode === 'height',
[styles.loaded]: revealMode === 'height' && isLoaded,
[styles.fadeInOnLoad]: revealMode === 'fade',
[styles.visible]: revealMode !== 'fade' || isLoaded,
})}
>
{visibleBanner.link ? (
<ExternalLink
href={visibleBanner.link}
Expand Down
3 changes: 1 addition & 2 deletions web/src/layout/package/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1411,8 +1411,7 @@ const PackageView = () => {
banner={banner}
removeBanner={() => setBanner(null)}
maxEqualRatio={false}
revealOnLoad={false}
fadeInOnLoad={true}
revealMode="fade"
/>
)}

Expand Down
Loading