stimulus-datepicker
Version:
StimulusJS datepicker component
647 lines (564 loc) • 21.1 kB
JavaScript
import { Controller } from '@hotwired/stimulus'
import IsoDate from './iso_date.js'
// All dates are local, not UTC.
export default class Datepicker extends Controller {
static targets = ['input', 'hidden', 'toggle', 'calendar',
'month', 'year', 'prevMonth', 'today', 'nextMonth', 'days']
static values = {
date: String,
min: String,
max: String,
format: {type: String, default: '%Y-%m-%d'},
firstDayOfWeek: {type: Number, default: 1},
dayNameLength: {type: Number, default: 2},
allowWeekends: {type: Boolean, default: true},
monthJump: {type: String, default: 'dayOfMonth'},
disallow: Array,
text: Object,
locales: {type: Array, default: ['default']}
}
static defaultTextValue = {
underflow: '',
overflow: '',
previousMonth: 'Previous month',
nextMonth: 'Next month',
today: 'Today',
chooseDate: 'Choose Date',
changeDate: 'Change Date'
}
text(key) {
return {...this.constructor.defaultTextValue, ...this.textValue}[key]
}
connect() {
if (!this.hasHiddenTarget) this.addHiddenInput()
this.addInputAction()
this.addToggleAction()
this.setToggleAriaLabel()
this.dateValue = this.inputTarget.value
}
dateValueChanged(value, previousValue) {
if (!this.hasHiddenTarget) return
const dispatchChangeEvent = value != this.hiddenTarget.value;
this.hiddenTarget.value = value
this.inputTarget.value = this.format(value)
// Trigger change event on input when user selects date from picker.
// http://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/change_event
if (dispatchChangeEvent) this.inputTarget.dispatchEvent(new Event('change'))
this.validate(value)
}
validate(dateStr) {
const message = this.validationMessage(dateStr)
this.inputTarget.setCustomValidity(message)
if (message) this.inputTarget.reportValidity()
}
validationMessage(dateStr) {
if (!dateStr) return ''
const isoDate = new IsoDate(dateStr)
return this.rangeUnderflow(isoDate) ? this.underflowMessage()
: this.rangeOverflow(isoDate) ? this.overflowMessage()
: ''
}
underflowMessage() {
return this.text('underflow').replace('%s', this.format(this.minValue))
}
overflowMessage() {
return this.text('overflow').replace('%s', this.format(this.maxValue))
}
addHiddenInput() {
this.inputTarget.insertAdjacentHTML('afterend', `
<input type="hidden"
name="${this.inputTarget.getAttribute('name')}"
value="${this.inputTarget.value}"
data-datepicker-target="hidden"/>
`)
}
addInputAction() {
this.addAction(this.inputTarget, 'datepicker#update')
}
addToggleAction() {
if (!this.hasToggleTarget) return
let action = 'click->datepicker#toggle'
if (!(this.toggleTarget instanceof HTMLButtonElement)) action += ' keydown->datepicker#toggle'
this.addAction(this.toggleTarget, action)
}
addAction(element, action) {
if (('action') in element.dataset) {
element.dataset.action += ` ${action}`
} else {
element.dataset.action = action
}
}
setToggleAriaLabel(value = this.text('chooseDate')) {
if (!this.hasToggleTarget) return
this.toggleTarget.setAttribute('aria-label', value);
}
update() {
const dateStr = this.parse(this.inputTarget.value)
if (dateStr != '') this.dateValue = dateStr
}
toggle(event) {
event.preventDefault()
event.stopPropagation()
if (event.type == 'keydown' && ![' ', 'Enter'].includes(event.key)) return
this.hasCalendarTarget ? this.close(true) : this.open(true)
}
close(animate) {
if (animate) {
this.calendarTarget.classList.add('fade-out')
if (this.hasCssAnimation(this.calendarTarget)) {
this.calendarTarget.onanimationend = e => e.target.remove()
} else {
this.calendarTarget.remove()
}
} else {
this.calendarTarget.remove()
}
this.toggleTarget.focus()
}
open(animate, isoDate = this.initialIsoDate()) {
this.render(isoDate, animate)
this.focusDate(isoDate)
}
// Returns the date to focus on initially. This is `dateValue` if given
// or today. Whichever is used, it is clamped to `minValue` and/or `maxValue`
// dates if given.
initialIsoDate() {
return this.clamp(new IsoDate(this.dateValue))
}
clamp(isoDate) {
return this.rangeUnderflow(isoDate) ? new IsoDate(this.minValue)
: this.rangeOverflow(isoDate) ? new IsoDate(this.maxValue)
: isoDate
}
rangeUnderflow(isoDate) {
return this.hasMinValue && isoDate.before(new IsoDate(this.minValue))
}
rangeOverflow(isoDate) {
return this.hasMaxValue && isoDate.after(new IsoDate(this.maxValue))
}
isOutOfRange(isoDate) {
return this.rangeUnderflow(isoDate) || this.rangeOverflow(isoDate)
}
closeOnOutsideClick(event) {
// `event.target` could already have been removed from the DOM
// (e.g. if the previous-month button was clicked) so we cannot
// use `this.calendarTarget.contains(event.target)`.
if (event.target.closest('[data-datepicker-target="calendar"]')) return
this.close(true)
}
redraw() {
const isoDate = this.dateFromMonthYearSelectsAndDayGrid()
this.close(false)
this.open(false, isoDate)
}
gotoPrevMonth() {
const isoDate = this.dateFromMonthYearSelectsAndDayGrid()
this.close(false)
this.open(false, isoDate.previousMonth(this.monthJumpValue == 'dayOfMonth'))
this.prevMonthTarget.focus()
}
gotoNextMonth() {
const isoDate = this.dateFromMonthYearSelectsAndDayGrid()
this.close(false)
this.open(false, isoDate.nextMonth(this.monthJumpValue == 'dayOfMonth'))
this.nextMonthTarget.focus()
}
gotoToday() {
this.close(false)
this.open(false, new IsoDate())
this.todayTarget.focus()
}
// Returns a date where the month and year come from the dropdowns
// and the day of the month from the grid.
// @return [IsoDate]
dateFromMonthYearSelectsAndDayGrid() {
const year = this.yearTarget.value
const month = this.monthTarget.value
let day = this.daysTarget.querySelector('button[tabindex="0"] time').textContent
const daysInMonth = IsoDate.daysInMonth(+month, +year)
if (day > daysInMonth) day = daysInMonth
return new IsoDate(year, month, day)
}
// Generates the HTML for the calendar and inserts it into the DOM.
//
// Does not focus the given date.
//
// @param isoDate [IsoDate] the date of interest
render(isoDate, animate) {
const cal = `
<div class="sdp-cal" data-datepicker-target="calendar" data-action="click@window->datepicker#closeOnOutsideClick keydown->datepicker#key" role="dialog" aria-modal="true" aria-label="${this.text('chooseDate')}">
<div class="sdp-nav">
<div class="sdp-nav-dropdowns">
<div>
<select class="sdp-month" data-datepicker-target="month" data-action="datepicker#redraw">
${this.monthOptions(+isoDate.mm)}
</select>
</div>
<div>
<select class="sdp-year" data-datepicker-target="year" data-action="datepicker#redraw">
${this.yearOptions(+isoDate.yyyy)}
</select>
</div>
</div>
<div class="sdp-nav-buttons">
<button class="sdp-goto-prev" data-datepicker-target="prevMonth" data-action="datepicker#gotoPrevMonth" title="${this.text('previousMonth')}" aria-label="${this.text('previousMonth')}">
<svg viewBox="0 0 10 10">
<polyline points="7,1 3,5 7,9" />
</svg>
</button>
<button class="sdp-goto-today" data-datepicker-target="today" data-action="datepicker#gotoToday" title="${this.text('today')}" aria-label="${this.text('today')}">
<svg viewBox="0 0 10 10">
<circle cx="5" cy="5" r="4" />
</svg>
</button>
<button class="sdp-goto-next" data-datepicker-target="nextMonth" data-action="datepicker#gotoNextMonth" title="${this.text('nextMonth')}" aria-label="${this.text('nextMonth')}">
<svg viewBox="0 0 10 10">
<polyline points="3,1 7,5 3,9" />
</svg>
</button>
</div>
</div>
<div class="sdp-days-of-week">
${this.daysOfWeek()}
</div>
<div class="sdp-days" data-datepicker-target="days" data-action="click->datepicker#pick" role="grid">
${this.days(isoDate)}
</div>
</div>
`
this.element.insertAdjacentHTML('beforeend', cal)
if (animate) {
this.calendarTarget.classList.add('fade-in')
if (this.hasCssAnimation(this.calendarTarget)) {
this.calendarTarget.onanimationend = e => this.calendarTarget.classList.remove('fade-in')
} else {
this.calendarTarget.classList.remove('fade-in')
}
}
}
monthTargetConnected() {
this.autoSizeSelect(this.monthTarget)
}
yearTargetConnected() {
this.autoSizeSelect(this.yearTarget)
}
// Set select's width to the width of the selected option.
autoSizeSelect(select) {
const tempSelect = document.createElement('select')
const tempOption = document.createElement('option')
tempOption.textContent = select.options[select.selectedIndex].text
tempSelect.style.cssText += 'visibility: hidden; position: fixed;'
tempSelect.appendChild(tempOption)
select.after(tempSelect)
const tempSelectWidth = window.getComputedStyle(tempSelect).width
select.style.width = tempSelectWidth
tempSelect.remove()
}
pick(event) {
event.preventDefault()
let button, time
switch (event.target.constructor) {
case HTMLTimeElement:
time = event.target
button = time.parentElement
break
case HTMLButtonElement:
button = event.target
time = button.children[0]
break
default:
return
}
if (button.hasAttribute('aria-disabled')) return
const dateStr = time.getAttribute('datetime')
this.selectDate(new IsoDate(dateStr))
}
key(event) {
switch (event.key) {
case 'Escape':
this.close(true)
return
case 'Tab':
if (event.shiftKey) {
if (document.activeElement == this.firstTabStop()) {
event.preventDefault()
this.lastTabStop().focus()
}
} else {
if (document.activeElement == this.lastTabStop()) {
event.preventDefault()
this.firstTabStop().focus()
}
}
return
}
const button = event.target
if (!this.daysTarget.contains(button)) return
const dateStr = button.children[0].getAttribute('datetime')
const isoDate = new IsoDate(dateStr)
switch (event.key) {
case 'Enter':
case ' ':
event.preventDefault()
if (!button.hasAttribute('aria-disabled')) this.selectDate(isoDate)
break
case 'ArrowUp':
case 'k':
this.focusDate(isoDate.previousWeek())
break
case 'ArrowDown':
case 'j':
this.focusDate(isoDate.nextWeek())
break
case 'ArrowLeft':
case 'h':
this.focusDate(isoDate.previousDay())
break
case 'ArrowRight':
case 'l':
this.focusDate(isoDate.nextDay())
break
case 'Home':
case '0':
case '^':
this.focusDate(isoDate.firstDayOfWeek(this.firstDayOfWeekValue))
break
case 'End':
case '$':
this.focusDate(isoDate.lastDayOfWeek(this.firstDayOfWeekValue))
break
case 'PageUp':
event.shiftKey
? this.focusDate(isoDate.previousYear())
: this.focusDate(isoDate.previousMonth(this.monthJumpIsDayOfMonth()))
break
case 'PageDown':
event.shiftKey
? this.focusDate(isoDate.nextYear())
: this.focusDate(isoDate.nextMonth(this.monthJumpIsDayOfMonth()))
break
case 'b':
this.focusDate(isoDate.previousMonth(this.monthJumpIsDayOfMonth()))
break
case 'B':
this.focusDate(isoDate.previousYear())
break
case 'w':
this.focusDate(isoDate.nextMonth(this.monthJumpIsDayOfMonth()))
break
case 'W':
this.focusDate(isoDate.nextYear())
break
}
}
firstTabStop() {
return this.monthTarget
}
lastTabStop() {
return this.calendarTarget.querySelector('.sdp-days button[tabindex="0"]')
}
monthJumpIsDayOfMonth() {
return this.monthJumpValue == 'dayOfMonth'
}
// @param isoDate [isoDate] the date to select
selectDate(isoDate) {
this.close(true)
this.toggleTarget.focus()
this.dateValue = isoDate.toString()
}
// Focuses the given date in the calendar.
// If the date is not visible because it is in the hidden part of the previous or
// next month, the calendar is updated to show the corresponding month.
//
// @param isoDate [IsoDate] the date to focus on in the calendar
focusDate(isoDate) {
const time = this.daysTarget.querySelector(`time[datetime="${isoDate.toString()}"]`)
if (!time) {
const leadingDatetime = this.daysTarget.querySelector('time').getAttribute('datetime')
if (isoDate.before(new IsoDate(leadingDatetime))) {
this.gotoPrevMonth()
} else {
this.gotoNextMonth()
}
this.focusDate(isoDate)
return
}
const currentFocus = this.daysTarget.querySelector('button[tabindex="0"]')
if (currentFocus) currentFocus.setAttribute('tabindex', -1)
const button = time.parentElement
button.setAttribute('tabindex', 0)
button.focus()
if (!button.hasAttribute('aria-disabled')) {
this.setToggleAriaLabel(`${this.text('changeDate')}, ${this.format(isoDate.toString())}`)
}
}
// @param selected [Number] the selected month (January is 1)
monthOptions(selected) {
return this.monthNames('long')
.map((name, i) => `<option value="${i + 1}" ${i + 1 == selected ? 'selected' : ''}>${name}</option>`)
.join('')
}
// @param selected [Number] the selected year
yearOptions(selected) {
const years = []
const extent = 10
for (let y = selected - extent; y <= selected + extent; y++) years.push(y)
return years
.map(year => `<option ${year == selected ? 'selected' : ''}>${year}</option>`)
.join('')
}
daysOfWeek() {
return this.dayNames('long')
.map(name => `<div title="${name}">${name.slice(0, this.dayNameLengthValue)}</div>`)
.join('')
}
// Generates the day grid for the given date's month.
// The end of the previous month and the start of the next month
// are shown if there is space in the grid.
//
// Does not focus on the given date.
//
// @param isoDate [IsoDate] the month of interest
// @return [String] HTML for the day grid
days(isoDate) {
const days = []
const selected = new IsoDate(this.dateValue)
let date = isoDate.setDayOfMonth(1).firstDayOfWeek(this.firstDayOfWeekValue)
while (true) {
const isPreviousMonth = date.mm != isoDate.mm && date.before(isoDate)
const isNextMonth = date.mm != isoDate.mm && date.after(isoDate)
if (isNextMonth && date.isFirstDayOfWeek(this.firstDayOfWeekValue)) break
const klass = this.classAttribute(
isPreviousMonth ? 'sdp-prev-month' : '',
isNextMonth ? 'sdp-next-month' : '',
date.isToday() ? 'sdp-today' : '',
date.isWeekend() ? 'sdp-weekend' : '',
date.equals(selected) ? 'sdp-selected' : ''
)
days.push(`
<button type="button"
tabindex="-1"
${klass}
${date.equals(selected) ? 'aria-selected="true"' : ''}
${this.isDisabled(date) ? 'aria-disabled="true"' : ''}
>
<time datetime="${date.toString()}">${+date.dd}</time>
</button>
`)
date = date.nextDay()
}
return days.join('')
}
classAttribute(...classes) {
const presentClasses = classes.filter(c => c)
if (presentClasses.length == 0) return ''
return `class="${presentClasses.join(' ')}"`
}
isDisabled(isoDate) {
return this.isOutOfRange(isoDate)
|| (isoDate.isWeekend() && !this.allowWeekendsValue)
|| (this.disallowValue.includes(isoDate.toString()))
}
// Formats an ISO8601 date, using the `format` value, for display to the user.
// Returns an empty string if `str` cannot be formatted.
//
// @param str [String] a date in YYYY-MM-DD format
// @return [String] the date in a user-facing format, or an empty string if the
// given date cannot be formatted
format(str) {
if (!IsoDate.isValidStr(str)) return ''
const [yyyy, mm, dd] = str.split('-')
return this.formatValue
.replace('%d', dd)
.replace('%-d', +dd)
.replace('%m', this.zeroPad(mm))
.replace('%-m', +mm)
.replace('%B', this.localisedMonth(mm, 'long'))
.replace('%b', this.localisedMonth(mm, 'short'))
.replace('%Y', yyyy)
.replace('%y', +yyyy % 100)
}
// Returns a two-digit zero-padded string.
zeroPad(num) {
return num.toString().padStart(2, '0')
}
// Parses a date from the user, using the `format` value, into an ISO8601 date.
// Returns an empty string if `str` cannot be parsed.
//
// @param str [String] a user-facing date, e.g. 19/03/2022
// @return [String] the date in ISO8601 format, e.g. 2022-03-19; or an empty string
// if the given date cannot be parsed
parse(str) {
const directives = {
'd': ['\\d{2}', function(match) { this.day = +match }],
'-d': ['\\d{1,2}', function(match) { this.day = +match }],
'm': ['\\d{2}', function(match) { this.month = +match }],
'-m': ['\\d{1,2}', function(match) { this.month = +match }],
'B': ['\\w+', function(match, controller) { this.month = controller.monthNumber(match, 'long') }],
'b': ['\\w{3}', function(match, controller) { this.month = controller.monthNumber(match, 'short') }],
'Y': ['\\d{4}', function(match) { this.year = +match }],
'y': ['\\d{2}', function(match) { this.year = 2000 + +match }]
}
const funcs = []
const re = new RegExp(
this.formatValue.replace(/%(d|-d|m|-m|B|b|Y|y)/g, function(_, p) {
const directive = directives[p]
funcs.push(directive[1])
return `(${directive[0]})`
}))
const matches = str.match(re)
if (!matches) return ''
const parts = {}
for (let i = 0, len = funcs.length; i < len; i++) {
funcs[i].call(parts, matches[i + 1], this)
}
if (!IsoDate.isValidDate(parts.year, parts.month, parts.day)) return ''
return new IsoDate(parts.year, parts.month, parts.day).toString()
}
// Returns the name of the month in the configured locale.
//
// @param month [Number] the month number (January is 1)
// @param monthFormat [String] "long" (January) | "short" (Jan)
// @return [String] the localised month name
localisedMonth(month, monthFormat) {
// Use the middle of the month to avoid timezone edge cases
return new Date(`2022-${month}-15`).toLocaleString(this.localesValue, {month: monthFormat})
}
// Returns the number of the month (January is 1).
//
// @param name [String] the name of the month in the current locale (e.g. January or Jan)
// @param monthFormat [String] "long" (January) | "short" (Jan)
// @return [Number] the number of the month, or 0 if name is not recognised
monthNumber(name, monthFormat) {
return this.monthNames(monthFormat).findIndex(m => name.includes(m)) + 1
}
// Returns the month names in the configured locale.
//
// @param format [String] "long" (January) | "short" (Jan)
// @return [Array] localised month names
monthNames(format) {
const formatter = new Intl.DateTimeFormat(this.localesValue, {month: format})
return ['01','02','03','04','05','06','07','08','09','10','11','12'].map(mm =>
// Use the middle of the month to avoid timezone edge cases
formatter.format(new Date(`2022-${mm}-15`))
)
}
// Returns the day names in the configured locale, starting with the
// firstDayOfTheWeekValue.
//
// @param format [String] "long" (Monday) | "short" (Mon) | "narrow" (M)
// @return [Array] localised day names
dayNames(format) {
const formatter = new Intl.DateTimeFormat(this.localesValue, {weekday: format})
const names = []
// Ensure date in month is two digits. 2022-04-10 is a Sunday
for (let i = this.firstDayOfWeekValue + 10, n = i + 7; i < n; i++) {
names.push(formatter.format(new Date(`2022-04-${i}T00:00:00`)))
}
return names
}
hasCssAnimation(el) {
return window.getComputedStyle(el).getPropertyValue('animation-name') !== 'none';
}
}
export { Datepicker }