Skip to content

Commit

Permalink
[EmptyState] Fix layout shift when image is loading with skeleton ima…
Browse files Browse the repository at this point in the history
…ge (Shopify#11804)

### WHY are these changes introduced?

Resolves
[Shopify#1545](https://github.com/Shopify/polaris-internal/issues/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
  • Loading branch information
laurkim committed Apr 1, 2024
1 parent 903b188 commit 8274e40
Show file tree
Hide file tree
Showing 4 changed files with 105 additions and 6 deletions.
5 changes: 5 additions & 0 deletions .changeset/tidy-dryers-stare.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/polaris': patch
---

Fixed layout shift on `EmptyState` when image is loading with skeleton image
50 changes: 50 additions & 0 deletions polaris-react/src/components/EmptyState/EmptyState.module.css
Original file line number Diff line number Diff line change
@@ -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;
}
}
39 changes: 33 additions & 6 deletions polaris-react/src/components/EmptyState/EmptyState.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from 'react';
import React, {useState, useCallback} from 'react';

import {classNames} from '../../utilities/css';
import type {ComplexAction} from '../../types';
Expand Down Expand Up @@ -46,31 +46,58 @@ export function EmptyState({
secondaryAction,
footerContent,
}: EmptyStateProps) {
const imageContainedClass = classNames(
const [imageLoaded, setImageLoaded] = useState<boolean>(false);

const handleLoad = useCallback(() => {
setImageLoaded(true);
}, []);

const imageClassNames = classNames(
styles.Image,
imageLoaded && styles.loaded,
imageContained && styles.imageContained,
);

const imageMarkup = largeImage ? (
const loadedImageMarkup = largeImage ? (
<Image
alt=""
role="presentation"
source={largeImage}
className={imageContainedClass}
className={imageClassNames}
sourceSet={[
{source: image, descriptor: '568w'},
{source: largeImage, descriptor: '1136w'},
]}
sizes="(max-width: 568px) 60vw"
onLoad={handleLoad}
/>
) : (
<Image
className={imageContainedClass}
role="presentation"
alt=""
role="presentation"
className={imageClassNames}
source={image}
onLoad={handleLoad}
/>
);

const skeletonImageClassNames = classNames(
styles.SkeletonImage,
imageLoaded && styles.loaded,
);

const imageContainerClassNames = classNames(
styles.ImageContainer,
!imageLoaded && styles.SkeletonImageContainer,
);

const imageMarkup = (
<div className={imageContainerClassNames}>
{loadedImageMarkup}
<div className={skeletonImageClassNames} />
</div>
);

const secondaryActionMarkup = secondaryAction
? buttonFrom(secondaryAction, {})
: null;
Expand Down
17 changes: 17 additions & 0 deletions polaris-react/src/components/EmptyState/tests/EmptyState.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,23 @@ describe('<EmptyState />', () => {
let imgSrc =
'https://cdn.shopify.com/s/files/1/0757/9955/files/empty-state.svg';

it('renders EmptyState with a skeleton image and hidden <Image> when the image is not loaded', () => {
const emptyState = mountWithApp(<EmptyState image={imgSrc} />);

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(
Expand Down

0 comments on commit 8274e40

Please sign in to comment.