Skip to content

Instantly share code, notes, and snippets.

@abonello
Forked from stevensacks/DatePicker.js
Last active December 30, 2021 21:38
Show Gist options
  • Save abonello/2d9994ed5b47ca45c8a8b306cb86af2c to your computer and use it in GitHub Desktop.
Save abonello/2d9994ed5b47ca45c8a8b306cb86af2c to your computer and use it in GitHub Desktop.
React Bulma DatePicker

DatePicker

The DatePicker component allows you to pick a single date, or date range (from -> to).

This component is built on top of my Dialog as a promise component and uses Bulma.

It uses the SASS from Bulma Calendar, converted to SCSS.

The Javascript has been rewritten from scratch for use in React and uses the date-fns library.

Required Props

  • onChange - When you select a date or a date range, this returns the selection (see below).

Optional Props

  • name - The data name value which will be included in the onChange callback (see below)
  • startDate - The date that you want to be pre-selected in a single date or range. The default is today.
  • isRange - If you want the DatePicker to act as a range picker, include this boolean prop (see below).
  • endDate - If the component is a range picker, this is the end date you want to be pre-selected. The default is tomorrow.
  • minDate - Optional minimum threshold for only allowing dates from this date or after.
  • maxDate - Optional maximum threshold for only allowing dates from this date or before.
  • dateFormat - What is displayed on the button, as per the date-fns format documentation. The default is YYYY-MM-DD.
  • className - A custom className for styling.
const onChange = ({name, startDate, endDate}) => {
    console.log({name, startDate, endDate});
};

<DatePicker
    name="availabilityWindow"
    startDate={new Date(2018, 2, 5)} // March 5th, 2018
    endDate={new Date(2018, 3, 12)} // April 12th, 2018
    minDate={new Date(2018, 1, 1)} // Feb 1st, 2018
    maxDate={new Date(2018, 4, 31)} // May 31st, 2018
    onChange={onChange}
    isRange 
