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
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 'core';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import PropTypes from 'prop-types';
import './index.css';
export default class DatePicker extends Component {
static propTypes = {
className: PropTypes.string,
dateFormat: PropTypes.string,
endDate: PropTypes.instanceOf(Date),
isRange: PropTypes.bool,
maxDate: PropTypes.instanceOf(Date),
minDate: PropTypes.instanceOf(Date),
name: PropTypes.string,
onSelect: PropTypes.func.isRequired,
startDate: PropTypes.instanceOf(Date),
};
static defaultProps = {
dateFormat: 'YYYY-MM-DD',
startDate: startOfDay(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.maxDate || isBefore(props.endDate, props.maxDate)
? props.endDate
: props.maxDate;
this.state = {
startDate: startOfDay(startDate),
endDate: props.isRange ? startOfDay(endDate) : undefined,
};
}
// This is my custom Dialog opener
// it opens a Bulma modal with a modal-card
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.onSelect);
onSelect = event =>
event &&
this.setState(
{startDate: event.startDate, endDate: event.endDate},
() =>
this.props.onSelect({
name: this.props.name,
startDate: this.state.startDate,
endDate: this.state.endDate,
})
);
render() {
const {className, dateFormat, isRange} = this.props;
const {startDate, endDate} = this.state;
const formattedStartDate = format(startDate, dateFormat);
const formattedEndDate = isRange ? format(endDate, dateFormat) : '';
return (
<div
className={classes('date-picker', {
[className]: !!className,
})}
>
<a className="button date-picker-button" onClick={this.onClick}>
<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 (isAfter(date, startDate)) {
// truncate start date
newDates.startDate = 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 {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {format} from 'date-fns';
import PropTypes from 'prop-types';
import React, {Fragment} from 'react';
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 'css/vars.scss';
@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;
}
.modal-card-body {
flex: none;
padding: 0;
}
&-range {
footer {
display: flex;
padding: 0.5em;
}
}
}
}
$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