Merge pull request woocommerce/woocommerce-admin#235 from woocommerce/add/custom-datepicker-styles

Add custom Datepicker styles and validation
This commit is contained in:
Paul Sealock 2018-07-30 10:41:28 +12:00 committed by GitHub
commit bf9eb38ab0
9 changed files with 3686 additions and 3400 deletions

View File

@ -2,7 +2,7 @@
/**
* External dependencies
*/
import { Component, Fragment } from '@wordpress/element';
import { Component } from '@wordpress/element';
import moment from 'moment';
import {
DayPickerRangeController,
@ -19,11 +19,11 @@ import 'react-dates/lib/css/_datepicker.css';
* Internal dependencies
*/
import { toMoment } from 'lib/date';
import DateInput from './input';
import phrases from './phrases';
import './style.scss';
const START_DATE = 'startDate';
const END_DATE = 'endDate';
// 782px is the width designated by Gutenberg's `</ Popover>` component.
// * https://github.com/WordPress/gutenberg/blob/c8f8806d4465a83c1a0bc62d5c61377b56fa7214/components/popover/utils.js#L6
@ -39,6 +39,8 @@ class DateRange extends Component {
focusedInput: START_DATE,
afterText: after ? after.format( shortDateFormat ) : '',
beforeText: before ? before.format( shortDateFormat ) : '',
afterError: null,
beforeError: null,
};
this.onDatesChange = this.onDatesChange.bind( this );
@ -47,10 +49,31 @@ class DateRange extends Component {
this.getOutsideRange = this.getOutsideRange.bind( this );
}
componentDidUpdate( prevProps ) {
const { after, before } = this.props;
/**
* Check if props have been reset. If so, reset internal state. Disabling
* eslint here because this setState cannot cause infinte loop
*/
/* eslint-disable react/no-did-update-set-state */
if ( ( prevProps.before || prevProps.after ) && ( null === after && null === before ) ) {
this.setState( {
focusedInput: START_DATE,
afterText: '',
beforeText: '',
afterError: null,
beforeError: null,
} );
}
/* eslint-enable react/no-did-update-set-state */
}
onDatesChange( { startDate, endDate } ) {
this.setState( {
afterText: startDate ? startDate.format( shortDateFormat ) : '',
beforeText: endDate ? endDate.format( shortDateFormat ) : '',
afterError: null,
beforeError: null,
} );
this.props.onSelect( {
after: startDate,
@ -64,15 +87,46 @@ class DateRange extends Component {
} );
}
getValidatedDate( input, value ) {
const { after, before } = this.props;
const date = toMoment( shortDateFormat, value );
if ( ! date ) {
return {
date: null,
error: __( 'Invalid date', 'wc-admin' ),
};
}
if ( moment().isBefore( date, 'day' ) ) {
return {
date: null,
error: __( 'Select a date in the past', 'wc-admin' ),
};
}
if ( 'after' === input && before && date.isAfter( before, 'day' ) ) {
return {
date: null,
error: __( 'Start date must be before end date', 'wc-admin' ),
};
}
if ( 'before' === input && after && date.isBefore( after, 'day' ) ) {
return {
date: null,
error: __( 'Start date must be before end date', 'wc-admin' ),
};
}
return { date };
}
onInputChange( input, event ) {
const value = event.target.value;
this.setState( { [ input + 'Text' ]: value } );
const date = toMoment( shortDateFormat, value );
if ( date ) {
this.props.onSelect( {
[ input ]: date,
} );
}
const { date, error } = this.getValidatedDate( input, value );
this.setState( {
[ input + 'Text' ]: value,
[ input + 'Error' ]: value.length > 0 ? error : null,
} );
this.props.onSelect( {
[ input ]: date,
} );
}
getOutsideRange() {
@ -91,77 +145,78 @@ class DateRange extends Component {
return 'function' === typeof inValidDays ? inValidDays : undefined;
}
setTnitialVisibleMonth( isDoubleCalendar, before ) {
return () => {
const visibleDate = before || moment();
if ( isDoubleCalendar ) {
return visibleDate.clone().subtract( 1, 'month' );
}
return visibleDate;
};
}
render() {
const { focusedInput, afterText, beforeText } = this.state;
const { focusedInput, afterText, beforeText, afterError, beforeError } = this.state;
const { after, before } = this.props;
const isOutsideRange = this.getOutsideRange();
const isMobile = isMobileViewport();
const isDoubleCalendar = isMobile && window.innerWidth > 624;
return (
<Fragment>
<div className="woocommerce-date-picker__date-inputs">
<input
<div
className={ classnames( 'woocommerce-calendar', {
'is-mobile': isMobile,
} ) }
>
<div className="woocommerce-calendar__inputs">
<DateInput
value={ afterText }
type="text"
onChange={ partial( this.onInputChange, 'after' ) }
aria-label={ __( 'Start Date', 'wc-admin' ) }
id="after-date-string"
aria-describedby="after-date-string-message"
/>
<p className="screen-reader-text" id="after-date-string-message">
{ sprintf(
dateFormat={ shortDateFormat }
label={ __( 'Start Date', 'wc-admin' ) }
error={ afterError }
describedBy={ sprintf(
__(
"Date input describing a selected date range's start date in format %s",
'wc-admin'
),
shortDateFormat
) }
</p>
<span>{ __( 'to', 'wc-admin' ) }</span>
<input
value={ beforeText }
type="text"
onChange={ partial( this.onInputChange, 'before' ) }
aria-label={ __( 'End Date', 'wc-admin' ) }
id="before-date-string"
aria-describedby="before-date-string-message"
/>
<p className="screen-reader-text" id="before-date-string-message">
{ sprintf(
<div className="woocommerce-calendar__inputs-to">{ __( 'to', 'wc-admin' ) }</div>
<DateInput
value={ beforeText }
onChange={ partial( this.onInputChange, 'before' ) }
dateFormat={ shortDateFormat }
label={ __( 'End Date', 'wc-admin' ) }
error={ beforeError }
describedBy={ sprintf(
__(
"Date input describing a selected date range's end date in format %s",
'wc-admin'
),
shortDateFormat
) }
</p>
/>
</div>
<div
className={ classnames( 'woocommerce-calendar', {
'is-mobile': isMobile,
} ) }
>
<div className="woocommerce-calendar__react-dates">
<DayPickerRangeController
onDatesChange={ this.onDatesChange }
onFocusChange={ this.onFocusChange }
focusedInput={ focusedInput }
startDate={ after }
startDateId={ START_DATE }
startDatePlaceholderText={ 'Start Date' }
endDate={ before }
endDateId={ END_DATE }
endDatePlaceholderText={ 'End Date' }
orientation={ 'horizontal' }
numberOfMonths={ 1 }
numberOfMonths={ isDoubleCalendar ? 2 : 1 }
isOutsideRange={ isOutsideRange }
minimumNights={ 0 }
hideKeyboardShortcutsPanel
noBorder
initialVisibleMonth={ () => after || moment() }
initialVisibleMonth={ this.setTnitialVisibleMonth( isDoubleCalendar, before ) }
phrases={ phrases }
firstDayOfWeek={ Number( wcSettings.date.dow ) }
/>
</div>
</Fragment>
</div>
);
}
}

View File

@ -0,0 +1,54 @@
/** @format */
/**
* External dependencies
*/
import { Dashicon, Popover } from '@wordpress/components';
import classnames from 'classnames';
import { uniqueId } from 'lodash';
import PropTypes from 'prop-types';
const DateInput = ( { value, onChange, dateFormat, label, describedBy, error } ) => {
const classes = classnames( 'woocommerce-calendar__input', {
'is-empty': value.length === 0,
'is-error': error,
} );
const id = uniqueId( '_woo-dates-input' );
return (
<div className={ classes }>
<input
type="text"
className="woocommerce-calendar__input-text"
value={ value }
onChange={ onChange }
aria-label={ label }
id={ id }
aria-describedby={ `${ id }-message` }
placeholder={ dateFormat.toLowerCase() }
/>
{ error && (
<Popover
className="woocommerce-calendar__input-error"
focusOnMount={ false }
position="bottom center"
>
{ error }
</Popover>
) }
<Dashicon icon="calendar" />
<p className="screen-reader-text" id={ `${ id }-message` }>
{ error || describedBy }
</p>
</div>
);
};
DateInput.propTypes = {
value: PropTypes.string,
onChange: PropTypes.func.isRequired,
dateFormat: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
describedBy: PropTypes.string.isRequired,
error: PropTypes.string,
};
export default DateInput;

View File

@ -1,6 +1,20 @@
/** @format */
.woocommerce-calendar {
width: 100%;
background-color: $core-grey-light-100;
border-top: 1px solid $core-grey-light-700;
height: 396px;
&.is-mobile {
height: 100%;
min-height: 537px;
}
}
.woocommerce-calendar__react-dates {
width: 100%;
.DayPicker {
margin: 0 auto;
}
@ -9,11 +23,127 @@
margin-top: 10px;
}
&.is-mobile {
height: 360px;
.CalendarDay__selected_span {
background: $woocommerce;
border: 1px solid $core-grey-light-700;
}
.DayPicker {
width: 318px;
.CalendarDay__selected {
background: $woocommerce-700;
border: 1px solid $core-grey-light-700;
}
.CalendarDay__hovered_span {
background: $woocommerce;
border: 1px solid $core-grey-light-500;
color: $white;
}
.CalendarDay__blocked_out_of_range {
color: $core-grey-light-900;
}
.DayPicker_transitionContainer,
.CalendarMonthGrid,
.CalendarMonth,
.DayPicker {
background-color: $core-grey-light-100;
}
.DayPicker_weekHeader_li {
color: $core-grey-dark-400;
}
}
.woocommerce-calendar__inputs {
padding: 1em;
width: 100%;
max-width: 500px;
display: grid;
grid-template-columns: 43% 14% 43%;
margin: 0 auto;
.components-base-control {
margin: 0;
}
}
.woocommerce-calendar__inputs-to {
display: flex;
align-items: center;
justify-content: center;
}
.woocommerce-calendar__input {
position: relative;
.dashicons-calendar {
position: absolute;
top: 50%;
transform: translateY(-50%);
left: 10px;
path {
fill: $core-grey-dark-300;
}
}
&.is-empty {
.dashicons-calendar path {
fill: $core-grey-dark-300;
}
}
&.is-error {
.dashicons-calendar path {
fill: $error-red;
}
.woocommerce-calendar__input-text {
border: 1px solid $error-red;
box-shadow: inset 0px 0px 8px $error-red;
&:focus {
box-shadow: inset 0px 0px 8px $error-red, 0 0 6px rgba(30, 140, 190, 0.8);
}
}
}
.woocommerce-calendar__input-text {
color: $core-grey-dark-500;
border-radius: 3px;
padding: 10px 10px 10px 30px;
width: 100%;
@include font-size( 13 );
&::placeholder {
color: $core-grey-dark-300;
}
&:focus + span .woocommerce-calendar__input-error {
display: block;
}
}
}
.woocommerce-calendar__input-error {
display: none;
.components-popover__content {
background-color: $core-grey-dark-400;
color: $white;
padding: 0.5em;
border: none;
}
&.components-popover {
.components-popover__content {
min-width: 100px;
text-align: center;
}
&:not(.no-arrow):after {
border-color: $core-grey-dark-400;
}
}
}

View File

@ -6,6 +6,7 @@ import { __ } from '@wordpress/i18n';
import { Component, Fragment } from '@wordpress/element';
import { TabPanel, Button } from '@wordpress/components';
import PropTypes from 'prop-types';
import classnames from 'classnames';
/**
* Internal dependencies
@ -16,6 +17,8 @@ import PresetPeriods from './preset-periods';
import Link from 'components/link';
import { DateRange } from 'components/calendar';
const isMobileViewport = () => window.innerWidth < 782;
class DatePickerContent extends Component {
constructor() {
super();
@ -44,9 +47,10 @@ class DatePickerContent extends Component {
onClose,
getUpdatePath,
isValidSelection,
resetCustomValues,
} = this.props;
return (
<Fragment>
<div>
<H className="screen-reader-text" tabIndex="0">
{ __( 'Select date range and comparison', 'wc-admin' ) }
</H>
@ -89,26 +93,53 @@ class DatePickerContent extends Component {
inValidDays="future"
/>
) }
<H className="woocommerce-date-picker__text">{ __( 'compare to', 'wc-admin' ) }</H>
<ComparePeriods onSelect={ onSelect } compare={ compare } />
{ isValidSelection( selectedTab ) ? (
<Link
className="woocommerce-date-picker__update-btn components-button is-button is-primary"
to={ getUpdatePath( selectedTab ) }
onClick={ onClose }
>
{ __( 'Update', 'wc-admin' ) }
</Link>
) : (
<Button className="woocommerce-date-picker__update-btn" isPrimary disabled>
{ __( 'Update', 'wc-admin' ) }
</Button>
) }
<div
className={ classnames( 'woocommerce-date-picker__content-controls', {
'is-sticky-bottom': selectedTab === 'custom' && isMobileViewport(),
'is-custom': selectedTab === 'custom',
} ) }
>
<H className="woocommerce-date-picker__text">
{ __( 'compare to', 'wc-admin' ) }
</H>
<ComparePeriods onSelect={ onSelect } compare={ compare } />
<div className="woocommerce-date-picker__content-controls-btns">
{ selectedTab === 'custom' && (
<Button
className="woocommerce-date-picker__content-controls-btn"
isPrimary
onClick={ resetCustomValues }
disabled={ ! ( after || before ) }
>
{ __( 'Reset', 'wc-admin' ) }
</Button>
) }
{ isValidSelection( selectedTab ) ? (
<Link
/* eslint-disable max-len */
className="woocommerce-date-picker__content-controls-btn components-button is-button is-primary"
/* eslint-enable max-len */
href={ getUpdatePath( selectedTab ) }
onClick={ onClose }
>
{ __( 'Update', 'wc-admin' ) }
</Link>
) : (
<Button
className="woocommerce-date-picker__content-controls-btn"
isPrimary
disabled
>
{ __( 'Update', 'wc-admin' ) }
</Button>
) }
</div>
</div>
</Fragment>
) }
</TabPanel>
</Section>
</Fragment>
</div>
);
}
}
@ -119,6 +150,7 @@ DatePickerContent.propTypes = {
onSelect: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
getUpdatePath: PropTypes.func.isRequired,
resetCustomValues: PropTypes.func.isRequired,
};
export default DatePickerContent;

View File

@ -23,6 +23,7 @@ class DatePicker extends Component {
this.select = this.select.bind( this );
this.getUpdatePath = this.getUpdatePath.bind( this );
this.isValidSelection = this.isValidSelection.bind( this );
this.resetCustomValues = this.resetCustomValues.bind( this );
}
// @TODO change this to `getDerivedStateFromProps` in React 16.4
@ -73,6 +74,13 @@ class DatePicker extends Component {
return true;
}
resetCustomValues() {
this.setState( {
after: null,
before: null,
} );
}
render() {
const { period, compare, after, before } = this.state;
return (
@ -99,6 +107,7 @@ class DatePicker extends Component {
onClose={ onClose }
getUpdatePath={ this.getUpdatePath }
isValidSelection={ this.isValidSelection }
resetCustomValues={ this.resetCustomValues }
/>
) }
/>

View File

@ -13,25 +13,12 @@
}
.woocommerce-date-picker__content {
.components-popover__content {
> .components-popover__content {
width: 320px;
padding: 1em 0;
border: 1px solid $gray;
background-color: $white;
display: flex;
flex-direction: column;
& > * {
margin: 0 0 1em 0;
&:last-child {
margin: 0;
}
}
}
&.is-mobile {
.components-popover__content {
& > .components-popover__content {
width: 100%;
height: 100%;
}
@ -44,19 +31,16 @@
.components-popover__close {
transform: translateY(22px);
}
.components-tab-panel__tab-content {
height: calc(100% - 38px);
}
}
}
.woocommerce-date-picker__date-inputs {
max-width: 320px;
width: 100%;
display: grid;
margin: 0 1em 1em 1em;
grid-template-columns: 45% 10% 45%;
text-align: center;
}
.woocommerce-date-picker__tabs {
height: calc(100% - 42px);
.components-tab-panel__tabs {
display: grid;
grid-template-columns: 1fr 1fr;
@ -68,14 +52,6 @@
display: flex;
flex-direction: column;
align-items: center;
& > * {
margin: 0 0 1em 0;
&:last-child {
margin: 0;
}
}
}
}
@ -101,21 +77,45 @@
}
}
.woocommerce-date-picker__update-btn {
border: 1px solid $gray;
background-color: transparent;
padding: 0.5em;
width: 50%;
text-align: center;
text-decoration: none;
justify-content: center;
line-height: inherit;
}
.woocommerce-date-picker__text {
@include font-size( 12 );
font-weight: 100;
text-transform: uppercase;
text-align: center;
color: $core-grey-dark-500;
color: $core-grey-dark-300;
width: 100%;
margin: 0;
padding: 1em;
background-color: $white;
}
.woocommerce-date-picker__content-controls {
display: flex;
flex-direction: column;
width: 100%;
align-items: center;
padding-bottom: 1em;
background-color: $white;
&.is-custom {
border-top: 1px solid $core-grey-light-700;
}
&.is-sticky-bottom {
position: absolute;
bottom: 0;
}
}
.woocommerce-date-picker__content-controls-btns {
padding-top: 1em;
display: flex;
justify-content: center;
width: 100%;
}
.woocommerce-date-picker__content-controls-btn {
justify-content: center;
width: 50%;
margin: 0 1em;
}

View File

@ -48,7 +48,7 @@ export function toMoment( format, str ) {
}
if ( 'string' === typeof str ) {
const date = moment( str, [ isoDateFormat, format ], true );
return date.isValid ? date : null;
return date.isValid() ? date : null;
}
throw new Error( 'toMoment requires a string to be passed as an argument' );
}

View File

@ -51,6 +51,11 @@ describe( 'toMoment', () => {
const fn = () => toMoment( '', 77 );
expect( fn ).toThrow();
} );
it( 'shoud return null on invalid date', () => {
const invalidDate = toMoment( 'YYYY', '2018-00-00' );
expect( invalidDate ).toBe( null );
} );
} );
describe( 'getCurrentPeriod', () => {

File diff suppressed because it is too large Load Diff