/>
import {addDays, format, isAfter, isBefore, startOfDay} from 'date-fns';
import React, {Component, Fragment} from 'react';
import classes from 'classnames';
import DatePickerDialog from './DatePickerDialog';
import Dialog from 'components/Dialog';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {getDisabled} from '../../../utils/component';
import PropTypes from 'prop-types';
import './index.css';
export default class DatePicker extends Component {
static propTypes = {
className: PropTypes.string,
dateFormat: PropTypes.string,
disabled: PropTypes.bool,
endDate: PropTypes.instanceOf(Date),
isRange: PropTypes.bool,
maxDate: PropTypes.instanceOf(Date),
minDate: PropTypes.instanceOf(Date),
name: PropTypes.string,
onChange: PropTypes.func.isRequired,
startDate: PropTypes.instanceOf(Date),
};
static defaultProps = {
dateFormat: 'YYYY-MM-DD',
startDate: new Date(),
endDate: addDays(new Date(), 1),
};
constructor(props) {
super(props);
const startDate =
!props.minDate || isAfter(props.startDate, props.minDate)
? props.startDate
: props.minDate;
const endDate = props.isRange
? !props.maxDate || isBefore(props.endDate, props.maxDate)
? props.endDate
: props.maxDate
: undefined;
this.state = {
startDate: startOfDay(startDate),
endDate: endDate ? startOfDay(endDate) : undefined,
};
}
onClick = () =>
Dialog({
title: '',
styles: {
dialog: classes('date-picker-dialog', {
'date-picker-dialog-range': this.props.isRange,
}),
},
cancel: this.props.isRange ? 'Cancel' : undefined,
submit: this.props.isRange ? 'Save' : undefined,
custom: {
View: DatePickerDialog,
props: {
maxDate: this.props.maxDate,
minDate: this.props.minDate,
isRange: this.props.isRange,
startDate: this.state.startDate,
endDate: this.state.endDate,
},
},
}).then(this.onChange);
onChange = event =>
event &&
this.setState(
{startDate: event.startDate, endDate: event.endDate},
() =>
this.props.onChange({
name: this.props.name,
startDate: this.state.startDate,
endDate: this.state.endDate,
})
);
render() {
const {className, dateFormat, disabled, isRange} = this.props;
const {startDate, endDate} = this.state;
const formattedStartDate = format(startDate, dateFormat);
const formattedEndDate = isRange ? format(endDate, dateFormat) : '';
const click = !disabled ? this.onClick : undefined;
return (
<div
className={classes('date-picker', {
[className]: !!className,
})}
>
<a
className="button date-picker-button"
onClick={click}
{...getDisabled(disabled)}
>
<span className="icon">
<FontAwesomeIcon icon={['fas', 'calendar-alt']} />
</span>
<span>{formattedStartDate}</span>
{isRange && (
<Fragment>
<span className="icon">
<FontAwesomeIcon
icon={['fas', 'long-arrow-alt-right']}
/>
</span>
<span>{formattedEndDate}</span>
</Fragment>
)}
</a>
</div>
);
}
}
import {
addDays,
differenceInDays,
endOfMonth,
endOfWeek,
format,
isAfter,
isBefore,
isEqual,
isSameMonth,
isToday,
isWithinRange,
startOfMonth,
startOfWeek,
} from 'date-fns';
import DatePickerDate from './DatePickerDate';
import PropTypes from 'prop-types';
import React from 'react';
const isValidDate = (date, minDate, maxDate) => {
if (!minDate && !maxDate) return true;
if (minDate && maxDate) return isWithinRange(date, minDate, maxDate);
if (maxDate) return isBefore(date, maxDate) || isEqual(date, maxDate);
return isAfter(date, minDate) || isEqual(date, minDate);
};
export const DatePickerCalendar = ({
endDate,
isRange,
maxDate,
minDate,
onClickDate,
onClickJump,
startDate,
visibleDate,
}) => {
// the 7 days of the week (Sun-Sat)
const labels = new Array(7)
.fill(startOfWeek(visibleDate))
.map((d, i) => format(addDays(d, i), 'ddd'));
// first day of current month view
const start = startOfWeek(startOfMonth(visibleDate));
// last day of current month view
const end = endOfWeek(endOfMonth(visibleDate));
// get all days and whether they are within the current month and range
const days = new Array(differenceInDays(end, start) + 1)
.fill(start)
.map((s, i) => {
const theDate = addDays(s, i);
const isThisMonth = isSameMonth(visibleDate, theDate);
const isInRange =
isRange && isWithinRange(theDate, startDate, endDate);
// if not in range, no click action
// if in this month, select the date
// if out of this month, jump to the date
const onClick = !isValidDate(theDate, minDate, maxDate)
? null
: isThisMonth
? onClickDate
: onClickJump;
return {
date: theDate,
isToday: isToday(theDate),
isStartDate: isEqual(startDate, theDate),
isEndDate: isEqual(endDate, theDate),
isThisMonth,
isInRange,
onClick,
};
});
return (
<div className="calendar-container">
<div className="calendar-header">
{labels.map(day => (
<div key={day} className="calendar-date">
{day}
</div>
))}
</div>
<div className="calendar-body">
{days.map(theDate => (
<DatePickerDate
key={theDate.date.toString()}
{...theDate}
/>
))}
</div>
</div>
);
};
DatePickerCalendar.propTypes = {
endDate: PropTypes.instanceOf(Date),
isRange: PropTypes.bool,
maxDate: PropTypes.instanceOf(Date),
minDate: PropTypes.instanceOf(Date),
onClickDate: PropTypes.func.isRequired,
onClickJump: PropTypes.func.isRequired,
startDate: PropTypes.instanceOf(Date),
visibleDate: PropTypes.instanceOf(Date),
};
export default DatePickerCalendar;
import classes from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
export const DatePickerDate = ({
date,
isEndDate,
isInRange,
isStartDate,
isThisMonth,
isToday,
onClick,
}) => (
<div
className={classes('calendar-date', {
'is-outside-month': !isThisMonth,
'is-disabled': !onClick,
'calendar-range': isInRange,
'calendar-range-start': isInRange && isStartDate,
'calendar-range-end': isInRange && isEndDate,
})}
>
<button
className={classes('date-item', {
'is-today': isToday,
'is-active': isStartDate || isEndDate,
})}
onClick={onClick ? () => onClick(date) : null}
>
{date.getDate()}
</button>
</div>
);
DatePickerDate.propTypes = {
date: PropTypes.instanceOf(Date).isRequired,
isEndDate: PropTypes.bool,
isInRange: PropTypes.bool,
isStartDate: PropTypes.bool,
isThisMonth: PropTypes.bool,
isToday: PropTypes.bool,
onClick: PropTypes.func,
};
export default DatePickerDate;
import {
addDays,
addMonths,
getDaysInMonth,
isAfter,
isBefore,
isEqual,
lastDayOfMonth,
setDate,
subDays,
subMonths,
} from 'date-fns';
import React, {Component} from 'react';
import DatePickerCalendar from './DatePickerCalendar';
import DatePickerNav from './DatePickerNav';
import PropTypes from 'prop-types';
export default class DatePickerDialog extends Component {
static propTypes = {
endDate: PropTypes.instanceOf(Date),
isRange: PropTypes.bool,
maxDate: PropTypes.instanceOf(Date),
minDate: PropTypes.instanceOf(Date),
onUpdate: PropTypes.func,
resolve: PropTypes.func,
startDate: PropTypes.instanceOf(Date),
};
constructor(props) {
super(props);
this.state = {
startDate: props.startDate,
endDate: props.endDate,
visibleDate: props.startDate,
};
}
onNext = () =>
this.setState(prevState => {
const nextMonth = addMonths(prevState.visibleDate, 1);
const day = Math.min(
getDaysInMonth(nextMonth),
prevState.visibleDate.getDate()
);
if (
!this.props.maxDate ||
isBefore(nextMonth, this.props.maxDate)
) {
const visibleDate = setDate(nextMonth, day);
return {
visibleDate,
};
}
return {
visibleDate: this.props.maxDate,
};
});
onPrev = () =>
this.setState(prevState => {
const prevMonth = lastDayOfMonth(
subMonths(
new Date(
prevState.visibleDate.getFullYear(),
prevState.visibleDate.getMonth()
),
1
)
);
const day = Math.min(
getDaysInMonth(prevMonth),
prevState.visibleDate.getDate()
);
if (!this.props.minDate || isAfter(prevMonth, this.props.minDate)) {
const visibleDate = setDate(prevMonth, day);
return {
visibleDate,
};
}
return {
visibleDate: this.props.minDate,
};
});
onClickJump = date => {
if (!this.props.isRange) {
this.props.resolve({startDate: date});
} else {
this.setStartAndEnd(date);
}
};
onClickDate = date => {
if (!this.props.isRange) {
this.props.resolve({startDate: date});
} else {
this.setStartAndEnd(date);
}
};
onClickNav = date => {
this.setState({visibleDate: date});
};
setStartAndEnd = date => {
const {startDate, endDate} = this.state;
const newDates = {
startDate,
endDate,
};
if (isEqual(date, startDate)) {
// reset start and end dates anchored to start
newDates.startDate = date;
newDates.endDate = addDays(date, 1);
} else if (isEqual(date, endDate)) {
// reset start and end dates anchored to end
newDates.startDate = subDays(date, 1);
newDates.endDate = date;
} else if (isBefore(date, startDate)) {
// extend the start date
newDates.startDate = date;
} else if (isAfter(date, endDate)) {
// extend the end date
newDates.endDate = date;
} else if (isBefore(date, endDate)) {
// truncate end date
newDates.endDate = date;
}
this.setState({...newDates, visibleDate: date});
this.props.onUpdate(newDates);
};
render() {
const {isRange} = this.props;
const {startDate, endDate, visibleDate} = this.state;
return (
<div className="calendar">
<DatePickerNav
visibleDate={visibleDate}
startDate={startDate}
endDate={endDate}
isRange={isRange}
onNext={this.onNext}
onPrev={this.onPrev}
onJump={this.onClickNav}
/>
<DatePickerCalendar
visibleDate={visibleDate}
startDate={startDate}
endDate={endDate}
isRange={isRange}
minDate={this.props.minDate}
maxDate={this.props.maxDate}
onClickDate={this.onClickDate}
onClickJump={this.onClickJump}
/>
</div>
);
}
}
import React, {Fragment} from 'react';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {format} from 'date-fns';
import PropTypes from 'prop-types';
/* eslint-disable jsx-a11y/anchor-is-valid */
export const DatePickerNav = ({
visibleDate,
startDate,
endDate,
isRange,
onJump,
onNext,
onPrev,
}) => (
<Fragment>
<div className="calendar-nav">
<div className="calendar-nav-prev-month" onClick={onPrev}>
<button className="button is-primary">
<span className="icon">
<FontAwesomeIcon icon={['fas', 'chevron-left']} />
</span>
</button>
</div>
<div className="calendar-month">
{format(visibleDate, 'MMMM YYYY')}
</div>
<div className="calendar-nav-next-month" onClick={onNext}>
<button className="button is-primary">
<span className="icon">
<FontAwesomeIcon icon={['fas', 'chevron-right']} />
</span>
</button>
</div>
</div>
{isRange && (
<div className="calendar-nav calendar-nav-range">
<a
className="button is-primary"
onClick={() => onJump(startDate)}
>
{format(startDate, 'YYYY-MM-DD')}
</a>
<span className="icon">
<FontAwesomeIcon icon={['fas', 'long-arrow-alt-right']} />
</span>
<a
className="button is-primary"
onClick={() => onJump(endDate)}
>
{format(endDate, 'YYYY-MM-DD')}
</a>
</div>
)}
</Fragment>
);
DatePickerNav.propTypes = {
endDate: PropTypes.instanceOf(Date),
isRange: PropTypes.bool,
onJump: PropTypes.func,
onNext: PropTypes.func.isRequired,
onPrev: PropTypes.func.isRequired,
startDate: PropTypes.instanceOf(Date),
visibleDate: PropTypes.instanceOf(Date),
};
export default DatePickerNav;
@import 'bulma/sass/utilities/functions';
@import 'bulma/sass/utilities/derived-variables';
// scss-lint:disable NameFormat
.date-picker {
position: relative;
.button {
&.date-picker-button {
position: relative;
.icon {
position: relative;
top: -1px;
}
padding-top: 0.375em;
padding-bottom: calc(0.375em - 2px);
}
}
&-dialog {
header,
footer {
display: none;
}
.modal-card {
display: block;
width: auto;
height: 403px;
}
.modal-card-body {
flex: none;
padding: 0;
}
&-range {
footer {
display: flex;
padding: 0.5em;
}
.modal-card {
height: 487px;
}
}
}
}
$calendar-border: none !default;
$calendar-border-radius: $radius-small !default;
$calendar-header-background-color: $primary !default;
$calendar-days-background-color: transparent !default;
$calendar-header-days-color: $grey-light !default;
$calendar-date-color: $text !default;
$calendar-date-hover-background-color: $white-ter !default;
$calendar-today-background: transparent !default;
$calendar-today-border-color: $primary !default;
$calendar-today-color: $primary !default;
$calendar-range-background-color: lighten($primary, 50%) !default;
$calendar-body-padding: 0 1em 1em 1em !default;
$calendar-header-padding: 1em 1em 0 1em !default;
$calendar-header-nav-padding: 0.5em !default;
$calendar-date-padding: 0.4rem 0 !default;
.calendar {
position: relative;
display: block;
min-width: 20rem;
max-width: 20rem;
border: $calendar-border;
border-radius: $calendar-border-radius;
background: $white;
text-align: center;
&.is-active {
display: initial;
}
.calendar-nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: $calendar-header-nav-padding;
border-top-left-radius: $calendar-border-radius;
border-top-right-radius: $calendar-border-radius;
background: $calendar-header-background-color;
color: $white;
font-size: $size-5;
.calendar-nav-month,
.calendar-nav-day,
.calendar-nav-year {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.calendar-month,
.calendar-day,
.calendar-year {
flex: 1;
}
.calendar-month {
font-size: $size-4;
user-select: none;
}
.calendar-day {
font-size: $size-2;
}
.calendar-nav-prev-month,
.calendar-nav-next-month,
.calendar-nav-prev-year,
.calendar-nav-next-year {
flex-basis: auto;
flex-grow: 0;
flex-shrink: 0;
color: $white;
text-decoration: none;
&:hover {
background-color: transparent;
svg {
stroke-width: 1em;
}
}
svg {
stroke: currentColor;
width: 11.25px;
height: 18px;
}
}
&-range {
justify-content: center;
margin-top: -5px;
padding: 0 0.5em;
.icon {
margin: 0 5px;
}
}
}
.calendar-header,
.calendar-body {
display: flex;
flex-wrap: wrap;
justify-content: center;
}
.calendar-header .calendar-date,
.calendar-body .calendar-date {
flex: 0 0 14.28%;
max-width: 14.28%;
}
.calendar-header {
padding: $calendar-header-padding;
background: $calendar-days-background-color;
color: findColorInvert($calendar-days-background-color);
font-size: $size-7;
user-select: none;
.calendar-date {
color: $calendar-header-days-color;
}
}
.calendar-body {
padding: $calendar-body-padding;
color: $grey;
}
.calendar-date {
padding: $calendar-date-padding;
border: 0;
user-select: none;
.date-item {
position: relative;
padding: 0.3rem;
width: 2.2rem;
height: 2.2rem;
outline: none;
border: 0.1rem solid transparent;
border-radius: 100%;
background: transparent;
color: $calendar-date-color;
vertical-align: middle;
text-align: center;
text-decoration: none;
white-space: nowrap;
line-height: 1.4rem;
cursor: pointer;
transition: all 0.2s ease;
appearance: none;
&.is-today {
border-color: $calendar-today-border-color;
background: $calendar-today-background;
color: $calendar-today-color;
}
&:focus {
border-color: $calendar-date-hover-background-color;
background: $calendar-date-hover-background-color;
color: findColorInvert($calendar-date-hover-background-color);
text-decoration: none;
}
&:hover {
border-color: $calendar-date-hover-background-color;
background: $calendar-date-hover-background-color;
color: findColorInvert($calendar-date-hover-background-color);
text-decoration: none;
}
&.is-active {
border-color: $primary;
background: $primary;
color: findColorInvert($primary);
}
}
&.is-disabled {
.date-item,
.calendar-event {
opacity: 0.1;
cursor: default;
pointer-events: none;
}
}
&.is-outside-month {
&:not(.calendar-range) {
.date-item {
opacity: 0.4;
}
}
&.is-disabled {
.date-item {
opacity: 0.1;
}
}
}
}
.calendar-range {
position: relative;
&::before {
position: absolute;
top: 50%;
right: 0;
left: 0;
height: 2.2rem;
background: $calendar-range-background-color;
content: '';
transform: translateY(-50%);
}
&.calendar-range-start::before {
left: 50%;
}
&.calendar-range-end::before {
right: 50%;
}
.date-item {
color: $primary;
}
}
&.is-large {
max-width: 100%;
.calendar-body {
.calendar-date {
display: flex;
flex-direction: column;
padding: 0;
height: 11rem;
border-right: $calendar-border;
border-bottom: $calendar-border;
&:nth-child(7n) {
border-right: 0;
}
&:nth-last-child(-n + 7) {
border-bottom: 0;
}
}
}
.date-item {
margin-top: 0.5rem;
margin-right: 0.5rem;
align-self: flex-end;
height: 2.2rem;
}
.calendar-range {
&::before {
top: 1.9rem;
}
&.calendar-range-start::before {
left: auto;
width: 1.9rem;
}
&.calendar-range-end::before {
right: 1.9rem;
}
}
.calendar-events {
overflow-y: auto;
padding: 0.5rem;
flex-grow: 1;
line-height: 1;
}
.calendar-event {
display: block;
overflow: hidden;
margin: 0.2rem auto;
padding: 0.3rem 0.4rem;
border-radius: $radius-small;
background-color: $grey;
color: $white;
vertical-align: baseline;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 1rem;
@each $name, $pair in $colors {
$color: nth($pair, 1);
$color-invert: nth($pair, 2);
&.is-#{$name} {
background-color: $color;
color: $color-invert;
}
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment