// Imported from: https://github.com/airblade/stimulus-datepicker/blob/main/src/datepicker.js import { Controller } from "@hotwired/stimulus"; import IsoDate from "utils/iso_date"; import { useClickOutside } from "stimulus-use"; // All dates are local, not UTC. export default class UIDatePickerController extends Controller { static targets = [ "input", "hidden", "toggle", "calendar", "month", "year", "prevMonth", "today", "nextMonth", "days", ]; static values = { date: String, month: String, year: String, min: String, max: String, isCalendarOpen: { type: Boolean, default: false }, isSelectOpen: { type: Boolean, default: false }, 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() { useClickOutside(this); if (!this.hasHiddenTarget) this.addHiddenInput(); this.addInputAction(); this.addToggleAction(); this.setToggleAriaLabel(); this.dateValue = this.validate(this.inputTarget.textContent) ? "" : this.inputTarget.textContent; } disconnect() { this.isCalendarOpenValue = false; } dateValueChanged(value, previousValue) { if (!this.hasHiddenTarget) return; const dispatchChangeEvent = value != this.hiddenTarget.value; this.hiddenTarget.value = value; // this.inputTarget.value = this.format(value) this.inputTarget.textContent = value || "Pick a date"; // 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) { this.validationMessage(dateStr); } 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", ` `, ); } addInputAction() { this.addAction(this.inputTarget, "ui--date-picker#update"); } addToggleAction() { if (!this.hasToggleTarget) return; let action = "click->ui--date-picker#toggle"; if (!(this.toggleTarget instanceof HTMLButtonElement)) action += " keydown->ui--date-picker#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.isCalendarOpenValue = false; this.toggleTarget.focus(); } open(animate, isoDate = this.initialIsoDate()) { this.isCalendarOpenValue = true; 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); } clickOutside(event) { if (this.isCalendarOpenValue) event.preventDefault(); if (!this.isCalendarOpenValue) return; if (event.target.closest('[data-ui--date-picker-target="calendar"]')) return; if (this.isSelectOpenValue) return; this.close(true); } // To track option is clicked clickOption() { this.isSelectOpenValue = true; } monthSelect(event) { this.monthValue = event.target.textContent; this.redraw(); setTimeout(() => { this.isSelectOpenValue = false; }, 500); } yearSelect(event) { this.yearValue = event.target.textContent; this.redraw(); setTimeout(() => { this.isSelectOpenValue = false; }, 500); } redraw() { const isoDate = this.dateFromMonthYearSelectsAndDayGrid(); this.close(false); this.open(false, isoDate); } gotoPrevMonth() { const isoDate = this.dateFromMonthYearSelectsAndDayGrid(); const previousMonthDate = isoDate.previousMonth(this.monthJumpValue == "dayOfMonth"); this.monthValue = previousMonthDate.mm; this.yearValue = previousMonthDate.yyyy; this.close(false); this.open(false, previousMonthDate); this.prevMonthTarget.focus(); } gotoNextMonth() { const isoDate = this.dateFromMonthYearSelectsAndDayGrid(); const nextMonthDate = isoDate.nextMonth(this.monthJumpValue == "dayOfMonth"); this.monthValue = nextMonthDate.mm; this.yearValue = nextMonthDate.yyyy; this.close(false); this.open(false, nextMonthDate); 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 isoDate = this.initialIsoDate(); this.yearValue ||= isoDate.yyyy; this.monthValue ||= isoDate.mm; let day = this.daysTarget.querySelector('button[tabindex="0"] time').textContent; const daysInMonth = IsoDate.daysInMonth(+this.monthValue, +this.yearValue); if (day > daysInMonth) day = daysInMonth; return new IsoDate(this.yearValue, this.monthValue, 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 = ``; this.element.insertAdjacentHTML("beforeend", cal); } 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) { // if(this.isSelectOpenValue){return} 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.prevMonthTarget; } lastTabStop() { return this.todayTarget; } monthJumpIsDayOfMonth() { return this.monthJumpValue == "dayOfMonth"; } // @param isoDate [isoDate] the date to select selectDate(isoDate) { if (this.isSelectOpenValue) return; 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.querySelectorAll(`time[datetime="${isoDate.toString()}"]`)[0]; if (!time) { const leadingDatetime = this.daysTarget.querySelectorAll("time")[0].getAttribute("datetime"); if (isoDate.before(new IsoDate(leadingDatetime))) { this.gotoPrevMonth(); } else { this.gotoNextMonth(); } this.focusDate(isoDate); return; } const currentFocus = this.daysTarget.querySelectorAll('button[tabindex="0"]')[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) { const klass = "hover:bg-gray-100 cursor-pointer py-2 px-4"; return this.monthNames("long") .map( (name, i) => `
${name}
`, ) .join(""); } // @param selected [Number] the selected year yearOptions(selected) { const years = []; const extent = 10; const klass = "hover:bg-gray-100 cursor-pointer py-2 px-4"; for (let y = selected - extent; y <= selected + extent; y++) years.push(y); return years .map( (year) => `
${year}
`, ) .join(""); } daysOfWeek() { return this.dayNames("long") .map( ( name, ) => `
${name.slice(0, this.dayNameLengthValue)}
`, ) .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) { let 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 outsideMonthClass = "day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30"; const klass = this.classAttribute( "sdp-day inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:bg-accent hover:text-accent-foreground h-9 w-9 p-0 font-normal aria-selected:opacity-100 text-accent-foreground", isPreviousMonth ? outsideMonthClass : "text-accent-foreground", isNextMonth ? outsideMonthClass : "text-accent-foreground", date.isToday() ? "sdp-today bg-accent" : "", date.isWeekend() ? "sdp-weekend" : "", date.equals(selected) ? "sdp-selected bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground" : "", ); days.push(` `); 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"; } }