Skip to content

Instantly share code, notes, and snippets.

@stevensacks
Last active June 26, 2023 08:59
Show Gist options
  • Save stevensacks/79c60d0f8b1f8bc06b475438f59d687e to your computer and use it in GitHub Desktop.
Save stevensacks/79c60d0f8b1f8bc06b475438f59d687e 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;
}
}
}
}
}
@davidstenstroem
Copy link

Great work. Have you considered publishing it to npm?

@devth
Copy link

devth commented Jul 1, 2019

This is awesome! Please publish on npm!

@chetan-pawar-myob
Copy link

Hi, Where I can find Dialog (import Dialog from 'components/Dialog';) ?

@manuganji
Copy link

Hi, Where I can find Dialog (import Dialog from 'components/Dialog';) ?

It's in his other gist that he mentioned up in the readme. Thanks @stevesacks! :)

@David-Ferrero1
Copy link

Hello, Have you the Bulma version? In fact I try to use isRange but it doesn't work. Thank you

@klys
Copy link

klys commented Jul 16, 2022

I found out the new version of date-fn don't allow YYYY-MM-DD formats, they must be lowercase or will throw out custom exceptions.

This should be on proper repository so we can do pull requests

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment