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 * External dependencies
*/ */
import { Component, Fragment } from '@wordpress/element'; import { Component } from '@wordpress/element';
import moment from 'moment'; import moment from 'moment';
import { import {
DayPickerRangeController, DayPickerRangeController,
@ -19,11 +19,11 @@ import 'react-dates/lib/css/_datepicker.css';
* Internal dependencies * Internal dependencies
*/ */
import { toMoment } from 'lib/date'; import { toMoment } from 'lib/date';
import DateInput from './input';
import phrases from './phrases'; import phrases from './phrases';
import './style.scss'; import './style.scss';
const START_DATE = 'startDate'; const START_DATE = 'startDate';
const END_DATE = 'endDate';
// 782px is the width designated by Gutenberg's `</ Popover>` component. // 782px is the width designated by Gutenberg's `</ Popover>` component.
// * https://github.com/WordPress/gutenberg/blob/c8f8806d4465a83c1a0bc62d5c61377b56fa7214/components/popover/utils.js#L6 // * https://github.com/WordPress/gutenberg/blob/c8f8806d4465a83c1a0bc62d5c61377b56fa7214/components/popover/utils.js#L6
@ -39,6 +39,8 @@ class DateRange extends Component {
focusedInput: START_DATE, focusedInput: START_DATE,
afterText: after ? after.format( shortDateFormat ) : '', afterText: after ? after.format( shortDateFormat ) : '',
beforeText: before ? before.format( shortDateFormat ) : '', beforeText: before ? before.format( shortDateFormat ) : '',
afterError: null,
beforeError: null,
}; };
this.onDatesChange = this.onDatesChange.bind( this ); this.onDatesChange = this.onDatesChange.bind( this );
@ -47,10 +49,31 @@ class DateRange extends Component {
this.getOutsideRange = this.getOutsideRange.bind( this ); 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 } ) { onDatesChange( { startDate, endDate } ) {
this.setState( { this.setState( {
afterText: startDate ? startDate.format( shortDateFormat ) : '', afterText: startDate ? startDate.format( shortDateFormat ) : '',
beforeText: endDate ? endDate.format( shortDateFormat ) : '', beforeText: endDate ? endDate.format( shortDateFormat ) : '',
afterError: null,
beforeError: null,
} ); } );
this.props.onSelect( { this.props.onSelect( {
after: startDate, 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 ) { onInputChange( input, event ) {
const value = event.target.value; const value = event.target.value;
this.setState( { [ input + 'Text' ]: value } ); const { date, error } = this.getValidatedDate( input, value );
const date = toMoment( shortDateFormat, value ); this.setState( {
if ( date ) { [ input + 'Text' ]: value,
this.props.onSelect( { [ input + 'Error' ]: value.length > 0 ? error : null,
[ input ]: date, } );
} ); this.props.onSelect( {
} [ input ]: date,
} );
} }
getOutsideRange() { getOutsideRange() {
@ -91,77 +145,78 @@ class DateRange extends Component {
return 'function' === typeof inValidDays ? inValidDays : undefined; 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() { render() {
const { focusedInput, afterText, beforeText } = this.state; const { focusedInput, afterText, beforeText, afterError, beforeError } = this.state;
const { after, before } = this.props; const { after, before } = this.props;
const isOutsideRange = this.getOutsideRange(); const isOutsideRange = this.getOutsideRange();
const isMobile = isMobileViewport(); const isMobile = isMobileViewport();
const isDoubleCalendar = isMobile && window.innerWidth > 624;
return ( return (
<Fragment> <div
<div className="woocommerce-date-picker__date-inputs"> className={ classnames( 'woocommerce-calendar', {
<input 'is-mobile': isMobile,
} ) }
>
<div className="woocommerce-calendar__inputs">
<DateInput
value={ afterText } value={ afterText }
type="text"
onChange={ partial( this.onInputChange, 'after' ) } onChange={ partial( this.onInputChange, 'after' ) }
aria-label={ __( 'Start Date', 'wc-admin' ) } dateFormat={ shortDateFormat }
id="after-date-string" label={ __( 'Start Date', 'wc-admin' ) }
aria-describedby="after-date-string-message" error={ afterError }
/> describedBy={ sprintf(
<p className="screen-reader-text" id="after-date-string-message">
{ sprintf(
__( __(
"Date input describing a selected date range's start date in format %s", "Date input describing a selected date range's start date in format %s",
'wc-admin' 'wc-admin'
), ),
shortDateFormat 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"> <div className="woocommerce-calendar__inputs-to">{ __( 'to', 'wc-admin' ) }</div>
{ sprintf( <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", "Date input describing a selected date range's end date in format %s",
'wc-admin' 'wc-admin'
), ),
shortDateFormat shortDateFormat
) } ) }
</p> />
</div> </div>
<div <div className="woocommerce-calendar__react-dates">
className={ classnames( 'woocommerce-calendar', {
'is-mobile': isMobile,
} ) }
>
<DayPickerRangeController <DayPickerRangeController
onDatesChange={ this.onDatesChange } onDatesChange={ this.onDatesChange }
onFocusChange={ this.onFocusChange } onFocusChange={ this.onFocusChange }
focusedInput={ focusedInput } focusedInput={ focusedInput }
startDate={ after } startDate={ after }
startDateId={ START_DATE }
startDatePlaceholderText={ 'Start Date' }
endDate={ before } endDate={ before }
endDateId={ END_DATE }
endDatePlaceholderText={ 'End Date' }
orientation={ 'horizontal' } orientation={ 'horizontal' }
numberOfMonths={ 1 } numberOfMonths={ isDoubleCalendar ? 2 : 1 }
isOutsideRange={ isOutsideRange } isOutsideRange={ isOutsideRange }
minimumNights={ 0 } minimumNights={ 0 }
hideKeyboardShortcutsPanel hideKeyboardShortcutsPanel
noBorder noBorder
initialVisibleMonth={ () => after || moment() } initialVisibleMonth={ this.setTnitialVisibleMonth( isDoubleCalendar, before ) }
phrases={ phrases } phrases={ phrases }
firstDayOfWeek={ Number( wcSettings.date.dow ) } firstDayOfWeek={ Number( wcSettings.date.dow ) }
/> />
</div> </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 */ /** @format */
.woocommerce-calendar { .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 { .DayPicker {
margin: 0 auto; margin: 0 auto;
} }
@ -9,11 +23,127 @@
margin-top: 10px; margin-top: 10px;
} }
&.is-mobile { .CalendarDay__selected_span {
height: 360px; background: $woocommerce;
border: 1px solid $core-grey-light-700;
}
.DayPicker { .CalendarDay__selected {
width: 318px; 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 { Component, Fragment } from '@wordpress/element';
import { TabPanel, Button } from '@wordpress/components'; import { TabPanel, Button } from '@wordpress/components';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classnames from 'classnames';
/** /**
* Internal dependencies * Internal dependencies
@ -16,6 +17,8 @@ import PresetPeriods from './preset-periods';
import Link from 'components/link'; import Link from 'components/link';
import { DateRange } from 'components/calendar'; import { DateRange } from 'components/calendar';
const isMobileViewport = () => window.innerWidth < 782;
class DatePickerContent extends Component { class DatePickerContent extends Component {
constructor() { constructor() {
super(); super();
@ -44,9 +47,10 @@ class DatePickerContent extends Component {
onClose, onClose,
getUpdatePath, getUpdatePath,
isValidSelection, isValidSelection,
resetCustomValues,
} = this.props; } = this.props;
return ( return (
<Fragment> <div>
<H className="screen-reader-text" tabIndex="0"> <H className="screen-reader-text" tabIndex="0">
{ __( 'Select date range and comparison', 'wc-admin' ) } { __( 'Select date range and comparison', 'wc-admin' ) }
</H> </H>
@ -89,26 +93,53 @@ class DatePickerContent extends Component {
inValidDays="future" inValidDays="future"
/> />
) } ) }
<H className="woocommerce-date-picker__text">{ __( 'compare to', 'wc-admin' ) }</H> <div
<ComparePeriods onSelect={ onSelect } compare={ compare } /> className={ classnames( 'woocommerce-date-picker__content-controls', {
{ isValidSelection( selectedTab ) ? ( 'is-sticky-bottom': selectedTab === 'custom' && isMobileViewport(),
<Link 'is-custom': selectedTab === 'custom',
className="woocommerce-date-picker__update-btn components-button is-button is-primary" } ) }
to={ getUpdatePath( selectedTab ) } >
onClick={ onClose } <H className="woocommerce-date-picker__text">
> { __( 'compare to', 'wc-admin' ) }
{ __( 'Update', 'wc-admin' ) } </H>
</Link> <ComparePeriods onSelect={ onSelect } compare={ compare } />
) : ( <div className="woocommerce-date-picker__content-controls-btns">
<Button className="woocommerce-date-picker__update-btn" isPrimary disabled> { selectedTab === 'custom' && (
{ __( 'Update', 'wc-admin' ) } <Button
</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> </Fragment>
) } ) }
</TabPanel> </TabPanel>
</Section> </Section>
</Fragment> </div>
); );
} }
} }
@ -119,6 +150,7 @@ DatePickerContent.propTypes = {
onSelect: PropTypes.func.isRequired, onSelect: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired,
getUpdatePath: PropTypes.func.isRequired, getUpdatePath: PropTypes.func.isRequired,
resetCustomValues: PropTypes.func.isRequired,
}; };
export default DatePickerContent; export default DatePickerContent;

View File

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

View File

@ -13,25 +13,12 @@
} }
.woocommerce-date-picker__content { .woocommerce-date-picker__content {
.components-popover__content { > .components-popover__content {
width: 320px; 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 { &.is-mobile {
.components-popover__content { & > .components-popover__content {
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
@ -44,19 +31,16 @@
.components-popover__close { .components-popover__close {
transform: translateY(22px); 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 { .woocommerce-date-picker__tabs {
height: calc(100% - 42px);
.components-tab-panel__tabs { .components-tab-panel__tabs {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@ -68,14 +52,6 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; 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 { .woocommerce-date-picker__text {
@include font-size( 12 ); @include font-size( 12 );
font-weight: 100; font-weight: 100;
text-transform: uppercase; text-transform: uppercase;
text-align: center; 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 ) { if ( 'string' === typeof str ) {
const date = moment( str, [ isoDateFormat, format ], true ); 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' ); 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 ); const fn = () => toMoment( '', 77 );
expect( fn ).toThrow(); expect( fn ).toThrow();
} ); } );
it( 'shoud return null on invalid date', () => {
const invalidDate = toMoment( 'YYYY', '2018-00-00' );
expect( invalidDate ).toBe( null );
} );
} ); } );
describe( 'getCurrentPeriod', () => { describe( 'getCurrentPeriod', () => {

File diff suppressed because it is too large Load Diff