Marketplace: modernize skeleton loaders (#40131)

This commit is contained in:
And Finally 2023-10-16 17:18:28 +01:00 committed by GitHub
commit 03897613ac
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 223 additions and 136 deletions

View File

@ -24,11 +24,14 @@ export default function CategoryLink( props: Category ): JSX.Element {
} );
}
const isLoading = props.label === '';
const classes = classNames(
'woocommerce-marketplace__category-item-button',
{
'woocommerce-marketplace__category-item-button--selected':
props.selected,
'is-loading': isLoading,
}
);
@ -37,6 +40,7 @@ export default function CategoryLink( props: Category ): JSX.Element {
className={ classes }
onClick={ updateCategorySelection }
value={ props.slug }
aria-hidden={ isLoading }
>
{ props.label }
</button>

View File

@ -32,6 +32,14 @@
background-color: $gray-900;
fill: $white;
}
&.is-loading {
animation: loading-fade 1.6s ease-in-out infinite;
background: $skeleton-loader-color;
height: 32px;
pointer-events: none;
width: 6em;
}
}
.woocommerce-marketplace__category-item-content {
@ -114,13 +122,3 @@
background-color: $gray-900;
}
}
.woocommerce-marketplace__category-selector-loading {
display: flex;
margin-top: $grid-unit-20;
p {
margin: 0;
line-height: $grid-unit-30;
}
}

View File

@ -3,7 +3,6 @@
*/
import { useState, useEffect } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { Spinner } from '@wordpress/components';
import { useQuery } from '@woocommerce/navigation';
import classNames from 'classnames';
@ -114,10 +113,18 @@ export default function CategorySelector(
if ( isLoading ) {
return (
<div className="woocommerce-marketplace__category-selector-loading">
<p>{ __( 'Loading categories…', 'woocommerce' ) }</p>
<Spinner />
</div>
<>
<ul className="woocommerce-marketplace__category-selector">
{ [ ...Array( 5 ) ].map( ( el, i ) => (
<li
key={ i }
className="woocommerce-marketplace__category-item"
>
<CategoryLink slug="" label="" selected={ false } />
</li>
) ) }
</ul>
</>
);
}

View File

@ -10,6 +10,7 @@ import ProductList from '../product-list/product-list';
import { fetchDiscoverPageData, ProductGroup } from '../../utils/functions';
import ProductLoader from '../product-loader/product-loader';
import { MarketplaceContext } from '../../contexts/marketplace-context';
import { ProductType } from '../product-list/types';
import './discover.scss';
export default function Discover(): JSX.Element | null {
@ -41,7 +42,14 @@ export default function Discover(): JSX.Element | null {
}, [] );
if ( isLoading ) {
return <ProductLoader />;
return (
<div className="woocommerce-marketplace__discover">
<ProductLoader
placeholderCount={ 9 }
type={ ProductType.extension }
/>
</div>
);
}
const groupsList = productGroups.flatMap( ( group ) => group );

View File

@ -5,6 +5,60 @@
padding: $large-gap;
border-radius: $grid-unit-05 !important;
/* When product card is loading, contents will be empty and we render skeleton loader wireframes: */
&.is-loading {
overflow: hidden;
pointer-events: none;
.woocommerce-marketplace__product-card__content {
overflow: hidden;
}
&.woocommerce-marketplace__product-card--extension .woocommerce-marketplace__product-card__title,
.woocommerce-marketplace__product-card__description,
.woocommerce-marketplace__product-card__price {
width: 100vw;
}
.woocommerce-marketplace__product-card__image,
.woocommerce-marketplace__product-card__vendor,
.woocommerce-marketplace__product-card__description,
.woocommerce-marketplace__product-card__price,
.woocommerce-marketplace__product-card__icon,
.woocommerce-marketplace__product-card__title {
background: $skeleton-loader-color;
}
// Font size of these elements times line height
.woocommerce-marketplace__product-card__vendor,
.woocommerce-marketplace__product-card__description,
.woocommerce-marketplace__product-card__price {
height: calc(13px * 1.5);
}
&.woocommerce-marketplace__product-card--extension .woocommerce-marketplace__product-card__title {
height: 48px;
}
&.woocommerce-marketplace__product-card--theme .woocommerce-marketplace__product-card__title {
height: 24px;
}
.woocommerce-marketplace__product-card__vendor,
.woocommerce-marketplace__product-card__price {
width: 8em;
}
&.woocommerce-marketplace__product-card--theme .woocommerce-marketplace__product-card__price {
margin-top: 22px;
}
// Font size of this elements times line height times number lines
.woocommerce-marketplace__product-card__description {
height: calc(13px * 1.5 * 3);
}
}
&:hover {
outline: 1.5px solid var(--wp-admin-theme-color);
}
@ -148,7 +202,7 @@
padding-left: $medium-gap;
}
.woocommerce-marketplace__product-card__price {
padding-right: $medium-gap;
margin-right: $medium-gap;
text-align: right;
display: inline-flex;
align-items: flex-end;
@ -171,3 +225,4 @@
}
}
}

