diff --git a/.changeset/tidy-dryers-stare.md b/.changeset/tidy-dryers-stare.md new file mode 100644 index 00000000000..2ca48bd820b --- /dev/null +++ b/.changeset/tidy-dryers-stare.md @@ -0,0 +1,5 @@ +--- +'@shopify/polaris': patch +--- + +Fixed layout shift on `EmptyState` when image is loading with skeleton image diff --git a/polaris-react/src/components/EmptyState/EmptyState.module.css b/polaris-react/src/components/EmptyState/EmptyState.module.css index 1f468771693..cd62dfe224d 100644 --- a/polaris-react/src/components/EmptyState/EmptyState.module.css +++ b/polaris-react/src/components/EmptyState/EmptyState.module.css @@ -1,6 +1,56 @@ +.ImageContainer { + position: relative; + display: flex; + align-items: center; + justify-content: center; +} + +.Image { + opacity: 0; + transition: opacity var(--p-motion-duration-150) var(--p-motion-ease); + z-index: var(--p-z-index-1); + + &.loaded { + opacity: 1; + } +} + .imageContained { @media (--p-breakpoints-md-up) { position: initial; width: 100%; } } + +.SkeletonImageContainer { + /* stylelint-disable polaris/conventions/polaris/custom-property-allowed-list -- container custom property for size to prevent layout shift */ + --pc-empty-state-skeleton-image-container-size: 226px; + height: var(--pc-empty-state-skeleton-image-container-size); + width: var(--pc-empty-state-skeleton-image-container-size); + /* stylelint-enable polaris/conventions/polaris/custom-property-allowed-list -- container custom property for size to prevent layout shift */ + display: flex; + align-items: center; + justify-content: center; +} + +.SkeletonImage { + position: absolute; + z-index: var(--p-z-index-0); + /* stylelint-disable polaris/conventions/polaris/custom-property-allowed-list -- container custom property for placeholder size */ + --pc-empty-state-skeleton-image-size: 145px; + height: var(--pc-empty-state-skeleton-image-size); + width: var(--pc-empty-state-skeleton-image-size); + /* stylelint-enable polaris/conventions/polaris/custom-property-allowed-list -- container custom property for placeholder size */ + background-color: var(--p-color-bg-fill-secondary); + border-radius: var(--p-border-radius-full); + opacity: 1; + transition: opacity var(--p-motion-duration-500) var(--p-motion-ease); + + &.loaded { + opacity: 0; + } + + @media screen and (-ms-high-contrast: active) { + background-color: grayText; + } +} diff --git a/polaris-react/src/components/EmptyState/EmptyState.tsx b/polaris-react/src/components/EmptyState/EmptyState.tsx index 9448ecc2200..6082727b7d6 100644 --- a/polaris-react/src/components/EmptyState/EmptyState.tsx +++ b/polaris-react/src/components/EmptyState/EmptyState.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {useState, useCallback} from 'react'; import {classNames} from '../../utilities/css'; import type {ComplexAction} from '../../types'; @@ -46,31 +46,58 @@ export function EmptyState({ secondaryAction, footerContent, }: EmptyStateProps) { - const imageContainedClass = classNames( + const [imageLoaded, setImageLoaded] = useState(false); + + const handleLoad = useCallback(() => { + setImageLoaded(true); + }, []); + + const imageClassNames = classNames( + styles.Image, + imageLoaded && styles.loaded, imageContained && styles.imageContained, ); - const imageMarkup = largeImage ? ( + const loadedImageMarkup = largeImage ? ( ) : ( ); + const skeletonImageClassNames = classNames( + styles.SkeletonImage, + imageLoaded && styles.loaded, + ); + + const imageContainerClassNames = classNames( + styles.ImageContainer, + !imageLoaded && styles.SkeletonImageContainer, + ); + + const imageMarkup = ( +
+ {loadedImageMarkup} +
+
+ ); + const secondaryActionMarkup = secondaryAction ? buttonFrom(secondaryAction, {}) : null; diff --git a/polaris-react/src/components/EmptyState/tests/EmptyState.test.tsx b/polaris-react/src/components/EmptyState/tests/EmptyState.test.tsx index a8336aa4a9d..2532419ae32 100644 --- a/polaris-react/src/components/EmptyState/tests/EmptyState.test.tsx +++ b/polaris-react/src/components/EmptyState/tests/EmptyState.test.tsx @@ -13,6 +13,23 @@ describe('', () => { let imgSrc = 'https://cdn.shopify.com/s/files/1/0757/9955/files/empty-state.svg'; + it('renders EmptyState with a skeleton image and hidden when the image is not loaded', () => { + const emptyState = mountWithApp(); + + expect(emptyState).toContainReactComponent('div', { + className: 'SkeletonImage', + }); + expect(emptyState).toContainReactComponent('div', { + className: expect.not.stringContaining('SkeletonImage loaded'), + }); + expect(emptyState).toContainReactComponent(Image, { + className: 'Image', + }); + expect(emptyState).toContainReactComponent(Image, { + className: expect.not.stringContaining('Image loaded'), + }); + }); + describe('action', () => { it('renders a button with the action content if action is set', () => { const emptyState = mountWithApp(