khaihihi
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { WC_BLOCKS_ASSET_URL } from '@woocommerce/block-settings';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const BlockError = ( {
|
||||
imageUrl = `${ WC_BLOCKS_ASSET_URL }img/block-error.svg`,
|
||||
header = __( 'Oops!', 'woocommerce' ),
|
||||
text = __(
|
||||
'There was an error with loading this content.',
|
||||
'woocommerce'
|
||||
),
|
||||
errorMessage,
|
||||
} ) => {
|
||||
return (
|
||||
<div className="wc-block-error">
|
||||
{ imageUrl && (
|
||||
<img
|
||||
className="wc-block-error__image"
|
||||
src={ imageUrl }
|
||||
alt=""
|
||||
/>
|
||||
) }
|
||||
<div className="wc-block-error__content">
|
||||
{ header && (
|
||||
<p className="wc-block-error__header">{ header }</p>
|
||||
) }
|
||||
{ text && <p className="wc-block-error__text">{ text }</p> }
|
||||
{ errorMessage && (
|
||||
<p className="wc-block-error__message">{ errorMessage }</p>
|
||||
) }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
BlockError.propTypes = {
|
||||
/**
|
||||
* Error message to display below the content.
|
||||
*/
|
||||
errorMessage: PropTypes.string,
|
||||
/**
|
||||
* Text to display as the heading of the error block.
|
||||
* If it's `null` or an empty string, no header will be displayed.
|
||||
* If it's not defined, the default header will be used.
|
||||
*/
|
||||
header: PropTypes.string,
|
||||
/**
|
||||
* URL of the image to display.
|
||||
* If it's `null` or an empty string, no image will be displayed.
|
||||
* If it's not defined, the default image will be used.
|
||||
*/
|
||||
imageUrl: PropTypes.string,
|
||||
/**
|
||||
* Text to display in the error block below the header.
|
||||
* If it's `null` or an empty string, nothing will be displayed.
|
||||
* If it's not defined, the default text will be used.
|
||||
*/
|
||||
text: PropTypes.string,
|
||||
};
|
||||
|
||||
export default BlockError;
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import BlockError from './block-error';
|
||||
import './style.scss';
|
||||
|
||||
class BlockErrorBoundary extends Component {
|
||||
state = { hasError: false };
|
||||
|
||||
static getDerivedStateFromError( error ) {
|
||||
return { errorMessage: error.message, hasError: true };
|
||||
}
|
||||
|
||||
render() {
|
||||
const { header, imageUrl, showErrorMessage, text } = this.props;
|
||||
const { errorMessage, hasError } = this.state;
|
||||
|
||||
if ( hasError ) {
|
||||
return (
|
||||
<BlockError
|
||||
errorMessage={ showErrorMessage ? errorMessage : null }
|
||||
header={ header }
|
||||
imageUrl={ imageUrl }
|
||||
text={ text }
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
BlockErrorBoundary.propTypes = {
|
||||
/**
|
||||
* Text to display as the heading of the error block.
|
||||
* If it's `null` or an empty string, no header will be displayed.
|
||||
* If it's not defined, the default header will be used.
|
||||
*/
|
||||
header: PropTypes.string,
|
||||
/**
|
||||
* URL of the image to display.
|
||||
* If it's `null` or an empty string, no image will be displayed.
|
||||
* If it's not defined, the default image will be used.
|
||||
*/
|
||||
imageUrl: PropTypes.string,
|
||||
/**
|
||||
* Whether to display the JS error message.
|
||||
*/
|
||||
showErrorMessage: PropTypes.bool,
|
||||
/**
|
||||
* Text to display in the error block below the header.
|
||||
* If it's `null` or an empty string, nothing will be displayed.
|
||||
* If it's not defined, the default text will be used.
|
||||
*/
|
||||
text: PropTypes.string,
|
||||
};
|
||||
|
||||
BlockErrorBoundary.defaultProps = {
|
||||
showErrorMessage: false,
|
||||
};
|
||||
|
||||
export default BlockErrorBoundary;
|
||||
@@ -0,0 +1,30 @@
|
||||
.wc-block-error {
|
||||
display: flex;
|
||||
background-color: #f3f3f4;
|
||||
border-left: 4px solid #6d6d6d;
|
||||
padding: $gap-larger $gap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wc-block-error__header {
|
||||
font-size: 2em;
|
||||
font-weight: bold;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wc-block-error__text,
|
||||
.wc-block-error__message {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@include breakpoint( ">480px" ) {
|
||||
.wc-block-error {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.wc-block-error__image + .wc-block-error__content {
|
||||
margin-left: $gap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __, _n, sprintf } from '@wordpress/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Fragment, useMemo, useState } from '@wordpress/element';
|
||||
import classNames from 'classnames';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import './style.scss';
|
||||
|
||||
/**
|
||||
* Component used to show a list of checkboxes in a group.
|
||||
*/
|
||||
const CheckboxList = ( {
|
||||
className,
|
||||
onChange = () => {},
|
||||
options = [],
|
||||
checked = [],
|
||||
isLoading = false,
|
||||
isDisabled = false,
|
||||
limit = 10,
|
||||
} ) => {
|
||||
const [ showExpanded, setShowExpanded ] = useState( false );
|
||||
|
||||
const placeholder = useMemo( () => {
|
||||
return [ ...Array( 5 ) ].map( ( x, i ) => (
|
||||
<li
|
||||
key={ i }
|
||||
style={ {
|
||||
/* stylelint-disable */
|
||||
width: Math.floor( Math.random() * 75 ) + 25 + '%',
|
||||
} }
|
||||
/>
|
||||
) );
|
||||
}, [] );
|
||||
|
||||
const renderedShowMore = useMemo( () => {
|
||||
const optionCount = options.length;
|
||||
const remainingOptionsCount = optionCount - limit;
|
||||
return (
|
||||
! showExpanded && (
|
||||
<li key="show-more" className="show-more">
|
||||
<button
|
||||
onClick={ () => {
|
||||
setShowExpanded( true );
|
||||
} }
|
||||
aria-expanded={ false }
|
||||
aria-label={ sprintf(
|
||||
_n(
|
||||
'Show %s more option',
|
||||
'Show %s more options',
|
||||
remainingOptionsCount,
|
||||
'woocommerce'
|
||||
),
|
||||
remainingOptionsCount
|
||||
) }
|
||||
>
|
||||
{ sprintf(
|
||||
// translators: %s number of options to reveal.
|
||||
_n(
|
||||
'Show %s more',
|
||||
'Show %s more',
|
||||
remainingOptionsCount,
|
||||
'woocommerce'
|
||||
),
|
||||
remainingOptionsCount
|
||||
) }
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
);
|
||||
}, [ options, limit, showExpanded ] );
|
||||
|
||||
const renderedShowLess = useMemo( () => {
|
||||
return (
|
||||
showExpanded && (
|
||||
<li key="show-less" className="show-less">
|
||||
<button
|
||||
onClick={ () => {
|
||||
setShowExpanded( false );
|
||||
} }
|
||||
aria-expanded={ true }
|
||||
aria-label={ __(
|
||||
'Show less options',
|
||||
'woocommerce'
|
||||
) }
|
||||
>
|
||||
{ __( 'Show less', 'woocommerce' ) }
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
);
|
||||
}, [ showExpanded ] );
|
||||
|
||||
const renderedOptions = useMemo( () => {
|
||||
// Truncate options if > the limit + 5.
|
||||
const optionCount = options.length;
|
||||
const shouldTruncateOptions = optionCount > limit + 5;
|
||||
return (
|
||||
<Fragment>
|
||||
{ options.map( ( option, index ) => (
|
||||
<Fragment key={ option.key }>
|
||||
<li
|
||||
{ ...( shouldTruncateOptions &&
|
||||
! showExpanded &&
|
||||
index >= limit && { hidden: true } ) }
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
id={ option.key }
|
||||
value={ option.key }
|
||||
onChange={ onChange }
|
||||
checked={ checked.includes( option.key ) }
|
||||
disabled={ isDisabled }
|
||||
/>
|
||||
<label htmlFor={ option.key }>
|
||||
{ option.label }
|
||||
</label>
|
||||
</li>
|
||||
{ shouldTruncateOptions &&
|
||||
index === limit - 1 &&
|
||||
renderedShowMore }
|
||||
</Fragment>
|
||||
) ) }
|
||||
{ shouldTruncateOptions && renderedShowLess }
|
||||
</Fragment>
|
||||
);
|
||||
}, [
|
||||
options,
|
||||
checked,
|
||||
showExpanded,
|
||||
limit,
|
||||
renderedShowLess,
|
||||
renderedShowMore,
|
||||
isDisabled,
|
||||
] );
|
||||
|
||||
const classes = classNames(
|
||||
'wc-block-checkbox-list',
|
||||
{
|
||||
'is-loading': isLoading,
|
||||
},
|
||||
className
|
||||
);
|
||||
|
||||
return (
|
||||
<ul className={ classes }>
|
||||
{ isLoading ? placeholder : renderedOptions }
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
CheckboxList.propTypes = {
|
||||
onChange: PropTypes.func,
|
||||
options: PropTypes.arrayOf(
|
||||
PropTypes.shape( {
|
||||
key: PropTypes.string.isRequired,
|
||||
label: PropTypes.node.isRequired,
|
||||
} )
|
||||
),
|
||||
checked: PropTypes.array,
|
||||
className: PropTypes.string,
|
||||
isLoading: PropTypes.bool,
|
||||
isDisabled: PropTypes.bool,
|
||||
limit: PropTypes.number,
|
||||
};
|
||||
|
||||
export default CheckboxList;
|
||||
@@ -0,0 +1,29 @@
|
||||
.editor-styles-wrapper .wc-block-checkbox-list,
|
||||
.wc-block-checkbox-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none outside;
|
||||
|
||||
li {
|
||||
margin: 0 0 $gap-smallest;
|
||||
padding: 0;
|
||||
list-style: none outside;
|
||||
}
|
||||
|
||||
li.show-more,
|
||||
li.show-less {
|
||||
button {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-loading {
|
||||
li {
|
||||
@include placeholder();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import classnames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import { __, sprintf } from '@wordpress/i18n';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import Label from '../../label';
|
||||
import './style.scss';
|
||||
|
||||
const StepNumber = ( { stepNumber } ) => {
|
||||
return (
|
||||
<div className="wc-components-checkout-step__number">
|
||||
<Label
|
||||
label={ stepNumber }
|
||||
screenReaderLabel={ sprintf(
|
||||
__(
|
||||
// translators: %s is a step number (1, 2, 3...)
|
||||
'Step %d',
|
||||
'woocommerce'
|
||||
),
|
||||
stepNumber
|
||||
) }
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const StepHeading = ( { title, stepHeadingContent } ) => (
|
||||
<div className="wc-components-checkout-step__heading">
|
||||
<h4 className="wc-components-checkout-step__title">{ title }</h4>
|
||||
<span className="wc-components-checkout-step__heading-content">
|
||||
{ stepHeadingContent }
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const FormStep = ( {
|
||||
id,
|
||||
className,
|
||||
stepNumber,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
stepHeadingContent = () => null,
|
||||
} ) => {
|
||||
return (
|
||||
<div
|
||||
className={ classnames( className, 'wc-components-checkout-step' ) }
|
||||
id={ id }
|
||||
>
|
||||
<StepNumber stepNumber={ stepNumber } />
|
||||
<StepHeading
|
||||
title={ title }
|
||||
stepHeadingContent={ stepHeadingContent() }
|
||||
/>
|
||||
<span className="wc-components-checkout-step__description">
|
||||
{ description }
|
||||
</span>
|
||||
<div className="wc-components-checkout-step__content">
|
||||
{ children }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
FormStep.propTypes = {
|
||||
id: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
stepNumber: PropTypes.number,
|
||||
title: PropTypes.string,
|
||||
description: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
stepHeadingContent: PropTypes.func,
|
||||
};
|
||||
|
||||
export default FormStep;
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import PropTypes from 'prop-types';
|
||||
import { Fragment } from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
/**
|
||||
* Component used to render an accessible text given a label and/or a
|
||||
* screenReaderLabel. The wrapper element and wrapper props can also be
|
||||
* specified via props.
|
||||
*/
|
||||
const Label = ( {
|
||||
label,
|
||||
screenReaderLabel,
|
||||
wrapperElement,
|
||||
wrapperProps,
|
||||
} ) => {
|
||||
let Wrapper;
|
||||
|
||||
if ( ! label && screenReaderLabel ) {
|
||||
Wrapper = wrapperElement || 'span';
|
||||
wrapperProps = {
|
||||
...wrapperProps,
|
||||
className: classNames(
|
||||
wrapperProps.className,
|
||||
'screen-reader-text'
|
||||
),
|
||||
};
|
||||
|
||||
return <Wrapper { ...wrapperProps }>{ screenReaderLabel }</Wrapper>;
|
||||
}
|
||||
|
||||
Wrapper = wrapperElement || Fragment;
|
||||
|
||||
if ( label && screenReaderLabel && label !== screenReaderLabel ) {
|
||||
return (
|
||||
<Wrapper { ...wrapperProps }>
|
||||
<span aria-hidden="true">{ label }</span>
|
||||
<span className="screen-reader-text">
|
||||
{ screenReaderLabel }
|
||||
</span>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return <Wrapper { ...wrapperProps }>{ label }</Wrapper>;
|
||||
};
|
||||
|
||||
Label.propTypes = {
|
||||
label: PropTypes.string,
|
||||
screenReaderLabel: PropTypes.string,
|
||||
wrapperElement: PropTypes.elementType,
|
||||
wrapperProps: PropTypes.object,
|
||||
};
|
||||
|
||||
Label.defaultProps = {
|
||||
wrapperProps: {},
|
||||
};
|
||||
|
||||
export default Label;
|
||||
@@ -0,0 +1,62 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Label with wrapperElement should render both label and screen reader label 1`] = `
|
||||
<label
|
||||
className="foo-bar"
|
||||
data-foo="bar"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
>
|
||||
Lorem
|
||||
</span>
|
||||
<span
|
||||
className="screen-reader-text"
|
||||
>
|
||||
Ipsum
|
||||
</span>
|
||||
</label>
|
||||
`;
|
||||
|
||||
exports[`Label with wrapperElement should render only the label 1`] = `
|
||||
<label
|
||||
className="foo-bar"
|
||||
data-foo="bar"
|
||||
>
|
||||
Lorem
|
||||
</label>
|
||||
`;
|
||||
|
||||
exports[`Label with wrapperElement should render only the screen reader label 1`] = `
|
||||
<label
|
||||
className="foo-bar screen-reader-text"
|
||||
data-foo="bar"
|
||||
>
|
||||
Ipsum
|
||||
</label>
|
||||
`;
|
||||
|
||||
exports[`Label without wrapperElement should render both label and screen reader label 1`] = `
|
||||
Array [
|
||||
<span
|
||||
aria-hidden="true"
|
||||
>
|
||||
Lorem
|
||||
</span>,
|
||||
<span
|
||||
className="screen-reader-text"
|
||||
>
|
||||
Ipsum
|
||||
</span>,
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`Label without wrapperElement should render only the label 1`] = `"Lorem"`;
|
||||
|
||||
exports[`Label without wrapperElement should render only the screen reader label 1`] = `
|
||||
<span
|
||||
className="screen-reader-text"
|
||||
>
|
||||
Ipsum
|
||||
</span>
|
||||
`;
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import TestRenderer from 'react-test-renderer';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import Label from '../';
|
||||
|
||||
describe( 'Label', () => {
|
||||
describe( 'without wrapperElement', () => {
|
||||
test( 'should render both label and screen reader label', () => {
|
||||
const component = TestRenderer.create(
|
||||
<Label label="Lorem" screenReaderLabel="Ipsum" />
|
||||
);
|
||||
|
||||
expect( component.toJSON() ).toMatchSnapshot();
|
||||
} );
|
||||
|
||||
test( 'should render only the label', () => {
|
||||
const component = TestRenderer.create( <Label label="Lorem" /> );
|
||||
|
||||
expect( component.toJSON() ).toMatchSnapshot();
|
||||
} );
|
||||
|
||||
test( 'should render only the screen reader label', () => {
|
||||
const component = TestRenderer.create(
|
||||
<Label screenReaderLabel="Ipsum" />
|
||||
);
|
||||
|
||||
expect( component.toJSON() ).toMatchSnapshot();
|
||||
} );
|
||||
} );
|
||||
|
||||
describe( 'with wrapperElement', () => {
|
||||
test( 'should render both label and screen reader label', () => {
|
||||
const component = TestRenderer.create(
|
||||
<Label
|
||||
label="Lorem"
|
||||
screenReaderLabel="Ipsum"
|
||||
wrapperElement="label"
|
||||
wrapperProps={ {
|
||||
className: 'foo-bar',
|
||||
'data-foo': 'bar',
|
||||
} }
|
||||
/>
|
||||
);
|
||||
|
||||
expect( component.toJSON() ).toMatchSnapshot();
|
||||
} );
|
||||
|
||||
test( 'should render only the label', () => {
|
||||
const component = TestRenderer.create(
|
||||
<Label
|
||||
label="Lorem"
|
||||
wrapperElement="label"
|
||||
wrapperProps={ {
|
||||
className: 'foo-bar',
|
||||
'data-foo': 'bar',
|
||||
} }
|
||||
/>
|
||||
);
|
||||
|
||||
expect( component.toJSON() ).toMatchSnapshot();
|
||||
} );
|
||||
|
||||
test( 'should render only the screen reader label', () => {
|
||||
const component = TestRenderer.create(
|
||||
<Label
|
||||
screenReaderLabel="Ipsum"
|
||||
wrapperElement="label"
|
||||
wrapperProps={ {
|
||||
className: 'foo-bar',
|
||||
'data-foo': 'bar',
|
||||
} }
|
||||
/>
|
||||
);
|
||||
|
||||
expect( component.toJSON() ).toMatchSnapshot();
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
import Label from '@woocommerce/base-components/label';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import './style.scss';
|
||||
|
||||
export const LoadMoreButton = ( { onClick, label, screenReaderLabel } ) => {
|
||||
return (
|
||||
<div className="wp-block-button wc-block-load-more">
|
||||
<button className="wp-block-button__link" onClick={ onClick }>
|
||||
<Label
|
||||
label={ label }
|
||||
screenReaderLabel={ screenReaderLabel }
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
LoadMoreButton.propTypes = {
|
||||
label: PropTypes.string,
|
||||
onClick: PropTypes.func,
|
||||
screenReaderLabel: PropTypes.string,
|
||||
};
|
||||
|
||||
LoadMoreButton.defaultProps = {
|
||||
label: __( 'Load more', 'woocommerce' ),
|
||||
};
|
||||
|
||||
export default LoadMoreButton;
|
||||
@@ -0,0 +1,4 @@
|
||||
.wc-block-load-more {
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import Label from '@woocommerce/base-components/label';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { getIndexes } from './utils.js';
|
||||
import './style.scss';
|
||||
|
||||
const Pagination = ( {
|
||||
currentPage,
|
||||
displayFirstAndLastPages,
|
||||
displayNextAndPreviousArrows,
|
||||
pagesToDisplay,
|
||||
onPageChange,
|
||||
totalPages,
|
||||
} ) => {
|
||||
let { minIndex, maxIndex } = getIndexes(
|
||||
pagesToDisplay,
|
||||
currentPage,
|
||||
totalPages
|
||||
);
|
||||
const showFirstPage = displayFirstAndLastPages && Boolean( minIndex !== 1 );
|
||||
const showLastPage =
|
||||
displayFirstAndLastPages && Boolean( maxIndex !== totalPages );
|
||||
const showFirstPageEllipsis =
|
||||
displayFirstAndLastPages && Boolean( minIndex > 3 );
|
||||
const showLastPageEllipsis =
|
||||
displayFirstAndLastPages && Boolean( maxIndex < totalPages - 2 );
|
||||
|
||||
// Handle the cases where there would be an ellipsis replacing one single page
|
||||
if ( showFirstPage && minIndex === 3 ) {
|
||||
minIndex = minIndex - 1;
|
||||
}
|
||||
if ( showLastPage && maxIndex === totalPages - 2 ) {
|
||||
maxIndex = maxIndex + 1;
|
||||
}
|
||||
|
||||
const pages = [];
|
||||
if ( minIndex && maxIndex ) {
|
||||
for ( let i = minIndex; i <= maxIndex; i++ ) {
|
||||
pages.push( i );
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="wc-block-pagination">
|
||||
<Label
|
||||
screenReaderLabel={ __(
|
||||
'Navigate to another page',
|
||||
'woocommerce'
|
||||
) }
|
||||
/>
|
||||
{ displayNextAndPreviousArrows && (
|
||||
<button
|
||||
className="wc-block-pagination-page"
|
||||
onClick={ () => onPageChange( currentPage - 1 ) }
|
||||
title={ __(
|
||||
'Previous page',
|
||||
'woocommerce'
|
||||
) }
|
||||
disabled={ currentPage <= 1 }
|
||||
>
|
||||
<Label
|
||||
label="<"
|
||||
screenReaderLabel={ __(
|
||||
'Previous page',
|
||||
'woocommerce'
|
||||
) }
|
||||
/>
|
||||
</button>
|
||||
) }
|
||||
{ showFirstPage && (
|
||||
<button
|
||||
className={ classNames( 'wc-block-pagination-page', {
|
||||
'wc-block-pagination-page--active': currentPage === 1,
|
||||
} ) }
|
||||
onClick={ () => onPageChange( 1 ) }
|
||||
disabled={ currentPage === 1 }
|
||||
>
|
||||
1
|
||||
</button>
|
||||
) }
|
||||
{ showFirstPageEllipsis && (
|
||||
<span
|
||||
className="wc-block-pagination-ellipsis"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{ __( '…', 'woocommerce' ) }
|
||||
</span>
|
||||
) }
|
||||
{ pages.map( ( page ) => {
|
||||
return (
|
||||
<button
|
||||
key={ page }
|
||||
className={ classNames( 'wc-block-pagination-page', {
|
||||
'wc-block-pagination-page--active':
|
||||
currentPage === page,
|
||||
} ) }
|
||||
onClick={
|
||||
currentPage === page
|
||||
? null
|
||||
: () => onPageChange( page )
|
||||
}
|
||||
disabled={ currentPage === page }
|
||||
>
|
||||
{ page }
|
||||
</button>
|
||||
);
|
||||
} ) }
|
||||
{ showLastPageEllipsis && (
|
||||
<span
|
||||
className="wc-block-pagination-ellipsis"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{ __( '…', 'woocommerce' ) }
|
||||
</span>
|
||||
) }
|
||||
{ showLastPage && (
|
||||
<button
|
||||
className={ classNames( 'wc-block-pagination-page', {
|
||||
'wc-block-pagination-page--active':
|
||||
currentPage === totalPages,
|
||||
} ) }
|
||||
onClick={ () => onPageChange( totalPages ) }
|
||||
disabled={ currentPage === totalPages }
|
||||
>
|
||||
{ totalPages }
|
||||
</button>
|
||||
) }
|
||||
{ displayNextAndPreviousArrows && (
|
||||
<button
|
||||
className="wc-block-pagination-page"
|
||||
onClick={ () => onPageChange( currentPage + 1 ) }
|
||||
title={ __( 'Next page', 'woocommerce' ) }
|
||||
disabled={ currentPage >= totalPages }
|
||||
>
|
||||
<Label
|
||||
label=">"
|
||||
screenReaderLabel={ __(
|
||||
'Next page',
|
||||
'woocommerce'
|
||||
) }
|
||||
/>
|
||||
</button>
|
||||
) }
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Pagination.propTypes = {
|
||||
/**
|
||||
* Number of the page currently being displayed.
|
||||
*/
|
||||
currentPage: PropTypes.number.isRequired,
|
||||
/**
|
||||
* Total number of pages.
|
||||
*/
|
||||
totalPages: PropTypes.number.isRequired,
|
||||
/**
|
||||
* Displays first and last pages if they are not in the current range of pages displayed.
|
||||
*/
|
||||
displayFirstAndLastPages: PropTypes.bool,
|
||||
/**
|
||||
* Displays arrows to navigate to the previous and next pages.
|
||||
*/
|
||||
displayNextAndPreviousArrows: PropTypes.bool,
|
||||
/**
|
||||
* Callback function called when the user triggers a page change.
|
||||
*/
|
||||
onPageChange: PropTypes.func,
|
||||
/**
|
||||
* Number of pages to display at the same time, including the active page
|
||||
* and the pages displayed before and after it. It doesn't include the first
|
||||
* and last pages.
|
||||
*/
|
||||
pagesToDisplay: PropTypes.number,
|
||||
};
|
||||
|
||||
Pagination.defaultProps = {
|
||||
displayFirstAndLastPages: true,
|
||||
displayNextAndPreviousArrows: true,
|
||||
pagesToDisplay: 3,
|
||||
};
|
||||
|
||||
export default Pagination;
|
||||
@@ -0,0 +1,51 @@
|
||||
.wc-block-pagination {
|
||||
margin: 0 auto $gap;
|
||||
}
|
||||
|
||||
.wc-block-pagination-page,
|
||||
.wc-block-pagination-ellipsis {
|
||||
color: #333;
|
||||
display: inline-block;
|
||||
font-size: 1em;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.wc-block-pagination-page {
|
||||
border-color: transparent;
|
||||
padding: 0.3em 0.6em;
|
||||
min-width: 2.2em;
|
||||
|
||||
@include breakpoint( "<782px" ) {
|
||||
padding: 0.1em 0.2em;
|
||||
min-width: 1.6em;
|
||||
}
|
||||
|
||||
// Twenty Twenty register a background color for buttons that is too specific
|
||||
// and broad at the same time `button:not(.toggle)` so we're engaing in a
|
||||
// specify war with them here.
|
||||
// https://github.com/woocommerce/woocommerce-gutenberg-products-block/issues/1203
|
||||
&:not(.toggle) {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-pagination-ellipsis {
|
||||
padding: 0.3em;
|
||||
|
||||
@include breakpoint( "<782px" ) {
|
||||
padding: 0.1em;
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-pagination-page--active[disabled] {
|
||||
color: #333;
|
||||
font-weight: bold;
|
||||
opacity: 1 !important;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: inherit;
|
||||
color: #333;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { getIndexes } from '../utils.js';
|
||||
|
||||
describe( 'getIndexes', () => {
|
||||
describe( 'when on the first page', () => {
|
||||
test( 'indexes include the first pages available', () => {
|
||||
expect( getIndexes( 5, 1, 100 ) ).toEqual( {
|
||||
minIndex: 2,
|
||||
maxIndex: 6,
|
||||
} );
|
||||
} );
|
||||
|
||||
test( 'indexes are null if there are 2 pages or less', () => {
|
||||
expect( getIndexes( 5, 1, 1 ) ).toEqual( {
|
||||
minIndex: null,
|
||||
maxIndex: null,
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
|
||||
describe( 'when on a page in the middle', () => {
|
||||
test( 'indexes include pages before and after the current page', () => {
|
||||
expect( getIndexes( 5, 50, 100 ) ).toEqual( {
|
||||
minIndex: 48,
|
||||
maxIndex: 52,
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
|
||||
describe( 'when on the last page', () => {
|
||||
test( 'indexes include the last pages available', () => {
|
||||
expect( getIndexes( 5, 100, 100 ) ).toEqual( {
|
||||
minIndex: 95,
|
||||
maxIndex: 99,
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Given the number of pages to display, the current page and the total pages,
|
||||
* returns the min and max index of the pages to display in the pagination component.
|
||||
*
|
||||
* @param {integer} pagesToDisplay Maximum number of pages to display in the pagination component.
|
||||
* @param {integer} currentPage Page currently visible.
|
||||
* @param {integer} totalPages Total pages available.
|
||||
* @return {Object} Object containing the min and max index to display in the pagination component.
|
||||
*/
|
||||
export const getIndexes = ( pagesToDisplay, currentPage, totalPages ) => {
|
||||
if ( totalPages <= 2 ) {
|
||||
return { minIndex: null, maxIndex: null };
|
||||
}
|
||||
const extraPagesToDisplay = pagesToDisplay - 1;
|
||||
const tentativeMinIndex = Math.max(
|
||||
Math.floor( currentPage - extraPagesToDisplay / 2 ),
|
||||
2
|
||||
);
|
||||
const maxIndex = Math.min(
|
||||
Math.ceil(
|
||||
currentPage +
|
||||
( extraPagesToDisplay - ( currentPage - tentativeMinIndex ) )
|
||||
),
|
||||
totalPages - 1
|
||||
);
|
||||
const minIndex = Math.max(
|
||||
Math.floor(
|
||||
currentPage - ( extraPagesToDisplay - ( maxIndex - currentPage ) )
|
||||
),
|
||||
2
|
||||
);
|
||||
|
||||
return { minIndex, maxIndex };
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Validate a min and max value for a range slider againt defined constraints (min, max, step).
|
||||
*
|
||||
* @param {Array} values Array containing min and max values.
|
||||
* @param {int} min Min allowed value for the sliders.
|
||||
* @param {int} max Max allowed value for the sliders.
|
||||
* @param {step} step Step value for the sliders.
|
||||
* @param {boolean} isMin Whether we're currently interacting with the min range slider or not, so we update the correct values.
|
||||
* @returns {Array} Validated and updated min/max values that fit within the range slider constraints.
|
||||
*/
|
||||
export const constrainRangeSliderValues = (
|
||||
values,
|
||||
min,
|
||||
max,
|
||||
step = 1,
|
||||
isMin = false
|
||||
) => {
|
||||
let minValue = parseInt( values[ 0 ], 10 );
|
||||
let maxValue = parseInt( values[ 1 ], 10 );
|
||||
|
||||
if ( ! Number.isFinite( minValue ) ) {
|
||||
minValue = min || 0;
|
||||
}
|
||||
|
||||
if ( ! Number.isFinite( maxValue ) ) {
|
||||
maxValue = max || step;
|
||||
}
|
||||
|
||||
if ( Number.isFinite( min ) && min > minValue ) {
|
||||
minValue = min;
|
||||
}
|
||||
|
||||
if ( Number.isFinite( max ) && max <= minValue ) {
|
||||
minValue = max - step;
|
||||
}
|
||||
|
||||
if ( Number.isFinite( min ) && min >= maxValue ) {
|
||||
maxValue = min + step;
|
||||
}
|
||||
|
||||
if ( Number.isFinite( max ) && max < maxValue ) {
|
||||
maxValue = max;
|
||||
}
|
||||
|
||||
if ( ! isMin && minValue >= maxValue ) {
|
||||
minValue = maxValue - step;
|
||||
}
|
||||
|
||||
if ( isMin && maxValue <= minValue ) {
|
||||
maxValue = minValue + step;
|
||||
}
|
||||
|
||||
return [ minValue, maxValue ];
|
||||
};
|
||||
@@ -0,0 +1,386 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import {
|
||||
Fragment,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from '@wordpress/element';
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import './style.scss';
|
||||
import { constrainRangeSliderValues } from './constrain-range-slider-values';
|
||||
import { formatPrice } from '../../utils/price';
|
||||
import SubmitButton from './submit-button';
|
||||
import PriceLabel from './price-label';
|
||||
import PriceInput from './price-input';
|
||||
|
||||
const PriceSlider = ( {
|
||||
minPrice,
|
||||
maxPrice,
|
||||
minConstraint,
|
||||
maxConstraint,
|
||||
onChange = () => {},
|
||||
step = 10,
|
||||
currencySymbol = '$',
|
||||
priceFormat = '%1$s%2$s',
|
||||
showInputFields = true,
|
||||
showFilterButton = false,
|
||||
isLoading = false,
|
||||
onSubmit = () => {},
|
||||
} ) => {
|
||||
const minRange = useRef();
|
||||
const maxRange = useRef();
|
||||
|
||||
const [ formattedMinPrice, setFormattedMinPrice ] = useState(
|
||||
formatPrice( minPrice, priceFormat, currencySymbol )
|
||||
);
|
||||
const [ formattedMaxPrice, setFormattedMaxPrice ] = useState(
|
||||
formatPrice( maxPrice, priceFormat, currencySymbol )
|
||||
);
|
||||
|
||||
useEffect( () => {
|
||||
setFormattedMinPrice(
|
||||
formatPrice( minPrice, priceFormat, currencySymbol )
|
||||
);
|
||||
}, [ minPrice, priceFormat, currencySymbol ] );
|
||||
|
||||
useEffect( () => {
|
||||
setFormattedMaxPrice(
|
||||
formatPrice( maxPrice, priceFormat, currencySymbol )
|
||||
);
|
||||
}, [ maxPrice, priceFormat, currencySymbol ] );
|
||||
|
||||
/**
|
||||
* Checks if the min and max constraints are valid.
|
||||
*/
|
||||
const hasValidConstraints = useMemo( () => {
|
||||
return isFinite( minConstraint ) && isFinite( maxConstraint );
|
||||
}, [ minConstraint, maxConstraint ] );
|
||||
|
||||
/**
|
||||
* Handles styles for the shaded area of the range slider.
|
||||
*/
|
||||
const progressStyles = useMemo( () => {
|
||||
if (
|
||||
! isFinite( minPrice ) ||
|
||||
! isFinite( maxPrice ) ||
|
||||
! hasValidConstraints
|
||||
) {
|
||||
return {
|
||||
'--low': '0%',
|
||||
'--high': '100%',
|
||||
};
|
||||
}
|
||||
|
||||
const low =
|
||||
Math.round(
|
||||
100 *
|
||||
( ( minPrice - minConstraint ) /
|
||||
( maxConstraint - minConstraint ) )
|
||||
) - 0.5;
|
||||
const high =
|
||||
Math.round(
|
||||
100 *
|
||||
( ( maxPrice - minConstraint ) /
|
||||
( maxConstraint - minConstraint ) )
|
||||
) + 0.5;
|
||||
|
||||
return {
|
||||
'--low': low + '%',
|
||||
'--high': high + '%',
|
||||
};
|
||||
}, [
|
||||
minPrice,
|
||||
maxPrice,
|
||||
minConstraint,
|
||||
maxConstraint,
|
||||
hasValidConstraints,
|
||||
step,
|
||||
] );
|
||||
|
||||
/**
|
||||
* Works around an IE issue where only one range selector is visible by changing the display order
|
||||
* based on the mouse position.
|
||||
*
|
||||
* @param {obj} event event data.
|
||||
*/
|
||||
const findClosestRange = useCallback(
|
||||
( event ) => {
|
||||
if ( isLoading || ! hasValidConstraints ) {
|
||||
return;
|
||||
}
|
||||
const bounds = event.target.getBoundingClientRect();
|
||||
const x = event.clientX - bounds.left;
|
||||
const minWidth = minRange.current.offsetWidth;
|
||||
const minValue = minRange.current.value;
|
||||
const maxWidth = maxRange.current.offsetWidth;
|
||||
const maxValue = maxRange.current.value;
|
||||
|
||||
const minX = minWidth * ( minValue / maxConstraint );
|
||||
const maxX = maxWidth * ( maxValue / maxConstraint );
|
||||
|
||||
const minXDiff = Math.abs( x - minX );
|
||||
const maxXDiff = Math.abs( x - maxX );
|
||||
|
||||
/**
|
||||
* The default z-index in the stylesheet as 20. 20 vs 21 is just for determining which range
|
||||
* slider should be at the front and has no meaning beyond
|
||||
*/
|
||||
if ( minXDiff > maxXDiff ) {
|
||||
minRange.current.style.zIndex = 20;
|
||||
maxRange.current.style.zIndex = 21;
|
||||
} else {
|
||||
minRange.current.style.zIndex = 21;
|
||||
maxRange.current.style.zIndex = 20;
|
||||
}
|
||||
},
|
||||
[ isLoading, maxConstraint, hasValidConstraints ]
|
||||
);
|
||||
|
||||
/**
|
||||
* Called when the slider is dragged.
|
||||
* @param {obj} event Event object.
|
||||
*/
|
||||
const rangeInputOnChange = useCallback(
|
||||
( event ) => {
|
||||
const isMin = event.target.classList.contains(
|
||||
'wc-block-price-filter__range-input--min'
|
||||
);
|
||||
const targetValue = event.target.value;
|
||||
const currentValues = isMin
|
||||
? [ Math.round( targetValue / step ) * step, maxPrice ]
|
||||
: [ minPrice, Math.round( targetValue / step ) * step ];
|
||||
const values = constrainRangeSliderValues(
|
||||
currentValues,
|
||||
minConstraint,
|
||||
maxConstraint,
|
||||
step,
|
||||
isMin
|
||||
);
|
||||
onChange( [
|
||||
parseInt( values[ 0 ], 10 ),
|
||||
parseInt( values[ 1 ], 10 ),
|
||||
] );
|
||||
},
|
||||
[ minPrice, maxPrice, minConstraint, maxConstraint, step ]
|
||||
);
|
||||
|
||||
/**
|
||||
* Called when a price input loses focus - commit changes to slider.
|
||||
* @param {obj} event Event object.
|
||||
*/
|
||||
const priceInputOnBlur = useCallback(
|
||||
( event ) => {
|
||||
const isMin = event.target.classList.contains(
|
||||
'wc-block-price-filter__amount--min'
|
||||
);
|
||||
const targetValue = event.target.value.replace( /[^0-9.-]+/g, '' );
|
||||
const currentValues = isMin
|
||||
? [ targetValue, maxPrice ]
|
||||
: [ minPrice, targetValue ];
|
||||
const values = constrainRangeSliderValues(
|
||||
currentValues,
|
||||
null,
|
||||
null,
|
||||
step,
|
||||
isMin
|
||||
);
|
||||
onChange( [
|
||||
parseInt( values[ 0 ], 10 ),
|
||||
parseInt( values[ 1 ], 10 ),
|
||||
] );
|
||||
setFormattedMinPrice(
|
||||
formatPrice(
|
||||
parseInt( values[ 0 ], 10 ),
|
||||
priceFormat,
|
||||
currencySymbol
|
||||
)
|
||||
);
|
||||
setFormattedMaxPrice(
|
||||
formatPrice(
|
||||
parseInt( values[ 1 ], 10 ),
|
||||
priceFormat,
|
||||
currencySymbol
|
||||
)
|
||||
);
|
||||
},
|
||||
[ minPrice, maxPrice, minConstraint, maxConstraint, step ]
|
||||
);
|
||||
|
||||
/**
|
||||
* Called when a price input is typed in - store value but don't update slider.
|
||||
* @param {obj} event Event object.
|
||||
*/
|
||||
const priceInputOnChange = useCallback(
|
||||
( event ) => {
|
||||
const newValue = event.target.value.replace( /[^0-9.-]+/g, '' );
|
||||
const isMin = event.target.classList.contains(
|
||||
'wc-block-price-filter__amount--min'
|
||||
);
|
||||
if ( isMin ) {
|
||||
setFormattedMinPrice(
|
||||
formatPrice( newValue, priceFormat, currencySymbol )
|
||||
);
|
||||
} else {
|
||||
setFormattedMaxPrice(
|
||||
formatPrice( newValue, priceFormat, currencySymbol )
|
||||
);
|
||||
}
|
||||
},
|
||||
[ priceFormat, currencySymbol ]
|
||||
);
|
||||
|
||||
const classes = classnames(
|
||||
'wc-block-price-filter',
|
||||
showInputFields && 'wc-block-price-filter--has-input-fields',
|
||||
showFilterButton && 'wc-block-price-filter--has-filter-button',
|
||||
isLoading && 'is-loading',
|
||||
! hasValidConstraints && 'is-disabled'
|
||||
);
|
||||
|
||||
const minRangeStep =
|
||||
minRange && document.activeElement === minRange.current ? step : 1;
|
||||
const maxRangeStep =
|
||||
maxRange && document.activeElement === maxRange.current ? step : 1;
|
||||
|
||||
return (
|
||||
<div className={ classes }>
|
||||
<div
|
||||
className="wc-block-price-filter__range-input-wrapper"
|
||||
onMouseMove={ findClosestRange }
|
||||
onFocus={ findClosestRange }
|
||||
>
|
||||
{ hasValidConstraints && (
|
||||
<Fragment>
|
||||
<div
|
||||
className="wc-block-price-filter__range-input-progress"
|
||||
style={ progressStyles }
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
className="wc-block-price-filter__range-input wc-block-price-filter__range-input--min"
|
||||
aria-label={ __(
|
||||
'Filter products by minimum price',
|
||||
'woocommerce'
|
||||
) }
|
||||
value={
|
||||
Number.isFinite( minPrice )
|
||||
? minPrice
|
||||
: minConstraint
|
||||
}
|
||||
onChange={ rangeInputOnChange }
|
||||
step={ minRangeStep }
|
||||
min={ minConstraint }
|
||||
max={ maxConstraint }
|
||||
ref={ minRange }
|
||||
disabled={ isLoading }
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
className="wc-block-price-filter__range-input wc-block-price-filter__range-input--max"
|
||||
aria-label={ __(
|
||||
'Filter products by maximum price',
|
||||
'woocommerce'
|
||||
) }
|
||||
value={
|
||||
Number.isFinite( maxPrice )
|
||||
? maxPrice
|
||||
: maxConstraint
|
||||
}
|
||||
onChange={ rangeInputOnChange }
|
||||
step={ maxRangeStep }
|
||||
min={ minConstraint }
|
||||
max={ maxConstraint }
|
||||
ref={ maxRange }
|
||||
disabled={ isLoading }
|
||||
/>
|
||||
</Fragment>
|
||||
) }
|
||||
</div>
|
||||
<div className="wc-block-price-filter__controls">
|
||||
{ showInputFields ? (
|
||||
<PriceInput
|
||||
disabled={ isLoading || ! hasValidConstraints }
|
||||
onChange={ priceInputOnChange }
|
||||
onBlur={ priceInputOnBlur }
|
||||
minPrice={ formattedMinPrice }
|
||||
maxPrice={ formattedMaxPrice }
|
||||
/>
|
||||
) : (
|
||||
<PriceLabel
|
||||
minPrice={ formattedMinPrice }
|
||||
maxPrice={ formattedMaxPrice }
|
||||
/>
|
||||
) }
|
||||
{ showFilterButton && (
|
||||
<SubmitButton
|
||||
disabled={ isLoading || ! hasValidConstraints }
|
||||
onClick={ onSubmit }
|
||||
/>
|
||||
) }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
PriceSlider.propTypes = {
|
||||
/**
|
||||
* Callback fired when prices changes.
|
||||
*/
|
||||
onChange: PropTypes.func.isRequired,
|
||||
/**
|
||||
* Callback fired when the filter button is pressed.
|
||||
*/
|
||||
onSubmit: PropTypes.func,
|
||||
/**
|
||||
* Min value.
|
||||
*/
|
||||
minPrice: PropTypes.number,
|
||||
/**
|
||||
* Max value.
|
||||
*/
|
||||
maxPrice: PropTypes.number,
|
||||
/**
|
||||
* Minimum allowed price.
|
||||
*/
|
||||
minConstraint: PropTypes.number,
|
||||
/**
|
||||
* Maximum allowed price.
|
||||
*/
|
||||
maxConstraint: PropTypes.number,
|
||||
/**
|
||||
* Step for slider inputs.
|
||||
*/
|
||||
step: PropTypes.number,
|
||||
/**
|
||||
* Currency symbol to use when formatting prices for display.
|
||||
*/
|
||||
currencySymbol: PropTypes.string,
|
||||
/**
|
||||
* Price format to use when formatting prices for display.
|
||||
*/
|
||||
priceFormat: PropTypes.string,
|
||||
/**
|
||||
* Whether or not to show input fields above the slider.
|
||||
*/
|
||||
showInputFields: PropTypes.bool,
|
||||
/**
|
||||
* Whether or not to show filter button above the slider.
|
||||
*/
|
||||
showFilterButton: PropTypes.bool,
|
||||
/**
|
||||
* Whether or not to show filter button above the slider.
|
||||
*/
|
||||
isLoading: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default PriceSlider;
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Fragment } from '@wordpress/element';
|
||||
|
||||
const PriceInput = ( { disabled, onBlur, onChange, minPrice, maxPrice } ) => {
|
||||
return (
|
||||
<Fragment>
|
||||
<input
|
||||
type="text"
|
||||
size="5"
|
||||
className="wc-block-price-filter__amount wc-block-price-filter__amount--min wc-block-form-text-input"
|
||||
aria-label={ __(
|
||||
'Filter products by minimum price',
|
||||
'woocommerce'
|
||||
) }
|
||||
onChange={ onChange }
|
||||
onBlur={ onBlur }
|
||||
disabled={ disabled }
|
||||
value={ minPrice }
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
size="5"
|
||||
className="wc-block-price-filter__amount wc-block-price-filter__amount--max wc-block-form-text-input"
|
||||
aria-label={ __(
|
||||
'Filter products by maximum price',
|
||||
'woocommerce'
|
||||
) }
|
||||
onChange={ onChange }
|
||||
onBlur={ onBlur }
|
||||
disabled={ disabled }
|
||||
value={ maxPrice }
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
PriceInput.propTypes = {
|
||||
/**
|
||||
* Is the text input disabled?
|
||||
*/
|
||||
disabled: PropTypes.bool,
|
||||
/**
|
||||
* Callback fired on input.
|
||||
*/
|
||||
onBlur: PropTypes.func,
|
||||
/**
|
||||
* Callback fired on input.
|
||||
*/
|
||||
onChange: PropTypes.func,
|
||||
/**
|
||||
* Min price to display. This is a string because it contains currency e.g. $10.00.
|
||||
*/
|
||||
minPrice: PropTypes.string.isRequired,
|
||||
/**
|
||||
* Max price to display. This is a string because it contains currency e.g. $10.00.
|
||||
*/
|
||||
maxPrice: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
PriceInput.defaultProps = {
|
||||
disabled: false,
|
||||
onBlur: () => {},
|
||||
onChange: () => {},
|
||||
};
|
||||
|
||||
export default PriceInput;
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { sprintf, __ } from '@wordpress/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const PriceLabel = ( { minPrice, maxPrice } ) => {
|
||||
if ( ! minPrice && ! maxPrice ) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="wc-block-price-filter__range-text">
|
||||
{ sprintf(
|
||||
// translators: %s: low price, %s: high price.
|
||||
__( 'Price: %s — %s', 'woocommerce' ),
|
||||
minPrice,
|
||||
maxPrice
|
||||
) }
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
PriceLabel.propTypes = {
|
||||
/**
|
||||
* Min price to display.
|
||||
*/
|
||||
minPrice: PropTypes.string.isRequired,
|
||||
/**
|
||||
* Max price to display.
|
||||
*/
|
||||
maxPrice: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default PriceLabel;
|
||||
@@ -0,0 +1,280 @@
|
||||
/* stylelint-disable */
|
||||
@mixin thumb {
|
||||
background-color: transparent;
|
||||
background-position: 0 0;
|
||||
width: 26px;
|
||||
height: 21px;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
vertical-align: top;
|
||||
cursor: pointer;
|
||||
z-index: 20;
|
||||
pointer-events: auto;
|
||||
background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='52' height='42'%3E%3Cdefs%3E%3Cpath id='a' d='M23.3176 7.9423l-8.4163-6.1432C13.1953.5706 11.2618-.0997 9.2146.0121h-.1137C4.2103.347.1159 4.368.0022 9.2827-.1115 14.644 4.2102 19 9.6696 19h.1137c1.8197 0 3.6395-.6702 5.118-1.787l8.4163-6.255c.9099-.8935.9099-2.2338 0-3.0157z'/%3E%3Cpath id='b' d='M23.3176 7.9423l-8.4163-6.1432C13.1953.5706 11.2618-.0997 9.2146.0121h-.1137C4.2103.347.1159 4.368.0022 9.2827-.1115 14.644 4.2102 19 9.6696 19h.1137c1.8197 0 3.6395-.6702 5.118-1.787l8.4163-6.255c.9099-.8935.9099-2.2338 0-3.0157z'/%3E%3C/defs%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cpath fill='%23FFF' fill-rule='nonzero' stroke='%2395588A' d='M24.3176 8.9423l-8.4163-6.1432c-1.706-1.2285-3.6395-1.8988-5.6867-1.787h-.1137c-4.8906.335-8.985 4.356-9.0987 9.2706C.8885 15.644 5.2102 20 10.6696 20h.1137c1.8197 0 3.6395-.6702 5.118-1.787l8.4163-6.255c.9099-.8935.9099-2.2338 0-3.0157z'/%3E%3Cpath stroke='%23B8B8B8' d='M9 6v9m3-9v9'/%3E%3Cg fill-rule='nonzero' transform='translate(1 22)'%3E%3Cuse fill='%23F8F3F7' stroke='%23FFF' stroke-opacity='.75' stroke-width='3' xlink:href='%23a'/%3E%3Cuse stroke='%2395588A' xlink:href='%23a'/%3E%3C/g%3E%3Cpath stroke='%2395588A' d='M9 27v9m3-9v9'/%3E%3Cg%3E%3Cpath fill='%23FFF' fill-rule='nonzero' stroke='%2395588A' d='M27.6824 8.9423l8.4163-6.1432c1.706-1.2285 3.6395-1.8988 5.6867-1.787h.1137c4.8906.335 8.985 4.356 9.0987 9.2706C51.1115 15.644 46.7898 20 41.3304 20h-.1137c-1.8197 0-3.6395-.6702-5.118-1.787l-8.4163-6.255c-.9099-.8935-.9099-2.2338 0-3.0157z'/%3E%3Cpath stroke='%23B8B8B8' d='M43 6v9m-3-9v9'/%3E%3C/g%3E%3Cg%3E%3Cg fill-rule='nonzero' transform='matrix(-1 0 0 1 51 22)'%3E%3Cuse fill='%23F8F3F7' stroke='%23FFF' stroke-opacity='.75' stroke-width='3' xlink:href='%23b'/%3E%3Cuse stroke='%2395588A' xlink:href='%23b'/%3E%3C/g%3E%3Cpath stroke='%2395588A' d='M43 27v9m-3-9v9'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
|
||||
transition: transform .2s ease-in-out;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
|
||||
&:hover {
|
||||
@include thumbFocus;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
@mixin thumbFocus {
|
||||
background-position-y: -21px;
|
||||
filter: drop-shadow(3px 0 0 rgba(255, 255, 255, .75)) drop-shadow(-3px 0 0 rgba(255, 255, 255, .75));
|
||||
}
|
||||
/* stylelint-enable */
|
||||
@mixin track {
|
||||
cursor: default;
|
||||
height: 1px; /* Required for Samsung internet based browsers */
|
||||
outline: 0;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
@mixin reset {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.wc-block-price-filter {
|
||||
.wc-block-price-filter__range-input-wrapper {
|
||||
@include reset;
|
||||
height: 9px;
|
||||
clear: both;
|
||||
position: relative;
|
||||
box-shadow: 0 0 0 1px inset rgba(0, 0, 0, 0.1);
|
||||
background: #e1e1e1;
|
||||
margin: 15px 0;
|
||||
|
||||
.wc-block-price-filter__range-input-progress {
|
||||
height: 9px;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
--track-background: linear-gradient(to right, transparent var(--low), var(--range-color) 0, var(--range-color) var(--high), transparent 0) no-repeat 0 100% / 100% 100%;
|
||||
--range-color: #a8739d;
|
||||
background: var(--track-background);
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-price-filter__controls {
|
||||
display: flex;
|
||||
margin: 0 0 20px;
|
||||
|
||||
.wc-block-price-filter__amount {
|
||||
margin: 0;
|
||||
border-radius: 4px;
|
||||
width: auto;
|
||||
max-width: 100px;
|
||||
min-width: 0;
|
||||
|
||||
&.wc-block-price-filter__amount--min {
|
||||
margin-right: 10px;
|
||||
}
|
||||
&.wc-block-price-filter__amount--max {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
&.wc-block-price-filter--has-filter-button {
|
||||
.wc-block-price-filter__controls {
|
||||
justify-content: flex-end;
|
||||
|
||||
.wc-block-price-filter__amount.wc-block-price-filter__amount--max {
|
||||
margin-left: 0;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.wc-block-price-filter__button {
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
.wc-block-price-filter__range-input {
|
||||
@include reset;
|
||||
width: 100%;
|
||||
height: 0;
|
||||
display: block;
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
outline: none !important;
|
||||
|
||||
&::-webkit-slider-runnable-track {
|
||||
@include track;
|
||||
}
|
||||
&::-webkit-slider-thumb {
|
||||
@include thumb;
|
||||
margin: -6px 0 0 0;
|
||||
}
|
||||
&::-webkit-slider-progress {
|
||||
@include reset;
|
||||
}
|
||||
|
||||
&::-moz-focus-outer {
|
||||
border: 0;
|
||||
}
|
||||
&::-moz-range-track {
|
||||
@include track;
|
||||
}
|
||||
&::-moz-range-progress {
|
||||
@include reset;
|
||||
}
|
||||
&::-moz-range-thumb {
|
||||
@include thumb;
|
||||
}
|
||||
|
||||
&::-ms-thumb {
|
||||
@include thumb;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
&::-webkit-slider-thumb {
|
||||
@include thumbFocus;
|
||||
}
|
||||
&::-moz-range-thumb {
|
||||
@include thumbFocus;
|
||||
}
|
||||
&::-ms-thumb {
|
||||
@include thumbFocus;
|
||||
}
|
||||
}
|
||||
|
||||
&.wc-block-price-filter__range-input--min {
|
||||
z-index: 21;
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
margin-left: -2px;
|
||||
}
|
||||
&::-moz-range-thumb {
|
||||
transform: translate(-2px, 4px);
|
||||
}
|
||||
}
|
||||
|
||||
&.wc-block-price-filter__range-input--max {
|
||||
z-index: 20;
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
background-position-x: 26px;
|
||||
margin-left: 2px;
|
||||
}
|
||||
&::-moz-range-thumb {
|
||||
background-position-x: 26px;
|
||||
transform: translate(2px, 4px);
|
||||
}
|
||||
&::-ms-thumb {
|
||||
background-position-x: 26px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.is-loading,
|
||||
&.is-disabled {
|
||||
.wc-block-price-filter__range-input-wrapper,
|
||||
.wc-block-price-filter__amount,
|
||||
.wc-block-price-filter__button {
|
||||
@include placeholder();
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-disabled:not(.is-loading) {
|
||||
.wc-block-price-filter__range-input-wrapper,
|
||||
.wc-block-price-filter__amount,
|
||||
.wc-block-price-filter__button {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@mixin ie {
|
||||
.wc-block-price-filter {
|
||||
.wc-block-price-filter__range-input-wrapper {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
height: 24px;
|
||||
|
||||
.wc-block-price-filter__range-input-progress {
|
||||
background: #a8739d;
|
||||
box-shadow: 0 0 0 1px inset #95588a;
|
||||
width: 100%;
|
||||
top: 7px;
|
||||
}
|
||||
}
|
||||
.wc-block-price-filter__range-input {
|
||||
height: 24px;
|
||||
pointer-events: auto;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
|
||||
&::-ms-track {
|
||||
/*remove bg colour from the track, we'll use ms-fill-lower and ms-fill-upper instead */
|
||||
background: transparent;
|
||||
|
||||
/*leave room for the larger thumb to overflow with a transparent border */
|
||||
border-color: transparent;
|
||||
border-width: 7px 0;
|
||||
|
||||
/*remove default tick marks*/
|
||||
color: transparent;
|
||||
}
|
||||
&::-ms-fill-lower {
|
||||
background: #e1e1e1;
|
||||
box-shadow: 0 0 0 1px inset #b8b8b8;
|
||||
}
|
||||
&::-ms-fill-upper {
|
||||
background: transparent;
|
||||
}
|
||||
&::-ms-tooltip {
|
||||
display: none;
|
||||
}
|
||||
&::-ms-thumb {
|
||||
transform: translate(1px, 0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
.wc-block-price-filter__range-input--max {
|
||||
&::-ms-fill-upper {
|
||||
background: #e1e1e1;
|
||||
box-shadow: 0 0 0 1px inset #b8b8b8;
|
||||
}
|
||||
&::-ms-fill-lower {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-loading,
|
||||
&.is-disabled {
|
||||
.wc-block-price-filter__range-input-wrapper {
|
||||
@include placeholder();
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-disabled:not(.is-loading) {
|
||||
.wc-block-price-filter__range-input-wrapper {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* IE 11 will not support multi-range slider due to poor pointer-events support on the thumb. Reverts to 2 sliders. */
|
||||
@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) {
|
||||
@include ie;
|
||||
}
|
||||
@supports (-ms-ime-align:auto) {
|
||||
@include ie;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const SubmitButton = ( { disabled, onClick } ) => {
|
||||
return (
|
||||
<button
|
||||
type="submit"
|
||||
className="wc-block-price-filter__button wc-block-form-button"
|
||||
disabled={ disabled }
|
||||
onClick={ onClick }
|
||||
>
|
||||
{ // translators: Submit button text for the price filter.
|
||||
__( 'Go', 'woocommerce' ) }
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
SubmitButton.propTypes = {
|
||||
/**
|
||||
* Is the button disabled?
|
||||
*/
|
||||
disabled: PropTypes.bool,
|
||||
/**
|
||||
* On click callback.
|
||||
*/
|
||||
onClick: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
SubmitButton.defaultProps = {
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
export default SubmitButton;
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { constrainRangeSliderValues } from '../constrain-range-slider-values';
|
||||
|
||||
describe( 'constrainRangeSliderValues', () => {
|
||||
test.each`
|
||||
values | min | max | step | isMin | expected
|
||||
${[ 20, 60 ]} | ${0} | ${70} | ${10} | ${true} | ${[ 20, 60 ]}
|
||||
${[ 20, 60 ]} | ${20} | ${60} | ${10} | ${true} | ${[ 20, 60 ]}
|
||||
${[ 20, 60 ]} | ${30} | ${50} | ${10} | ${true} | ${[ 30, 50 ]}
|
||||
${[ 50, 50 ]} | ${20} | ${60} | ${10} | ${true} | ${[ 50, 60 ]}
|
||||
${[ 50, 50 ]} | ${20} | ${60} | ${10} | ${false} | ${[ 40, 50 ]}
|
||||
${[ 20, 60 ]} | ${null} | ${null} | ${10} | ${true} | ${[ 20, 60 ]}
|
||||
${[ null, null ]} | ${20} | ${60} | ${10} | ${true} | ${[ 20, 60 ]}
|
||||
${[ '20', '60' ]} | ${30} | ${50} | ${10} | ${true} | ${[ 30, 50 ]}
|
||||
${[ -60, -20 ]} | ${-70} | ${0} | ${10} | ${true} | ${[ -60, -20 ]}
|
||||
${[ -60, -20 ]} | ${-60} | ${-20} | ${10} | ${true} | ${[ -60, -20 ]}
|
||||
${[ -60, -20 ]} | ${-50} | ${-30} | ${10} | ${true} | ${[ -50, -30 ]}
|
||||
${[ -50, -50 ]} | ${-60} | ${-20} | ${10} | ${true} | ${[ -50, -40 ]}
|
||||
${[ -50, -50 ]} | ${-60} | ${-20} | ${10} | ${false} | ${[ -60, -50 ]}
|
||||
${[ -60, -20 ]} | ${null} | ${null} | ${10} | ${true} | ${[ -60, -20 ]}
|
||||
${[ null, null ]} | ${-60} | ${-20} | ${10} | ${true} | ${[ -60, -20 ]}
|
||||
${[ '-60', '-20' ]} | ${-50} | ${-30} | ${10} | ${true} | ${[ -50, -30 ]}
|
||||
`(
|
||||
`correctly sets prices to its constraints with arguments values: $values, min: $min, max: $max, step: $step and isMin: $isMin`,
|
||||
( { values, min, max, step, isMin, expected } ) => {
|
||||
const constrainedValues = constrainRangeSliderValues(
|
||||
values,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
isMin
|
||||
);
|
||||
|
||||
expect( constrainedValues ).toEqual( expected );
|
||||
}
|
||||
);
|
||||
} );
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
import { useInnerBlockConfigurationContext } from '@woocommerce/base-context/inner-block-configuration-context';
|
||||
import { useProductLayoutContext } from '@woocommerce/base-context/product-layout-context';
|
||||
import withComponentId from '@woocommerce/base-hocs/with-component-id';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { renderProductLayout } from './utils';
|
||||
|
||||
const ProductListItem = ( { product, attributes, componentId } ) => {
|
||||
const { layoutConfig } = attributes;
|
||||
const { parentName } = useInnerBlockConfigurationContext();
|
||||
const { layoutStyleClassPrefix } = useProductLayoutContext();
|
||||
const isLoading = ! Object.keys( product ).length > 0;
|
||||
const classes = classnames( `${ layoutStyleClassPrefix }__product`, {
|
||||
'is-loading': isLoading,
|
||||
} );
|
||||
|
||||
return (
|
||||
<li className={ classes } aria-hidden={ isLoading }>
|
||||
{ renderProductLayout(
|
||||
parentName,
|
||||
product,
|
||||
layoutConfig,
|
||||
componentId
|
||||
) }
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
ProductListItem.propTypes = {
|
||||
attributes: PropTypes.object.isRequired,
|
||||
product: PropTypes.object,
|
||||
// from withComponentId
|
||||
componentId: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
export default withComponentId( ProductListItem );
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { getBlockMap } from '../../../blocks/products/base-utils';
|
||||
|
||||
/**
|
||||
* Maps a layout config into atomic components.
|
||||
*
|
||||
* @param {string} blockName Name of the parent block. Used to get extension children.
|
||||
* @param {Object} product Product object to pass to atomic components.
|
||||
* @param {Object[]} layoutConfig Object with component data.
|
||||
* @param {number} componentId Parent component ID needed for key generation.
|
||||
*/
|
||||
export const renderProductLayout = (
|
||||
blockName,
|
||||
product,
|
||||
layoutConfig,
|
||||
componentId
|
||||
) => {
|
||||
if ( ! layoutConfig ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const blockMap = getBlockMap( blockName );
|
||||
|
||||
return layoutConfig.map( ( [ name, props = {} ], index ) => {
|
||||
let children = [];
|
||||
|
||||
if ( !! props.children && props.children.length > 0 ) {
|
||||
children = renderProductLayout(
|
||||
blockName,
|
||||
product,
|
||||
props.children,
|
||||
componentId
|
||||
);
|
||||
}
|
||||
|
||||
const LayoutComponent = blockMap[ name ];
|
||||
|
||||
if ( ! LayoutComponent ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const productID = product.id || 0;
|
||||
const keyParts = [ 'layout', name, index, componentId, productID ];
|
||||
|
||||
return (
|
||||
<LayoutComponent
|
||||
key={ keyParts.join( '_' ) }
|
||||
{ ...props }
|
||||
children={ children }
|
||||
product={ product }
|
||||
/>
|
||||
);
|
||||
} );
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useState, useEffect } from '@wordpress/element';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import ProductList from './index';
|
||||
|
||||
const ProductListContainer = ( { attributes } ) => {
|
||||
const [ currentPage, setPage ] = useState( 1 );
|
||||
const [ currentSort, setSort ] = useState( attributes.orderby );
|
||||
useEffect( () => {
|
||||
// if default sort is changed in editor
|
||||
setSort( attributes.orderby );
|
||||
}, [ attributes.orderby ] );
|
||||
const onPageChange = ( newPage ) => {
|
||||
setPage( newPage );
|
||||
};
|
||||
const onSortChange = ( event ) => {
|
||||
const newSortValue = event.target.value;
|
||||
setSort( newSortValue );
|
||||
setPage( 1 );
|
||||
};
|
||||
|
||||
return (
|
||||
<ProductList
|
||||
attributes={ attributes }
|
||||
currentPage={ currentPage }
|
||||
onPageChange={ onPageChange }
|
||||
onSortChange={ onSortChange }
|
||||
sortValue={ currentSort }
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
ProductListContainer.propTypes = {
|
||||
attributes: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
export default ProductListContainer;
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { isEqual } from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
import Pagination from '@woocommerce/base-components/pagination';
|
||||
import ProductSortSelect from '@woocommerce/base-components/product-sort-select';
|
||||
import ProductListItem from '@woocommerce/base-components/product-list-item';
|
||||
import { useEffect } from '@wordpress/element';
|
||||
import {
|
||||
usePrevious,
|
||||
useStoreProducts,
|
||||
useSynchronizedQueryState,
|
||||
useQueryStateByKey,
|
||||
} from '@woocommerce/base-hooks';
|
||||
import withScrollToTop from '@woocommerce/base-hocs/with-scroll-to-top';
|
||||
import { useProductLayoutContext } from '@woocommerce/base-context/product-layout-context';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import './style.scss';
|
||||
import NoProducts from './no-products';
|
||||
import NoMatchingProducts from './no-matching-products';
|
||||
|
||||
const generateQuery = ( { sortValue, currentPage, attributes } ) => {
|
||||
const { columns, rows } = attributes;
|
||||
const getSortArgs = ( orderName ) => {
|
||||
switch ( orderName ) {
|
||||
case 'menu_order':
|
||||
case 'popularity':
|
||||
case 'rating':
|
||||
case 'price':
|
||||
return {
|
||||
orderby: orderName,
|
||||
order: 'asc',
|
||||
};
|
||||
case 'price-desc':
|
||||
return {
|
||||
orderby: 'price',
|
||||
order: 'desc',
|
||||
};
|
||||
case 'date':
|
||||
return {
|
||||
orderby: 'date',
|
||||
order: 'desc',
|
||||
};
|
||||
}
|
||||
};
|
||||
return {
|
||||
...getSortArgs( sortValue ),
|
||||
per_page: columns * rows,
|
||||
page: currentPage,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Given a query state, returns the same query without the attributes related to
|
||||
* pagination and sorting.
|
||||
*
|
||||
* @param {Object} query Query to extract the attributes from.
|
||||
*
|
||||
* @return {Object} Same query without pagination and sorting attributes.
|
||||
*/
|
||||
|
||||
const extractPaginationAndSortAttributes = ( query ) => {
|
||||
/* eslint-disable-next-line no-unused-vars, camelcase */
|
||||
const { order, orderby, page, per_page, ...totalQuery } = query;
|
||||
return totalQuery;
|
||||
};
|
||||
|
||||
const ProductList = ( {
|
||||
attributes,
|
||||
currentPage,
|
||||
onPageChange,
|
||||
onSortChange,
|
||||
sortValue,
|
||||
scrollToTop,
|
||||
} ) => {
|
||||
const [ queryState ] = useSynchronizedQueryState(
|
||||
generateQuery( {
|
||||
attributes,
|
||||
sortValue,
|
||||
currentPage,
|
||||
} )
|
||||
);
|
||||
const results = useStoreProducts( queryState );
|
||||
const { products, productsLoading } = results;
|
||||
const totalProducts = parseInt( results.totalProducts );
|
||||
|
||||
const { layoutStyleClassPrefix } = useProductLayoutContext();
|
||||
const totalQuery = extractPaginationAndSortAttributes( queryState );
|
||||
|
||||
// These are possible filters.
|
||||
const [ productAttributes, setProductAttributes ] = useQueryStateByKey(
|
||||
'attributes',
|
||||
[]
|
||||
);
|
||||
const [ minPrice, setMinPrice ] = useQueryStateByKey( 'min_price' );
|
||||
const [ maxPrice, setMaxPrice ] = useQueryStateByKey( 'max_price' );
|
||||
|
||||
// Only update previous query totals if the query is different and
|
||||
// the total number of products is a finite number.
|
||||
const previousQueryTotals = usePrevious(
|
||||
{ totalQuery, totalProducts },
|
||||
(
|
||||
{ totalQuery: nextQuery, totalProducts: nextProducts },
|
||||
{ totalQuery: currentQuery } = {}
|
||||
) =>
|
||||
! isEqual( nextQuery, currentQuery ) &&
|
||||
Number.isFinite( nextProducts )
|
||||
);
|
||||
const isPreviousTotalQueryEqual =
|
||||
typeof previousQueryTotals === 'object' &&
|
||||
isEqual( totalQuery, previousQueryTotals.totalQuery );
|
||||
useEffect( () => {
|
||||
// If query state (excluding pagination/sorting attributes) changed,
|
||||
// reset pagination to the first page.
|
||||
if ( ! isPreviousTotalQueryEqual ) {
|
||||
onPageChange( 1 );
|
||||
}
|
||||
}, [ queryState ] );
|
||||
|
||||
const onPaginationChange = ( newPage ) => {
|
||||
scrollToTop( { focusableSelector: 'a, button' } );
|
||||
onPageChange( newPage );
|
||||
};
|
||||
|
||||
const getClassnames = () => {
|
||||
const { columns, rows, alignButtons, align } = attributes;
|
||||
const alignClass = typeof align !== 'undefined' ? 'align' + align : '';
|
||||
|
||||
return classnames(
|
||||
layoutStyleClassPrefix,
|
||||
alignClass,
|
||||
'has-' + columns + '-columns',
|
||||
{
|
||||
'has-multiple-rows': rows > 1,
|
||||
'has-aligned-buttons': alignButtons,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const { contentVisibility } = attributes;
|
||||
const perPage = attributes.columns * attributes.rows;
|
||||
const totalPages =
|
||||
! Number.isFinite( totalProducts ) && isPreviousTotalQueryEqual
|
||||
? Math.ceil( previousQueryTotals.totalProducts / perPage )
|
||||
: Math.ceil( totalProducts / perPage );
|
||||
const listProducts = products.length
|
||||
? products
|
||||
: Array.from( { length: perPage } );
|
||||
const hasProducts = products.length !== 0 || productsLoading;
|
||||
const hasFilters =
|
||||
productAttributes.length > 0 ||
|
||||
Number.isFinite( minPrice ) ||
|
||||
Number.isFinite( maxPrice );
|
||||
|
||||
return (
|
||||
<div className={ getClassnames() }>
|
||||
{ contentVisibility.orderBy && hasProducts && (
|
||||
<ProductSortSelect
|
||||
onChange={ onSortChange }
|
||||
value={ sortValue }
|
||||
/>
|
||||
) }
|
||||
{ ! hasProducts && hasFilters && (
|
||||
<NoMatchingProducts
|
||||
resetCallback={ () => {
|
||||
setProductAttributes( [] );
|
||||
setMinPrice( null );
|
||||
setMaxPrice( null );
|
||||
} }
|
||||
/>
|
||||
) }
|
||||
{ ! hasProducts && ! hasFilters && <NoProducts /> }
|
||||
{ hasProducts && (
|
||||
<ul className={ `${ layoutStyleClassPrefix }__products` }>
|
||||
{ listProducts.map( ( product = {}, i ) => (
|
||||
<ProductListItem
|
||||
key={ product.id || i }
|
||||
attributes={ attributes }
|
||||
product={ product }
|
||||
/>
|
||||
) ) }
|
||||
</ul>
|
||||
) }
|
||||
{ totalPages > 1 && (
|
||||
<Pagination
|
||||
currentPage={ currentPage }
|
||||
onPageChange={ onPaginationChange }
|
||||
totalPages={ totalPages }
|
||||
/>
|
||||
) }
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ProductList.propTypes = {
|
||||
attributes: PropTypes.object.isRequired,
|
||||
// From withScrollToTop.
|
||||
scrollToTop: PropTypes.func,
|
||||
};
|
||||
|
||||
export default withScrollToTop( ProductList );
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { WC_BLOCKS_ASSET_URL } from '@woocommerce/block-settings';
|
||||
import { useProductLayoutContext } from '@woocommerce/base-context/product-layout-context';
|
||||
|
||||
const NoMatchingProducts = ( { resetCallback = () => {} } ) => {
|
||||
const { layoutStyleClassPrefix } = useProductLayoutContext();
|
||||
return (
|
||||
<div className={ `${ layoutStyleClassPrefix }__no-products` }>
|
||||
<img
|
||||
src={ WC_BLOCKS_ASSET_URL + 'img/no-matching-products.svg' }
|
||||
alt={ __( 'No products', 'woocommerce' ) }
|
||||
className={ `${ layoutStyleClassPrefix }__no-products-image` }
|
||||
/>
|
||||
<strong
|
||||
className={ `${ layoutStyleClassPrefix }__no-products-title` }
|
||||
>
|
||||
{ __( 'No products found', 'woocommerce' ) }
|
||||
</strong>
|
||||
<p
|
||||
className={ `${ layoutStyleClassPrefix }__no-products-description` }
|
||||
>
|
||||
{ __(
|
||||
'We were unable to find any results based on your search.',
|
||||
'woocommerce'
|
||||
) }
|
||||
</p>
|
||||
<button onClick={ resetCallback }>
|
||||
{ __( 'Reset Search', 'woocommerce' ) }
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NoMatchingProducts;
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { WC_BLOCKS_ASSET_URL } from '@woocommerce/block-settings';
|
||||
import { useProductLayoutContext } from '@woocommerce/base-context/product-layout-context';
|
||||
|
||||
const NoProducts = () => {
|
||||
const { layoutStyleClassPrefix } = useProductLayoutContext();
|
||||
return (
|
||||
<div className={ `${ layoutStyleClassPrefix }__no-products` }>
|
||||
<img
|
||||
src={ WC_BLOCKS_ASSET_URL + 'img/no-products.svg' }
|
||||
alt={ __( 'No products', 'woocommerce' ) }
|
||||
className={ `${ layoutStyleClassPrefix }__no-products-image` }
|
||||
/>
|
||||
<strong
|
||||
className={ `${ layoutStyleClassPrefix }__no-products-title` }
|
||||
>
|
||||
{ __( 'No products', 'woocommerce' ) }
|
||||
</strong>
|
||||
<p
|
||||
className={ `${ layoutStyleClassPrefix }__no-products-description` }
|
||||
>
|
||||
{ __(
|
||||
'There are currently no products available to display.',
|
||||
'woocommerce'
|
||||
) }
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NoProducts;
|
||||
@@ -0,0 +1,371 @@
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-grid {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.wc-block-grid__no-products {
|
||||
padding: $gap-largest;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
|
||||
.wc-block-grid__no-products-image {
|
||||
max-width: 150px;
|
||||
margin: 0 auto 1em;
|
||||
display: block;
|
||||
}
|
||||
.wc-block-grid__no-products-title {
|
||||
display: block;
|
||||
margin: 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
.wc-block-grid__no-products-description {
|
||||
display: block;
|
||||
margin: 0.25em 0 1em 0;
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-grid__products {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 (-$gap/2) $gap;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.wc-block-grid__product {
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
float: none;
|
||||
width: auto;
|
||||
position: relative;
|
||||
text-align: center;
|
||||
border-left: $gap/2 solid transparent;
|
||||
border-right: $gap/2 solid transparent;
|
||||
border-bottom: $gap solid transparent;
|
||||
}
|
||||
|
||||
// Extra specificity to avoid editor styles on linked images.
|
||||
.entry-content .wc-block-grid__product-image,
|
||||
.wc-block-grid__product-image {
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
position: relative;
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.wc-block-grid__product-image__image {
|
||||
vertical-align: middle;
|
||||
width: 100%;
|
||||
|
||||
&[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.is-loading & {
|
||||
@include placeholder();
|
||||
height: 0;
|
||||
padding-bottom: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.edit-post-visual-editor .editor-block-list__block .wc-block-grid__product-title,
|
||||
.editor-styles-wrapper .wc-block-grid__product-title,
|
||||
.wc-block-grid__product-title {
|
||||
line-height: 1.2em;
|
||||
font-weight: 700;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
font-size: inherit;
|
||||
display: block;
|
||||
|
||||
.is-loading &::before {
|
||||
@include placeholder();
|
||||
content: ".";
|
||||
display: inline-block;
|
||||
width: 6em;
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-grid__product-price {
|
||||
display: block;
|
||||
|
||||
.wc-block-grid__product-price__regular {
|
||||
font-size: 0.8em;
|
||||
line-height: 1;
|
||||
color: #aaa;
|
||||
margin-top: -0.25em;
|
||||
display: block;
|
||||
}
|
||||
.wc-block-grid__product-price__value {
|
||||
letter-spacing: -1px;
|
||||
font-weight: 600;
|
||||
display: block;
|
||||
font-size: 1.25em;
|
||||
line-height: 1.25;
|
||||
color: #000;
|
||||
span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.is-loading &::before {
|
||||
@include placeholder();
|
||||
content: ".";
|
||||
display: inline-block;
|
||||
width: 3em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-grid__product-add-to-cart {
|
||||
word-break: break-word;
|
||||
white-space: normal;
|
||||
a,
|
||||
button {
|
||||
word-break: break-word;
|
||||
white-space: normal;
|
||||
margin: 0 auto !important;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
&.loading {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
&::after {
|
||||
margin-left: 0.5em;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
&.added::after {
|
||||
font-family: WooCommerce; /* stylelint-disable-line */
|
||||
content: "\e017";
|
||||
}
|
||||
|
||||
&.loading::after {
|
||||
font-family: WooCommerce; /* stylelint-disable-line */
|
||||
content: "\e031";
|
||||
animation: spin 2s linear infinite;
|
||||
}
|
||||
|
||||
.is-loading & {
|
||||
@include placeholder();
|
||||
min-width: 7em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-grid__product-rating {
|
||||
display: block;
|
||||
|
||||
.wc-block-grid__product-rating__stars {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 5.3em;
|
||||
height: 1.618em;
|
||||
line-height: 1.618;
|
||||
font-size: 1em;
|
||||
font-family: star; /* stylelint-disable-line */
|
||||
font-weight: 400;
|
||||
display: -block;
|
||||
margin: 0 auto;
|
||||
text-align: left;
|
||||
|
||||
&::before {
|
||||
content: "\53\53\53\53\53";
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
position: absolute;
|
||||
opacity: 0.5;
|
||||
color: #aaa;
|
||||
}
|
||||
span {
|
||||
overflow: hidden;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
position: absolute;
|
||||
padding-top: 1.5em;
|
||||
}
|
||||
span::before {
|
||||
content: "\53\53\53\53\53";
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
position: absolute;
|
||||
color: #000;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.editor-styles-wrapper .wc-block-grid__products .wc-block-grid__product .wc-block-grid__product-onsale,
|
||||
.wc-block-grid__product-onsale {
|
||||
border: 1px solid #43454b;
|
||||
color: #43454b;
|
||||
background: #fff;
|
||||
padding: 0.202em 0.6180469716em;
|
||||
font-size: 0.875rem;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
display: inline-block;
|
||||
width: auto;
|
||||
border-radius: 3px;
|
||||
z-index: 9;
|
||||
position: relative;
|
||||
margin: $gap-smaller auto;
|
||||
}
|
||||
.editor-styles-wrapper .wc-block-grid__products .wc-block-grid__product .wc-block-grid__product-image,
|
||||
.wc-block-grid__product-image {
|
||||
.wc-block-grid__product-onsale {
|
||||
&.wc-block-grid__product-onsale--alignleft {
|
||||
position: absolute;
|
||||
left: $gap-smaller/2;
|
||||
top: $gap-smaller/2;
|
||||
right: auto;
|
||||
margin: 0;
|
||||
}
|
||||
&.wc-block-grid__product-onsale--aligncenter {
|
||||
position: absolute;
|
||||
top: $gap-smaller/2;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin: 0;
|
||||
}
|
||||
&.wc-block-grid__product-onsale--alignright {
|
||||
position: absolute;
|
||||
right: $gap-smaller/2;
|
||||
top: $gap-smaller/2;
|
||||
left: auto;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Element spacing.
|
||||
.wc-block-grid__product {
|
||||
.wc-block-grid__product-image,
|
||||
.wc-block-grid__product-title,
|
||||
.wc-block-grid__product-price,
|
||||
.wc-block-grid__product-rating {
|
||||
margin-top: 0;
|
||||
margin-bottom: $gap-small;
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-grid {
|
||||
&.has-aligned-buttons {
|
||||
.wc-block-grid__product {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.wc-block-grid__product-add-to-cart {
|
||||
margin-top: auto !important;
|
||||
}
|
||||
}
|
||||
@for $i from 1 to 9 {
|
||||
&.has-#{$i}-columns .wc-block-grid__product {
|
||||
flex: 1 0 calc(#{ 100% / $i });
|
||||
max-width: 100% / $i;
|
||||
}
|
||||
}
|
||||
&.has-4-columns:not(.alignwide):not(.alignfull),
|
||||
&.has-5-columns:not(.alignfull),
|
||||
&.has-6-columns:not(.alignfull),
|
||||
&.has-7-columns,
|
||||
&.has-8-columns {
|
||||
.wc-block-grid__product {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive media styles.
|
||||
@include breakpoint( "<480px" ) {
|
||||
.wc-block-grid {
|
||||
@for $i from 2 to 9 {
|
||||
&.has-#{$i}-columns {
|
||||
.wc-block-grid__products {
|
||||
display: block;
|
||||
}
|
||||
.wc-block-grid__product {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
flex: 1 0 100%;
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
.wc-block-grid__product-image img {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
@include breakpoint( "480px-600px" ) {
|
||||
.wc-block-grid {
|
||||
@for $i from 2 to 9 {
|
||||
&.has-#{$i}-columns {
|
||||
.wc-block-grid__product {
|
||||
flex: 1 0 50%;
|
||||
max-width: 50%;
|
||||
padding: 0;
|
||||
margin: 0 0 $gap-large 0;
|
||||
}
|
||||
.wc-block-grid__product:nth-child(odd) {
|
||||
padding-right: $gap/2;
|
||||
}
|
||||
.wc-block-grid__product:nth-child(even) {
|
||||
padding-left: $gap/2;
|
||||
.wc-block-grid__product-onsale {
|
||||
left: $gap/2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.wc-block-grid__product-image img {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.theme-twentysixteen {
|
||||
.wc-block-grid {
|
||||
// Prevent white theme styles.
|
||||
.price ins {
|
||||
color: #77a464;
|
||||
}
|
||||
}
|
||||
}
|
||||
.theme-twentynineteen {
|
||||
.wc-block-grid__product {
|
||||
font-size: 0.88889em;
|
||||
}
|
||||
// Change the title font to match headings.
|
||||
.wc-block-grid__product-title,
|
||||
.wc-block-grid__product-onsale {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
|
||||
}
|
||||
.wc-block-grid__product-title::before {
|
||||
display: none;
|
||||
}
|
||||
.wc-block-grid__product-onsale {
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
import SortSelect from '@woocommerce/base-components/sort-select';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import './style.scss';
|
||||
|
||||
const ProductSortSelect = ( { defaultValue, onChange, readOnly, value } ) => {
|
||||
return (
|
||||
<SortSelect
|
||||
className="wc-block-product-sort-select"
|
||||
defaultValue={ defaultValue }
|
||||
name="orderby"
|
||||
onChange={ onChange }
|
||||
options={ [
|
||||
{
|
||||
key: 'menu_order',
|
||||
label: __(
|
||||
'Default sorting',
|
||||
'woocommerce'
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'popularity',
|
||||
label: __( 'Popularity', 'woocommerce' ),
|
||||
},
|
||||
{
|
||||
key: 'rating',
|
||||
label: __(
|
||||
'Average rating',
|
||||
'woocommerce'
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'date',
|
||||
label: __( 'Latest', 'woocommerce' ),
|
||||
},
|
||||
{
|
||||
key: 'price',
|
||||
label: __(
|
||||
'Price: low to high',
|
||||
'woocommerce'
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'price-desc',
|
||||
label: __(
|
||||
'Price: high to low',
|
||||
'woocommerce'
|
||||
),
|
||||
},
|
||||
] }
|
||||
readOnly={ readOnly }
|
||||
screenReaderLabel={ __(
|
||||
'Order products by',
|
||||
'woocommerce'
|
||||
) }
|
||||
value={ value }
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
ProductSortSelect.propTypes = {
|
||||
defaultValue: PropTypes.oneOf( [
|
||||
'menu_order',
|
||||
'popularity',
|
||||
'rating',
|
||||
'date',
|
||||
'price',
|
||||
'price-desc',
|
||||
] ),
|
||||
onChange: PropTypes.func,
|
||||
readOnly: PropTypes.bool,
|
||||
value: PropTypes.oneOf( [
|
||||
'menu_order',
|
||||
'popularity',
|
||||
'rating',
|
||||
'date',
|
||||
'price',
|
||||
'price-desc',
|
||||
] ),
|
||||
};
|
||||
|
||||
export default ProductSortSelect;
|
||||
@@ -0,0 +1,4 @@
|
||||
.wc-block-product-sort-select {
|
||||
margin-bottom: $gap-large;
|
||||
text-align: left;
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import React, { createRef, Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { clampLines } from './utils';
|
||||
|
||||
/**
|
||||
* Show text based content, limited to a number of lines, with a read more link.
|
||||
*
|
||||
* Based on https://github.com/zoltantothcom/react-clamp-lines.
|
||||
*/
|
||||
class ReadMore extends Component {
|
||||
constructor( props ) {
|
||||
super( ...arguments );
|
||||
|
||||
this.state = {
|
||||
/**
|
||||
* This is true when read more has been pressed and the full review is shown.
|
||||
*/
|
||||
isExpanded: false,
|
||||
/**
|
||||
* True if we are clamping content. False if the review is short. Null during init.
|
||||
*/
|
||||
clampEnabled: null,
|
||||
/**
|
||||
* Content is passed in via children.
|
||||
*/
|
||||
content: props.children,
|
||||
/**
|
||||
* Summary content generated from content HTML.
|
||||
*/
|
||||
summary: '.',
|
||||
};
|
||||
|
||||
this.reviewSummary = createRef();
|
||||
this.reviewContent = createRef();
|
||||
this.getButton = this.getButton.bind( this );
|
||||
this.onClick = this.onClick.bind( this );
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if ( this.props.children ) {
|
||||
const { maxLines, ellipsis } = this.props;
|
||||
|
||||
const lineHeight = this.reviewSummary.current.clientHeight + 1;
|
||||
const reviewHeight = this.reviewContent.current.clientHeight + 1;
|
||||
const maxHeight = lineHeight * maxLines + 1;
|
||||
const clampEnabled = reviewHeight > maxHeight;
|
||||
|
||||
this.setState( {
|
||||
clampEnabled,
|
||||
} );
|
||||
|
||||
if ( clampEnabled ) {
|
||||
this.setState( {
|
||||
summary: clampLines(
|
||||
this.reviewContent.current.innerHTML,
|
||||
this.reviewSummary.current,
|
||||
maxHeight,
|
||||
ellipsis
|
||||
),
|
||||
} );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getButton() {
|
||||
const { isExpanded } = this.state;
|
||||
const { className, lessText, moreText } = this.props;
|
||||
|
||||
const buttonText = isExpanded ? lessText : moreText;
|
||||
|
||||
if ( ! buttonText ) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href="#more"
|
||||
className={ className + '__read_more' }
|
||||
onClick={ this.onClick }
|
||||
aria-expanded={ ! isExpanded }
|
||||
role="button"
|
||||
>
|
||||
{ buttonText }
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the click event for the read more/less button.
|
||||
*
|
||||
* @param {obj} e event
|
||||
*/
|
||||
onClick( e ) {
|
||||
e.preventDefault();
|
||||
|
||||
const { isExpanded } = this.state;
|
||||
|
||||
this.setState( {
|
||||
isExpanded: ! isExpanded,
|
||||
} );
|
||||
}
|
||||
|
||||
render() {
|
||||
const { className } = this.props;
|
||||
const { content, summary, clampEnabled, isExpanded } = this.state;
|
||||
|
||||
if ( ! content ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ( clampEnabled === false ) {
|
||||
return (
|
||||
<div className={ className }>
|
||||
<div ref={ this.reviewContent }>{ content }</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={ className }>
|
||||
{ ( ! isExpanded || clampEnabled === null ) && (
|
||||
<div
|
||||
ref={ this.reviewSummary }
|
||||
aria-hidden={ isExpanded }
|
||||
dangerouslySetInnerHTML={ {
|
||||
__html: summary,
|
||||
} }
|
||||
/>
|
||||
) }
|
||||
{ ( isExpanded || clampEnabled === null ) && (
|
||||
<div
|
||||
ref={ this.reviewContent }
|
||||
aria-hidden={ ! isExpanded }
|
||||
>
|
||||
{ content }
|
||||
</div>
|
||||
) }
|
||||
{ this.getButton() }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ReadMore.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
maxLines: PropTypes.number,
|
||||
ellipsis: PropTypes.string,
|
||||
moreText: PropTypes.string,
|
||||
lessText: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
ReadMore.defaultProps = {
|
||||
maxLines: 3,
|
||||
ellipsis: '…',
|
||||
moreText: __( 'Read more', 'woocommerce' ),
|
||||
lessText: __( 'Read less', 'woocommerce' ),
|
||||
className: 'read-more-content',
|
||||
};
|
||||
|
||||
export default ReadMore;
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { truncateHtml } from '../utils';
|
||||
const shortContent =
|
||||
'<p>Lorem ipsum dolor sit amet, <strong>consectetur.</strong>.</p>';
|
||||
|
||||
const longContent =
|
||||
'<p>Lorem ipsum dolor sit amet, <strong>consectetur adipiscing elit. Nullam a condimentum diam.</strong> Donec finibus enim eros, et lobortis magna varius quis. Nulla lacinia tellus ac neque aliquet, in porttitor metus interdum. Maecenas vestibulum nisi et auctor vestibulum. Maecenas vehicula, lacus et pellentesque tempor, orci nulla mattis purus, id porttitor augue magna et metus. Aenean hendrerit aliquet massa ac convallis. Mauris vestibulum neque in condimentum porttitor. Donec viverra, orci a accumsan vehicula, dui massa lobortis lorem, et cursus est purus pulvinar elit. Vestibulum vitae tincidunt ex, ut vulputate nisi.</p>' +
|
||||
'<p>Morbi tristique iaculis felis, sed porta urna tincidunt vitae. Etiam nisl sem, eleifend non varius quis, placerat a arcu. Donec consectetur nunc at orci fringilla pulvinar. Nam hendrerit tellus in est aliquet varius id in diam. Donec eu ullamcorper ante. Ut ultricies, felis vel sodales aliquet, nibh massa vestibulum ipsum, sed dignissim mi nunc eget lacus. Curabitur mattis placerat magna a aliquam. Nullam diam elit, cursus nec erat ullamcorper, tempor eleifend mauris. Nunc placerat nunc ut enim ornare tempus. Fusce porta molestie ante eget faucibus. Fusce eu lectus sit amet diam auctor lacinia et in diam. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Mauris eu lacus lobortis, faucibus est vel, pulvinar odio. Duis feugiat tortor quis dui euismod varius.</p>';
|
||||
|
||||
describe( 'ReadMore Component', () => {
|
||||
describe( 'Test the truncateHtml function', () => {
|
||||
it( 'Truncate long HTML content to length of 10', async () => {
|
||||
const truncatedContent = truncateHtml( longContent, 10 );
|
||||
|
||||
expect( truncatedContent ).toEqual( '<p>Lorem ipsum...</p>' );
|
||||
} );
|
||||
it( 'Truncate long HTML content, but avoid cutting off HTML tags.', async () => {
|
||||
const truncatedContent = truncateHtml( longContent, 40 );
|
||||
|
||||
expect( truncatedContent ).toEqual(
|
||||
'<p>Lorem ipsum dolor sit amet, <strong>consectetur...</strong></p>'
|
||||
);
|
||||
} );
|
||||
it( 'No need to truncate short HTML content.', async () => {
|
||||
const truncatedContent = truncateHtml( shortContent, 100 );
|
||||
|
||||
expect( truncatedContent ).toEqual(
|
||||
'<p>Lorem ipsum dolor sit amet, <strong>consectetur.</strong>.</p>'
|
||||
);
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import trimHtml from 'trim-html';
|
||||
|
||||
/**
|
||||
* Truncate some HTML content to a given length.
|
||||
*
|
||||
* @param {string} html HTML that will be truncated.
|
||||
* @param {int} length Legth to truncate the string to.
|
||||
* @param {string} ellipsis Character to append to truncated content.
|
||||
*/
|
||||
export const truncateHtml = ( html, length, ellipsis = '...' ) => {
|
||||
const trimmed = trimHtml( html, {
|
||||
suffix: ellipsis,
|
||||
limit: length,
|
||||
} );
|
||||
|
||||
return trimmed.html;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clamp lines calculates the height of a line of text and then limits it to the
|
||||
* value of the lines prop. Content is updated once limited.
|
||||
*
|
||||
* @param {string} originalContent Content to be clamped.
|
||||
* @param {Object} targetElement Element which will contain the clamped content.
|
||||
* @param {integer} maxHeight Max height of the clamped content.
|
||||
* @param {string} ellipsis Character to append to clamped content.
|
||||
* @return {string} clamped content
|
||||
*/
|
||||
export const clampLines = (
|
||||
originalContent,
|
||||
targetElement,
|
||||
maxHeight,
|
||||
ellipsis
|
||||
) => {
|
||||
const length = calculateLength( originalContent, targetElement, maxHeight );
|
||||
|
||||
return truncateHtml( originalContent, length - ellipsis.length, ellipsis );
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate how long the content can be based on the maximum number of lines allowed, and client height.
|
||||
*
|
||||
* @param {string} originalContent Content to be clamped.
|
||||
* @param {Object} targetElement Element which will contain the clamped content.
|
||||
* @param {integer} maxHeight Max height of the clamped content.
|
||||
*/
|
||||
const calculateLength = ( originalContent, targetElement, maxHeight ) => {
|
||||
let markers = {
|
||||
start: 0,
|
||||
middle: 0,
|
||||
end: originalContent.length,
|
||||
};
|
||||
|
||||
while ( markers.start <= markers.end ) {
|
||||
markers.middle = Math.floor( ( markers.start + markers.end ) / 2 );
|
||||
|
||||
// We set the innerHTML directly in the DOM here so we can reliably check the clientHeight later in moveMarkers.
|
||||
targetElement.innerHTML = truncateHtml(
|
||||
originalContent,
|
||||
markers.middle
|
||||
);
|
||||
|
||||
markers = moveMarkers( markers, targetElement.clientHeight, maxHeight );
|
||||
}
|
||||
|
||||
return markers.middle;
|
||||
};
|
||||
|
||||
/**
|
||||
* Move string markers. Used by calculateLength.
|
||||
*
|
||||
* @param {Object} markers Markers for clamped content.
|
||||
* @param {integer} currentHeight Current height of clamped content.
|
||||
* @param {integer} maxHeight Max height of the clamped content.
|
||||
*/
|
||||
const moveMarkers = ( markers, currentHeight, maxHeight ) => {
|
||||
if ( currentHeight <= maxHeight ) {
|
||||
markers.start = markers.middle + 1;
|
||||
} else {
|
||||
markers.end = markers.middle - 1;
|
||||
}
|
||||
|
||||
return markers;
|
||||
};
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __, sprintf } from '@wordpress/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import ReadMore from '@woocommerce/base-components/read-more';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import './style.scss';
|
||||
|
||||
function getReviewImage( review, imageType, isLoading ) {
|
||||
if ( isLoading || ! review ) {
|
||||
return (
|
||||
<div
|
||||
className="wc-block-review-list-item__image"
|
||||
width="48"
|
||||
height="48"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="wc-block-review-list-item__image">
|
||||
{ imageType === 'product' ? (
|
||||
<img
|
||||
aria-hidden="true"
|
||||
alt=""
|
||||
src={ review.product_picture || '' }
|
||||
className="wc-block-review-list-item__image"
|
||||
width="48"
|
||||
height="48"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
aria-hidden="true"
|
||||
alt=""
|
||||
src={ review.reviewer_avatar_urls[ '48' ] || '' }
|
||||
srcSet={ review.reviewer_avatar_urls[ '96' ] + ' 2x' }
|
||||
className="wc-block-review-list-item__image"
|
||||
width="48"
|
||||
height="48"
|
||||
/>
|
||||
) }
|
||||
{ review.verified && (
|
||||
<div
|
||||
className="wc-block-review-list-item__verified"
|
||||
title={ __(
|
||||
'Verified buyer',
|
||||
'woocommerce'
|
||||
) }
|
||||
>
|
||||
{ __( 'Verified buyer', 'woocommerce' ) }
|
||||
</div>
|
||||
) }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getReviewContent( review ) {
|
||||
return (
|
||||
<ReadMore
|
||||
maxLines={ 10 }
|
||||
moreText={ __(
|
||||
'Read full review',
|
||||
'woocommerce'
|
||||
) }
|
||||
lessText={ __(
|
||||
'Hide full review',
|
||||
'woocommerce'
|
||||
) }
|
||||
className="wc-block-review-list-item__text"
|
||||
>
|
||||
<div
|
||||
dangerouslySetInnerHTML={ {
|
||||
// `content` is the `review` parameter returned by the `reviews` endpoint.
|
||||
// It's filtered with `wp_filter_post_kses()`, which removes dangerous HTML tags,
|
||||
// so using it inside `dangerouslySetInnerHTML` is safe.
|
||||
__html: review.review || '',
|
||||
} }
|
||||
/>
|
||||
</ReadMore>
|
||||
);
|
||||
}
|
||||
|
||||
function getReviewProductName( review ) {
|
||||
return (
|
||||
<div className="wc-block-review-list-item__product">
|
||||
<a
|
||||
href={ review.product_permalink }
|
||||
dangerouslySetInnerHTML={ {
|
||||
// `product_name` might have html entities for things like
|
||||
// emdash. So to display properly we need to allow the
|
||||
// browser to render.
|
||||
__html: review.product_name,
|
||||
} }
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getReviewerName( review ) {
|
||||
const { reviewer = '' } = review;
|
||||
return (
|
||||
<div className="wc-block-review-list-item__author">{ reviewer }</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getReviewDate( review ) {
|
||||
const {
|
||||
date_created: dateCreated,
|
||||
formatted_date_created: formattedDateCreated,
|
||||
} = review;
|
||||
return (
|
||||
<time
|
||||
className="wc-block-review-list-item__published-date"
|
||||
dateTime={ dateCreated }
|
||||
>
|
||||
{ formattedDateCreated }
|
||||
</time>
|
||||
);
|
||||
}
|
||||
|
||||
function getReviewRating( review ) {
|
||||
const { rating } = review;
|
||||
const starStyle = {
|
||||
width: ( rating / 5 ) * 100 + '%' /* stylelint-disable-line */,
|
||||
};
|
||||
return (
|
||||
<div className="wc-block-review-list-item__rating">
|
||||
<div
|
||||
className="wc-block-review-list-item__rating__stars"
|
||||
role="img"
|
||||
>
|
||||
<span style={ starStyle }>
|
||||
{ sprintf(
|
||||
__(
|
||||
'Rated %d out of 5',
|
||||
'woocommerce'
|
||||
),
|
||||
rating
|
||||
) }
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ReviewListItem = ( { attributes, review = {} } ) => {
|
||||
const {
|
||||
imageType,
|
||||
showReviewDate,
|
||||
showReviewerName,
|
||||
showReviewImage,
|
||||
showReviewRating: showReviewRatingAttr,
|
||||
showReviewContent,
|
||||
showProductName,
|
||||
} = attributes;
|
||||
const { rating } = review;
|
||||
const isLoading = ! Object.keys( review ).length > 0;
|
||||
const showReviewRating = Number.isFinite( rating ) && showReviewRatingAttr;
|
||||
|
||||
return (
|
||||
<li
|
||||
className={ classNames( 'wc-block-review-list-item__item', {
|
||||
'is-loading': isLoading,
|
||||
} ) }
|
||||
aria-hidden={ isLoading }
|
||||
>
|
||||
{ ( showProductName ||
|
||||
showReviewDate ||
|
||||
showReviewerName ||
|
||||
showReviewImage ||
|
||||
showReviewRating ) && (
|
||||
<div className="wc-block-review-list-item__info">
|
||||
{ showReviewImage &&
|
||||
getReviewImage( review, imageType, isLoading ) }
|
||||
{ ( showProductName ||
|
||||
showReviewerName ||
|
||||
showReviewRating ||
|
||||
showReviewDate ) && (
|
||||
<div className="wc-block-review-list-item__meta">
|
||||
{ showReviewRating && getReviewRating( review ) }
|
||||
{ showProductName &&
|
||||
getReviewProductName( review ) }
|
||||
{ showReviewerName && getReviewerName( review ) }
|
||||
{ showReviewDate && getReviewDate( review ) }
|
||||
</div>
|
||||
) }
|
||||
</div>
|
||||
) }
|
||||
{ showReviewContent && getReviewContent( review ) }
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
ReviewListItem.propTypes = {
|
||||
attributes: PropTypes.object.isRequired,
|
||||
review: PropTypes.object,
|
||||
};
|
||||
|
||||
/**
|
||||
* BE AWARE. ReviewListItem expects product data that is equivalent to what is
|
||||
* made available for output in a public view. Thus content that may contain
|
||||
* html data is not sanitized further.
|
||||
*
|
||||
* Currently the following data is trusted (assumed to already be sanitized):
|
||||
* - `review.review` (review content)
|
||||
* - `review.product_name` (the product title)
|
||||
*/
|
||||
export default ReviewListItem;
|
||||
@@ -0,0 +1,197 @@
|
||||
.is-loading {
|
||||
.wc-block-review-list-item__text {
|
||||
@include placeholder();
|
||||
display: block;
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
.wc-block-review-list-item__info {
|
||||
.wc-block-review-list-item__image {
|
||||
@include placeholder();
|
||||
}
|
||||
|
||||
.wc-block-review-list-item__meta {
|
||||
.wc-block-review-list-item__author {
|
||||
@include placeholder();
|
||||
font-size: 1em;
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.wc-block-review-list-item__product {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wc-block-review-list-item__rating {
|
||||
.wc-block-review-list-item__rating__stars > span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-review-list-item__published-date {
|
||||
@include placeholder();
|
||||
height: 1em;
|
||||
width: 120px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.editor-styles-wrapper .wc-block-review-list-item__item,
|
||||
.wc-block-review-list-item__item {
|
||||
margin: 0 0 $gap-large * 2;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.wc-block-review-list-item__info {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
margin-bottom: $gap-large;
|
||||
}
|
||||
|
||||
.wc-block-review-list-item__meta {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.has-image {
|
||||
.wc-block-review-list-item__info {
|
||||
grid-template-columns: #{48px + $gap} 1fr;
|
||||
}
|
||||
.wc-block-review-list-item__meta {
|
||||
grid-column: 2;
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-review-list-item__image {
|
||||
height: 48px;
|
||||
grid-column: 1;
|
||||
grid-row: 1 / 3;
|
||||
width: 48px;
|
||||
position: relative;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-review-list-item__verified {
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
text-indent: 21px;
|
||||
margin: 0;
|
||||
line-height: 21px;
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
right: -7px;
|
||||
bottom: -7px;
|
||||
|
||||
&::before {
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
background: transparent url('data:image/svg+xml;utf8,%3Csvg xmlns="http://www.w3.org/2000/svg" width="21" height="21" fill="none"%3E%3Ccircle cx="10.5" cy="10.5" r="10.5" fill="%23fff"/%3E%3Cpath fill="%23008A21" fill-rule="evenodd" d="M2.1667 10.5003c0-4.6 3.7333-8.3333 8.3333-8.3333s8.3334 3.7333 8.3334 8.3333S15.1 18.8337 10.5 18.8337s-8.3333-3.7334-8.3333-8.3334zm2.5 0l4.1666 4.1667 7.5001-7.5-1.175-1.1833-6.325 6.325-2.9917-2.9834-1.175 1.175z" clip-rule="evenodd"/%3E%3Cmask id="a" width="17" height="17" x="2" y="2" maskUnits="userSpaceOnUse"%3E%3Cpath fill="%23fff" fill-rule="evenodd" d="M2.1667 10.5003c0-4.6 3.7333-8.3333 8.3333-8.3333s8.3334 3.7333 8.3334 8.3333S15.1 18.8337 10.5 18.8337s-8.3333-3.7334-8.3333-8.3334zm2.5 0l4.1666 4.1667 7.5001-7.5-1.175-1.1833-6.325 6.325-2.9917-2.9834-1.175 1.175z" clip-rule="evenodd"/%3E%3C/mask%3E%3Cg mask="url(%23a)"%3E%3Cpath fill="%23008A21" d="M.5.5h20v20H.5z"/%3E%3C/g%3E%3C/svg%3E') center center no-repeat; /* stylelint-disable-line */
|
||||
display: block;
|
||||
content: "";
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-review-list-item__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-flow: row wrap;
|
||||
|
||||
&::after {
|
||||
// Force wrap after star rating.
|
||||
order: 3;
|
||||
content: "";
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-review-list-item__product {
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
order: 1;
|
||||
margin-right: $gap/2;
|
||||
}
|
||||
|
||||
.wc-block-review-list-item__author {
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
order: 1;
|
||||
margin-right: $gap/2;
|
||||
}
|
||||
|
||||
.wc-block-review-list-item__product + .wc-block-review-list-item__author {
|
||||
font-weight: normal;
|
||||
color: #808080;
|
||||
order: 4;
|
||||
}
|
||||
|
||||
.wc-block-review-list-item__published-date {
|
||||
color: #808080;
|
||||
order: 5;
|
||||
}
|
||||
|
||||
.wc-block-review-list-item__author + .wc-block-review-list-item__published-date {
|
||||
&::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
margin-right: $gap/2;
|
||||
border-right: 1px solid #ddd;
|
||||
height: 1em;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-review-list-item__author:first-child + .wc-block-review-list-item__published-date,
|
||||
.wc-block-review-list-item__rating + .wc-block-review-list-item__author + .wc-block-review-list-item__published-date {
|
||||
&::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-review-list-item__rating {
|
||||
order: 2;
|
||||
|
||||
> .wc-block-review-list-item__rating__stars {
|
||||
display: inline-block;
|
||||
top: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
height: 1.618em;
|
||||
line-height: 1.618;
|
||||
font-size: 1em;
|
||||
width: 5.3em;
|
||||
font-family: star; /* stylelint-disable-line */
|
||||
font-weight: 400;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
> .wc-block-review-list-item__rating__stars::before {
|
||||
content: "\53\53\53\53\53";
|
||||
opacity: 0.25;
|
||||
float: left;
|
||||
top: 0;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
> .wc-block-review-list-item__rating__stars span {
|
||||
overflow: hidden;
|
||||
float: left;
|
||||
top: 0;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
padding-top: 1.5em;
|
||||
}
|
||||
|
||||
> .wc-block-review-list-item__rating__stars span::before {
|
||||
content: "\53\53\53\53\53";
|
||||
top: 0;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: #e6a237;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
ENABLE_REVIEW_RATING,
|
||||
SHOW_AVATARS,
|
||||
} from '@woocommerce/block-settings';
|
||||
import ReviewListItem from '@woocommerce/base-components/review-list-item';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import './style.scss';
|
||||
|
||||
const ReviewList = ( { attributes, reviews } ) => {
|
||||
const showReviewImage =
|
||||
( SHOW_AVATARS || attributes.imageType === 'product' ) &&
|
||||
attributes.showReviewImage;
|
||||
const showReviewRating =
|
||||
ENABLE_REVIEW_RATING && attributes.showReviewRating;
|
||||
const attrs = {
|
||||
...attributes,
|
||||
showReviewImage,
|
||||
showReviewRating,
|
||||
};
|
||||
|
||||
return (
|
||||
<ul className="wc-block-review-list">
|
||||
{ reviews.length === 0 ? (
|
||||
<ReviewListItem attributes={ attrs } />
|
||||
) : (
|
||||
reviews.map( ( review, i ) => (
|
||||
<ReviewListItem
|
||||
key={ review.id || i }
|
||||
attributes={ attrs }
|
||||
review={ review }
|
||||
/>
|
||||
) )
|
||||
) }
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
ReviewList.propTypes = {
|
||||
attributes: PropTypes.object.isRequired,
|
||||
reviews: PropTypes.array.isRequired,
|
||||
};
|
||||
|
||||
export default ReviewList;
|
||||
@@ -0,0 +1,4 @@
|
||||
.wc-block-review-list,
|
||||
.editor-styles .wc-block-review-list {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
import SortSelect from '@woocommerce/base-components/sort-select';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import './style.scss';
|
||||
|
||||
const ReviewSortSelect = ( { defaultValue, onChange, readOnly, value } ) => {
|
||||
return (
|
||||
<SortSelect
|
||||
className="wc-block-review-sort-select"
|
||||
defaultValue={ defaultValue }
|
||||
label={ __( 'Order by', 'woocommerce' ) }
|
||||
onChange={ onChange }
|
||||
options={ [
|
||||
{
|
||||
key: 'most-recent',
|
||||
label: __( 'Most recent', 'woocommerce' ),
|
||||
},
|
||||
{
|
||||
key: 'highest-rating',
|
||||
label: __(
|
||||
'Highest rating',
|
||||
'woocommerce'
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'lowest-rating',
|
||||
label: __(
|
||||
'Lowest rating',
|
||||
'woocommerce'
|
||||
),
|
||||
},
|
||||
] }
|
||||
readOnly={ readOnly }
|
||||
screenReaderLabel={ __(
|
||||
'Order reviews by',
|
||||
'woocommerce'
|
||||
) }
|
||||
value={ value }
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
ReviewSortSelect.propTypes = {
|
||||
defaultValue: PropTypes.oneOf( [
|
||||
'most-recent',
|
||||
'highest-rating',
|
||||
'lowest-rating',
|
||||
] ),
|
||||
onChange: PropTypes.func,
|
||||
readOnly: PropTypes.bool,
|
||||
value: PropTypes.oneOf( [
|
||||
'most-recent',
|
||||
'highest-rating',
|
||||
'lowest-rating',
|
||||
] ),
|
||||
};
|
||||
|
||||
export default ReviewSortSelect;
|
||||
@@ -0,0 +1,3 @@
|
||||
.wc-block-review-sort-select {
|
||||
text-align: right;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import Label from '@woocommerce/base-components/label';
|
||||
import withComponentId from '@woocommerce/base-hocs/with-component-id';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import './style.scss';
|
||||
|
||||
/**
|
||||
* Component used for 'Order by' selectors, which renders a label
|
||||
* and a <select> with the options provided in the props.
|
||||
*/
|
||||
const SortSelect = ( {
|
||||
className,
|
||||
componentId,
|
||||
defaultValue,
|
||||
label,
|
||||
onChange,
|
||||
options,
|
||||
screenReaderLabel,
|
||||
readOnly,
|
||||
value,
|
||||
} ) => {
|
||||
const selectId = `wc-block-sort-select__select-${ componentId }`;
|
||||
|
||||
return (
|
||||
<div className={ classNames( 'wc-block-sort-select', className ) }>
|
||||
<Label
|
||||
label={ label }
|
||||
screenReaderLabel={ screenReaderLabel }
|
||||
wrapperElement="label"
|
||||
wrapperProps={ {
|
||||
className: 'wc-block-sort-select__label',
|
||||
htmlFor: selectId,
|
||||
} }
|
||||
/>
|
||||
<select // eslint-disable-line jsx-a11y/no-onchange
|
||||
id={ selectId }
|
||||
className="wc-block-sort-select__select"
|
||||
defaultValue={ defaultValue }
|
||||
onChange={ onChange }
|
||||
readOnly={ readOnly }
|
||||
value={ value }
|
||||
>
|
||||
{ options.map( ( option ) => (
|
||||
<option key={ option.key } value={ option.key }>
|
||||
{ option.label }
|
||||
</option>
|
||||
) ) }
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
SortSelect.propTypes = {
|
||||
defaultValue: PropTypes.string,
|
||||
label: PropTypes.string,
|
||||
onChange: PropTypes.func,
|
||||
options: PropTypes.arrayOf(
|
||||
PropTypes.shape( {
|
||||
key: PropTypes.string.isRequired,
|
||||
label: PropTypes.string.isRequired,
|
||||
} )
|
||||
),
|
||||
readOnly: PropTypes.bool,
|
||||
screenReaderLabel: PropTypes.string,
|
||||
value: PropTypes.string,
|
||||
// from withComponentId
|
||||
componentId: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
export default withComponentId( SortSelect );
|
||||
@@ -0,0 +1,9 @@
|
||||
.wc-block-sort-select {
|
||||
margin-bottom: $gap-small;
|
||||
}
|
||||
|
||||
.wc-block-sort-select__label {
|
||||
margin-right: $gap-small;
|
||||
display: inline-block;
|
||||
font-weight: normal;
|
||||
}
|
||||
Reference in New Issue
Block a user