Skip to content

Commit d1b46c2

Browse files
authored
[EmptyState] Fix layout shift when image is loading with skeleton image (#11804)
### WHY are these changes introduced? Resolves [#1545](Shopify/polaris-internal#1545). Fixes layout shift when image is loading in `EmptyState`. ### WHAT is this pull request doing? Initially renders a skeleton image with set width/height to prevent layout shift as image asset is loading. Renders final `<Image>` component with transition to prevent flicker for smoother UX. <details> <summary>EmptyState — before</summary> <img src="https://github.com/Shopify/polaris/assets/26749317/d186b70e-7f59-40cb-b2ad-8487c1f8e276" alt="EmptyState — before"> </details> <details> <summary>EmptyState — after</summary> <img src="https://github.com/Shopify/polaris/assets/26749317/31ad9f5f-80a9-4d8a-a325-9863458e292e" alt="EmptyState — after"> </details> ### How to 🎩 [Spin](https://admin.web.fix-empty-state.lo-kim.us.spin.dev/store/shop1/orders) - Throttle network (open dev console, select `Network` tab, choose either `Fast 3G` or `Slow 3G`) - Refresh page that renders EmptyState 🖥 [Local development instructions](https://github.com/Shopify/polaris/blob/main/README.md#install-dependencies-and-build-workspaces) 🗒 [General tophatting guidelines](https://github.com/Shopify/polaris/blob/main/documentation/Tophatting.md) 📄 [Changelog guidelines](https://github.com/Shopify/polaris/blob/main/.github/CONTRIBUTING.md#changelog) ### 🎩 checklist - [x] Tested a [snapshot](https://github.com/Shopify/polaris/blob/main/documentation/Releasing.md#-snapshot-releases) - [x] Tested on [mobile](https://github.com/Shopify/polaris/blob/main/documentation/Tophatting.md#cross-browser-testing) - [x] Tested on [multiple browsers](https://help.shopify.com/en/manual/shopify-admin/supported-browsers) - [ ] Tested for [accessibility](https://github.com/Shopify/polaris/blob/main/documentation/Accessibility%20testing.md) - [ ] Updated the component's `README.md` with documentation changes - [ ] [Tophatted documentation](https://github.com/Shopify/polaris/blob/main/documentation/Tophatting%20documentation.md) changes in the style guide
1 parent 0c26884 commit d1b46c2

File tree

4 files changed

+105
-6
lines changed

4 files changed

+105
-6
lines changed

.changeset/tidy-dryers-stare.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@shopify/polaris': patch
3+
---
4+
5+
Fixed layout shift on `EmptyState` when image is loading with skeleton image
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,56 @@
1+
.ImageContainer {
2+
position: relative;
3+
display: flex;
4+
align-items: center;
5+
justify-content: center;
6+
}
7+
8+
.Image {
9+
opacity: 0;
10+
transition: opacity var(--p-motion-duration-150) var(--p-motion-ease);
11+
z-index: var(--p-z-index-1);
12+
13+
&.loaded {
14+
opacity: 1;
15+
}
16+
}
17+
118
.imageContained {
219
@media (--p-breakpoints-md-up) {
320
position: initial;
421
width: 100%;
522
}
623
}
24+
25+
.SkeletonImageContainer {
26+
/* stylelint-disable polaris/conventions/polaris/custom-property-allowed-list -- container custom property for size to prevent layout shift */
27+
--pc-empty-state-skeleton-image-container-size: 226px;
28+
height: var(--pc-empty-state-skeleton-image-container-size);
29+
width: var(--pc-empty-state-skeleton-image-container-size);
30+
/* stylelint-enable polaris/conventions/polaris/custom-property-allowed-list -- container custom property for size to prevent layout shift */
31+
display: flex;
32+
align-items: center;
33+
justify-content: center;
34+
}
35+
36+
.SkeletonImage {
37+
position: absolute;
38+
z-index: var(--p-z-index-0);
39+
/* stylelint-disable polaris/conventions/polaris/custom-property-allowed-list -- container custom property for placeholder size */
40+
--pc-empty-state-skeleton-image-size: 145px;
41+
height: var(--pc-empty-state-skeleton-image-size);
42+
width: var(--pc-empty-state-skeleton-image-size);
43+
/* stylelint-enable polaris/conventions/polaris/custom-property-allowed-list -- container custom property for placeholder size */
44+
background-color: var(--p-color-bg-fill-secondary);
45+
border-radius: var(--p-border-radius-full);
46+
opacity: 1;
47+
transition: opacity var(--p-motion-duration-500) var(--p-motion-ease);
48+
49+
&.loaded {
50+
opacity: 0;
51+
}
52+
53+
@media screen and (-ms-high-contrast: active) {
54+
background-color: grayText;
55+
}
56+
}

polaris-react/src/components/EmptyState/EmptyState.tsx

+33-6
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React from 'react';
1+
import React, {useState, useCallback} from 'react';
22

33
import {classNames} from '../../utilities/css';
44
import type {ComplexAction} from '../../types';
@@ -46,31 +46,58 @@ export function EmptyState({
4646
secondaryAction,
4747
footerContent,
4848
}: EmptyStateProps) {
49-
const imageContainedClass = classNames(
49+
const [imageLoaded, setImageLoaded] = useState<boolean>(false);
50+
51+
const handleLoad = useCallback(() => {
52+
setImageLoaded(true);
53+
}, []);
54+
55+
const imageClassNames = classNames(
56+
styles.Image,
57+
imageLoaded && styles.loaded,
5058
imageContained && styles.imageContained,
5159
);
5260

53-
const imageMarkup = largeImage ? (
61+
const loadedImageMarkup = largeImage ? (
5462
<Image
5563
alt=""
5664
role="presentation"
5765
source={largeImage}
58-
className={imageContainedClass}
66+
className={imageClassNames}
5967
sourceSet={[
6068
{source: image, descriptor: '568w'},
6169
{source: largeImage, descriptor: '1136w'},
6270
]}
6371
sizes="(max-width: 568px) 60vw"
72+
onLoad={handleLoad}
6473
/>
6574
) : (
6675
<Image
67-
className={imageContainedClass}
68-
role="presentation"
6976
alt=""
77+
role="presentation"
78+
className={imageClassNames}
7079
source={image}
80+
onLoad={handleLoad}
7181
/>
7282
);
7383

84+
const skeletonImageClassNames = classNames(
85+
styles.SkeletonImage,
86+
imageLoaded && styles.loaded,
87+
);
88+
89+
const imageContainerClassNames = classNames(
90+
styles.ImageContainer,
91+
!imageLoaded && styles.SkeletonImageContainer,
92+
);
93+
94+
const imageMarkup = (
95+
<div className={imageContainerClassNames}>
96+
{loadedImageMarkup}
97+
<div className={skeletonImageClassNames} />
98+
</div>
99+
);
100+
74101
const secondaryActionMarkup = secondaryAction
75102
? buttonFrom(secondaryAction, {})
76103
: null;

polaris-react/src/components/EmptyState/tests/EmptyState.test.tsx

+17
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,23 @@ describe('<EmptyState />', () => {
1313
let imgSrc =
1414
'https://cdn.shopify.com/s/files/1/0757/9955/files/empty-state.svg';
1515

16+
it('renders EmptyState with a skeleton image and hidden <Image> when the image is not loaded', () => {
17+
const emptyState = mountWithApp(<EmptyState image={imgSrc} />);
18+
19+
expect(emptyState).toContainReactComponent('div', {
20+
className: 'SkeletonImage',
21+
});
22+
expect(emptyState).toContainReactComponent('div', {
23+
className: expect.not.stringContaining('SkeletonImage loaded'),
24+
});
25+
expect(emptyState).toContainReactComponent(Image, {
26+
className: 'Image',
27+
});
28+
expect(emptyState).toContainReactComponent(Image, {
29+
className: expect.not.stringContaining('Image loaded'),
30+
});
31+
});
32+
1633
describe('action', () => {
1734
it('renders a button with the action content if action is set', () => {
1835
const emptyState = mountWithApp(

0 commit comments

Comments
 (0)