Last active
June 26, 2023 08:59
-
-
Save stevensacks/79c60d0f8b1f8bc06b475438f59d687e to your computer and use it in GitHub Desktop.
React Bulma DatePicker
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | |
| ); | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | |
| ); | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| @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; | |
| } | |
| } | |
| } | |
| } | |
| } |
This is awesome! Please publish on npm!
Hi, Where I can find Dialog (import Dialog from 'components/Dialog';) ?
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! :)
Hello, Have you the Bulma version? In fact I try to use isRange but it doesn't work. Thank you
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
Great work. Have you considered publishing it to npm?