From f9bd7e951ae489bd255487054f6fada6094ccec1 Mon Sep 17 00:00:00 2001 From: Neeraj Date: Thu, 2 Feb 2023 20:52:57 +0530 Subject: [PATCH] datepicker added --- package-lock.json | 1 - {styles => src}/adaptiveform.css | 157 ++- src/components/datepicker/DatePickerWidget.js | 1103 +++++++++++++++++ src/components/datepicker/cache.js | 33 + src/components/datepicker/datepicker.js | 51 + src/images/calendar.png | Bin 0 -> 1347 bytes src/images/leftnav.png | Bin 0 -> 290 bytes src/images/leftnav_hover.png | Bin 0 -> 291 bytes src/images/rightnav.png | Bin 0 -> 295 bytes src/images/rightnav_hover.png | Bin 0 -> 316 bytes 10 files changed, 1343 insertions(+), 2 deletions(-) rename {styles => src}/adaptiveform.css (97%) create mode 100644 src/components/datepicker/DatePickerWidget.js create mode 100644 src/components/datepicker/cache.js create mode 100644 src/components/datepicker/datepicker.js create mode 100644 src/images/calendar.png create mode 100644 src/images/leftnav.png create mode 100644 src/images/leftnav_hover.png create mode 100644 src/images/rightnav.png create mode 100644 src/images/rightnav_hover.png diff --git a/package-lock.json b/package-lock.json index 576c3f3..a68848a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,6 @@ "": { "name": "@adobe/adaptiveformblock", "version": "0.0.1", - "hasInstallScript": true, "license": "ISC", "devDependencies": { "@adobe/json-formula": "0.1.50", diff --git a/styles/adaptiveform.css b/src/adaptiveform.css similarity index 97% rename from styles/adaptiveform.css rename to src/adaptiveform.css index 195d072..d291d78 100644 --- a/styles/adaptiveform.css +++ b/src/adaptiveform.css @@ -4388,4 +4388,159 @@ textarea.cmp-adaptiveform-textinput__widget { padding-left: 75px; padding-right: 75px; } -} \ No newline at end of file +} + + + + + .datetimepicker { + border: none; + background-color: #FFF; + display: none; + position: absolute; + cursor: default; + z-index: 100; + outline: solid #CCCCCC 2px; + flex-direction: column; + width: 433px; +} + +.datetimepicker .dp-clear { + overflow: auto; + background-color: #F5F5F5; + text-align: center; +} + +.datetimepicker .dp-clear a { + cursor: pointer; + height: 40px; + line-height: 40px; + padding: 0px 5px 0px 5px; + text-align: center; + display: inline-block; + font-size: 0.875rem; + color: #969696; +} + +.datetimepicker-notouch .dp-close a:hover { + color: #c8bbff; +} + +.datetimepicker .dp-header { + height: 40px; + line-height: 40px; + color: #555555; + margin-bottom: 5px; + background-color: #E6E6E6; + display: flex; + justify-content: space-between; +} + +.datetimepicker .dp-header .dp-leftnav, +.datetimepicker .dp-header .dp-rightnav, +.datetimepicker .dp-header .dp-caption { + float: left; + text-align: center; + cursor: pointer; + height: 40px; +} + +.datetimepicker-notouch .dp-header .dp-caption:not(.disabled):hover { + color: #969696; +} + +.datetimepicker .dp-header .dp-rightnav { + float: right; + background: url("./images/rightnav.png") no-repeat center center; + width: 40px; +} + +.datetimepicker .dp-header .dp-leftnav { + width: 40px; + background: url("./images/leftnav.png") no-repeat center center; +} + +.datetimepicker .dp-header .dp-rightnav:hover { + background: url("./images/rightnav_hover.png") no-repeat center center; +} + +.datetimepicker .dp-header .dp-leftnav:hover { + background: url("./images/leftnav_hover.png") no-repeat center center; +} + +.datetimepicker .view { + display: none; +} + +.datetimepicker .view ul { + display: flex; + justify-content: space-around; + list-style: none; + margin: 0; + padding: 5px; + overflow: hidden; + box-sizing: border-box; + -moz-box-sizing: border-box; +} + +.datetimepicker .view ul li { + float: left; + padding: 5px; + text-align: center; + border: none; + box-sizing: border-box; + -moz-box-sizing: border-box; + color: #666666; +} + +.datetimepicker .view ul.header li { + color: #555555; +} + +.datetimepicker .view ul:not(.header) li:not(.disabled) { + cursor: pointer; +} + +.datetimepicker .view ul.header { + color: #000; + background-color: #FFF; + border-bottom: #E6E6E6 1px solid; + display: flex; + justify-content: space-around; +} + +.datetimepicker-notouch .view ul:not(.header) li:not(.disabled):hover { + color: black; + background-color: #E6E6E6; + opacity: 0.5; +} + +.datetimepicker .view ul li.disabled { + color: #CCCCCC; +} + +.datetimepicker .view ul li.dp-selected { + outline: none; + background-color: #666666; + color: #FFFFFF; + opacity: 1.0; +} + +.datetimepicker .view ul li.dp-focus { + border: 1px dashed black; +} + +.datepicker-calendar-icon { + position: absolute; + top: 11px; + right: 16px; + z-index: 10; + background: url("./images/calendar.png") no-repeat center center; + background-size: contain; + width: 24px; + height: 24px; +} + +.datefieldwidget.widgetreadonly .datepicker-calendar-icon { + display: none; +} diff --git a/src/components/datepicker/DatePickerWidget.js b/src/components/datepicker/DatePickerWidget.js new file mode 100644 index 0000000..8918eb0 --- /dev/null +++ b/src/components/datepicker/DatePickerWidget.js @@ -0,0 +1,1103 @@ +/******************************************************************************* + * Copyright 2023 Adobe + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + ******************************************************************************/ + +import { formatDate } from './../../libs/afb-formatters.js'; + +import { + getWidget +} from "../../libs/afb-interaction.js"; +import { datePickerCacheGet, datePickerCachePut } from "./cache.js"; + +export default class DatePickerWidget { + + #widget=null; + #model=null // passed by reference + #options=null + + #lang="en"; + + #dp=null; + #curInstance=null; + static #visible=false; + static #clickedWindow; + + /** default configuration options + * + * yearsPerView: number of years to show in the yearset view + * + * width: with of the widget + * + * viewHeight: Height of the month,year and yearset view. This doesn't include + * the height of the header + * + * locale: locale information for the locale in which to show the datepicker which comprises of + * days: day names to display in the monthview + * months: month names to display in the yearview + * zero: string representation of zero in the locale. Numbers will be + * displayed in that locale only + * clearText: Text to display for the reset button + * name: name of the locale + * + * format: input format for the datepicker (not implemented) + * + * pickerType: type of the datetimepicker (date, datetime and time) + * + * positioning: element around which datepicker will be displayed. if null then it + * will be displayed around the input element + * + * showCalendarIcon: to show the Calendar on the right of the text field or not + */ + #defaultOptions={ + yearsPerView: 16, + width: 433, + viewHeight: 248, + locale: { + days:[1, 2, 3, 4, 5, 6, 7].map(d => new Date(2001, 0, d)), + months: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11].map(m => new Date(2000, m, 1)), + zero: "0", + clearText: "Clear", + name:"en_US" + }, + format:"YYYY-MM-DD", + pickerType:"date", + positioning: null, + showCalendarIcon: true + } + + #dates = [31,28,31,30,31,30,31,31,30,31,30,31] + /* + * Actions to perform when clicked on the datepicker buttons + * for different views + * caption: view to show when clicked on caption + * (Year/YearSet/Month/null) null means don't change the view + * li: view to show when clicked on date, month or year element + * upDown: add(up key) or subtract(down key) current date (for monthview), + * month(Year View) or year(YearSetView) with the number provided + * key: identifies the key that needs to be changed for that view + */ + #viewAction = { + Month: { + caption: 'Year', + li: null, + key:"day", + upDown:7 + }, + Year: { + caption: "Yearset", + li: "Month", + key:"month", + upDown:3 + }, + Yearset: { + caption: null, + li: "Year", + key:"year", + upDown:4 + } + } + + selectedDay = 0; + selectedMonth=0; + selectedYear=0; + currentDay=0; + currentMonth=0; + currentYear=0; + //view="Month"; + #touchSupported = !!("ontouchstart" in window || window.DocumentTouch && document instanceof DocumentTouch); + #defaultView="Month"; + #keysEnabled=false; + #focusedOnLi =false; + #keyboardAccessibility=true; + #scriptFocus; + + /* template for the clear Button */ + #clearButtonTemplate = '
' + + '' + + '
'; + + /* template for the calendar + * header contains the navigation icons (left and right arrows) + * and the current caption (which can be date, year or month) + * + * monthview displays the grid for showing the dates for a particular + * month + * + * yearview displays all the months of that year + * + * yearsetview displays a grid of 16 years. This can be configured + * through the option: yearsPerView + * + */ + #calendarTemplate = '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
'; + + constructor(view, widget, model) { + + this.#model = model; + this.#lang = model.lang === undefined ? model.lang : "en"; + let editValFn = (value) => { + return this.#formatDate(value, model.editFormat); + }; + let displayValueFn = (value) => { + return this.#formatDate(value, model.displayFormat); + }; + this.#options = Object.assign({editValue:editValFn, displayValue:displayValueFn}, this.#defaultOptions, this.#model._jsonModel); + this.#localizeDateElements(this.#options); + + this.#initialiseCalenderElement(); + + //update widget markup to have input type=text element. + let newDatePickerElement = widget.cloneNode(true); + newDatePickerElement.setAttribute("type", "text"); + newDatePickerElement.id = widget.name; + + widget.parentNode.replaceChild(newDatePickerElement, widget); + this.#widget = getWidget(view.element); + this.#attachField(getWidget(view.element), this.#options); + } + + #formatDate(value, format) { + let dateValue = new Date(value); + if (!isNaN(dateValue)) { + return formatDate(new Date(value), this.#lang, format); + } + return null; + } + + #initialiseCalenderElement() { + let self = this, + html=""; + if (this.#options.pickerType.match(/date/)) { + html += this.#calendarTemplate; + } + html += this.#clearButtonTemplate; + + this.#dp = document.getElementsByClassName("datetimepicker")[0]; + if (!this.#dp) { + this.#dp = document.createElement("div"); + this.#dp.classList.add("datetimepicker", "datePickerTarget"); + this.#dp.innerHTML = html; + //Always inserting it in body + document.body.appendChild(this.#dp); + } + // attach click event on entire datePicker popup + this.#dp.addEventListener("click", + function (evnt) { + //focus only if the device doesn't support touch + // input otherwise on screen keyboard will popup + if (!self.#touchSupported) { + if (self.#curInstance) { + self.#curInstance.$field.focus(); + } + } + }, false); + "touchstart mousedown".split(" ").forEach(function (e) { + self.#dp.addEventListener(e, function (evt) { + self.#checkWindowClicked(evt) + }, false); + }); + + this.$month = this.#dp.getElementsByClassName("dp-monthview")[0]; + this.$year = this.#dp.getElementsByClassName("dp-yearview")[0]; + this.$yearset = this.#dp.getElementsByClassName("dp-yearsetview")[0]; + + this.clear = this.#dp.getElementsByClassName("dp-clear")[0].getElementsByTagName("a")[0]; + this.clear.addEventListener("click", function (evnt) { + if (self.view && self.#curInstance) { + self.#clearDate(self.view); + } + }); + this.prevNavWidthBtn = this.#dp.getElementsByClassName("dp-leftnav")[0]; + this.prevNavWidthBtn.addEventListener("click", + function (evnt) { + if (self.view && self.#curInstance) { + self.#adjustDate(-1, self.view, false) + } + }); + this.nextNavWidthBtn = this.#dp.getElementsByClassName("dp-rightnav")[0]; + this.nextNavWidthBtn.addEventListener("click", + function (evnt) { + if (self.view && self.#curInstance) { + self.#adjustDate(1, self.view, false) + } + }); + this.caption = this.#dp.getElementsByClassName("dp-caption")[0]; + this.caption.addEventListener("click", + function (evnt) { + if (self.view && self.#curInstance) { + if (!self.caption.classList.contains("disabled")) { + self.#layout(self.#viewAction[self.view].caption); + } + } + }); + if (this.#keyboardAccessibility) { + [this.prevNavWidthBtn, this.caption, this.nextNavWidthBtn, this.clear].forEach(function (elem, i) { + elem.setAttribute("tabIndex", i + 1); + }); + } + + } + + + /* + * attaches the date picker to the field. This is a one time operation + * First creates a configuration object and stores it in the field data attributes + * then attaches event handlers on click, focus (to show the picker) and blur (to hide) events + */ + #attachField(widget, options) { + let inst = this.#newInst(widget, options), + self = this, + activateField = function(evnt) { + if(!self.#curInstance) + self.#activateField(evnt); + + if(self.#options.showCalendarIcon) { + if (evnt.type === self.#getEvent()) { + if (self._iconClicked) { + self._iconClicked = false; + if (DatePickerWidget.#visible) { + self.#hide(); // hide the calendar popup if visible when calendar icon is clicked + self.#curInstance.$field.focus(); // bring back focus in field + } else { + self.#show(); //// show the calendar popup if not visible when calendar icon is clicked + } + } + } + } else { + /*show the popup only if + 1. click/touch event + 2. focus event in case of non-touch devices and focus is not done using script + */ + if (evnt.type === self.#getEvent() || (evnt.type === "focus" && !self.#touchSupported && !self.#scriptFocus)) { + self.#show(evnt); + } + } + + DatePickerWidget.#clickedWindow = true; + self.#scriptFocus = false; + }, + deactivateField = function(evnt) { + //deactivate only if clicked outside window + // on touch devices only keyboard or calendar should be active at once, touching keyboard should deactivate calendar + if ((DatePickerWidget.#clickedWindow && !self.#focusedOnLi) && (self.#options.showCalendarIcon || !self.#touchSupported)) { + self.#hide(); + self.#deactivateField(); + DatePickerWidget.#clickedWindow = true; + } + }; + + datePickerCachePut(widget,"datetimepicker",inst); + + widget.addEventListener(this.#getEvent(), activateField); + widget.onfocus = activateField; + widget.onblur = deactivateField; + + if(options.showCalendarIcon) { + let calendarIcon = document.createElement("div"); + calendarIcon.classList.add("datepicker-calendar-icon"); + + widget.parentNode.insertBefore(calendarIcon, widget.nextSibling); + + if (this.#keyboardAccessibility) { + calendarIcon.setAttribute("tabindex", 0); + } + calendarIcon.addEventListener(this.#getEvent(), function (evnt) { + self._iconClicked = true; + widget.click(); + }); + calendarIcon.addEventListener("keydown", function (event) { + if (event.keyCode === 32 || event.keyCode === 13) { + widget.click(); + } + }); + } + } + + #newInst(widget, options) { + return { + $field:widget, + locale: options.locale, + positioning: options.positioning || widget, + access:options.access, + selectedDate:options.value, + editValue :options.editValue, + displayValue: options.displayValue, + minValidDate : options.minimum, + maxValidDate : options.maximum, + exclMinDate : options.exclusiveMinimum, + exclMaxDate : options.exclusiveMaximum + } + } + + /* + * To check where the click happened, if happened outside the datepicker + * then hide the picker. This is checked whether any ancestor of clicked target + * has a class datePickerTarget. This class is added to the attached element as well + */ + #checkWindowClicked(evnt) { + let self = this; + if(self.#curInstance) { + // datepickerTarget class depicts that the component is a part of the Date Field + // and on click of that class, we should not hide the datepicker or fire exit events. + if(!evnt.target.closest(".datePickerTarget")) { + //non-touch devices do not deactivate on blur. Hence needs to be done here + if(self.#touchSupported) { + self.#hide(); + //clicking outside a field doesn't blur the field in IPad. Doing it by script + self.#curInstance.$field.blur(); + self.#deactivateField(); + } else{ + DatePickerWidget.#clickedWindow = true; + } + } + else { + DatePickerWidget.#clickedWindow = false; + } + } + } + + /* + * handling of key strokes. All the key strokes prevent the default browser action + * unless specified otherwise + * tab: set focus on calendar icon when dateinput field is active, navigates through date picker buttons when datepicker is open, + * otherwise perform default browser action + * escape: hides the datepicker + * down arrow key: navigate the picker downwards by the number specified in actionView.upDown of the current View + * up arrow key: navigate the picker upwards by the number specified in actionView.upDown of the current View + * left arrow key: navigate the picker one unit of that view backward + * right arrow key: navigate the picker one unit of that view forward + * shift + up: perform the action that happens on clicking the caption (as specified in actionView.caption) + * shift + left: perform the action that happens on clicking the left navigation button + * shift + right: perform the action that happens on clicking the right navigation button + * space/enter: triggers the click event for the current focused element from datepicker/ opens datepicker when calendar icon is focused. + */ + #hotKeys(evnt) { + var handled = false, date; + switch(evnt.keyCode) { + case 9: //tab + // CQ-4239352 : Setting clickedWindow property to true on tabbing so that deactivateField logic gets executed + // When clicking on "x" on input field in in IE and when selecting the content and releasing the mouse select outside the field + // the click event is not trigerred on the field and hence activateField is not executed, so clickedWindow remains as false + DatePickerWidget.#clickedWindow = true; + handled = false; + break; + case 27://escape + if(DatePickerWidget.#visible) { + this.#hide(); + this.#curInstance.$field.focus(); + handled = true; + } + break; + case 32: //space + case 13: // enter + if(evnt.target.classList.contains("datepicker-calendar-icon")){ + if(!DatePickerWidget.#visible) { + this.#show(); + return; + } + this.$focusedDate.classList.add("dp-focus"); + } + break; + case 40: //down arrow key + if(!DatePickerWidget.#visible) { + this.#show(); + return; + } + this.$focusedDate.classList.add("dp-focus"); + break; + } + + if(DatePickerWidget.#visible && this.#keysEnabled) { + var v = this.#viewAction[this.view].key, + updown = this.#viewAction[this.view].upDown; + switch(evnt.keyCode) { + case 9: // tab + if (evnt.shiftKey) { + if (evnt.target.classList.contains("dp-leftnav") || evnt.target.classList.contains("dp-focus")) { + this.#hide(); + this.#curInstance.$field.focus(); + handled = true; + } else { + handled = false; + } + } else { + const buttonTabindex = evnt.target.getAttribute("tabindex"); + if (buttonTabindex === '0') { + if (evnt.target.tagName.toLocaleLowerCase() === "input") { + this.#hide(); + handled = false; + } else { + this.prevNavWidthBtn.focus(); + handled = true; + } + } else if (buttonTabindex === '4') { + this.#hide(); + this.#curInstance.$field.focus(); + handled = true; + } else { + handled = false; + } + } + break; + case 32: //select on space + case 13: // select on enter + this.hotKeyPressed = true; + this.#focusedOnLi = false; + if (!this.#focusedOnLi) { + evnt.target.click(); + } else { + if (this.$focusedDate) { + this.$focusedDate.click() //todo + } + } + this.hotKeyPressed = false; + handled = true; + break; + case 37: //left arrow key + if (evnt.shiftKey) { + this.prevNavWidthBtn.click(); + } else { + this.#adjustDate(-1, v, true); + } + handled = true; + break; + case 38: //up arrow key + if (evnt.shiftKey) { + this.caption.click(); + } else { + this.#adjustDate(-updown, v, true); + } + handled = true; + break; + case 39: //right arrow key + if (evnt.shiftKey) { + this.nextNavWidthBtn.click(); + } else { + this.#adjustDate(+1, v, true); + } + handled = true; + break; + case 40: //down arrow key + this.#adjustDate(updown, v, true); + handled = true; + break; + default: + } + } + if(handled) { + evnt.preventDefault(); + } + } + + /* + * show the datepicker. //TODO: migrate this to vanilla js + */ + #show() { + this.#options.locale = this.#curInstance.locale; + if(!DatePickerWidget.#visible) { + let date, + validDate; + validDate = this.#curInstance.selectedDate; + date = validDate ? new Date(validDate): new Date(); + this.selectedDay = this.currentDay = date.getDate(); + this.selectedMonth = this.currentMonth = date.getMonth(); + this.selectedYear = this.currentYear = date.getFullYear(); + this.maxValidDate = this.#options.maximum ? new Date(this.#curInstance.maxValidDate) : null; + this.minValidDate = this.#options.minimum ? new Date(this.#curInstance.minValidDate) : null; + this.exclMaxDate = this.#curInstance.exclMaxDate; + this.exclMinDate = this.#curInstance.exclMinDate; + this.#dp.getElementsByClassName("dp-clear")[0].getElementsByTagName("a")[0].innerText = this.#options.locale.clearText; + this.#layout(this.#defaultView); + this.#position(); + this.#dp.style.display = "flex"; + this.#focusedOnLi = false; + DatePickerWidget.#visible = true; + if (this.#options.showCalendarIcon) { + this.#curInstance.$field.setAttribute('readonly', true); // when the datepicker is active, deactivate the field + } + } + + // Disabling the focus on ipad due to a bug where value of + // date picker is not being set + // Removing this code will only hamper one use case + // where on ipad if you click on the calander then + // the field becomes read only so + // there is no indication where the current focus is + // And if you remove this foucs code all together + // then what happens is that on desktop MF in iframe the exit event + // is not getting called hence calander getting remained open even + // when you click somewhere on window or focus into some other field + if (this.#options.showCalendarIcon && !this.#touchSupported ) { + this.#curInstance.$field.focus(); // field loses focus after being marked readonly, causing blur event not to be fired later + } + } + + /* + * position the datepicker around the positioning element //TODO: migrate this to vanilla js + * provided in the options + */ + #position() { + let $elem = this.#curInstance.positioning, + windowScrollX = window.scrollX, + windowScrollY = window.scrollY, + windowInnerHeight = window.innerHeight, + windowInnerWidth = window.innerWidth, + height = $elem.offsetHeight, + top = this.#getOffset($elem).top + height, + left = this.#getOffset($elem).left, + styles = {"top": (top+"px"), "left": (left+"px")}, + diffBottom = top + 333 - windowInnerHeight - windowScrollY, //todo: hard-coded dp height to 333 + newLeft, + newTop; + if(diffBottom > 0) { + //can't appear at the bottom + //check top + newTop = top - height - 333 - 20; + if(newTop < windowScrollY) { + //can't appear at the top as well ... the datePicker pop up overlaps the date field + newTop = top - diffBottom; + } + styles.top = newTop + "px"; + } + if(left + 433 > windowScrollX + windowInnerWidth ) { //todo: hard-coding width to 433 + //align with the right edge + newLeft = windowScrollX + windowInnerWidth - 433 - 20; + styles.left = newLeft + "px"; + } + this.#dp.style.top = styles.top; + this.#dp.style.left = styles.left; + return this; + } + + #getOffset(elem) { + let box = elem.getBoundingClientRect(); + return { + top: box.top + window.scrollY , + left: box.left + window.scrollX + }; + } + + /* + * layout the nextView. if nextView is null return + * + */ + #layout(nextView) { + if(nextView == null) { + this.#hide(); + } else { + if(this.view) { + this['$' + this.view.toLowerCase()].style.display = "none"; + } + this.view = nextView; + this.caption.classList.toggle("disabled",!this.#viewAction[this.view].caption); + this['$'+this.view.toLowerCase()].style.display = "block"; + this["show"+this.view](); + } + return this; + } + + /* + * show the month view + */ + showMonth() { + var self = this, + curDate = new Date(this.currentYear, this.currentMonth), + maxDay = this.#maxDate(this.currentMonth), + prevMaxDay = this.#maxDate((this.currentMonth + 12)%12), + day1 = new Date(this.currentYear,this.currentMonth,1).getDay(), + rowsReq = Math.ceil((day1 + maxDay) / 7) + 1, + data, display; + + this.#tabulateView( + { + caption: this.#options.locale.months[this.currentMonth] + ", "+ this.#convertNumberToLocale(this.currentYear), + header:this.#options.locale.days, + numRows:rowsReq, + numColumns:7, + elementAt: function(row,col) { + var day = (row-1)*7 + col - day1 + 1; + let gridId = "day-" + day; + display = self.#convertNumberToLocale(day); + data = day; + if(day < 1) { + display = self.#convertNumberToLocale(prevMaxDay + day); + data = -1 + } + else if(day > maxDay) { + display = self.#convertNumberToLocale(day-maxDay); + data = -1; + } + else { + curDate.setDate(day); + // check if the currentdate is valid based on max and min valid date + if(self.#compareVal(curDate, self.maxValidDate, self.exclMaxDate) || self.#compareVal(self.minValidDate, curDate, self.exclMinDate)) { + data = -1; + } + } + return { + data : data, + gridId: gridId, + display : display, + ariaLabel : self.#options.editValue(self.currentYear+"-"+self.#pad2(self.currentMonth + 1)+"-"+self.#pad2(display)) + }; + } + }); + } + + /** + * returns boolean based on val1, val2, checkEqual + * For comparing date, use date object + */ + #compareVal(val1, val2, checkEqual) { + if(!val1 || !val2) { + return false; + } + + if(checkEqual) { + return val1 >= val2; + } else { + return val1 > val2; + } + } + + /* + * show the year view + */ + showYear() { + var self = this, + minDate = this.minValidDate ? new Date(this.minValidDate.getFullYear(), this.minValidDate.getMonth()) : null, + maxDate = this.maxValidDate ? new Date(this.maxValidDate.getFullYear(), this.maxValidDate.getMonth()) : null, + curDate = new Date(this.currentYear, 0), //can't omit month, if only one param present it is treated as millisecond + data, + month; + this.#tabulateView( + { + caption : this.#convertNumberToLocale(this.currentYear), + numRows : 4, + numColumns : 3, + elementAt : function(row,col) { + data = month = row*3 + col; + let gridId = "month-" + data; + curDate.setMonth(month); + if ((minDate && curDate < minDate) || (maxDate && curDate > maxDate)) { + data = -1; + } + return { + data : data, + gridId: gridId, + display : self.#options.locale.months[month], + ariaLabel : self.#options.locale.months[month] + " " +self.currentYear + }; + } + }); + } + + /* + * show the year set view + */ + showYearset() { + var year, + minDate = this.minValidDate ? new Date(this.minValidDate.getFullYear(), 0) : null , + maxDate = this.maxValidDate ? new Date(this.maxValidDate.getFullYear() + 1, 0) : null, + curDate = new Date(), + data, + self = this; + this.#tabulateView( + { + caption: this.#convertNumberToLocale(this.currentYear - this.#options.yearsPerView/2) +"-"+this.#convertNumberToLocale(this.currentYear - this.#options.yearsPerView/2 + this.#options.yearsPerView - 1), + numRows:4, + numColumns:4, + elementAt: function(row,col) { + data = year = self.currentYear - 8 + (row*4 + col); + let gridId = "year-"+data; + curDate.setFullYear(year); + if ((minDate && curDate < minDate) || (maxDate && curDate > maxDate)) { + data = -1; + } + return { + "data" : data, + "gridId" : gridId, + "display" : self.#convertNumberToLocale(year), + ariaLabel : year + }; + } + }); + } + + insertRow (rowNum, rowArray, isHeader, elementAt) { + let $view = this["$"+this.view.toLowerCase()], + $row = $view.getElementsByTagName("ul")[rowNum], + items,$li,element,$tmp, + self= this; + if(!$row) { + let rowEl = document.createElement("ul"); + rowEl.removeAttribute("aria-label"); + rowEl.classList.toggle("header", isHeader); + $row = $view.appendChild(rowEl); + } + items = $row.getElementsByTagName("li").length; + while(items++ < rowArray.length) { + let listItemEl = document.createElement("li"); + listItemEl.id = "li-" + elementAt(rowNum, items).gridId; + $tmp = $row.appendChild(listItemEl); + if(!isHeader) { + let cb = function (evnt) { + self.#selectDate(evnt); + }; + $tmp.addEventListener("click", cb); + } + } + + rowArray.forEach(function(el,index) { + $li = $row.getElementsByTagName("li")[index]; + if(isHeader) + $li.innerText = rowArray[index]; + else { + element = rowArray[index]; + datePickerCachePut($li, "value", element.data); + if(self.#checkDateIsFocussed(element.data)) { + if(self.$focusedDate) { + self.$focusedDate.classList.remove("dp-focus"); + self.$focusedDate.setAttribute("tabindex", "-1"); + } + self.$focusedDate = $li; + if(self.#keysEnabled) + self.$focusedDate.classList.add("dp-focus") + } + $li.setAttribute("title", element.ariaLabel); + $li.setAttribute("aria-label", element.ariaLabel); + $li.setAttribute("tabindex", -1); + $li.classList.toggle("dp-selected",self.#checkDateIsSelected(element.data)); + $li.classList.toggle("disabled", element.data === -1); + $li.innerText = element.display; + } + }); + return $row; + } + + /* + * creates a tabular view based on the options provided. The options that can be passed are + * numRows: number of rows that needs rendering + * numCols: number of columns that needs rendering + * caption: text for the datepickers caption element + * header: an array of elements that identifies the header row + * elementAt: a function(row, column) that returns an object (data: , display: ) where + * is the value to set for that view when the element at (row,column) is clicked and + * is the value that will be visible to the user + */ + #tabulateView (options) { + var r = 0,rows = 0, + row = [], + c; + this.caption.innerHTML = options.caption; + if(options.header) { + this.insertRow(r++,options.header,true, options.elementAt); + } + while(r < options.numRows) { + c = 0; + while(c < options.numColumns) { + row[c] = options.elementAt(r,c); + c++; + } + this.insertRow(r++,row,false, options.elementAt); + } + + rows = this["$" + this.view.toLowerCase()].querySelectorAll("ul"); + rows = [...rows].filter((r)=> {return !(r.offsetHeight===0 && r.offsetWidth===0)}); + let len = rows.length; + while (len > options.numRows) { + this["$" + this.view.toLowerCase()].querySelectorAll("ul")[--len].style.display = "none"; + } + while (options.numRows > len) { + this["$" + this.view.toLowerCase()].querySelectorAll("ul")[len++].style.display = "flex"; + } + } + + #activateField(evnt) { + let self = this; + this.#curInstance = datePickerCacheGet(evnt.target,"datetimepicker"); + // Issue LC-7049: + // datepickerTarget should be added when activate the field and should be removed + // after the fields gets deactivated. + if (this.#options.showCalendarIcon) { + this.#curInstance.$field.parentNode.classList.add("datePickerTarget"); + } + //enable hot keys only for non touch devices + if(!this.#touchSupported && !this.#keysEnabled) { + this.#dp.addEventListener("keydown", function(evnt){self.#hotKeys(evnt);},false); + this.#keysEnabled = true; + } + } + + #deactivateField() { + let self = this; + if(self.#curInstance) { + if(this.#keysEnabled) { + this.#dp.removeEventListener("keydown", self.#hotKeys); + this.#keysEnabled = false; + } + // Issue LC-7049: + // datepickerTarget should be added when activate the field and should be removed + // after the fields gets deactivated. Otherwise clicking on any other datefield + // will not hide the existing datepicker + if (this.#options.showCalendarIcon) { + self.#curInstance.$field.parentNode.classList.remove("datePickerTarget"); + } + self.#curInstance = null; + this.#hideAllViews(); + } + } + + #hide() { + if(DatePickerWidget.#visible) { + this.#dp.style.display = "none"; + this.#hideAllViews(); + DatePickerWidget.#visible = false; + if (this.#options.showCalendarIcon) { + this.#curInstance.$field.removeAttribute('readonly'); // when the datepicker is deactivated, activate the field + } + } + } + #hideAllViews() { + this['$month'].replaceChildren(); + this['$year'].replaceChildren(); + this['$yearset'].replaceChildren(); + this['$month'].style.display = "none"; + this['$year'].style.display = "none"; + this['$yearset'].style.display = "none"; + } + + #adjustDate(step, view, focus) { + let maxDate,prevMaxDate; + let _focus = focus || false; + switch(view.toLowerCase()) { + case "day": + this.currentDay += step; + maxDate = this.#maxDate(this.currentMonth) + if(this.currentDay < 1) { + prevMaxDate = this.#maxDate((this.currentMonth - 1 + 12)%12); + this.currentDay = prevMaxDate + this.currentDay; + return this.#adjustDate(-1, "month", _focus); + } + if(this.currentDay > maxDate) { + this.currentDay -= maxDate; + return this.#adjustDate(+1, "month", _focus); + } + break; + case "month": + this.currentMonth += step; + if(this.currentMonth > 11) { + this.currentYear++; + this.currentMonth = 0; + } + if(this.currentMonth < 0) { + this.currentYear--; + this.currentMonth = 11; + } + break; + case "year": + this.currentYear += step; + break; + case "yearset": + this.currentYear += step*this.#options.yearsPerView; + break; + } + this.#layout(this.view); + if (_focus) { + this.#focusedOnLi = true; + this.$focusedDate.setAttribute("tabindex", 0); + this.$focusedDate.focus(); + } + } + + #checkDateIsSelected(data) { + switch(this.view.toLowerCase()) { + case "month": + return this.currentYear === this.selectedYear && this.currentMonth === this.selectedMonth && data === this.selectedDay; + case "year": + return this.currentYear === this.selectedYear && this.selectedMonth === data; + case "yearset": + return this.selectedYear === data; + } + } + + #checkDateIsFocussed(data) { + switch(this.view.toLowerCase()) { + case "month": + return data === this.currentDay; + case "year": + return this.currentMonth === data; + case "yearset": + return this.currentYear === data; + } + } + + #convertNumberToLocale(number) { + const zeroCode = this.#options.locale.zero.charCodeAt(0); + number += ""; + let newNumber = []; + for(let i = 0;i < number.length;i++) { + newNumber.push(String.fromCharCode(zeroCode + parseInt(number.charAt(i)))); + } + return newNumber.join(""); + } + + #clearDate(view) { + this.setValue(""); + let existingSelectedItem = this['$'+view.toLowerCase()].getElementsByClassName("dp-selected")[0]; + if (existingSelectedItem) { + existingSelectedItem.classList.remove("dp-selected"); + } + } + + #getEvent() { + return "click";//this.#touchSupported ? "touchstart" : "click"; + } + + #pad2(m) { + return m = m < 10 ?"0"+m:m; + } + + toString() { + return this.selectedYear + "-" + this.#pad2(this.selectedMonth + 1) + "-" + this.#pad2(this.selectedDay); + } + + #selectDate(evnt) { + let val = datePickerCacheGet(evnt.target, "value"), + nextView = this.#viewAction[this.view].li; + + //disabled dates have a value of -1. Do nothing in that case + if(val === -1) + return; + switch(this.view.toLowerCase()) { + case "month": + this.selectedMonth = this.currentMonth; + this.selectedYear = this.currentYear; + this.selectedDay = val; + this.setValue(this.toString()); + this.#curInstance.$field.focus(); + let existingSelectedElement = this['$'+this.view.toLowerCase()].getElementsByClassName("dp-selected")[0]; + if (existingSelectedElement) { + existingSelectedElement.classList.remove("dp-selected"); + } + evnt.target.classList.add("dp-selected"); + break; + case "year": + this.currentMonth = val; + break; + case "yearset": + this.currentYear = val; + break; + } + this.#layout(nextView); + //manually focus on the field if clicked on the popup buttons for non-touch device + if(!this.#touchSupported) { + //No need to focus if selection is made by pressing space. + if(!this.hotKeyPressed) { + this.#scriptFocus = true; + } + } else if(nextView == null){ + //For touch devices, deactivate the field if a selection is made + this.#deactivateField() + } + } + + #leapYear() { + return this.currentYear % 400 === 0 || (this.currentYear % 100 !== 0 && this.currentYear % 4 === 0); + } + + #maxDate(m) { + if(this.#leapYear() && m === 1) + return 29; + else return this.#dates[m]; + } + + #localizeDateElements(options, locale) { + options.locale.months = options.locale.months.map(month => { + const parts = new Intl.DateTimeFormat(locale, {month: "long"}).formatToParts(month); + const m = parts.find(p => p.type === 'month'); + return m && m.value; + }); + options.locale.days = options.locale.days.map(day => { + const parts = new Intl.DateTimeFormat(locale, {weekday: 'short'}).formatToParts(day); + const m = parts.find(p => p.type === 'weekday'); + return m && m.value[0]; + }); + } + + /** + * returns the original value of format YYYY-MM-DD + * @returns {*|string} + */ + getValue() { + let dateVal = new Date(this.toString()); + if (!isNaN(dateVal)) { + return this.toString(); + } + return this.#widget.value; + } + + /** + * Sets the formatted display value on the widget. + * value should be in YYYY-MM-DD format + * @param value + */ + setDisplayValue(value) { + if (this.#curInstance != null) { + this.#curInstance.$field.value = this.#curInstance.displayValue(this.toString()) || value; + } else { + this.#widget.value = this.#formatDate(value, this.#model.displayFormat) || value; + } + } + + /** + * Sets the formatted edit value on the widget + * @param value + */ + + setValue(value) { + let currDate = new Date(value); + if (!isNaN(currDate) && value != null) { + //in case the value is directly updated from the field without using calendar widget + this.selectedMonth = currDate.getMonth(); + this.selectedYear = currDate.getFullYear(); + this.selectedDay = currDate.getDate(); + } else { + this.selectedYear + = this.selectedMonth + = this.selectedYear + = -1; + } + if (this.#curInstance != null) { + this.#curInstance.selectedDate = value; + this.#curInstance.$field.value = this.#curInstance.editValue(this.toString()) || value; + } else { + this.#widget.value = this.#formatDate(value, this.#model.editFormat) || value; + } + } + + addEventListener(event, handler, widget) { + let inst = datePickerCacheGet(widget, "datetimepicker"); + inst.$field.addEventListener(event, handler); + } + +} \ No newline at end of file diff --git a/src/components/datepicker/cache.js b/src/components/datepicker/cache.js new file mode 100644 index 0000000..ad94203 --- /dev/null +++ b/src/components/datepicker/cache.js @@ -0,0 +1,33 @@ +/******************************************************************************* + * Copyright 2023 Adobe + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + ******************************************************************************/ + + + + +let store = {}; + +export function datePickerCachePut(el, key, value) { + this.store[el.id ] = {}; + this.store[el.id][key] = value; +} + +export function datePickerCacheGet(el, key) { + return this.store[el.id][key]; +} + + + + diff --git a/src/components/datepicker/datepicker.js b/src/components/datepicker/datepicker.js new file mode 100644 index 0000000..086eaaa --- /dev/null +++ b/src/components/datepicker/datepicker.js @@ -0,0 +1,51 @@ +import * as builder from "../../libs/afb-builder.js"; +import { + getWidget, + subscribe +} from "../../libs/afb-interaction.js"; +import { Constants } from "../../libs/constants.js"; +import { DefaultField } from "../defaultInput.js"; +import DatePickerWidget from "./DatePickerWidget.js"; + + + +export class DatePicker extends DefaultField { + blockName = Constants.DATE_PICKER; + + widgetFormatter; + + updateValue(value) { + if (this.widgetFormatter) { + if (this.isActive()) { + this.widgetFormatter.setValue(value); + } else { + this.widgetFormatter.setDisplayValue(value); + } + } else { + super.updateValue(value); + } + } + + render() { + this.element = builder?.default?.renderField(this.model, this.blockName); + const widget = getWidget(this.element); + if ( + this.widgetFormatter == null && + (this.model.editFormat || this.model.displayFormat) + ) + { + this.widgetFormatter = new DatePickerWidget(this, widget, this.model); + } + this.block.appendChild(this.element); + if (!this.widgetFormatter) { + this.addListener(); + } + // eslint-disable-next-line no-underscore-dangle + subscribe(this.model, this.element, { value: this.updateValue }); + } +} + +export default async function decorate(block, model) { + const component = new DatePicker(block, model); + component.render(); +} diff --git a/src/images/calendar.png b/src/images/calendar.png new file mode 100644 index 0000000000000000000000000000000000000000..b5bc4c98ac87cf2a9dfaf425df555e15e8d1313f GIT binary patch literal 1347 zcmV-J1-$x+P)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D02p*dSaefwW^{L9 za%BK;VQFr3E^cLXAT%y8E;(#7eog=Y1hh#+K~z{r?O0uC990+{HMR*mGk0cOX~AG< zi7`8K@2C5T1Zhaw81LZRq; zExu?ZFIs)l(x#?~>$&62wq$I*d9{AYhoSAP=?%eO2??$_BK>CY0Lb>R=W9w~- zY8eUYuaP+j69bC6av+{~)uQ5Oo%vi*EhE8#&B$?@c>Fa*T{&1@aoS3c)r>Uv71c5l zUa(2#AXL1gsFk8}la}6+(A5t z%xRjz{JcerGft-RF%lea9O4JW626~`YAXxuy_3kXi1@LQF2hcjKd~r3th?eqWlz&g z#wTqoy`8CCK$7np1(KdY{Eqmkk-n0^y{8fQBgL6Ux(qvATCg*vn&I+Y${tpb`a3Mf zmvF&*iInd(3T*DfNPM0!KGjHHNtl#Bf*j`%M;hre@H=uV)r?fITiKHZz;}YYK)hhT zq9QOo{staF&7gclQ7uDfe51^PTyH5V0^<2kz{?W$=^jO0G>98tR8$0E6;r-QSWs3} z%P^Q2#tJR`RTX2Ao^NUpH@~E)2ux25!rWs<|8Sq8TE^-=f1TVSMoJ7TDgt;o@Q)1s zTe!9j;xQgnRNDY!`07393d#y-az<{XPX+t(*ED5{|@mC_71H!U4O{VP3%R*@3rV?K`ENnQ|L52 zF%}dxvg?_L-C0 zR&(yEKohS^UZRT)b)$`)keV$uDwPNqDEvFf>ymvBec5?VYeH47SkXP;6ch-VO|KZ6B#8f-jouwtIDt?QKl z6gqTIr0N=GKPAARyGoAp%UWk@q(>zXR2|Ks@n^=2ljZ{mt{oVDJ}ADPfasYlJTVKA%YO2~E?qKLG8V9^mL|B;f!6002ovPDHLk FV1g&feboQ} literal 0 HcmV?d00001 diff --git a/src/images/leftnav.png b/src/images/leftnav.png new file mode 100644 index 0000000000000000000000000000000000000000..47d047ddfa08e92084b5ef2d5993ecc247c08930 GIT binary patch literal 290 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM#=yWhy?{3l$YCrFa(7}_cTVOdki(Mh=>$h?_mX=7D>g$CCcB~4dUHj zaLB4kZ<$jPmJ_0J)_K*cnn|k3=gf`>I-az&-^-lT6TVB-)$H=xA`zCkTzf*bUR)FD zIN?&h@aBwnOifGwwR)fb<;&e-QvY(+-`1`=#~T-BPCeVHxm4P@c0#}re!br}k`1ps zTW$TPLt@2_8#ksLv{x_O$!pDa>?l?_mX=7D>g$B}?+2*dLIQ zU8tNgCFerXrAR}QfLTH-SAFvII5sD~aE?%N%@ei{_u@0fS_H&qx%M!nFJqeR#d>uW zli9hM literal 0 HcmV?d00001 diff --git a/src/images/rightnav.png b/src/images/rightnav.png new file mode 100644 index 0000000000000000000000000000000000000000..af4507989cadb1d9373f3b4f28dc633279cdce3e GIT binary patch literal 295 zcmeAS@N?(olHy`uVBq!ia0vp^l0YoM!N$PASg|QB0LWo14sv&5Sa(k5C6L3C?&#~t zz_78O`%fY(kk47*5n0T@z%2~Ij105pNB{-*c)B=-Sopu4dYkvK0*|Yt;^LB)JSV$~ zHB$wxyv(y|C$7|yV+zka>fYD#Ncj4#Rs_V?EZ) zW?nOYicrRL`8ThWk32q~B4{Z4^Y)e>$Y?;!)8R%0e*10dpByunfL z=T4&__k@-_hPo5|Y_}%3EDYlRyTrpU$KET((Q>bi8& zR#-mVD}VKmi_G<-JPNJ)k?JzWH-9+`CEF-7tWJs&&dK}h!j}HtVHNY!OUG9^G})J!1177QgFkT0>{0z@@VoJ=(js#)53b&` zcc*zg-zJ`=6z*}ic1>A#>&oOwq`njxg HN@xNAhv{(o literal 0 HcmV?d00001