From 03ba981eea41b07472116386e61b691e29059323 Mon Sep 17 00:00:00 2001 From: And Finally Date: Mon, 16 Oct 2023 16:08:33 +0100 Subject: [PATCH] This branch replaces all "skeleton" loading indicators on the Marketplace with more-standard ones. It also: - Aims to make those skeleton loaders more accurately represent this size and shape of the content that will replace them. - Refactors the code so that components are responsible for hosting their own skeleton code, attached to an `isLoading` variable, making it easier to stay consistent as changes are made in future. --- .../category-selector/category-link.tsx | 4 + .../category-selector/category-selector.scss | 18 ++-- .../category-selector/category-selector.tsx | 17 ++- .../components/discover/discover.tsx | 10 +- .../components/product-card/product-card.scss | 57 +++++++++- .../components/product-card/product-card.tsx | 102 ++++++++++++------ .../product-list-content/no-results.tsx | 6 +- .../product-list-header.scss | 8 +- .../product-list-header.tsx | 15 ++- .../product-loader/product-loader.scss | 72 ------------- .../product-loader/product-loader.tsx | 29 +++-- .../components/products/products.tsx | 16 ++- .../marketplace/stylesheets/_variables.scss | 1 + .../fix-wccom-18034-modern-skeleton-loaders | 4 + 14 files changed, 223 insertions(+), 136 deletions(-) delete mode 100644 plugins/woocommerce-admin/client/marketplace/components/product-loader/product-loader.scss create mode 100644 plugins/woocommerce/changelog/fix-wccom-18034-modern-skeleton-loaders diff --git a/plugins/woocommerce-admin/client/marketplace/components/category-selector/category-link.tsx b/plugins/woocommerce-admin/client/marketplace/components/category-selector/category-link.tsx index a3efb6cde61..c859f51fb92 100644 --- a/plugins/woocommerce-admin/client/marketplace/components/category-selector/category-link.tsx +++ b/plugins/woocommerce-admin/client/marketplace/components/category-selector/category-link.tsx @@ -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 } diff --git a/plugins/woocommerce-admin/client/marketplace/components/category-selector/category-selector.scss b/plugins/woocommerce-admin/client/marketplace/components/category-selector/category-selector.scss index 83b58a66884..a7a5000ec1f 100644 --- a/plugins/woocommerce-admin/client/marketplace/components/category-selector/category-selector.scss +++ b/plugins/woocommerce-admin/client/marketplace/components/category-selector/category-selector.scss @@ -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; - } -} diff --git a/plugins/woocommerce-admin/client/marketplace/components/category-selector/category-selector.tsx b/plugins/woocommerce-admin/client/marketplace/components/category-selector/category-selector.tsx index 85c41cc011c..869148333d0 100644 --- a/plugins/woocommerce-admin/client/marketplace/components/category-selector/category-selector.tsx +++ b/plugins/woocommerce-admin/client/marketplace/components/category-selector/category-selector.tsx @@ -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 ( -
-

{ __( 'Loading categories…', 'woocommerce' ) }

- -
+ <> + + ); } diff --git a/plugins/woocommerce-admin/client/marketplace/components/discover/discover.tsx b/plugins/woocommerce-admin/client/marketplace/components/discover/discover.tsx index 2d621546639..79d8001ba8d 100644 --- a/plugins/woocommerce-admin/client/marketplace/components/discover/discover.tsx +++ b/plugins/woocommerce-admin/client/marketplace/components/discover/discover.tsx @@ -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 ; + return ( +
+ +
+ ); } const groupsList = productGroups.flatMap( ( group ) => group ); diff --git a/plugins/woocommerce-admin/client/marketplace/components/product-card/product-card.scss b/plugins/woocommerce-admin/client/marketplace/components/product-card/product-card.scss index e3ad22a5306..2f6ed356d73 100644 --- a/plugins/woocommerce-admin/client/marketplace/components/product-card/product-card.scss +++ b/plugins/woocommerce-admin/client/marketplace/components/product-card/product-card.scss @@ -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 @@ } } } + diff --git a/plugins/woocommerce-admin/client/marketplace/components/product-card/product-card.tsx b/plugins/woocommerce-admin/client/marketplace/components/product-card/product-card.tsx index 89d7d650917..04959c3fb14 100644 --- a/plugins/woocommerce-admin/client/marketplace/components/product-card/product-card.tsx +++ b/plugins/woocommerce-admin/client/marketplace/components/product-card/product-card.tsx @@ -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 ( - +
{ isTheme && (
- { + { ! isLoading && ( + { + ) }
) }
- { ! isTheme && product.icon && ( - { + { ! isTheme && ( + <> + { isLoading && ( +
+ ) } + { ! isLoading && product.icon && ( + { + ) } + ) }

@@ -60,10 +89,13 @@ function ProductCard( props: ProductCardProps ): JSX.Element { href={ product.url } rel="noopener noreferrer" > - { product.title } + { isLoading ? ' ' : product.title }

- { productVendor && ( + { isLoading && ( +

+ ) } + { ! isLoading && productVendor && (

{ __( 'By ', 'woocommerce' ) } { productVendor } @@ -74,23 +106,27 @@ function ProductCard( props: ProductCardProps ): JSX.Element {

{ ! isTheme && (

- { product.description } + { ! isLoading && product.description }

) }
- - { - // '0' is a free product - product.price === 0 - ? __( 'Free download', 'woocommerce' ) - : currencySymbol + product.price - } - - - { product.price === 0 - ? '' - : __( ' annually', 'woocommerce' ) } - + { ! isLoading && ( + <> + + { + // '0' is a free product + product.price === 0 + ? __( 'Free download', 'woocommerce' ) + : currencySymbol + product.price + } + + + { product.price === 0 + ? '' + : __( ' annually', 'woocommerce' ) } + + + ) }
diff --git a/plugins/woocommerce-admin/client/marketplace/components/product-list-content/no-results.tsx b/plugins/woocommerce-admin/client/marketplace/components/product-list-content/no-results.tsx index b64d74125d3..cecd2b81f69 100644 --- a/plugins/woocommerce-admin/client/marketplace/components/product-list-content/no-results.tsx +++ b/plugins/woocommerce-admin/client/marketplace/components/product-list-content/no-results.tsx @@ -88,7 +88,11 @@ export default function NoResults( props: NoResultsProps ): JSX.Element { function renderProductGroup() { if ( isLoadingProductGroup ) { - return ; + return ( + + ); } if ( ! productGroup ) { diff --git a/plugins/woocommerce-admin/client/marketplace/components/product-list-header/product-list-header.scss b/plugins/woocommerce-admin/client/marketplace/components/product-list-header/product-list-header.scss index 16702332caa..c9a87f6503a 100644 --- a/plugins/woocommerce-admin/client/marketplace/components/product-list-header/product-list-header.scss +++ b/plugins/woocommerce-admin/client/marketplace/components/product-list-header/product-list-header.scss @@ -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; } } - diff --git a/plugins/woocommerce-admin/client/marketplace/components/product-list-header/product-list-header.tsx b/plugins/woocommerce-admin/client/marketplace/components/product-list-header/product-list-header.tsx index 83aaf5af862..0dd4843b852 100644 --- a/plugins/woocommerce-admin/client/marketplace/components/product-list-header/product-list-header.tsx +++ b/plugins/woocommerce-admin/client/marketplace/components/product-list-header/product-list-header.tsx @@ -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 ( -
+

{ title }

diff --git a/plugins/woocommerce-admin/client/marketplace/components/product-loader/product-loader.scss b/plugins/woocommerce-admin/client/marketplace/components/product-loader/product-loader.scss deleted file mode 100644 index 560b46cf014..00000000000 --- a/plugins/woocommerce-admin/client/marketplace/components/product-loader/product-loader.scss +++ /dev/null @@ -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; - } -} diff --git a/plugins/woocommerce-admin/client/marketplace/components/product-loader/product-loader.tsx b/plugins/woocommerce-admin/client/marketplace/components/product-loader/product-loader.tsx index 293b1b6e4ba..d5103c6386d 100644 --- a/plugins/woocommerce-admin/client/marketplace/components/product-loader/product-loader.tsx +++ b/plugins/woocommerce-admin/client/marketplace/components/product-loader/product-loader.tsx @@ -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 ( -
-
-
-
-
+
+ { hasTitle !== false && ( + + ) } +
+ { [ ...Array( placeholderCount ) ].map( ( element, i ) => ( + + ) ) }
); diff --git a/plugins/woocommerce-admin/client/marketplace/components/products/products.tsx b/plugins/woocommerce-admin/client/marketplace/components/products/products.tsx index dd712482ffc..af6f017cc91 100644 --- a/plugins/woocommerce-admin/client/marketplace/components/products/products.tsx +++ b/plugins/woocommerce-admin/client/marketplace/components/products/products.tsx @@ -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 ; + return ( + <> + { props.categorySelector && ( + + ) } + + + ); } if ( products.length === 0 ) { @@ -124,7 +132,9 @@ export default function Products( props: ProductsProps ): JSX.Element { return (
-

{ title }

+

+ { isLoading ? ' ' : title } +

{ content() }
); diff --git a/plugins/woocommerce-admin/client/marketplace/stylesheets/_variables.scss b/plugins/woocommerce-admin/client/marketplace/stylesheets/_variables.scss index 275941fd774..1ac3d139c5e 100644 --- a/plugins/woocommerce-admin/client/marketplace/stylesheets/_variables.scss +++ b/plugins/woocommerce-admin/client/marketplace/stylesheets/_variables.scss @@ -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; diff --git a/plugins/woocommerce/changelog/fix-wccom-18034-modern-skeleton-loaders b/plugins/woocommerce/changelog/fix-wccom-18034-modern-skeleton-loaders new file mode 100644 index 00000000000..da6ff1e1a67 --- /dev/null +++ b/plugins/woocommerce/changelog/fix-wccom-18034-modern-skeleton-loaders @@ -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. +