diff --git a/src/Card/CardCarousel/tests/__snapshots__/CardCarousel.test.jsx.snap b/src/Card/CardCarousel/tests/__snapshots__/CardCarousel.test.jsx.snap index 2ad5754ef4..d668a3679d 100644 --- a/src/Card/CardCarousel/tests/__snapshots__/CardCarousel.test.jsx.snap +++ b/src/Card/CardCarousel/tests/__snapshots__/CardCarousel.test.jsx.snap @@ -108,12 +108,26 @@ exports[` renders card carousel with custom title and subtitles
+ + + ‌ + +
+
renders card carousel with custom title and subtitles
+ + + ‌ + +
+
renders card carousel with custom title and subtitles
+ + + ‌ + +
+
renders card carousel with custom title and subtitles
+ + + ‌ + +
+
renders card carousel with custom title and subtitles
+ + + ‌ + +
+
renders card carousel with title and subtitles 1`] = `
+ + + ‌ + +
+
renders card carousel with title and subtitles 1`] = `
+ + + ‌ + +
+
renders card carousel with title and subtitles 1`] = `
+ + + ‌ + +
+
renders card carousel with title and subtitles 1`] = `
+ + + ‌ + +
+
renders card carousel with title and subtitles 1`] = `
+ + + ‌ + +
+
renders default card carousel 1`] = `
+ + + ‌ + +
+
renders default card carousel 1`] = `
+ + + ‌ + +
+
renders default card carousel 1`] = `
+ + + ‌ + +
+
renders default card carousel 1`] = `
+ + + ‌ + +
+
renders default card carousel 1`] = `
+ + + ‌ + +
+
{ const { orientation, isLoading } = useContext(CardContext); - const [showImageCap, setShowImageCap] = useState(false); - const [showLogoCap, setShowLogoCap] = useState(false); - - const wrapperClassName = `pgn__card-wrapper-image-cap ${orientation}`; - - if (isLoading) { - return ( -
- - {logoSkeleton && ( - - )} -
- ); - } - - const handleSrcFallback = (event, altSrc, imageKey) => { - const { currentTarget } = event; - if (!altSrc || currentTarget.src.endsWith(altSrc)) { - if (imageKey === 'imageCap') { - currentTarget.src = cardSrcFallbackImg; - } else { - setShowLogoCap(false); - } + const imageSkeletonHeight = useMemo(() => ( + orientation === 'horizontal' ? '100%' : skeletonHeight + ), [orientation, skeletonHeight]); - return; - } - - currentTarget.src = altSrc; - }; + const wrapperClassName = `pgn__card-wrapper-image-cap ${orientation}`; return (
{!!src && ( - handleSrcFallback(event, fallbackSrc, 'imageCap')} - onLoad={() => setShowImageCap(true)} alt={srcAlt} - loading={imageLoadingType} + fallback={fallbackSrc} + className="pgn__card-image-cap" + useDefaultSrc + withSkeleton + skeletonWidth={skeletonWidth} + skeletonHeight={imageSkeletonHeight} + imageLoadingType={imageLoadingType} + skeletonClassName="pgn__card-image-cap-loader" + isLoading={isLoading} /> )} - {!!logoSrc && ( - handleSrcFallback(event, fallbackLogoSrc, 'logoCap')} - onLoad={() => setShowLogoCap(true)} alt={logoAlt} - loading={imageLoadingType} + fallback={fallbackLogoSrc} + withSkeleton={logoSkeleton} + className="pgn__card-logo-cap" + skeletonWidth={logoSkeletonWidth} + skeletonHeight={logoSkeletonHeight} + imageLoadingType={imageLoadingType} + skeletonClassName="pgn__card-logo-cap" /> )}
@@ -109,9 +79,9 @@ CardImageCap.propTypes = { /** Specifies logo image alt text. */ logoAlt: PropTypes.string, /** Specifies height of Image skeleton in loading state. */ - skeletonHeight: PropTypes.number, + skeletonHeight: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), /** Specifies width of Image skeleton in loading state. */ - skeletonWidth: PropTypes.number, + skeletonWidth: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), /** Specifies whether the cap should be displayed during loading. */ logoSkeleton: PropTypes.bool, /** Specifies height of Logo skeleton in loading state. */ diff --git a/src/Card/CardImageWithSkeleton.jsx b/src/Card/CardImageWithSkeleton.jsx new file mode 100644 index 0000000000..8d326217e7 --- /dev/null +++ b/src/Card/CardImageWithSkeleton.jsx @@ -0,0 +1,74 @@ +import React, { useMemo } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import Skeleton from 'react-loading-skeleton'; + +import Image from '../Image'; +import useImageLoader from '../hooks/useImageLoader'; + +function CardImageWithSkeleton({ + src, + alt, + fallback, + className, + withSkeleton, + useDefaultSrc, + skeletonWidth, + skeletonHeight, + imageLoadingType, + skeletonClassName, + isLoading = false, +}) { + const config = useMemo( + () => ({ + mainSrc: src, fallbackSrc: fallback, useDefaultSrc, + }), + [src, fallback, useDefaultSrc], + ); + + const { ref, isSrcLoading } = useImageLoader(config); + + return ( + <> + + {alt} + + ); +} + +CardImageWithSkeleton.propTypes = { + className: PropTypes.string, + src: PropTypes.string.isRequired, + fallback: PropTypes.string, + useDefaultSrc: PropTypes.bool, + alt: PropTypes.string.isRequired, + skeletonHeight: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + skeletonWidth: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + skeletonClassName: PropTypes.string, + imageLoadingType: PropTypes.oneOf(['eager', 'lazy']), + isLoading: PropTypes.bool, + withSkeleton: PropTypes.bool, +}; + +CardImageWithSkeleton.defaultProps = { + className: undefined, + skeletonClassName: undefined, + fallback: undefined, + useDefaultSrc: false, + imageLoadingType: 'eager', + skeletonHeight: undefined, + skeletonWidth: undefined, + isLoading: false, + withSkeleton: false, +}; + +export default CardImageWithSkeleton; diff --git a/src/Card/_variables.scss b/src/Card/_variables.scss index 703e99dc26..5e1c581bd8 100644 --- a/src/Card/_variables.scss +++ b/src/Card/_variables.scss @@ -48,7 +48,7 @@ $card-footer-text-font-size: $x-small-font-size; $card-image-horizontal-max-width: 240px !default; $card-image-horizontal-min-width: $card-image-horizontal-max-width !default; $card-image-vertical-max-height: 140px !default; -$loading-skeleton-spacer: .313rem !default; +$loading-skeleton-spacer: -4px !default; $card-focus-border-offset: 5px !default; $card-focus-border-width: 2px !default; diff --git a/src/Card/constants.js b/src/Card/constants.js new file mode 100644 index 0000000000..09a1677f28 --- /dev/null +++ b/src/Card/constants.js @@ -0,0 +1,2 @@ +export const SKELETON_HEIGHT_VALUE = 140; +export const LOGO_SKELETON_HEIGHT_VALUE = 41; diff --git a/src/Card/index.scss b/src/Card/index.scss index 56d0269aad..33860726ce 100644 --- a/src/Card/index.scss +++ b/src/Card/index.scss @@ -337,10 +337,15 @@ a.pgn__card { } .pgn__card-image-cap-loader { + margin-top: $loading-skeleton-spacer; + display: none; + + &.show { + display: block; + height: 100%; + } + .react-loading-skeleton { - margin-bottom: -$loading-skeleton-spacer; - position: relative; - top: -$loading-skeleton-spacer; height: 100%; border-bottom-right-radius: 0; border-bottom-left-radius: 0; diff --git a/src/Card/tests/CardImageCap.test.jsx b/src/Card/tests/CardImageCap.test.jsx index e459413d65..b46607d21d 100644 --- a/src/Card/tests/CardImageCap.test.jsx +++ b/src/Card/tests/CardImageCap.test.jsx @@ -1,6 +1,6 @@ import React from 'react'; import renderer from 'react-test-renderer'; -import { render, fireEvent, screen } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import CardImageCap from '../CardImageCap'; import CardContext from '../CardContext'; @@ -61,71 +61,7 @@ describe('', () => { />, ); - expect(screen.getByAltText('Src alt text').src).toEqual('http://src.image/'); - expect(screen.getByAltText('Logo alt text').src).toEqual('http://logo.image/'); - }); - - it('render with loading state', () => { - render( - , - ); - - expect(screen.queryByAltText('Src alt text')).toBeNull(); - expect(screen.queryByAltText('Logo alt text')).toBeNull(); - - expect(screen.getByTestId('image-loader-wrapper')).toBeInTheDocument(); - expect(screen.getByTestId('image-loader-wrapper').firstChild).toHaveClass('pgn__card-image-cap-loader'); - expect(screen.getByTestId('image-loader-wrapper').lastChild).toHaveClass('pgn__card-logo-cap'); - }); - - it('replaces image with fallback one in case of error', () => { - render( - , - ); - const srcImg = screen.getByAltText('Src alt text'); - expect(srcImg.src).toEqual('http://src.image/'); - fireEvent.load(srcImg); - fireEvent.error(srcImg); - expect(srcImg.src).toEqual('http://src.image.fallback/'); - - const logoImg = screen.getByAltText('Logo alt text'); - expect(logoImg.src).toEqual('http://logo.image/'); - fireEvent.load(srcImg); - fireEvent.error(logoImg); - expect(logoImg.src).toEqual('http://logo.image.fallback/'); - }); - - it('hiding component if it isn`t fallbackLogoSrc and logoSrc don`t work', () => { - render(); - - const logoImg = screen.getByAltText('Logo alt text'); - fireEvent.load(logoImg); - expect(logoImg.className).toEqual('pgn__card-logo-cap show'); - fireEvent.error(logoImg); - expect(logoImg.className).toEqual('pgn__card-logo-cap'); - }); - - it('hiding component if it isn`t fallbackSrc and src don`t work', () => { - render(); - - const srcImg = screen.getByAltText('Src alt text'); - fireEvent.load(srcImg); - fireEvent.error(srcImg); - // test-file-stub is what our fileMock.js returns for all images - expect(srcImg.src.endsWith('test-file-stub')).toEqual(true); + expect(screen.getByAltText('Src alt text')).toBeInTheDocument(); + expect(screen.getByAltText('Logo alt text')).toBeInTheDocument(); }); }); diff --git a/src/Card/tests/__snapshots__/CardDeck.test.jsx.snap b/src/Card/tests/__snapshots__/CardDeck.test.jsx.snap index b5bc5b8e96..58b0c978d5 100644 --- a/src/Card/tests/__snapshots__/CardDeck.test.jsx.snap +++ b/src/Card/tests/__snapshots__/CardDeck.test.jsx.snap @@ -18,12 +18,26 @@ exports[` has tabIndex="-1" when \`hasInteractiveChildren\` is true
+ + + ‌ + +
+
has tabIndex="-1" when \`hasInteractiveChildren\` is true
+ + + ‌ + +
+
has tabIndex="-1" when \`hasInteractiveChildren\` is true
+ + + ‌ + +
+
has tabIndex="-1" when \`hasInteractiveChildren\` is true
+ + + ‌ + +
+
has tabIndex="-1" when \`hasInteractiveChildren\` is true
+ + + ‌ + +
+
renders default columnSizes 1`] = `
+ + + ‌ + +
+
renders default columnSizes 1`] = `
+ + + ‌ + +
+
renders default columnSizes 1`] = `
+ + + ‌ + +
+
renders default columnSizes 1`] = `
+ + + ‌ + +
+
renders default columnSizes 1`] = `
+ + + ‌ + +
+
renders with controlled columnSizes 1`] = `
+ + + ‌ + +
+
renders with controlled columnSizes 1`] = `
+ + + ‌ + +
+
renders with controlled columnSizes 1`] = `
+ + + ‌ + +
+
renders with controlled columnSizes 1`] = `
+ + + ‌ + +
+
renders with controlled columnSizes 1`] = `
+ + + ‌ + +
+
renders with disabled equal height 1`] = `
+ + + ‌ + +
+
renders with disabled equal height 1`] = `
+ + + ‌ + +
+
renders with disabled equal height 1`] = `
+ + + ‌ + +
+
renders with disabled equal height 1`] = `
+ + + ‌ + +
+
renders with disabled equal height 1`] = `
+ + + ‌ + +
+
renders with disabled equal height 1`] = `
-`; \ No newline at end of file +`; diff --git a/src/Card/tests/__snapshots__/CardGrid.test.jsx.snap b/src/Card/tests/__snapshots__/CardGrid.test.jsx.snap index 65425dd9d7..0369e7579c 100644 --- a/src/Card/tests/__snapshots__/CardGrid.test.jsx.snap +++ b/src/Card/tests/__snapshots__/CardGrid.test.jsx.snap @@ -17,12 +17,26 @@ exports[` Controlled Rendering renders with controlled columnSizes 1
+ + + ‌ + +
+
Controlled Rendering renders with disabled equal height 1`
+ + + ‌ + +
+
Uncontrolled Rendering renders default columnSizes 1`] = `
+ + + ‌ + +
+
Uncontrolled Rendering renders default columnSizes 1`] = `
-`; \ No newline at end of file +`; diff --git a/src/Card/tests/__snapshots__/CardImageCap.test.jsx.snap b/src/Card/tests/__snapshots__/CardImageCap.test.jsx.snap index ad20491c2a..736696b4fb 100644 --- a/src/Card/tests/__snapshots__/CardImageCap.test.jsx.snap +++ b/src/Card/tests/__snapshots__/CardImageCap.test.jsx.snap @@ -1,16 +1,80 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[` renders with loading equals lazy 1`] = ` +
+ + + ‌ + +
+
+ + + + ‌ + +
+
+ Logo alt +
+`; + exports[` renders with scr prop and srcAlt 1`] = `
+ + + ‌ + +
+
Alt text
`; @@ -19,12 +83,26 @@ exports[` renders with scr prop in horizontal orientation 1`] =
+ + + ‌ + +
+
`; @@ -33,19 +111,47 @@ exports[` renders with src and logoSrc prop in horizontal orient
+ + + ‌ + +
+
+ + + ‌ + +
+
`; @@ -54,42 +160,48 @@ exports[` renders with src, logoSrc and logoAlt props 1`] = `
+ + + ‌ + +
+
+ + + ‌ + +
+
Logo alt
`; - -exports[` renders with loading equals lazy 1`] = ` -
- - Logo alt -
-`; \ No newline at end of file diff --git a/src/declaration.d.ts b/src/declaration.d.ts new file mode 100644 index 0000000000..e2937d470e --- /dev/null +++ b/src/declaration.d.ts @@ -0,0 +1 @@ +declare module '*.png'; diff --git a/src/hooks/tests/useImageLoader.test.jsx b/src/hooks/tests/useImageLoader.test.jsx new file mode 100644 index 0000000000..ce4d8b69e5 --- /dev/null +++ b/src/hooks/tests/useImageLoader.test.jsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { + render, screen, act, waitFor, fireEvent, +} from '@testing-library/react'; +import { Image } from '../..'; + +import useImageLoader from '../useImageLoader'; + +const MAIN_SRC = 'main-source.jpg'; +const FALLBACK_SRC = 'fallback-source.jpg'; +const ALT_TEXT = 'test'; +const TEST_ID = 'loading-indicator'; + +function TestComponent({ +// eslint-disable-next-line react/prop-types + mainSrc, fallbackSrc, alt, ...rest +}) { + const { ref, isSrcLoading } = useImageLoader({ mainSrc, fallbackSrc, ...rest }); + return ( + <> + {isSrcLoading &&
Loading...
} + {alt} + + ); +} + +describe('useImageLoader', () => { + describe('should set loading state to false', () => { + it('when the main source loads successfully and update img src', async () => { + render(); + const imgElement = screen.getByAltText(ALT_TEXT); + + expect(screen.getByTestId(TEST_ID)).toBeInTheDocument(); + + await act(async () => { + fireEvent.load(imgElement); + }); + + await waitFor(() => expect(screen.queryByTestId(TEST_ID)).not.toBeInTheDocument()); + + expect(imgElement.src).toContain(MAIN_SRC); + }); + + it('when the main source fails to load and falls back to the fallback source, and update img src', async () => { + render(); + const imgElement = screen.getByAltText(ALT_TEXT); + + expect(screen.getByTestId(TEST_ID)).toBeInTheDocument(); + + await act(async () => { + fireEvent.error(imgElement); + }); + + expect(imgElement.src).toContain(FALLBACK_SRC); + }); + }); +}); diff --git a/src/hooks/useImageLoader.jsx b/src/hooks/useImageLoader.jsx new file mode 100644 index 0000000000..76c75e9c6d --- /dev/null +++ b/src/hooks/useImageLoader.jsx @@ -0,0 +1,72 @@ +import { useState, useEffect, useRef } from 'react'; +import cardSrcFallbackImg from '../Card/fallback-default.png'; + +const useImageLoader = ({ + mainSrc, + fallbackSrc, + useDefaultSrc = false, +}) => { + const ref = useRef(null); + const [isSrcLoading, setIsLoading] = useState(true); + + useEffect(() => { + if ((!mainSrc && !fallbackSrc) || !ref.current) { + return; + } + const img = ref.current; + + const loadImageWithRetry = async (src) => { + setIsLoading(true); + + await new Promise((resolve, reject) => { + img.onload = () => resolve(); + img.onerror = () => reject(new Error(`Failed to load image: ${src}`)); + img.src = src; + }); + }; + + const loadImage = async () => { + let imageSrc = null; + const sources = [mainSrc]; + + if (fallbackSrc) { + sources.push(fallbackSrc); + } + + // Add default image source if useDefaultSrc is true + if (useDefaultSrc) { + sources.push(cardSrcFallbackImg); + } + + // eslint-disable-next-line no-restricted-syntax + for (const src of sources) { + if (!src) { + // eslint-disable-next-line no-continue + continue; + } + + try { + // eslint-disable-next-line no-await-in-loop + await loadImageWithRetry(src); + imageSrc = src; + break; + } catch (error) { + console.error(error); + // Continue to the next source if loading fails + } + } + return imageSrc; + }; + + const loadImages = async () => { + await loadImage(); + setIsLoading(false); + }; + + loadImages(); + }, [mainSrc, fallbackSrc, useDefaultSrc]); + + return { ref, isSrcLoading }; +}; + +export default useImageLoader; diff --git a/src/hooks/useImageLoader.mdx b/src/hooks/useImageLoader.mdx new file mode 100644 index 0000000000..45b3e52c31 --- /dev/null +++ b/src/hooks/useImageLoader.mdx @@ -0,0 +1,46 @@ +--- +title: 'useImageLoader' +type: 'hook' +categories: +- Hooks +components: +- useImageLoader +status: 'New' +designStatus: 'Done' +devStatus: 'Done' +notes: '' +--- + +## Base Usage +This hook designed to simplify the loading and handling of images. It provides a convenient +way to asynchronously load images with fallbacks and retry mechanisms, making it robust for scenarios where primary +image sources may fail. + +```jsx live + +() => { + const MyImageComponent = ({ mainSrc, fallbackSrc, alt, useDefaultSrc }) => { + const { ref, isSrcLoading } = useImageLoader({ + mainSrc, + fallbackSrc, + useDefaultSrc, + }); + + return ( +
+ {isSrcLoading &&
Loading...
} + {alt} +
+ ); + } + + return ( + + ); +} +``` \ No newline at end of file diff --git a/src/index.js b/src/index.js index a25410d8f9..cfde7bd75d 100644 --- a/src/index.js +++ b/src/index.js @@ -166,6 +166,7 @@ export { default as useToggle } from './hooks/useToggle'; export { default as useArrowKeyNavigation } from './hooks/useArrowKeyNavigation'; export { default as useIndexOfLastVisibleChild } from './hooks/useIndexOfLastVisibleChild'; export { default as useIsVisible } from './hooks/useIsVisible'; +export { default as useImageLoader } from './hooks/useImageLoader'; export { OverflowScrollContext, OverflowScroll,