Marketplace: modernize skeleton loaders (#40131)
This commit is contained in:
commit
03897613ac
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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 );
|
||||
|
|
|
@ -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 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 ) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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.
|
||||
|
Loading…
Reference in New Issue