View File

@ -3,6 +3,7 @@
*/
import { __ } from '@wordpress/i18n';
import { Card } from '@wordpress/components';
import classnames from 'classnames';
/**
* Internal dependencies
@ -11,12 +12,25 @@ import './product-card.scss';
import { Product, ProductType } from '../product-list/types';
export interface ProductCardProps {
type: ProductType;
product: Product;
type?: string;
product?: Product;
isLoading?: boolean;
}
function ProductCard( props: ProductCardProps ): JSX.Element {
const { product, type } = props;
const { isLoading, type } = props;
// Get the product if provided; if not provided, render a skeleton loader
const product = props.product ?? {
title: '',
description: '',
vendorName: '',
vendorUrl: '',
icon: '',
url: '',
price: 0,
image: '',
};
// We hardcode this for now while we only display prices in USD.
const currencySymbol = '$';
@ -30,28 +44,43 @@ function ProductCard( props: ProductCardProps ): JSX.Element {
);
}
const classNames = classnames(
'woocommerce-marketplace__product-card',
`woocommerce-marketplace__product-card--${ type }`,
{
'is-loading': isLoading,
}
);
return (
<Card
className={ `woocommerce-marketplace__product-card woocommerce-marketplace__product-card--${ type }` }
>
<Card className={ classNames } aria-hidden={ isLoading }>
<div className="woocommerce-marketplace__product-card__content">
{ isTheme && (
<div className="woocommerce-marketplace__product-card__image">
<img
className="woocommerce-marketplace__product-card__image-inner"
src={ product.image }
alt={ product.title }
/>
{ ! isLoading && (
<img
className="woocommerce-marketplace__product-card__image-inner"
src={ product.image }
alt={ product.title }
/>
) }
</div>
) }
<div className="woocommerce-marketplace__product-card__header">
<div className="woocommerce-marketplace__product-card__details">
{ ! isTheme && product.icon && (
<img
className="woocommerce-marketplace__product-card__icon"
src={ product.icon }
alt={ product.title }
/>
{ ! isTheme && (
<>
{ isLoading && (
<div className="woocommerce-marketplace__product-card__icon" />
) }
{ ! isLoading && product.icon && (
<img
className="woocommerce-marketplace__product-card__icon"
src={ product.icon }
alt={ product.title }
/>
) }
</>
) }
<div className="woocommerce-marketplace__product-card__meta">
<h2 className="woocommerce-marketplace__product-card__title">
@ -60,10 +89,13 @@ function ProductCard( props: ProductCardProps ): JSX.Element {
href={ product.url }
rel="noopener noreferrer"
>
{ product.title }
{ isLoading ? ' ' : product.title }
</a>
</h2>
{ productVendor && (
{ isLoading && (
<p className="woocommerce-marketplace__product-card__vendor" />
) }
{ ! isLoading && productVendor && (
<p className="woocommerce-marketplace__product-card__vendor">
<span>{ __( 'By ', 'woocommerce' ) }</span>
{ productVendor }
@ -74,23 +106,27 @@ function ProductCard( props: ProductCardProps ): JSX.Element {
</div>
{ ! isTheme && (
<p className="woocommerce-marketplace__product-card__description">
{ product.description }
{ ! isLoading && product.description }
</p>
) }
<div className="woocommerce-marketplace__product-card__price">
<span className="woocommerce-marketplace__product-card__price-label">
{
// '0' is a free product
product.price === 0
? __( 'Free download', 'woocommerce' )
: currencySymbol + product.price
}
</span>
<span className="woocommerce-marketplace__product-card__price-billing">
{ product.price === 0
? ''
: __( ' annually', 'woocommerce' ) }
</span>
{ ! isLoading && (
<>
<span className="woocommerce-marketplace__product-card__price-label">
{
// '0' is a free product
product.price === 0
? __( 'Free download', 'woocommerce' )
: currencySymbol + product.price
}
</span>
<span className="woocommerce-marketplace__product-card__price-billing">
{ product.price === 0
? ''
: __( ' annually', 'woocommerce' ) }
</span>
</>
) }
</div>
</div>
</Card>

View File

@ -88,7 +88,11 @@ export default function NoResults( props: NoResultsProps ): JSX.Element {
function renderProductGroup() {
if ( isLoadingProductGroup ) {
return <ProductLoader />;
return (
<ProductLoader
type={ productGroup?.itemType || ProductType.extension }
/>
);
}
if ( ! productGroup ) {

View File

@ -15,6 +15,13 @@
font-weight: 500;
margin-bottom: $medium-gap;
margin-top: $small-gap;
&.is-loading {
background: $skeleton-loader-color;
height: 26px;
margin: 0;
width: min(100%, 12em);
}
}
&__product-list-title--extensions,
@ -36,4 +43,3 @@
text-decoration: none;
}
}

View File

@ -3,6 +3,8 @@
*/
import { Link } from '@woocommerce/components';
import { __ } from '@wordpress/i18n';
import classnames from 'classnames';
/**
* Internal dependencies
*/
@ -10,15 +12,24 @@ import './product-list-header.scss';
interface ProductListHeaderProps {
title: string;
groupURL: string;
groupURL: string | null;
}
export default function ProductListHeader(
props: ProductListHeaderProps
): JSX.Element {
const { title, groupURL } = props;
const isLoading = title === '';
const classNames = classnames(
'woocommerce-marketplace__product-list-header',
{
'is-loading': isLoading,
}
);
return (
<div className="woocommerce-marketplace__product-list-header">
<div className={ classNames } aria-hidden={ isLoading }>
<h2 className="woocommerce-marketplace__product-list-title">
{ title }
</h2>

View File

@ -1,72 +0,0 @@
@import '../../stylesheets/_variables.scss';
.woocommerce-marketplace {
&__product-loader {
margin-top: $grid-unit-20;
}
&__product-loader-cards {
display: grid;
background: linear-gradient(to right, $gray-0 40%, $gray-5 60%, $gray-0 80%);
background-color: $gray-0;
background-size: 500% 200%;
animation: GradientSlide 4s linear infinite;
height: 270px;
}
&__product-loader-divider {
background: #fff;
width: 24px;
display: none;
}
.divider-1 {
grid-column-start: 2;
}
}
@media screen and (min-width: $breakpoint-medium) {
.woocommerce-marketplace {
&__product-loader-cards {
grid-template-columns: repeat(2, 1fr);
}
.divider-1 {
display: block;
}
}
}
@media screen and (min-width: $breakpoint-large) {
.woocommerce-marketplace {
&__product-loader-cards {
grid-template-columns: repeat(3, 1fr);
}
.divider-2 {
display: block;
}
}
}
@media screen and (min-width: $breakpoint-xlarge) {
.woocommerce-marketplace {
&__product-loader-cards {
grid-template-columns: repeat(4, 1fr);
}
.divider-3 {
display: block;
}
}
}
@keyframes GradientSlide {
0% {
background-position: 100% 0;
}
100% {
background-position: -100% 0;
}
}

View File

@ -5,15 +5,30 @@
/**
* Internal dependencies
*/
import './product-loader.scss';
import ProductListHeader from '../product-list-header/product-list-header';
import ProductCard from '../product-card/product-card';
interface ProductLoaderProps {
hasTitle?: boolean;
placeholderCount?: number;
type: string;
}
export default function ProductLoader(
props: ProductLoaderProps
): JSX.Element {
const { hasTitle, type } = props;
const placeholderCount = props.placeholderCount || 12;
export default function ProductLoader(): JSX.Element {
return (
<div className="woocommerce-marketplace__product-loader">
<div className="woocommerce-marketplace__product-loader-cards">
<div className="woocommerce-marketplace__product-loader-divider divider-1"></div>
<div className="woocommerce-marketplace__product-loader-divider divider-2"></div>
<div className="woocommerce-marketplace__product-loader-divider divider-3"></div>
<div className="woocommerce-marketplace__product-list">
{ hasTitle !== false && (
<ProductListHeader title="" groupURL={ null } />
) }
<div className="woocommerce-marketplace__product-list-content">
{ [ ...Array( placeholderCount ) ].map( ( element, i ) => (
<ProductCard key={ i } isLoading={ true } type={ type } />
) ) }
</div>
</div>
);

View File

@ -88,7 +88,8 @@ export default function Products( props: ProductsProps ): JSX.Element {
const containerClassName = classnames( baseContainerClass + label );
const productListTitleClassName = classnames(
'woocommerce-marketplace__product-list-title',
baseContainerClass + baseProductListTitleClass + label
baseContainerClass + baseProductListTitleClass + label,
{ 'is-loading': isLoading }
);
const viewAllButonClassName = classnames(
'woocommerce-marketplace__view-all-button',
@ -97,7 +98,14 @@ export default function Products( props: ProductsProps ): JSX.Element {
function content() {
if ( isLoading ) {
return <ProductLoader />;
return (
<>
{ props.categorySelector && (
<CategorySelector type={ props.type } />
) }
<ProductLoader hasTitle={ false } type={ props.type } />
</>
);
}
if ( products.length === 0 ) {
@ -124,7 +132,9 @@ export default function Products( props: ProductsProps ): JSX.Element {
return (
<div className={ containerClassName }>
<h2 className={ productListTitleClassName }>{ title }</h2>
<h2 className={ productListTitleClassName }>
{ isLoading ? ' ' : title }
</h2>
{ content() }
</div>
);

View File

@ -32,3 +32,4 @@ $woo-purple-50: #7f54b3;
$wp-gray-0: $gray-0;
$wp-gray-50: $gray-50;
$wp-gray-60: $gray-60;
$skeleton-loader-color: #f0f0f0;

View File

@ -0,0 +1,4 @@
Significance: patch
Type: update
Comment: Improved loading indicators on WooCommerce > Extensions so they more-accurately reflect the layout of the content that will replace them.