From: Alexander Ebert Date: Thu, 22 Oct 2020 23:16:03 +0000 (+0200) Subject: Convert `Date/Picker` to TypeScript X-Git-Tag: 5.4.0_Alpha_1~704^2~29 X-Git-Url: https://git.stricted.de/?a=commitdiff_plain;h=9a11d3a3b9959aea13a700fa4b32ec35bdc064f0;p=GitHub%2FWoltLab%2FWCF.git Convert `Date/Picker` to TypeScript --- diff --git a/global.d.ts b/global.d.ts index 66ac5f7551..77c9044361 100644 --- a/global.d.ts +++ b/global.d.ts @@ -1,3 +1,4 @@ +import DatePicker from './wcfsetup/install/files/ts/WoltLabSuite/Core/Date/Picker'; import Devtools from './wcfsetup/install/files/ts/WoltLabSuite/Core/Devtools'; import DomUtil from './wcfsetup/install/files/ts/WoltLabSuite/Core/Dom/Util'; import * as ColorUtil from './wcfsetup/install/files/ts/WoltLabSuite/Core/ColorUtil'; @@ -14,8 +15,9 @@ declare global { WCF: any; bc_wcfDomUtil: typeof DomUtil; - __wcf_bc_colorUtil: typeof ColorUtil; bc_wcfSimpleDropdown: typeof UiDropdownSimple; + __wcf_bc_colorUtil: typeof ColorUtil; + __wcf_bc_datePicker: typeof DatePicker; } interface String { diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Date/Picker.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Date/Picker.js index e1b0910b97..f9d4d0109d 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Date/Picker.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Date/Picker.js @@ -1,71 +1,570 @@ /** * Date picker with time support. * - * @author Alexander Ebert - * @copyright 2001-2019 WoltLab GmbH - * @license GNU Lesser General Public License - * @module WoltLabSuite/Core/Date/Picker + * @author Alexander Ebert + * @copyright 2001-2019 WoltLab GmbH + * @license GNU Lesser General Public License + * @module WoltLabSuite/Core/Date/Picker */ -define(['DateUtil', 'Dom/Traverse', 'Dom/Util', 'EventHandler', 'Language', 'ObjectMap', 'Dom/ChangeListener', 'Ui/Alignment', 'WoltLabSuite/Core/Ui/CloseOverlay'], function (DateUtil, DomTraverse, DomUtil, EventHandler, Language, ObjectMap, DomChangeListener, UiAlignment, UiCloseOverlay) { +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } }); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); + __setModuleDefault(result, mod); + return result; +}; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +define(["require", "exports", "../Core", "./Util", "../Dom/Change/Listener", "../Event/Handler", "../Language", "../Ui/Alignment", "../Ui/CloseOverlay", "../Dom/Util"], function (require, exports, Core, DateUtil, Listener_1, EventHandler, Language, UiAlignment, CloseOverlay_1, Util_1) { "use strict"; - var _didInit = false; - var _firstDayOfWeek = 0; - var _wasInsidePicker = false; - var _data = new ObjectMap(); - var _input = null; - var _maxDate = 0; - var _minDate = 0; - var _dateCells = []; - var _dateGrid = null; - var _dateHour = null; - var _dateMinute = null; - var _dateMonth = null; - var _dateMonthNext = null; - var _dateMonthPrevious = null; - var _dateTime = null; - var _dateYear = null; - var _datePicker = null; - var _callbackOpen = null; - var _callbackFocus = null; + Core = __importStar(Core); + DateUtil = __importStar(DateUtil); + Listener_1 = __importDefault(Listener_1); + EventHandler = __importStar(EventHandler); + Language = __importStar(Language); + UiAlignment = __importStar(UiAlignment); + CloseOverlay_1 = __importDefault(CloseOverlay_1); + Util_1 = __importDefault(Util_1); + let _didInit = false; + let _firstDayOfWeek = 0; + let _wasInsidePicker = false; + const _data = new Map(); + let _input = null; + let _maxDate; + let _minDate; + const _dateCells = []; + let _dateGrid; + let _dateHour; + let _dateMinute; + let _dateMonth; + let _dateMonthNext; + let _dateMonthPrevious; + let _dateTime; + let _dateYear; + let _datePicker = null; + /** + * Creates the date picker DOM. + */ + function createPicker() { + if (_datePicker !== null) { + return; + } + _datePicker = document.createElement('div'); + _datePicker.className = 'datePicker'; + _datePicker.addEventListener('click', event => { + event.stopPropagation(); + }); + const header = document.createElement('header'); + _datePicker.appendChild(header); + _dateMonthPrevious = document.createElement('a'); + _dateMonthPrevious.className = 'previous jsTooltip'; + _dateMonthPrevious.href = '#'; + _dateMonthPrevious.setAttribute('role', 'button'); + _dateMonthPrevious.tabIndex = 0; + _dateMonthPrevious.title = Language.get('wcf.date.datePicker.previousMonth'); + _dateMonthPrevious.setAttribute('aria-label', Language.get('wcf.date.datePicker.previousMonth')); + _dateMonthPrevious.innerHTML = ''; + _dateMonthPrevious.addEventListener('click', DatePicker.previousMonth); + header.appendChild(_dateMonthPrevious); + const monthYearContainer = document.createElement('span'); + header.appendChild(monthYearContainer); + _dateMonth = document.createElement('select'); + _dateMonth.className = 'month jsTooltip'; + _dateMonth.title = Language.get('wcf.date.datePicker.month'); + _dateMonth.setAttribute('aria-label', Language.get('wcf.date.datePicker.month')); + _dateMonth.addEventListener('change', changeMonth); + monthYearContainer.appendChild(_dateMonth); + let months = ''; + const monthNames = Language.get('__monthsShort'); + for (let i = 0; i < 12; i++) { + months += ''; + } + _dateMonth.innerHTML = months; + _dateYear = document.createElement('select'); + _dateYear.className = 'year jsTooltip'; + _dateYear.title = Language.get('wcf.date.datePicker.year'); + _dateYear.setAttribute('aria-label', Language.get('wcf.date.datePicker.year')); + _dateYear.addEventListener('change', changeYear); + monthYearContainer.appendChild(_dateYear); + _dateMonthNext = document.createElement('a'); + _dateMonthNext.className = 'next jsTooltip'; + _dateMonthNext.href = '#'; + _dateMonthNext.setAttribute('role', 'button'); + _dateMonthNext.tabIndex = 0; + _dateMonthNext.title = Language.get('wcf.date.datePicker.nextMonth'); + _dateMonthNext.setAttribute('aria-label', Language.get('wcf.date.datePicker.nextMonth')); + _dateMonthNext.innerHTML = ''; + _dateMonthNext.addEventListener('click', DatePicker.nextMonth); + header.appendChild(_dateMonthNext); + _dateGrid = document.createElement('ul'); + _datePicker.appendChild(_dateGrid); + const item = document.createElement('li'); + item.className = 'weekdays'; + _dateGrid.appendChild(item); + const weekdays = Language.get('__daysShort'); + for (let i = 0; i < 7; i++) { + let day = i + _firstDayOfWeek; + if (day > 6) + day -= 7; + const span = document.createElement('span'); + span.textContent = weekdays[day]; + item.appendChild(span); + } + // create date grid + for (let i = 0; i < 6; i++) { + const row = document.createElement('li'); + _dateGrid.appendChild(row); + for (let j = 0; j < 7; j++) { + const cell = document.createElement('a'); + cell.addEventListener('click', click); + _dateCells.push(cell); + row.appendChild(cell); + } + } + _dateTime = document.createElement('footer'); + _datePicker.appendChild(_dateTime); + _dateHour = document.createElement('select'); + _dateHour.className = 'hour'; + _dateHour.title = Language.get('wcf.date.datePicker.hour'); + _dateHour.setAttribute('aria-label', Language.get('wcf.date.datePicker.hour')); + _dateHour.addEventListener('change', formatValue); + const date = new Date(2000, 0, 1); + const timeFormat = Language.get('wcf.date.timeFormat').replace(/:/, '').replace(/[isu]/g, ''); + let tmp = ''; + for (let i = 0; i < 24; i++) { + date.setHours(i); + tmp += '"; + } + _dateHour.innerHTML = tmp; + _dateTime.appendChild(_dateHour); + _dateTime.appendChild(document.createTextNode('\u00A0:\u00A0')); + _dateMinute = document.createElement('select'); + _dateMinute.className = 'minute'; + _dateMinute.title = Language.get('wcf.date.datePicker.minute'); + _dateMinute.setAttribute('aria-label', Language.get('wcf.date.datePicker.minute')); + _dateMinute.addEventListener('change', formatValue); + tmp = ''; + for (let i = 0; i < 60; i++) { + tmp += ''; + } + _dateMinute.innerHTML = tmp; + _dateTime.appendChild(_dateMinute); + document.body.appendChild(_datePicker); + document.body.addEventListener('focus', maintainFocus, { capture: true }); + } + /** + * Initializes the minimum/maximum date range. + */ + function initDateRange(element, now, isMinDate) { + const name = isMinDate ? 'minDate' : 'maxDate'; + let value = (element.dataset[name] || '').trim(); + if (value.match(/^(\d{4})-(\d{2})-(\d{2})$/)) { + // YYYY-mm-dd + value = new Date(value).getTime().toString(); + } + else if (value === 'now') { + value = now.getTime().toString(); + } + else if (value.match(/^\d{1,3}$/)) { + // relative time span in years + const date = new Date(now.getTime()); + date.setFullYear(date.getFullYear() + ~~value * (isMinDate ? -1 : 1)); + value = date.getTime().toString(); + } + else if (value.match(/^datePicker-(.+)$/)) { + // element id, e.g. `datePicker-someOtherElement` + value = RegExp.$1; + if (document.getElementById(value) === null) { + throw new Error("Reference date picker identified by '" + value + "' does not exists (element id: '" + element.id + "')."); + } + } + else if (/^\d{4}-\d{2}-\d{2}T/.test(value)) { + value = new Date(value).getTime().toString(); + } + else { + value = new Date((isMinDate ? 1902 : 2038), 0, 1).getTime().toString(); + } + element.dataset[name] = value; + } + /** + * Sets up callbacks and event listeners. + */ + function setup() { + if (_didInit) + return; + _didInit = true; + _firstDayOfWeek = parseInt(Language.get('wcf.date.firstDayOfTheWeek'), 10); + Listener_1.default.add('WoltLabSuite/Core/Date/Picker', DatePicker.init); + CloseOverlay_1.default.add('WoltLabSuite/Core/Date/Picker', close); + } + function getDateValue(attributeName) { + let date = _input.dataset[attributeName] || ''; + if (date.match(/^datePicker-(.+)$/)) { + const referenceElement = document.getElementById(RegExp.$1); + if (referenceElement === null) { + throw new Error(`Unable to find an element with the id '${RegExp.$1}'.`); + } + date = referenceElement.dataset.value || ''; + } + return new Date(parseInt(date, 10)); + } + /** + * Opens the date picker. + */ + function open(event) { + event.preventDefault(); + event.stopPropagation(); + createPicker(); + const target = event.currentTarget; + const input = (target.nodeName === 'INPUT') ? target : target.previousElementSibling; + if (input === _input) { + close(); + return; + } + const dialogContent = input.closest('.dialogContent'); + if (dialogContent !== null) { + if (!Core.stringToBool(dialogContent.dataset.hasDatepickerScrollListener || '')) { + dialogContent.addEventListener('scroll', onDialogScroll); + dialogContent.dataset.hasDatepickerScrollListener = '1'; + } + } + _input = input; + const data = _data.get(_input); + const value = _input.dataset.value; + let date; + if (value) { + date = new Date(parseInt(value, 10)); + if (date.toString() === 'Invalid Date') { + date = new Date(); + } + } + else { + date = new Date(); + } + // set min/max date + _minDate = getDateValue('minDate'); + if (_minDate.getTime() > date.getTime()) { + date = _minDate; + } + _maxDate = getDateValue('maxDate'); + if (data.isDateTime) { + _dateHour.value = date.getHours().toString(); + _dateMinute.value = date.getMinutes().toString(); + _datePicker.classList.add('datePickerTime'); + } + else { + _datePicker.classList.remove('datePickerTime'); + } + _datePicker.classList[(data.isTimeOnly) ? 'add' : 'remove']('datePickerTimeOnly'); + renderPicker(date.getDate(), date.getMonth(), date.getFullYear()); + UiAlignment.set(_datePicker, _input); + _input.nextElementSibling.setAttribute('aria-expanded', 'true'); + _wasInsidePicker = false; + } + /** + * Closes the date picker. + */ + function close() { + if (_datePicker === null || !_datePicker.classList.contains('active')) { + return; + } + _datePicker.classList.remove('active'); + const data = _data.get(_input); + if (typeof data.onClose === 'function') { + data.onClose(); + } + EventHandler.fire('WoltLabSuite/Core/Date/Picker', 'close', { element: _input }); + const sibling = _input.nextElementSibling; + sibling.setAttribute('aria-expanded', 'false'); + _input = null; + } + /** + * Updates the position of the date picker in a dialog if the dialog content + * is scrolled. + */ + function onDialogScroll(event) { + if (_input === null) { + return; + } + const dialogContent = event.currentTarget; + const offset = Util_1.default.offset(_input); + const dialogOffset = Util_1.default.offset(dialogContent); + // check if date picker input field is still (partially) visible + if (offset.top + _input.clientHeight <= dialogOffset.top) { + // top check + close(); + } + else if (offset.top >= dialogOffset.top + dialogContent.offsetHeight) { + // bottom check + close(); + } + else if (offset.left <= dialogOffset.left) { + // left check + close(); + } + else if (offset.left >= dialogOffset.left + dialogContent.offsetWidth) { + // right check + close(); + } + else { + UiAlignment.set(_datePicker, _input); + } + } + /** + * Renders the full picker on init. + */ + function renderPicker(day, month, year) { + renderGrid(day, month, year); + // create options for month and year + let years = ''; + for (let i = _minDate.getFullYear(), last = _maxDate.getFullYear(); i <= last; i++) { + years += ''; + } + _dateYear.innerHTML = years; + _dateYear.value = year.toString(); + _dateMonth.value = month.toString(); + _datePicker.classList.add('active'); + } + /** + * Updates the date grid. + */ + function renderGrid(day, month, year) { + const hasDay = (day !== undefined); + const hasMonth = (month !== undefined); + if (typeof day !== 'number') { + day = parseInt(day || _dateGrid.dataset.day || '0', 10); + } + if (typeof month !== 'number') { + month = parseInt(month || '0', 10); + } + if (typeof year !== 'number') { + year = parseInt(year || '0', 10); + } + // rebuild cells + if (hasMonth || year) { + let rebuildMonths = (year !== 0); + // rebuild grid + const fragment = document.createDocumentFragment(); + fragment.appendChild(_dateGrid); + if (!hasMonth) { + month = parseInt(_dateGrid.dataset.month, 10); + } + if (!year) { + year = parseInt(_dateGrid.dataset.year, 10); + } + // check if current selection exceeds min/max date + let date = new Date(year + '-' + ('0' + (month + 1).toString()).slice(-2) + '-' + ('0' + day.toString()).slice(-2)); + if (date < _minDate) { + year = _minDate.getFullYear(); + month = _minDate.getMonth(); + day = _minDate.getDate(); + _dateMonth.value = month.toString(); + _dateYear.value = year.toString(); + rebuildMonths = true; + } + else if (date > _maxDate) { + year = _maxDate.getFullYear(); + month = _maxDate.getMonth(); + day = _maxDate.getDate(); + _dateMonth.value = month.toString(); + _dateYear.value = year.toString(); + rebuildMonths = true; + } + date = new Date(year + '-' + ('0' + (month + 1).toString()).slice(-2) + '-01'); + // shift until first displayed day equals first day of week + while (date.getDay() !== _firstDayOfWeek) { + date.setDate(date.getDate() - 1); + } + // show the last row + Util_1.default.show(_dateCells[35].parentNode); + let selectable; + const comparableMinDate = new Date(_minDate.getFullYear(), _minDate.getMonth(), _minDate.getDate()); + for (let i = 0; i < 42; i++) { + if (i === 35 && date.getMonth() !== month) { + // skip the last row if it only contains the next month + Util_1.default.hide(_dateCells[35].parentNode); + break; + } + const cell = _dateCells[i]; + cell.textContent = date.getDate().toString(); + selectable = (date.getMonth() === month); + if (selectable) { + if (date < comparableMinDate) + selectable = false; + else if (date > _maxDate) + selectable = false; + } + cell.classList[selectable ? 'remove' : 'add']('otherMonth'); + if (selectable) { + cell.href = '#'; + cell.setAttribute('role', 'button'); + cell.tabIndex = 0; + cell.title = DateUtil.formatDate(date); + cell.setAttribute('aria-label', DateUtil.formatDate(date)); + } + date.setDate(date.getDate() + 1); + } + _dateGrid.dataset.month = month.toString(); + _dateGrid.dataset.year = year.toString(); + _datePicker.insertBefore(fragment, _dateTime); + if (!hasDay) { + // check if date is valid + date = new Date(year, month, day); + if (date.getDate() !== day) { + while (date.getMonth() !== month) { + date.setDate(date.getDate() - 1); + } + day = date.getDate(); + } + } + if (rebuildMonths) { + for (let i = 0; i < 12; i++) { + const currentMonth = _dateMonth.children[i]; + currentMonth.disabled = (year === _minDate.getFullYear() && +currentMonth.value < _minDate.getMonth()) || (year === _maxDate.getFullYear() && +currentMonth.value > _maxDate.getMonth()); + } + const nextMonth = new Date(year + '-' + ('0' + (month + 1).toString()).slice(-2) + '-01'); + nextMonth.setMonth(nextMonth.getMonth() + 1); + _dateMonthNext.classList[(nextMonth < _maxDate) ? 'add' : 'remove']('active'); + const previousMonth = new Date(year + '-' + ('0' + (month + 1).toString()).slice(-2) + '-01'); + previousMonth.setDate(previousMonth.getDate() - 1); + _dateMonthPrevious.classList[(previousMonth > _minDate) ? 'add' : 'remove']('active'); + } + } + // update active day + if (day) { + for (let i = 0; i < 35; i++) { + const cell = _dateCells[i]; + cell.classList[(!cell.classList.contains('otherMonth') && +cell.textContent === day) ? 'add' : 'remove']('active'); + } + _dateGrid.dataset.day = day.toString(); + } + formatValue(); + } + /** + * Sets the visible and shadow value + */ + function formatValue() { + const data = _data.get(_input); + let date; + if (Core.stringToBool(_input.dataset.empty || '')) { + return; + } + if (data.isDateTime) { + date = new Date(+_dateGrid.dataset.year, +_dateGrid.dataset.month, +_dateGrid.dataset.day, +_dateHour.value, +_dateMinute.value); + } + else { + date = new Date(+_dateGrid.dataset.year, +_dateGrid.dataset.month, +_dateGrid.dataset.day); + } + DatePicker.setDate(_input, date); + } + /** + * Handles changes to the month select element. + */ + function changeMonth(event) { + const target = event.currentTarget; + renderGrid(undefined, +target.value); + } + /** + * Handles changes to the year select element. + */ + function changeYear(event) { + const target = event.currentTarget; + renderGrid(undefined, undefined, +target.value); + } + /** + * Handles clicks on an individual day. + */ + function click(event) { + event.preventDefault(); + const target = event.currentTarget; + if (target.classList.contains('otherMonth')) { + return; + } + _input.dataset.empty = 'false'; + renderGrid(+target.textContent); + const data = _data.get(_input); + if (!data.isDateTime) { + close(); + } + } /** - * @exports WoltLabSuite/Core/Date/Picker + * Validates given element or id if it represents an active date picker. */ - var DatePicker = { + function getElement(element) { + if (typeof element === 'string') { + element = document.getElementById(element); + } + if (!(element instanceof HTMLInputElement) || !element.classList.contains('inputDatePicker') || !_data.has(element)) { + throw new Error("Expected a valid date picker input element or id."); + } + return element; + } + function maintainFocus(event) { + if (_datePicker === null || !_datePicker.classList.contains('active')) { + return; + } + if (!_datePicker.contains(event.target)) { + if (_wasInsidePicker) { + const sibling = _input.nextElementSibling; + sibling.focus(); + _wasInsidePicker = false; + } + else { + _datePicker.querySelector('.previous').focus(); + } + } + else { + _wasInsidePicker = true; + } + } + const DatePicker = { /** * Initializes all date and datetime input fields. */ - init: function () { - this._setup(); - var elements = elBySelAll('input[type="date"]:not(.inputDatePicker), input[type="datetime"]:not(.inputDatePicker)'); - var now = new Date(); - for (var i = 0, length = elements.length; i < length; i++) { - var element = elements[i]; + init() { + setup(); + const now = new Date(); + document.querySelectorAll('input[type="date"]:not(.inputDatePicker), input[type="datetime"]:not(.inputDatePicker)').forEach(element => { element.classList.add('inputDatePicker'); element.readOnly = true; - var isDateTime = (elAttr(element, 'type') === 'datetime'); - var isTimeOnly = (isDateTime && elDataBool(element, 'time-only')); - var disableClear = elDataBool(element, 'disable-clear'); - var ignoreTimezone = isDateTime && elDataBool(element, 'ignore-timezone'); - var isBirthday = element.classList.contains('birthday'); - elData(element, 'is-date-time', isDateTime); - elData(element, 'is-time-only', isTimeOnly); + const isDateTime = (element.type === 'datetime'); + const isTimeOnly = isDateTime && Core.stringToBool(element.dataset.timeOnly || ''); + const disableClear = Core.stringToBool(element.dataset.disableClear || ''); + const ignoreTimezone = isDateTime && Core.stringToBool(element.dataset.ignoreTimezone || ''); + const isBirthday = element.classList.contains('birthday'); + element.dataset.isDateTime = isDateTime ? 'true' : 'false'; + element.dataset.isTimeOnly = isTimeOnly ? 'true' : 'false'; // convert value - var date = null, value = elAttr(element, 'value'); + let date = null; + let value = element.value; // ignore the timezone, if the value is only a date (YYYY-MM-DD) - var isDateOnly = /^\d+-\d+-\d+$/.test(value); - if (elAttr(element, 'value')) { + const isDateOnly = /^\d+-\d+-\d+$/.test(value); + if (value) { if (isTimeOnly) { date = new Date(); - var tmp = value.split(':'); - date.setHours(tmp[0], tmp[1]); + const tmp = value.split(':'); + date.setHours(+tmp[0], +tmp[1]); } else { if (ignoreTimezone || isBirthday || isDateOnly) { - var timezoneOffset = new Date(value).getTimezoneOffset(); - var timezone = (timezoneOffset > 0) ? '-' : '+'; // -120 equals GMT+0200 + let timezoneOffset = new Date(value).getTimezoneOffset(); + let timezone = (timezoneOffset > 0) ? '-' : '+'; // -120 equals GMT+0200 timezoneOffset = Math.abs(timezoneOffset); - var hours = (Math.floor(timezoneOffset / 60)).toString(); - var minutes = (timezoneOffset % 60).toString(); + const hours = (Math.floor(timezoneOffset / 60)).toString(); + const minutes = (timezoneOffset % 60).toString(); timezone += (hours.length === 2) ? hours : '0' + hours; timezone += ':'; timezone += (minutes.length === 2) ? minutes : '0' + minutes; @@ -78,44 +577,47 @@ define(['DateUtil', 'Dom/Traverse', 'Dom/Util', 'EventHandler', 'Language', 'Obj } date = new Date(value); } - var time = date.getTime(); + const time = date.getTime(); // check for invalid dates if (isNaN(time)) { value = ''; } else { - elData(element, 'value', time); - var format = (isTimeOnly) ? 'formatTime' : ('formatDate' + (isDateTime ? 'Time' : '')); + element.dataset.value = time.toString(); + const format = (isTimeOnly) ? 'formatTime' : ('formatDate' + (isDateTime ? 'Time' : '')); value = DateUtil[format](date); } } - var isEmpty = (value.length === 0); + const isEmpty = (value.length === 0); // handle birthday input if (isBirthday) { - elData(element, 'min-date', '120'); + element.dataset.minDate = '120'; // do not use 'now' here, all though it makes sense, it causes bad UX - elData(element, 'max-date', new Date().getFullYear() + '-12-31'); + element.dataset.maxDate = new Date().getFullYear() + '-12-31'; } else { - if (element.min) - elData(element, 'min-date', element.min); - if (element.max) - elData(element, 'max-date', element.max); + if (element.min) { + element.dataset.minDate = element.min; + } + if (element.max) { + element.dataset.maxDate = element.max; + } } - this._initDateRange(element, now, true); - this._initDateRange(element, now, false); - if (elData(element, 'min-date') === elData(element, 'max-date')) { + initDateRange(element, now, true); + initDateRange(element, now, false); + if ((element.dataset.minDate || '') === (element.dataset.maxDate || '')) { throw new Error("Minimum and maximum date cannot be the same (element id '" + element.id + "')."); } // change type to prevent browser's datepicker to trigger element.type = 'text'; element.value = value; - elData(element, 'empty', isEmpty); - if (elData(element, 'placeholder')) { - elAttr(element, 'placeholder', elData(element, 'placeholder')); + element.dataset.empty = isEmpty ? 'true' : 'false'; + const placeholder = element.dataset.placeholder || ''; + if (placeholder) { + element.placeholder = placeholder; } // add a hidden element to hold the actual date - var shadowElement = elCreate('input'); + const shadowElement = document.createElement('input'); shadowElement.id = element.id + 'DatePicker'; shadowElement.name = element.name; shadowElement.type = 'hidden'; @@ -132,42 +634,44 @@ define(['DateUtil', 'Dom/Traverse', 'Dom/Util', 'EventHandler', 'Language', 'Obj } element.parentNode.insertBefore(shadowElement, element); element.removeAttribute('name'); - element.addEventListener(WCF_CLICK_EVENT, _callbackOpen); + element.addEventListener('click', open); + let clearButton = null; if (!element.disabled) { // create input addon - var container = elCreate('div'); + const container = document.createElement('div'); container.className = 'inputAddon'; - var button = elCreate('a'); - button.className = 'inputSuffix button jsTooltip'; - button.href = '#'; - elAttr(button, 'role', 'button'); - elAttr(button, 'tabindex', '0'); - elAttr(button, 'title', Language.get('wcf.date.datePicker')); - elAttr(button, 'aria-label', Language.get('wcf.date.datePicker')); - elAttr(button, 'aria-haspopup', true); - elAttr(button, 'aria-expanded', false); - button.addEventListener(WCF_CLICK_EVENT, _callbackOpen); - container.appendChild(button); - var icon = elCreate('span'); + clearButton = document.createElement('a'); + clearButton.className = 'inputSuffix button jsTooltip'; + clearButton.href = '#'; + clearButton.setAttribute('role', 'button'); + clearButton.tabIndex = 0; + clearButton.title = Language.get('wcf.date.datePicker'); + clearButton.setAttribute('aria-label', Language.get('wcf.date.datePicker')); + clearButton.setAttribute('aria-haspopup', 'true'); + clearButton.setAttribute('aria-expanded', 'false'); + clearButton.addEventListener('click', open); + container.appendChild(clearButton); + let icon = document.createElement('span'); icon.className = 'icon icon16 fa-calendar'; - button.appendChild(icon); + clearButton.appendChild(icon); element.parentNode.insertBefore(container, element); - container.insertBefore(element, button); + container.insertBefore(element, clearButton); if (!disableClear) { - button = elCreate('a'); + const button = document.createElement('a'); button.className = 'inputSuffix button'; - button.addEventListener(WCF_CLICK_EVENT, this.clear.bind(this, element)); + button.addEventListener('click', this.clear.bind(this, element)); if (isEmpty) button.style.setProperty('visibility', 'hidden', ''); container.appendChild(button); - icon = elCreate('span'); + icon = document.createElement('span'); icon.className = 'icon icon16 fa-times'; button.appendChild(icon); } } // check if the date input has one of the following classes set otherwise default to 'short' - var hasClass = false, knownClasses = ['tiny', 'short', 'medium', 'long']; - for (var j = 0; j < 4; j++) { + const knownClasses = ['tiny', 'short', 'medium', 'long']; + let hasClass = false; + for (let j = 0; j < 4; j++) { if (element.classList.contains(knownClasses[j])) { hasClass = true; } @@ -176,515 +680,68 @@ define(['DateUtil', 'Dom/Traverse', 'Dom/Util', 'EventHandler', 'Language', 'Obj element.classList.add('short'); } _data.set(element, { - clearButton: button, + clearButton, shadow: shadowElement, - disableClear: disableClear, - isDateTime: isDateTime, - isEmpty: isEmpty, - isTimeOnly: isTimeOnly, - ignoreTimezone: ignoreTimezone, - onClose: null + disableClear, + isDateTime, + isEmpty, + isTimeOnly, + ignoreTimezone, + onClose: null, }); - } - }, - /** - * Initializes the minimum/maximum date range. - * - * @param {Element} element input element - * @param {Date} now current date - * @param {boolean} isMinDate true for the minimum date - */ - _initDateRange: function (element, now, isMinDate) { - var attribute = 'data-' + (isMinDate ? 'min' : 'max') + '-date'; - var value = (element.hasAttribute(attribute)) ? elAttr(element, attribute).trim() : ''; - if (value.match(/^(\d{4})-(\d{2})-(\d{2})$/)) { - // YYYY-mm-dd - value = new Date(value).getTime(); - } - else if (value === 'now') { - value = now.getTime(); - } - else if (value.match(/^\d{1,3}$/)) { - // relative time span in years - var date = new Date(now.getTime()); - date.setFullYear(date.getFullYear() + ~~value * (isMinDate ? -1 : 1)); - value = date.getTime(); - } - else if (value.match(/^datePicker-(.+)$/)) { - // element id, e.g. `datePicker-someOtherElement` - value = RegExp.$1; - if (elById(value) === null) { - throw new Error("Reference date picker identified by '" + value + "' does not exists (element id: '" + element.id + "')."); - } - } - else if (/^\d{4}\-\d{2}\-\d{2}T/.test(value)) { - value = new Date(value).getTime(); - } - else { - value = new Date((isMinDate ? 1902 : 2038), 0, 1).getTime(); - } - elAttr(element, attribute, value); - }, - /** - * Sets up callbacks and event listeners. - */ - _setup: function () { - if (_didInit) - return; - _didInit = true; - _firstDayOfWeek = ~~Language.get('wcf.date.firstDayOfTheWeek'); - _callbackOpen = this._open.bind(this); - DomChangeListener.add('WoltLabSuite/Core/Date/Picker', this.init.bind(this)); - UiCloseOverlay.add('WoltLabSuite/Core/Date/Picker', this._close.bind(this)); - }, - /** - * Opens the date picker. - * - * @param {object} event event object - */ - _open: function (event) { - event.preventDefault(); - event.stopPropagation(); - this._createPicker(); - if (_callbackFocus === null) { - _callbackFocus = this._maintainFocus.bind(this); - document.body.addEventListener('focus', _callbackFocus, { capture: true }); - } - var input = (event.currentTarget.nodeName === 'INPUT') ? event.currentTarget : event.currentTarget.previousElementSibling; - if (input === _input) { - this._close(); - return; - } - var dialogContent = DomTraverse.parentByClass(input, 'dialogContent'); - if (dialogContent !== null) { - if (!elDataBool(dialogContent, 'has-datepicker-scroll-listener')) { - dialogContent.addEventListener('scroll', this._onDialogScroll.bind(this)); - elData(dialogContent, 'has-datepicker-scroll-listener', 1); - } - } - _input = input; - var data = _data.get(_input), date, value = elData(_input, 'value'); - if (value) { - date = new Date(+value); - if (date.toString() === 'Invalid Date') { - date = new Date(); - } - } - else { - date = new Date(); - } - // set min/max date - _minDate = elData(_input, 'min-date'); - if (_minDate.match(/^datePicker-(.+)$/)) - _minDate = elData(elById(RegExp.$1), 'value'); - _minDate = new Date(+_minDate); - if (_minDate.getTime() > date.getTime()) - date = _minDate; - _maxDate = elData(_input, 'max-date'); - if (_maxDate.match(/^datePicker-(.+)$/)) - _maxDate = elData(elById(RegExp.$1), 'value'); - _maxDate = new Date(+_maxDate); - if (data.isDateTime) { - _dateHour.value = date.getHours(); - _dateMinute.value = date.getMinutes(); - _datePicker.classList.add('datePickerTime'); - } - else { - _datePicker.classList.remove('datePickerTime'); - } - _datePicker.classList[(data.isTimeOnly) ? 'add' : 'remove']('datePickerTimeOnly'); - this._renderPicker(date.getDate(), date.getMonth(), date.getFullYear()); - UiAlignment.set(_datePicker, _input); - elAttr(_input.nextElementSibling, 'aria-expanded', true); - _wasInsidePicker = false; - }, - /** - * Closes the date picker. - */ - _close: function () { - if (_datePicker !== null && _datePicker.classList.contains('active')) { - _datePicker.classList.remove('active'); - var data = _data.get(_input); - if (typeof data.onClose === 'function') { - data.onClose(); - } - EventHandler.fire('WoltLabSuite/Core/Date/Picker', 'close', { element: _input }); - elAttr(_input.nextElementSibling, 'aria-expanded', false); - _input = null; - _minDate = 0; - _maxDate = 0; - } - }, - /** - * Updates the position of the date picker in a dialog if the dialog content - * is scrolled. - * - * @param {Event} event scroll event - */ - _onDialogScroll: function (event) { - if (_input === null) { - return; - } - var dialogContent = event.currentTarget; - var offset = DomUtil.offset(_input); - var dialogOffset = DomUtil.offset(dialogContent); - // check if date picker input field is still (partially) visible - if (offset.top + _input.clientHeight <= dialogOffset.top) { - // top check - this._close(); - } - else if (offset.top >= dialogOffset.top + dialogContent.offsetHeight) { - // bottom check - this._close(); - } - else if (offset.left <= dialogOffset.left) { - // left check - this._close(); - } - else if (offset.left >= dialogOffset.left + dialogContent.offsetWidth) { - // right check - this._close(); - } - else { - UiAlignment.set(_datePicker, _input); - } - }, - /** - * Renders the full picker on init. - * - * @param {int} day - * @param {int} month - * @param {int} year - */ - _renderPicker: function (day, month, year) { - this._renderGrid(day, month, year); - // create options for month and year - var years = ''; - for (var i = _minDate.getFullYear(), last = _maxDate.getFullYear(); i <= last; i++) { - years += ''; - } - _dateYear.innerHTML = years; - _dateYear.value = year; - _dateMonth.value = month; - _datePicker.classList.add('active'); - }, - /** - * Updates the date grid. - * - * @param {int} day - * @param {int} month - * @param {int} year - */ - _renderGrid: function (day, month, year) { - var cell, hasDay = (day !== undefined), hasMonth = (month !== undefined), i; - day = ~~day || ~~elData(_dateGrid, 'day'); - month = ~~month; - year = ~~year; - // rebuild cells - if (hasMonth || year) { - var rebuildMonths = (year !== 0); - // rebuild grid - var fragment = document.createDocumentFragment(); - fragment.appendChild(_dateGrid); - if (!hasMonth) - month = ~~elData(_dateGrid, 'month'); - year = year || ~~elData(_dateGrid, 'year'); - // check if current selection exceeds min/max date - var date = new Date(year + '-' + ('0' + (month + 1).toString()).slice(-2) + '-' + ('0' + day.toString()).slice(-2)); - if (date < _minDate) { - year = _minDate.getFullYear(); - month = _minDate.getMonth(); - day = _minDate.getDate(); - _dateMonth.value = month; - _dateYear.value = year; - rebuildMonths = true; - } - else if (date > _maxDate) { - year = _maxDate.getFullYear(); - month = _maxDate.getMonth(); - day = _maxDate.getDate(); - _dateMonth.value = month; - _dateYear.value = year; - rebuildMonths = true; - } - date = new Date(year + '-' + ('0' + (month + 1).toString()).slice(-2) + '-01'); - // shift until first displayed day equals first day of week - while (date.getDay() !== _firstDayOfWeek) { - date.setDate(date.getDate() - 1); - } - // show the last row - elShow(_dateCells[35].parentNode); - var selectable; - var comparableMinDate = new Date(_minDate.getFullYear(), _minDate.getMonth(), _minDate.getDate()); - for (i = 0; i < 42; i++) { - if (i === 35 && date.getMonth() !== month) { - // skip the last row if it only contains the next month - elHide(_dateCells[35].parentNode); - break; - } - cell = _dateCells[i]; - cell.textContent = date.getDate(); - selectable = (date.getMonth() === month); - if (selectable) { - if (date < comparableMinDate) - selectable = false; - else if (date > _maxDate) - selectable = false; - } - cell.classList[selectable ? 'remove' : 'add']('otherMonth'); - if (selectable) { - cell.href = '#'; - elAttr(cell, 'role', 'button'); - elAttr(cell, 'tabindex', '0'); - elAttr(cell, 'title', DateUtil.formatDate(date)); - elAttr(cell, 'aria-label', DateUtil.formatDate(date)); - } - date.setDate(date.getDate() + 1); - } - elData(_dateGrid, 'month', month); - elData(_dateGrid, 'year', year); - _datePicker.insertBefore(fragment, _dateTime); - if (!hasDay) { - // check if date is valid - date = new Date(year, month, day); - if (date.getDate() !== day) { - while (date.getMonth() !== month) { - date.setDate(date.getDate() - 1); - } - day = date.getDate(); - } - } - if (rebuildMonths) { - for (i = 0; i < 12; i++) { - var currentMonth = _dateMonth.children[i]; - currentMonth.disabled = (year === _minDate.getFullYear() && currentMonth.value < _minDate.getMonth()) || (year === _maxDate.getFullYear() && currentMonth.value > _maxDate.getMonth()); - } - var nextMonth = new Date(year + '-' + ('0' + (month + 1).toString()).slice(-2) + '-01'); - nextMonth.setMonth(nextMonth.getMonth() + 1); - _dateMonthNext.classList[(nextMonth < _maxDate) ? 'add' : 'remove']('active'); - var previousMonth = new Date(year + '-' + ('0' + (month + 1).toString()).slice(-2) + '-01'); - previousMonth.setDate(previousMonth.getDate() - 1); - _dateMonthPrevious.classList[(previousMonth > _minDate) ? 'add' : 'remove']('active'); - } - } - // update active day - if (day) { - for (i = 0; i < 35; i++) { - cell = _dateCells[i]; - cell.classList[(!cell.classList.contains('otherMonth') && ~~cell.textContent === day) ? 'add' : 'remove']('active'); - } - elData(_dateGrid, 'day', day); - } - this._formatValue(); - }, - /** - * Sets the visible and shadow value - */ - _formatValue: function () { - var data = _data.get(_input), date; - if (elData(_input, 'empty') === 'true') { - return; - } - if (data.isDateTime) { - date = new Date(elData(_dateGrid, 'year'), elData(_dateGrid, 'month'), elData(_dateGrid, 'day'), _dateHour.value, _dateMinute.value); - } - else { - date = new Date(elData(_dateGrid, 'year'), elData(_dateGrid, 'month'), elData(_dateGrid, 'day')); - } - this.setDate(_input, date); - }, - /** - * Creates the date picker DOM. - */ - _createPicker: function () { - if (_datePicker !== null) { - return; - } - _datePicker = elCreate('div'); - _datePicker.className = 'datePicker'; - _datePicker.addEventListener(WCF_CLICK_EVENT, function (event) { event.stopPropagation(); }); - var header = elCreate('header'); - _datePicker.appendChild(header); - _dateMonthPrevious = elCreate('a'); - _dateMonthPrevious.className = 'previous jsTooltip'; - _dateMonthPrevious.href = '#'; - elAttr(_dateMonthPrevious, 'role', 'button'); - elAttr(_dateMonthPrevious, 'tabindex', '0'); - elAttr(_dateMonthPrevious, 'title', Language.get('wcf.date.datePicker.previousMonth')); - elAttr(_dateMonthPrevious, 'aria-label', Language.get('wcf.date.datePicker.previousMonth')); - _dateMonthPrevious.innerHTML = ''; - _dateMonthPrevious.addEventListener(WCF_CLICK_EVENT, this.previousMonth.bind(this)); - header.appendChild(_dateMonthPrevious); - var monthYearContainer = elCreate('span'); - header.appendChild(monthYearContainer); - _dateMonth = elCreate('select'); - _dateMonth.className = 'month jsTooltip'; - elAttr(_dateMonth, 'title', Language.get('wcf.date.datePicker.month')); - elAttr(_dateMonth, 'aria-label', Language.get('wcf.date.datePicker.month')); - _dateMonth.addEventListener('change', this._changeMonth.bind(this)); - monthYearContainer.appendChild(_dateMonth); - var i, months = '', monthNames = Language.get('__monthsShort'); - for (i = 0; i < 12; i++) { - months += ''; - } - _dateMonth.innerHTML = months; - _dateYear = elCreate('select'); - _dateYear.className = 'year jsTooltip'; - elAttr(_dateYear, 'title', Language.get('wcf.date.datePicker.year')); - elAttr(_dateYear, 'aria-label', Language.get('wcf.date.datePicker.year')); - _dateYear.addEventListener('change', this._changeYear.bind(this)); - monthYearContainer.appendChild(_dateYear); - _dateMonthNext = elCreate('a'); - _dateMonthNext.className = 'next jsTooltip'; - _dateMonthNext.href = '#'; - elAttr(_dateMonthNext, 'role', 'button'); - elAttr(_dateMonthNext, 'tabindex', '0'); - elAttr(_dateMonthNext, 'title', Language.get('wcf.date.datePicker.nextMonth')); - elAttr(_dateMonthNext, 'aria-label', Language.get('wcf.date.datePicker.nextMonth')); - _dateMonthNext.innerHTML = ''; - _dateMonthNext.addEventListener(WCF_CLICK_EVENT, this.nextMonth.bind(this)); - header.appendChild(_dateMonthNext); - _dateGrid = elCreate('ul'); - _datePicker.appendChild(_dateGrid); - var item = elCreate('li'); - item.className = 'weekdays'; - _dateGrid.appendChild(item); - var span, weekdays = Language.get('__daysShort'); - for (i = 0; i < 7; i++) { - var day = i + _firstDayOfWeek; - if (day > 6) - day -= 7; - span = elCreate('span'); - span.textContent = weekdays[day]; - item.appendChild(span); - } - // create date grid - var callbackClick = this._click.bind(this), cell, row; - for (i = 0; i < 6; i++) { - row = elCreate('li'); - _dateGrid.appendChild(row); - for (var j = 0; j < 7; j++) { - cell = elCreate('a'); - cell.addEventListener(WCF_CLICK_EVENT, callbackClick); - _dateCells.push(cell); - row.appendChild(cell); - } - } - _dateTime = elCreate('footer'); - _datePicker.appendChild(_dateTime); - _dateHour = elCreate('select'); - _dateHour.className = 'hour'; - elAttr(_dateHour, 'title', Language.get('wcf.date.datePicker.hour')); - elAttr(_dateHour, 'aria-label', Language.get('wcf.date.datePicker.hour')); - _dateHour.addEventListener('change', this._formatValue.bind(this)); - var tmp = ''; - var date = new Date(2000, 0, 1); - var timeFormat = Language.get('wcf.date.timeFormat').replace(/:/, '').replace(/[isu]/g, ''); - for (i = 0; i < 24; i++) { - date.setHours(i); - tmp += '"; - } - _dateHour.innerHTML = tmp; - _dateTime.appendChild(_dateHour); - _dateTime.appendChild(document.createTextNode('\u00A0:\u00A0')); - _dateMinute = elCreate('select'); - _dateMinute.className = 'minute'; - elAttr(_dateMinute, 'title', Language.get('wcf.date.datePicker.minute')); - elAttr(_dateMinute, 'aria-label', Language.get('wcf.date.datePicker.minute')); - _dateMinute.addEventListener('change', this._formatValue.bind(this)); - tmp = ''; - for (i = 0; i < 60; i++) { - tmp += ''; - } - _dateMinute.innerHTML = tmp; - _dateTime.appendChild(_dateMinute); - document.body.appendChild(_datePicker); + }); }, /** * Shows the previous month. */ - previousMonth: function (event) { + previousMonth(event) { event.preventDefault(); if (_dateMonth.value === '0') { - _dateMonth.value = 11; - _dateYear.value = ~~_dateYear.value - 1; + _dateMonth.value = '11'; + _dateYear.value = (+_dateYear.value - 1).toString(); } else { - _dateMonth.value = ~~_dateMonth.value - 1; + _dateMonth.value = (+_dateMonth.value - 1).toString(); } - this._renderGrid(undefined, _dateMonth.value, _dateYear.value); + renderGrid(undefined, +_dateMonth.value, +_dateYear.value); }, /** * Shows the next month. */ - nextMonth: function (event) { + nextMonth(event) { event.preventDefault(); if (_dateMonth.value === '11') { - _dateMonth.value = 0; - _dateYear.value = ~~_dateYear.value + 1; + _dateMonth.value = '0'; + _dateYear.value = (+_dateYear.value + 1).toString(); } else { - _dateMonth.value = ~~_dateMonth.value + 1; - } - this._renderGrid(undefined, _dateMonth.value, _dateYear.value); - }, - /** - * Handles changes to the month select element. - * - * @param {object} event event object - */ - _changeMonth: function (event) { - this._renderGrid(undefined, event.currentTarget.value); - }, - /** - * Handles changes to the year select element. - * - * @param {object} event event object - */ - _changeYear: function (event) { - this._renderGrid(undefined, undefined, event.currentTarget.value); - }, - /** - * Handles clicks on an individual day. - * - * @param {object} event event object - */ - _click: function (event) { - event.preventDefault(); - if (event.currentTarget.classList.contains('otherMonth')) { - return; - } - elData(_input, 'empty', false); - this._renderGrid(event.currentTarget.textContent); - var data = _data.get(_input); - if (!data.isDateTime) { - this._close(); + _dateMonth.value = (+_dateMonth.value + 1).toString(); } + renderGrid(undefined, +_dateMonth.value, +_dateYear.value); }, /** * Returns the current Date object or null. - * - * @param {(Element|string)} element input element or id - * @return {?Date} Date object or null */ - getDate: function (element) { - element = this._getElement(element); - if (element.hasAttribute('data-value')) { - return new Date(+elData(element, 'value')); + getDate(element) { + element = getElement(element); + const value = element.dataset.value || ''; + if (value) { + return new Date(+value); } return null; }, /** * Sets the date of given element. * - * @param {(HTMLInputElement|string)} element input element or id - * @param {Date} date Date object + * @param {(HTMLInputElement|string)} element input element or id + * @param {Date} date Date object */ - setDate: function (element, date) { - element = this._getElement(element); - var data = _data.get(element); - elData(element, 'value', date.getTime()); - var format = '', value; + setDate(element, date) { + element = getElement(element); + const data = _data.get(element); + element.dataset.value = date.getTime().toString(); + let format = ''; + let value; if (data.isDateTime) { if (data.isTimeOnly) { value = DateUtil.formatTime(date); @@ -712,13 +769,10 @@ define(['DateUtil', 'Dom/Traverse', 'Dom/Util', 'EventHandler', 'Language', 'Obj }, /** * Returns the current value. - * - * @param {(Element|string)} element input element or id - * @return {string} current date value */ - getValue: function (element) { - element = this._getElement(element); - var data = _data.get(element); + getValue(element) { + element = getElement(element); + const data = _data.get(element); if (data) { return data.shadow.value; } @@ -726,83 +780,44 @@ define(['DateUtil', 'Dom/Traverse', 'Dom/Util', 'EventHandler', 'Language', 'Obj }, /** * Clears the date value of given element. - * - * @param {(HTMLInputElement|string)} element input element or id */ - clear: function (element) { - element = this._getElement(element); - var data = _data.get(element); + clear(element) { + element = getElement(element); + const data = _data.get(element); element.removeAttribute('data-value'); element.value = ''; - if (!data.disableClear) + if (!data.disableClear) { data.clearButton.style.setProperty('visibility', 'hidden', ''); + } data.isEmpty = true; data.shadow.value = ''; }, /** * Reverts the date picker into a normal input field. - * - * @param {(HTMLInputElement|string)} element input element or id */ - destroy: function (element) { - element = this._getElement(element); - var data = _data.get(element); - var container = element.parentNode; + destroy(element) { + element = getElement(element); + const data = _data.get(element); + const container = element.parentNode; container.parentNode.insertBefore(element, container); - elRemove(container); - elAttr(element, 'type', 'date' + (data.isDateTime ? 'time' : '')); + container.remove(); + element.type = 'date' + (data.isDateTime ? 'time' : ''); element.name = data.shadow.name; element.value = data.shadow.value; element.removeAttribute('data-value'); - element.removeEventListener(WCF_CLICK_EVENT, _callbackOpen); - elRemove(data.shadow); + element.removeEventListener('click', open); + data.shadow.remove(); element.classList.remove('inputDatePicker'); element.readOnly = false; - _data['delete'](element); + _data.delete(element); }, /** * Sets the callback invoked on picker close. - * - * @param {(Element|string)} element input element or id - * @param {function} callback callback function */ - setCloseCallback: function (element, callback) { - element = this._getElement(element); + setCloseCallback(element, callback) { + element = getElement(element); _data.get(element).onClose = callback; }, - /** - * Validates given element or id if it represents an active date picker. - * - * @param {(Element|string)} element input element or id - * @return {Element} input element - */ - _getElement: function (element) { - if (typeof element === 'string') - element = elById(element); - if (!(element instanceof Element) || !element.classList.contains('inputDatePicker') || !_data.has(element)) { - throw new Error("Expected a valid date picker input element or id."); - } - return element; - }, - /** - * @param {Event} event - */ - _maintainFocus: function (event) { - if (_datePicker !== null && _datePicker.classList.contains('active')) { - if (!_datePicker.contains(event.target)) { - if (_wasInsidePicker) { - _input.nextElementSibling.focus(); - _wasInsidePicker = false; - } - else { - elBySel('.previous', _datePicker).focus(); - } - } - else { - _wasInsidePicker = true; - } - } - } }; // backward-compatibility for `$.ui.datepicker` shim window.__wcf_bc_datePicker = DatePicker; diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Dom/Change/Listener.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Dom/Change/Listener.js index 15771affc8..fc76a104ba 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Dom/Change/Listener.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Dom/Change/Listener.js @@ -16,7 +16,7 @@ define(["require", "exports", "../../CallbackList"], function (require, exports, CallbackList_1 = __importDefault(CallbackList_1); const _callbackList = new CallbackList_1.default(); let _hot = false; - return { + const DomChangeListener = { /** * @see CallbackList.add */ @@ -43,4 +43,5 @@ define(["require", "exports", "../../CallbackList"], function (require, exports, } }, }; + return DomChangeListener; }); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Alignment.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Alignment.js index a6b6dbc8f7..66c613f198 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Alignment.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Alignment.js @@ -150,7 +150,7 @@ define(["require", "exports", "../Core", "../Dom/Traverse", "../Dom/Util", "../L vertical: 'bottom', // allow flipping over axis, possible values: both, horizontal, vertical and none allowFlip: 'both', - }, options); + }, options || {}); if (!Array.isArray(options.pointerClassNames) || options.pointerClassNames.length !== (options.pointer ? 1 : 2)) { options.pointerClassNames = []; } diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Date/Picker.js b/wcfsetup/install/files/ts/WoltLabSuite/Core/Date/Picker.js deleted file mode 100644 index fa2a5baa9b..0000000000 --- a/wcfsetup/install/files/ts/WoltLabSuite/Core/Date/Picker.js +++ /dev/null @@ -1,983 +0,0 @@ -/** - * Date picker with time support. - * - * @author Alexander Ebert - * @copyright 2001-2019 WoltLab GmbH - * @license GNU Lesser General Public License - * @module WoltLabSuite/Core/Date/Picker - */ -define(['DateUtil', 'Dom/Traverse', 'Dom/Util', 'EventHandler', 'Language', 'ObjectMap', 'Dom/ChangeListener', 'Ui/Alignment', 'WoltLabSuite/Core/Ui/CloseOverlay'], function(DateUtil, DomTraverse, DomUtil, EventHandler, Language, ObjectMap, DomChangeListener, UiAlignment, UiCloseOverlay) { - "use strict"; - - var _didInit = false; - var _firstDayOfWeek = 0; - var _wasInsidePicker = false; - - var _data = new ObjectMap(); - var _input = null; - var _maxDate = 0; - var _minDate = 0; - - var _dateCells = []; - var _dateGrid = null; - var _dateHour = null; - var _dateMinute = null; - var _dateMonth = null; - var _dateMonthNext = null; - var _dateMonthPrevious = null; - var _dateTime = null; - var _dateYear = null; - var _datePicker = null; - - var _callbackOpen = null; - var _callbackFocus = null; - - /** - * @exports WoltLabSuite/Core/Date/Picker - */ - var DatePicker = { - /** - * Initializes all date and datetime input fields. - */ - init: function() { - this._setup(); - - var elements = elBySelAll('input[type="date"]:not(.inputDatePicker), input[type="datetime"]:not(.inputDatePicker)'); - var now = new Date(); - for (var i = 0, length = elements.length; i < length; i++) { - var element = elements[i]; - element.classList.add('inputDatePicker'); - element.readOnly = true; - - var isDateTime = (elAttr(element, 'type') === 'datetime'); - var isTimeOnly = (isDateTime && elDataBool(element, 'time-only')); - var disableClear = elDataBool(element, 'disable-clear'); - var ignoreTimezone = isDateTime && elDataBool(element, 'ignore-timezone'); - var isBirthday = element.classList.contains('birthday'); - - elData(element, 'is-date-time', isDateTime); - elData(element, 'is-time-only', isTimeOnly); - - // convert value - var date = null, value = elAttr(element, 'value'); - - // ignore the timezone, if the value is only a date (YYYY-MM-DD) - var isDateOnly = /^\d+-\d+-\d+$/.test(value); - - if (elAttr(element, 'value')) { - if (isTimeOnly) { - date = new Date(); - var tmp = value.split(':'); - date.setHours(tmp[0], tmp[1]); - } - else { - if (ignoreTimezone || isBirthday || isDateOnly) { - var timezoneOffset = new Date(value).getTimezoneOffset(); - var timezone = (timezoneOffset > 0) ? '-' : '+'; // -120 equals GMT+0200 - timezoneOffset = Math.abs(timezoneOffset); - var hours = (Math.floor(timezoneOffset / 60)).toString(); - var minutes = (timezoneOffset % 60).toString(); - timezone += (hours.length === 2) ? hours : '0' + hours; - timezone += ':'; - timezone += (minutes.length === 2) ? minutes : '0' + minutes; - - if (isBirthday || isDateOnly) { - value += 'T00:00:00' + timezone; - } - else { - value = value.replace(/[+-][0-9]{2}:[0-9]{2}$/, timezone); - } - } - - date = new Date(value); - } - - var time = date.getTime(); - - // check for invalid dates - if (isNaN(time)) { - value = ''; - } - else { - elData(element, 'value', time); - var format = (isTimeOnly) ? 'formatTime' : ('formatDate' + (isDateTime ? 'Time' : '')); - value = DateUtil[format](date); - } - } - - var isEmpty = (value.length === 0); - - // handle birthday input - if (isBirthday) { - elData(element, 'min-date', '120'); - - // do not use 'now' here, all though it makes sense, it causes bad UX - elData(element, 'max-date', new Date().getFullYear() + '-12-31'); - } - else { - if (element.min) elData(element, 'min-date', element.min); - if (element.max) elData(element, 'max-date', element.max); - } - - this._initDateRange(element, now, true); - this._initDateRange(element, now, false); - - if (elData(element, 'min-date') === elData(element, 'max-date')) { - throw new Error("Minimum and maximum date cannot be the same (element id '" + element.id + "')."); - } - - // change type to prevent browser's datepicker to trigger - element.type = 'text'; - element.value = value; - elData(element, 'empty', isEmpty); - - if (elData(element, 'placeholder')) { - elAttr(element, 'placeholder', elData(element, 'placeholder')); - } - - // add a hidden element to hold the actual date - var shadowElement = elCreate('input'); - shadowElement.id = element.id + 'DatePicker'; - shadowElement.name = element.name; - shadowElement.type = 'hidden'; - - if (date !== null) { - if (isTimeOnly) { - shadowElement.value = DateUtil.format(date, 'H:i'); - } - else if (ignoreTimezone) { - shadowElement.value = DateUtil.format(date, 'Y-m-dTH:i:s'); - } - else { - shadowElement.value = DateUtil.format(date, (isDateTime) ? 'c' : 'Y-m-d'); - } - } - - element.parentNode.insertBefore(shadowElement, element); - element.removeAttribute('name'); - - element.addEventListener(WCF_CLICK_EVENT, _callbackOpen); - - if (!element.disabled) { - // create input addon - var container = elCreate('div'); - container.className = 'inputAddon'; - - var button = elCreate('a'); - - button.className = 'inputSuffix button jsTooltip'; - button.href = '#'; - elAttr(button, 'role', 'button'); - elAttr(button, 'tabindex', '0'); - elAttr(button, 'title', Language.get('wcf.date.datePicker')); - elAttr(button, 'aria-label', Language.get('wcf.date.datePicker')); - elAttr(button, 'aria-haspopup', true); - elAttr(button, 'aria-expanded', false); - button.addEventListener(WCF_CLICK_EVENT, _callbackOpen); - container.appendChild(button); - - var icon = elCreate('span'); - icon.className = 'icon icon16 fa-calendar'; - button.appendChild(icon); - - element.parentNode.insertBefore(container, element); - container.insertBefore(element, button); - - if (!disableClear) { - button = elCreate('a'); - button.className = 'inputSuffix button'; - button.addEventListener(WCF_CLICK_EVENT, this.clear.bind(this, element)); - if (isEmpty) button.style.setProperty('visibility', 'hidden', ''); - - container.appendChild(button); - - icon = elCreate('span'); - icon.className = 'icon icon16 fa-times'; - button.appendChild(icon); - } - } - - // check if the date input has one of the following classes set otherwise default to 'short' - var hasClass = false, knownClasses = ['tiny', 'short', 'medium', 'long']; - for (var j = 0; j < 4; j++) { - if (element.classList.contains(knownClasses[j])) { - hasClass = true; - } - } - - if (!hasClass) { - element.classList.add('short'); - } - - _data.set(element, { - clearButton: button, - shadow: shadowElement, - - disableClear: disableClear, - isDateTime: isDateTime, - isEmpty: isEmpty, - isTimeOnly: isTimeOnly, - ignoreTimezone: ignoreTimezone, - - onClose: null - }); - } - }, - - /** - * Initializes the minimum/maximum date range. - * - * @param {Element} element input element - * @param {Date} now current date - * @param {boolean} isMinDate true for the minimum date - */ - _initDateRange: function(element, now, isMinDate) { - var attribute = 'data-' + (isMinDate ? 'min' : 'max') + '-date'; - var value = (element.hasAttribute(attribute)) ? elAttr(element, attribute).trim() : ''; - - if (value.match(/^(\d{4})-(\d{2})-(\d{2})$/)) { - // YYYY-mm-dd - value = new Date(value).getTime(); - } - else if (value === 'now') { - value = now.getTime(); - } - else if (value.match(/^\d{1,3}$/)) { - // relative time span in years - var date = new Date(now.getTime()); - date.setFullYear(date.getFullYear() + ~~value * (isMinDate ? -1 : 1)); - - value = date.getTime(); - } - else if (value.match(/^datePicker-(.+)$/)) { - // element id, e.g. `datePicker-someOtherElement` - value = RegExp.$1; - - if (elById(value) === null) { - throw new Error("Reference date picker identified by '" + value + "' does not exists (element id: '" + element.id + "')."); - } - } - else if (/^\d{4}\-\d{2}\-\d{2}T/.test(value)) { - value = new Date(value).getTime(); - } - else { - value = new Date((isMinDate ? 1902 : 2038), 0, 1).getTime(); - } - - elAttr(element, attribute, value); - }, - - /** - * Sets up callbacks and event listeners. - */ - _setup: function() { - if (_didInit) return; - _didInit = true; - - _firstDayOfWeek = ~~Language.get('wcf.date.firstDayOfTheWeek'); - _callbackOpen = this._open.bind(this); - - DomChangeListener.add('WoltLabSuite/Core/Date/Picker', this.init.bind(this)); - UiCloseOverlay.add('WoltLabSuite/Core/Date/Picker', this._close.bind(this)); - }, - - /** - * Opens the date picker. - * - * @param {object} event event object - */ - _open: function(event) { - event.preventDefault(); - event.stopPropagation(); - - this._createPicker(); - - if (_callbackFocus === null) { - _callbackFocus = this._maintainFocus.bind(this); - document.body.addEventListener('focus', _callbackFocus, { capture: true }); - } - - var input = (event.currentTarget.nodeName === 'INPUT') ? event.currentTarget : event.currentTarget.previousElementSibling; - if (input === _input) { - this._close(); - return; - } - - var dialogContent = DomTraverse.parentByClass(input, 'dialogContent'); - if (dialogContent !== null) { - if (!elDataBool(dialogContent, 'has-datepicker-scroll-listener')) { - dialogContent.addEventListener('scroll', this._onDialogScroll.bind(this)); - elData(dialogContent, 'has-datepicker-scroll-listener', 1); - } - } - - _input = input; - var data = _data.get(_input), date, value = elData(_input, 'value'); - if (value) { - date = new Date(+value); - - if (date.toString() === 'Invalid Date') { - date = new Date(); - } - } - else { - date = new Date(); - } - - // set min/max date - _minDate = elData(_input, 'min-date'); - if (_minDate.match(/^datePicker-(.+)$/)) _minDate = elData(elById(RegExp.$1), 'value'); - _minDate = new Date(+_minDate); - if (_minDate.getTime() > date.getTime()) date = _minDate; - - _maxDate = elData(_input, 'max-date'); - if (_maxDate.match(/^datePicker-(.+)$/)) _maxDate = elData(elById(RegExp.$1), 'value'); - _maxDate = new Date(+_maxDate); - - if (data.isDateTime) { - _dateHour.value = date.getHours(); - _dateMinute.value = date.getMinutes(); - - _datePicker.classList.add('datePickerTime'); - } - else { - _datePicker.classList.remove('datePickerTime'); - } - - _datePicker.classList[(data.isTimeOnly) ? 'add' : 'remove']('datePickerTimeOnly'); - - this._renderPicker(date.getDate(), date.getMonth(), date.getFullYear()); - - UiAlignment.set(_datePicker, _input); - - elAttr(_input.nextElementSibling, 'aria-expanded', true); - - _wasInsidePicker = false; - }, - - /** - * Closes the date picker. - */ - _close: function() { - if (_datePicker !== null && _datePicker.classList.contains('active')) { - _datePicker.classList.remove('active'); - - var data = _data.get(_input); - if (typeof data.onClose === 'function') { - data.onClose(); - } - - EventHandler.fire('WoltLabSuite/Core/Date/Picker', 'close', {element: _input}); - - elAttr(_input.nextElementSibling, 'aria-expanded', false); - _input = null; - _minDate = 0; - _maxDate = 0; - } - }, - - /** - * Updates the position of the date picker in a dialog if the dialog content - * is scrolled. - * - * @param {Event} event scroll event - */ - _onDialogScroll: function(event) { - if (_input === null) { - return; - } - - var dialogContent = event.currentTarget; - - var offset = DomUtil.offset(_input); - var dialogOffset = DomUtil.offset(dialogContent); - - // check if date picker input field is still (partially) visible - if (offset.top + _input.clientHeight <= dialogOffset.top) { - // top check - this._close(); - } - else if (offset.top >= dialogOffset.top + dialogContent.offsetHeight) { - // bottom check - this._close(); - } - else if (offset.left <= dialogOffset.left) { - // left check - this._close(); - } - else if (offset.left >= dialogOffset.left + dialogContent.offsetWidth) { - // right check - this._close(); - } - else { - UiAlignment.set(_datePicker, _input); - } - }, - - /** - * Renders the full picker on init. - * - * @param {int} day - * @param {int} month - * @param {int} year - */ - _renderPicker: function(day, month, year) { - this._renderGrid(day, month, year); - - // create options for month and year - var years = ''; - for (var i = _minDate.getFullYear(), last = _maxDate.getFullYear(); i <= last; i++) { - years += ''; - } - _dateYear.innerHTML = years; - _dateYear.value = year; - - _dateMonth.value = month; - - _datePicker.classList.add('active'); - }, - - /** - * Updates the date grid. - * - * @param {int} day - * @param {int} month - * @param {int} year - */ - _renderGrid: function(day, month, year) { - var cell, hasDay = (day !== undefined), hasMonth = (month !== undefined), i; - - day = ~~day || ~~elData(_dateGrid, 'day'); - month = ~~month; - year = ~~year; - - // rebuild cells - if (hasMonth || year) { - var rebuildMonths = (year !== 0); - - // rebuild grid - var fragment = document.createDocumentFragment(); - fragment.appendChild(_dateGrid); - - if (!hasMonth) month = ~~elData(_dateGrid, 'month'); - year = year || ~~elData(_dateGrid, 'year'); - - // check if current selection exceeds min/max date - var date = new Date(year + '-' + ('0' + (month + 1).toString()).slice(-2) + '-' + ('0' + day.toString()).slice(-2)); - if (date < _minDate) { - year = _minDate.getFullYear(); - month = _minDate.getMonth(); - day = _minDate.getDate(); - - _dateMonth.value = month; - _dateYear.value = year; - - rebuildMonths = true; - } - else if (date > _maxDate) { - year = _maxDate.getFullYear(); - month = _maxDate.getMonth(); - day = _maxDate.getDate(); - - _dateMonth.value = month; - _dateYear.value = year; - - rebuildMonths = true; - } - - date = new Date(year + '-' + ('0' + (month + 1).toString()).slice(-2) + '-01'); - - // shift until first displayed day equals first day of week - while (date.getDay() !== _firstDayOfWeek) { - date.setDate(date.getDate() - 1); - } - - // show the last row - elShow(_dateCells[35].parentNode); - - var selectable; - var comparableMinDate = new Date(_minDate.getFullYear(), _minDate.getMonth(), _minDate.getDate()); - for (i = 0; i < 42; i++) { - if (i === 35 && date.getMonth() !== month) { - // skip the last row if it only contains the next month - elHide(_dateCells[35].parentNode); - - break; - } - - cell = _dateCells[i]; - - cell.textContent = date.getDate(); - selectable = (date.getMonth() === month); - if (selectable) { - if (date < comparableMinDate) selectable = false; - else if (date > _maxDate) selectable = false; - } - - cell.classList[selectable ? 'remove' : 'add']('otherMonth'); - if (selectable) { - cell.href = '#'; - elAttr(cell, 'role', 'button'); - elAttr(cell, 'tabindex', '0'); - elAttr(cell, 'title', DateUtil.formatDate(date)); - elAttr(cell, 'aria-label', DateUtil.formatDate(date)); - } - - date.setDate(date.getDate() + 1); - } - - elData(_dateGrid, 'month', month); - elData(_dateGrid, 'year', year); - - _datePicker.insertBefore(fragment, _dateTime); - - if (!hasDay) { - // check if date is valid - date = new Date(year, month, day); - if (date.getDate() !== day) { - while (date.getMonth() !== month) { - date.setDate(date.getDate() - 1); - } - - day = date.getDate(); - } - } - - if (rebuildMonths) { - for (i = 0; i < 12; i++) { - var currentMonth = _dateMonth.children[i]; - - currentMonth.disabled = (year === _minDate.getFullYear() && currentMonth.value < _minDate.getMonth()) || (year === _maxDate.getFullYear() && currentMonth.value > _maxDate.getMonth()); - } - - var nextMonth = new Date(year + '-' + ('0' + (month + 1).toString()).slice(-2) + '-01'); - nextMonth.setMonth(nextMonth.getMonth() + 1); - - _dateMonthNext.classList[(nextMonth < _maxDate) ? 'add' : 'remove']('active'); - - var previousMonth = new Date(year + '-' + ('0' + (month + 1).toString()).slice(-2) + '-01'); - previousMonth.setDate(previousMonth.getDate() - 1); - - _dateMonthPrevious.classList[(previousMonth > _minDate) ? 'add' : 'remove']('active'); - } - } - - // update active day - if (day) { - for (i = 0; i < 35; i++) { - cell = _dateCells[i]; - - cell.classList[(!cell.classList.contains('otherMonth') && ~~cell.textContent === day) ? 'add' : 'remove']('active'); - } - - elData(_dateGrid, 'day', day); - } - - this._formatValue(); - }, - - /** - * Sets the visible and shadow value - */ - _formatValue: function() { - var data = _data.get(_input), date; - - if (elData(_input, 'empty') === 'true') { - return; - } - - if (data.isDateTime) { - date = new Date( - elData(_dateGrid, 'year'), - elData(_dateGrid, 'month'), - elData(_dateGrid, 'day'), - _dateHour.value, - _dateMinute.value - ); - } - else { - date = new Date( - elData(_dateGrid, 'year'), - elData(_dateGrid, 'month'), - elData(_dateGrid, 'day') - ); - } - - this.setDate(_input, date); - }, - - /** - * Creates the date picker DOM. - */ - _createPicker: function() { - if (_datePicker !== null) { - return; - } - - _datePicker = elCreate('div'); - _datePicker.className = 'datePicker'; - _datePicker.addEventListener(WCF_CLICK_EVENT, function(event) { event.stopPropagation(); }); - - var header = elCreate('header'); - _datePicker.appendChild(header); - - _dateMonthPrevious = elCreate('a'); - _dateMonthPrevious.className = 'previous jsTooltip'; - _dateMonthPrevious.href = '#'; - elAttr(_dateMonthPrevious, 'role', 'button'); - elAttr(_dateMonthPrevious, 'tabindex', '0'); - elAttr(_dateMonthPrevious, 'title', Language.get('wcf.date.datePicker.previousMonth')); - elAttr(_dateMonthPrevious, 'aria-label', Language.get('wcf.date.datePicker.previousMonth')); - _dateMonthPrevious.innerHTML = ''; - _dateMonthPrevious.addEventListener(WCF_CLICK_EVENT, this.previousMonth.bind(this)); - header.appendChild(_dateMonthPrevious); - - var monthYearContainer = elCreate('span'); - header.appendChild(monthYearContainer); - - _dateMonth = elCreate('select'); - _dateMonth.className = 'month jsTooltip'; - elAttr(_dateMonth, 'title', Language.get('wcf.date.datePicker.month')); - elAttr(_dateMonth, 'aria-label', Language.get('wcf.date.datePicker.month')); - _dateMonth.addEventListener('change', this._changeMonth.bind(this)); - monthYearContainer.appendChild(_dateMonth); - - var i, months = '', monthNames = Language.get('__monthsShort'); - for (i = 0; i < 12; i++) { - months += ''; - } - _dateMonth.innerHTML = months; - - _dateYear = elCreate('select'); - _dateYear.className = 'year jsTooltip'; - elAttr(_dateYear, 'title', Language.get('wcf.date.datePicker.year')); - elAttr(_dateYear, 'aria-label', Language.get('wcf.date.datePicker.year')); - _dateYear.addEventListener('change', this._changeYear.bind(this)); - monthYearContainer.appendChild(_dateYear); - - _dateMonthNext = elCreate('a'); - _dateMonthNext.className = 'next jsTooltip'; - _dateMonthNext.href = '#'; - elAttr(_dateMonthNext, 'role', 'button'); - elAttr(_dateMonthNext, 'tabindex', '0'); - elAttr(_dateMonthNext, 'title', Language.get('wcf.date.datePicker.nextMonth')); - elAttr(_dateMonthNext, 'aria-label', Language.get('wcf.date.datePicker.nextMonth')); - _dateMonthNext.innerHTML = ''; - _dateMonthNext.addEventListener(WCF_CLICK_EVENT, this.nextMonth.bind(this)); - header.appendChild(_dateMonthNext); - - _dateGrid = elCreate('ul'); - _datePicker.appendChild(_dateGrid); - - var item = elCreate('li'); - item.className = 'weekdays'; - _dateGrid.appendChild(item); - - var span, weekdays = Language.get('__daysShort'); - for (i = 0; i < 7; i++) { - var day = i + _firstDayOfWeek; - if (day > 6) day -= 7; - - span = elCreate('span'); - span.textContent = weekdays[day]; - item.appendChild(span); - } - - // create date grid - var callbackClick = this._click.bind(this), cell, row; - for (i = 0; i < 6; i++) { - row = elCreate('li'); - _dateGrid.appendChild(row); - - for (var j = 0; j < 7; j++) { - cell = elCreate('a'); - cell.addEventListener(WCF_CLICK_EVENT, callbackClick); - _dateCells.push(cell); - - row.appendChild(cell); - } - } - - _dateTime = elCreate('footer'); - _datePicker.appendChild(_dateTime); - - _dateHour = elCreate('select'); - _dateHour.className = 'hour'; - elAttr(_dateHour, 'title', Language.get('wcf.date.datePicker.hour')); - elAttr(_dateHour, 'aria-label', Language.get('wcf.date.datePicker.hour')); - _dateHour.addEventListener('change', this._formatValue.bind(this)); - - var tmp = ''; - var date = new Date(2000, 0, 1); - var timeFormat = Language.get('wcf.date.timeFormat').replace(/:/, '').replace(/[isu]/g, ''); - for (i = 0; i < 24; i++) { - date.setHours(i); - tmp += '"; - } - _dateHour.innerHTML = tmp; - - _dateTime.appendChild(_dateHour); - - _dateTime.appendChild(document.createTextNode('\u00A0:\u00A0')); - - _dateMinute = elCreate('select'); - _dateMinute.className = 'minute'; - elAttr(_dateMinute, 'title', Language.get('wcf.date.datePicker.minute')); - elAttr(_dateMinute, 'aria-label', Language.get('wcf.date.datePicker.minute')); - _dateMinute.addEventListener('change', this._formatValue.bind(this)); - - tmp = ''; - for (i = 0; i < 60; i++) { - tmp += ''; - } - _dateMinute.innerHTML = tmp; - - _dateTime.appendChild(_dateMinute); - - document.body.appendChild(_datePicker); - }, - - /** - * Shows the previous month. - */ - previousMonth: function(event) { - event.preventDefault(); - - if (_dateMonth.value === '0') { - _dateMonth.value = 11; - _dateYear.value = ~~_dateYear.value - 1; - } - else { - _dateMonth.value = ~~_dateMonth.value - 1; - } - - this._renderGrid(undefined, _dateMonth.value, _dateYear.value); - }, - - /** - * Shows the next month. - */ - nextMonth: function(event) { - event.preventDefault(); - - if (_dateMonth.value === '11') { - _dateMonth.value = 0; - _dateYear.value = ~~_dateYear.value + 1; - } - else { - _dateMonth.value = ~~_dateMonth.value + 1; - } - - this._renderGrid(undefined, _dateMonth.value, _dateYear.value); - }, - - /** - * Handles changes to the month select element. - * - * @param {object} event event object - */ - _changeMonth: function(event) { - this._renderGrid(undefined, event.currentTarget.value); - }, - - /** - * Handles changes to the year select element. - * - * @param {object} event event object - */ - _changeYear: function(event) { - this._renderGrid(undefined, undefined, event.currentTarget.value); - }, - - /** - * Handles clicks on an individual day. - * - * @param {object} event event object - */ - _click: function(event) { - event.preventDefault(); - - if (event.currentTarget.classList.contains('otherMonth')) { - return; - } - - elData(_input, 'empty', false); - - this._renderGrid(event.currentTarget.textContent); - - var data = _data.get(_input); - if (!data.isDateTime) { - this._close(); - } - }, - - /** - * Returns the current Date object or null. - * - * @param {(Element|string)} element input element or id - * @return {?Date} Date object or null - */ - getDate: function(element) { - element = this._getElement(element); - - if (element.hasAttribute('data-value')) { - return new Date(+elData(element, 'value')); - } - - return null; - }, - - /** - * Sets the date of given element. - * - * @param {(HTMLInputElement|string)} element input element or id - * @param {Date} date Date object - */ - setDate: function(element, date) { - element = this._getElement(element); - var data = _data.get(element); - - elData(element, 'value', date.getTime()); - - var format = '', value; - if (data.isDateTime) { - if (data.isTimeOnly) { - value = DateUtil.formatTime(date); - format = 'H:i'; - } - else if (data.ignoreTimezone) { - value = DateUtil.formatDateTime(date); - format = 'Y-m-dTH:i:s'; - } - else { - value = DateUtil.formatDateTime(date); - format = 'c'; - } - } - else { - value = DateUtil.formatDate(date); - format = 'Y-m-d'; - } - - element.value = value; - data.shadow.value = DateUtil.format(date, format); - - // show clear button - if (!data.disableClear) { - data.clearButton.style.removeProperty('visibility'); - } - }, - - /** - * Returns the current value. - * - * @param {(Element|string)} element input element or id - * @return {string} current date value - */ - getValue: function (element) { - element = this._getElement(element); - var data = _data.get(element); - - if (data) { - return data.shadow.value; - } - - return ''; - }, - - /** - * Clears the date value of given element. - * - * @param {(HTMLInputElement|string)} element input element or id - */ - clear: function(element) { - element = this._getElement(element); - var data = _data.get(element); - - element.removeAttribute('data-value'); - element.value = ''; - - if (!data.disableClear) data.clearButton.style.setProperty('visibility', 'hidden', ''); - data.isEmpty = true; - data.shadow.value = ''; - }, - - /** - * Reverts the date picker into a normal input field. - * - * @param {(HTMLInputElement|string)} element input element or id - */ - destroy: function(element) { - element = this._getElement(element); - var data = _data.get(element); - - var container = element.parentNode; - container.parentNode.insertBefore(element, container); - elRemove(container); - - elAttr(element, 'type', 'date' + (data.isDateTime ? 'time' : '')); - element.name = data.shadow.name; - element.value = data.shadow.value; - - element.removeAttribute('data-value'); - element.removeEventListener(WCF_CLICK_EVENT, _callbackOpen); - elRemove(data.shadow); - - element.classList.remove('inputDatePicker'); - element.readOnly = false; - _data['delete'](element); - }, - - /** - * Sets the callback invoked on picker close. - * - * @param {(Element|string)} element input element or id - * @param {function} callback callback function - */ - setCloseCallback: function(element, callback) { - element = this._getElement(element); - _data.get(element).onClose = callback; - }, - - /** - * Validates given element or id if it represents an active date picker. - * - * @param {(Element|string)} element input element or id - * @return {Element} input element - */ - _getElement: function(element) { - if (typeof element === 'string') element = elById(element); - - if (!(element instanceof Element) || !element.classList.contains('inputDatePicker') || !_data.has(element)) { - throw new Error("Expected a valid date picker input element or id."); - } - - return element; - }, - - /** - * @param {Event} event - */ - _maintainFocus: function(event) { - if (_datePicker !== null && _datePicker.classList.contains('active')) { - if (!_datePicker.contains(event.target)) { - if (_wasInsidePicker) { - _input.nextElementSibling.focus(); - _wasInsidePicker = false; - } - else { - elBySel('.previous', _datePicker).focus(); - } - } - else { - _wasInsidePicker = true; - } - } - } - }; - - // backward-compatibility for `$.ui.datepicker` shim - window.__wcf_bc_datePicker = DatePicker; - - return DatePicker; -}); diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Date/Picker.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Date/Picker.ts new file mode 100644 index 0000000000..6a9b7003f3 --- /dev/null +++ b/wcfsetup/install/files/ts/WoltLabSuite/Core/Date/Picker.ts @@ -0,0 +1,978 @@ +/** + * Date picker with time support. + * + * @author Alexander Ebert + * @copyright 2001-2019 WoltLab GmbH + * @license GNU Lesser General Public License + * @module WoltLabSuite/Core/Date/Picker + */ + +import * as Core from '../Core'; +import * as DateUtil from './Util'; +import DomChangeListener from '../Dom/Change/Listener'; +import * as EventHandler from '../Event/Handler'; +import * as Language from '../Language'; +import * as UiAlignment from '../Ui/Alignment'; +import UiCloseOverlay from '../Ui/CloseOverlay'; +import DomUtil from '../Dom/Util'; + +let _didInit = false; +let _firstDayOfWeek = 0; +let _wasInsidePicker = false; + +const _data = new Map(); +let _input: HTMLInputElement | null = null; +let _maxDate: Date; +let _minDate: Date; + +const _dateCells: HTMLAnchorElement[] = []; +let _dateGrid: HTMLUListElement; +let _dateHour: HTMLSelectElement; +let _dateMinute: HTMLSelectElement; +let _dateMonth: HTMLSelectElement; +let _dateMonthNext: HTMLAnchorElement; +let _dateMonthPrevious: HTMLAnchorElement; +let _dateTime: HTMLElement; +let _dateYear: HTMLSelectElement; +let _datePicker: HTMLElement | null = null; + +/** + * Creates the date picker DOM. + */ +function createPicker() { + if (_datePicker !== null) { + return; + } + + _datePicker = document.createElement('div'); + _datePicker.className = 'datePicker'; + _datePicker.addEventListener('click', event => { + event.stopPropagation(); + }); + + const header = document.createElement('header'); + _datePicker.appendChild(header); + + _dateMonthPrevious = document.createElement('a'); + _dateMonthPrevious.className = 'previous jsTooltip'; + _dateMonthPrevious.href = '#'; + _dateMonthPrevious.setAttribute('role', 'button'); + _dateMonthPrevious.tabIndex = 0; + _dateMonthPrevious.title = Language.get('wcf.date.datePicker.previousMonth'); + _dateMonthPrevious.setAttribute('aria-label', Language.get('wcf.date.datePicker.previousMonth')); + _dateMonthPrevious.innerHTML = ''; + _dateMonthPrevious.addEventListener('click', DatePicker.previousMonth); + header.appendChild(_dateMonthPrevious); + + const monthYearContainer = document.createElement('span'); + header.appendChild(monthYearContainer); + + _dateMonth = document.createElement('select'); + _dateMonth.className = 'month jsTooltip'; + _dateMonth.title = Language.get('wcf.date.datePicker.month'); + _dateMonth.setAttribute('aria-label', Language.get('wcf.date.datePicker.month')); + _dateMonth.addEventListener('change', changeMonth); + monthYearContainer.appendChild(_dateMonth); + + let months = ''; + const monthNames = Language.get('__monthsShort'); + for (let i = 0; i < 12; i++) { + months += ''; + } + _dateMonth.innerHTML = months; + + _dateYear = document.createElement('select'); + _dateYear.className = 'year jsTooltip'; + _dateYear.title = Language.get('wcf.date.datePicker.year'); + _dateYear.setAttribute('aria-label', Language.get('wcf.date.datePicker.year')); + _dateYear.addEventListener('change', changeYear); + monthYearContainer.appendChild(_dateYear); + + _dateMonthNext = document.createElement('a'); + _dateMonthNext.className = 'next jsTooltip'; + _dateMonthNext.href = '#'; + _dateMonthNext.setAttribute('role', 'button'); + _dateMonthNext.tabIndex = 0; + _dateMonthNext.title = Language.get('wcf.date.datePicker.nextMonth'); + _dateMonthNext.setAttribute('aria-label', Language.get('wcf.date.datePicker.nextMonth')); + _dateMonthNext.innerHTML = ''; + _dateMonthNext.addEventListener('click', DatePicker.nextMonth); + header.appendChild(_dateMonthNext); + + _dateGrid = document.createElement('ul'); + _datePicker.appendChild(_dateGrid); + + const item = document.createElement('li'); + item.className = 'weekdays'; + _dateGrid.appendChild(item); + + const weekdays = Language.get('__daysShort'); + for (let i = 0; i < 7; i++) { + let day = i + _firstDayOfWeek; + if (day > 6) day -= 7; + + const span = document.createElement('span'); + span.textContent = weekdays[day]; + item.appendChild(span); + } + + // create date grid + for (let i = 0; i < 6; i++) { + const row = document.createElement('li'); + _dateGrid.appendChild(row); + + for (let j = 0; j < 7; j++) { + const cell = document.createElement('a'); + cell.addEventListener('click', click); + _dateCells.push(cell); + + row.appendChild(cell); + } + } + + _dateTime = document.createElement('footer'); + _datePicker.appendChild(_dateTime); + + _dateHour = document.createElement('select'); + _dateHour.className = 'hour'; + _dateHour.title = Language.get('wcf.date.datePicker.hour'); + _dateHour.setAttribute('aria-label', Language.get('wcf.date.datePicker.hour')); + _dateHour.addEventListener('change', formatValue); + + const date = new Date(2000, 0, 1); + const timeFormat = Language.get('wcf.date.timeFormat').replace(/:/, '').replace(/[isu]/g, ''); + let tmp = ''; + for (let i = 0; i < 24; i++) { + date.setHours(i); + tmp += '"; + } + _dateHour.innerHTML = tmp; + + _dateTime.appendChild(_dateHour); + + _dateTime.appendChild(document.createTextNode('\u00A0:\u00A0')); + + _dateMinute = document.createElement('select'); + _dateMinute.className = 'minute'; + _dateMinute.title = Language.get('wcf.date.datePicker.minute'); + _dateMinute.setAttribute('aria-label', Language.get('wcf.date.datePicker.minute')); + _dateMinute.addEventListener('change', formatValue); + + tmp = ''; + for (let i = 0; i < 60; i++) { + tmp += ''; + } + _dateMinute.innerHTML = tmp; + + _dateTime.appendChild(_dateMinute); + + document.body.appendChild(_datePicker); + + document.body.addEventListener('focus', maintainFocus, {capture: true}); +} + +/** + * Initializes the minimum/maximum date range. + */ +function initDateRange(element: HTMLInputElement, now: Date, isMinDate: boolean): void { + const name = isMinDate ? 'minDate' : 'maxDate'; + let value = (element.dataset[name] || '').trim(); + + if (value.match(/^(\d{4})-(\d{2})-(\d{2})$/)) { + // YYYY-mm-dd + value = new Date(value).getTime().toString(); + } else if (value === 'now') { + value = now.getTime().toString(); + } else if (value.match(/^\d{1,3}$/)) { + // relative time span in years + const date = new Date(now.getTime()); + date.setFullYear(date.getFullYear() + ~~value * (isMinDate ? -1 : 1)); + + value = date.getTime().toString(); + } else if (value.match(/^datePicker-(.+)$/)) { + // element id, e.g. `datePicker-someOtherElement` + value = RegExp.$1; + + if (document.getElementById(value) === null) { + throw new Error("Reference date picker identified by '" + value + "' does not exists (element id: '" + element.id + "')."); + } + } else if (/^\d{4}-\d{2}-\d{2}T/.test(value)) { + value = new Date(value).getTime().toString(); + } else { + value = new Date((isMinDate ? 1902 : 2038), 0, 1).getTime().toString(); + } + + element.dataset[name] = value; +} + +/** + * Sets up callbacks and event listeners. + */ +function setup() { + if (_didInit) return; + _didInit = true; + + _firstDayOfWeek = parseInt(Language.get('wcf.date.firstDayOfTheWeek'), 10); + + DomChangeListener.add('WoltLabSuite/Core/Date/Picker', DatePicker.init); + UiCloseOverlay.add('WoltLabSuite/Core/Date/Picker', close); +} + +function getDateValue(attributeName: string): Date { + let date = _input!.dataset[attributeName] || ''; + if (date.match(/^datePicker-(.+)$/)) { + const referenceElement = document.getElementById(RegExp.$1); + if (referenceElement === null) { + throw new Error(`Unable to find an element with the id '${RegExp.$1}'.`); + } + date = referenceElement.dataset.value || ''; + } + + return new Date(parseInt(date, 10)); +} + +/** + * Opens the date picker. + */ +function open(event: MouseEvent): void { + event.preventDefault(); + event.stopPropagation(); + + createPicker(); + + const target = event.currentTarget as HTMLInputElement; + const input = (target.nodeName === 'INPUT') ? target : target.previousElementSibling as HTMLInputElement; + if (input === _input) { + close(); + return; + } + + const dialogContent = input.closest('.dialogContent') as HTMLElement; + if (dialogContent !== null) { + if (!Core.stringToBool(dialogContent.dataset.hasDatepickerScrollListener || '')) { + dialogContent.addEventListener('scroll', onDialogScroll); + dialogContent.dataset.hasDatepickerScrollListener = '1'; + } + } + + _input = input; + const data = _data.get(_input) as DatePickerData; + const value = _input.dataset.value!; + let date: Date; + if (value) { + date = new Date(parseInt(value, 10)); + + if (date.toString() === 'Invalid Date') { + date = new Date(); + } + } else { + date = new Date(); + } + + // set min/max date + _minDate = getDateValue('minDate'); + if (_minDate.getTime() > date.getTime()) { + date = _minDate; + } + + _maxDate = getDateValue('maxDate'); + + if (data.isDateTime) { + _dateHour.value = date.getHours().toString(); + _dateMinute.value = date.getMinutes().toString(); + + _datePicker!.classList.add('datePickerTime'); + } else { + _datePicker!.classList.remove('datePickerTime'); + } + + _datePicker!.classList[(data.isTimeOnly) ? 'add' : 'remove']('datePickerTimeOnly'); + + renderPicker(date.getDate(), date.getMonth(), date.getFullYear()); + + UiAlignment.set(_datePicker!, _input); + + _input.nextElementSibling!.setAttribute('aria-expanded', 'true'); + + _wasInsidePicker = false; +} + +/** + * Closes the date picker. + */ +function close() { + if (_datePicker === null || !_datePicker.classList.contains('active')) { + return; + } + + _datePicker.classList.remove('active'); + + const data = _data.get(_input!) as DatePickerData; + if (typeof data.onClose === 'function') { + data.onClose(); + } + + EventHandler.fire('WoltLabSuite/Core/Date/Picker', 'close', {element: _input}); + + const sibling = _input!.nextElementSibling as HTMLElement; + sibling.setAttribute('aria-expanded', 'false'); + _input = null; +} + +/** + * Updates the position of the date picker in a dialog if the dialog content + * is scrolled. + */ +function onDialogScroll(event: WheelEvent): void { + if (_input === null) { + return; + } + + const dialogContent = event.currentTarget as HTMLElement; + + const offset = DomUtil.offset(_input); + const dialogOffset = DomUtil.offset(dialogContent); + + // check if date picker input field is still (partially) visible + if (offset.top + _input.clientHeight <= dialogOffset.top) { + // top check + close(); + } else if (offset.top >= dialogOffset.top + dialogContent.offsetHeight) { + // bottom check + close(); + } else if (offset.left <= dialogOffset.left) { + // left check + close(); + } else if (offset.left >= dialogOffset.left + dialogContent.offsetWidth) { + // right check + close(); + } else { + UiAlignment.set(_datePicker!, _input); + } +} + +/** + * Renders the full picker on init. + */ +function renderPicker(day: number, month: number, year: number): void { + renderGrid(day, month, year); + + // create options for month and year + let years = ''; + for (let i = _minDate.getFullYear(), last = _maxDate.getFullYear(); i <= last; i++) { + years += ''; + } + _dateYear.innerHTML = years; + _dateYear.value = year.toString(); + + _dateMonth.value = month.toString(); + + _datePicker!.classList.add('active'); +} + +/** + * Updates the date grid. + */ +function renderGrid(day?: number, month?: number, year?: number): void { + const hasDay = (day !== undefined); + const hasMonth = (month !== undefined); + + if (typeof day !== 'number') { + day = parseInt(day || _dateGrid.dataset.day || '0', 10); + } + if (typeof month !== 'number') { + month = parseInt(month || '0', 10); + } + if (typeof year !== 'number') { + year = parseInt(year || '0', 10); + } + + // rebuild cells + if (hasMonth || year) { + let rebuildMonths = (year !== 0); + + // rebuild grid + const fragment = document.createDocumentFragment(); + fragment.appendChild(_dateGrid); + + if (!hasMonth) { + month = parseInt(_dateGrid.dataset.month!, 10); + } + if (!year) { + year = parseInt(_dateGrid.dataset.year!, 10); + } + + // check if current selection exceeds min/max date + let date = new Date(year + '-' + ('0' + (month + 1).toString()).slice(-2) + '-' + ('0' + day.toString()).slice(-2)); + if (date < _minDate) { + year = _minDate.getFullYear(); + month = _minDate.getMonth(); + day = _minDate.getDate(); + + _dateMonth.value = month.toString(); + _dateYear.value = year.toString(); + + rebuildMonths = true; + } else if (date > _maxDate) { + year = _maxDate.getFullYear(); + month = _maxDate.getMonth(); + day = _maxDate.getDate(); + + _dateMonth.value = month.toString(); + _dateYear.value = year.toString(); + + rebuildMonths = true; + } + + date = new Date(year + '-' + ('0' + (month + 1).toString()).slice(-2) + '-01'); + + // shift until first displayed day equals first day of week + while (date.getDay() !== _firstDayOfWeek) { + date.setDate(date.getDate() - 1); + } + + // show the last row + DomUtil.show(_dateCells[35].parentNode as HTMLElement); + + let selectable: boolean; + const comparableMinDate = new Date(_minDate.getFullYear(), _minDate.getMonth(), _minDate.getDate()); + for (let i = 0; i < 42; i++) { + if (i === 35 && date.getMonth() !== month) { + // skip the last row if it only contains the next month + DomUtil.hide(_dateCells[35].parentNode as HTMLElement); + + break; + } + + const cell = _dateCells[i]; + + cell.textContent = date.getDate().toString(); + selectable = (date.getMonth() === month); + if (selectable) { + if (date < comparableMinDate) selectable = false; + else if (date > _maxDate) selectable = false; + } + + cell.classList[selectable ? 'remove' : 'add']('otherMonth'); + if (selectable) { + cell.href = '#'; + cell.setAttribute('role', 'button'); + cell.tabIndex = 0; + cell.title = DateUtil.formatDate(date); + cell.setAttribute('aria-label', DateUtil.formatDate(date)); + } + + date.setDate(date.getDate() + 1); + } + + _dateGrid.dataset.month = month.toString(); + _dateGrid.dataset.year = year.toString(); + + _datePicker!.insertBefore(fragment, _dateTime); + + if (!hasDay) { + // check if date is valid + date = new Date(year, month, day); + if (date.getDate() !== day) { + while (date.getMonth() !== month) { + date.setDate(date.getDate() - 1); + } + + day = date.getDate(); + } + } + + if (rebuildMonths) { + for (let i = 0; i < 12; i++) { + const currentMonth = _dateMonth.children[i] as HTMLOptionElement; + + currentMonth.disabled = (year === _minDate.getFullYear() && +currentMonth.value < _minDate.getMonth()) || (year === _maxDate.getFullYear() && +currentMonth.value > _maxDate.getMonth()); + } + + const nextMonth = new Date(year + '-' + ('0' + (month + 1).toString()).slice(-2) + '-01'); + nextMonth.setMonth(nextMonth.getMonth() + 1); + + _dateMonthNext.classList[(nextMonth < _maxDate) ? 'add' : 'remove']('active'); + + const previousMonth = new Date(year + '-' + ('0' + (month + 1).toString()).slice(-2) + '-01'); + previousMonth.setDate(previousMonth.getDate() - 1); + + _dateMonthPrevious.classList[(previousMonth > _minDate) ? 'add' : 'remove']('active'); + } + } + + // update active day + if (day) { + for (let i = 0; i < 35; i++) { + const cell = _dateCells[i]; + + cell.classList[(!cell.classList.contains('otherMonth') && +cell.textContent! === day) ? 'add' : 'remove']('active'); + } + + _dateGrid.dataset.day = day.toString(); + } + + formatValue(); +} + +/** + * Sets the visible and shadow value + */ +function formatValue(): void { + const data = _data.get(_input!) as DatePickerData; + let date: Date; + + if (Core.stringToBool(_input!.dataset.empty || '')) { + return; + } + + if (data.isDateTime) { + date = new Date( + +_dateGrid.dataset.year!, + +_dateGrid.dataset.month!, + +_dateGrid.dataset.day!, + +_dateHour.value, + +_dateMinute.value, + ); + } else { + date = new Date( + +_dateGrid.dataset.year!, + +_dateGrid.dataset.month!, + +_dateGrid.dataset.day!, + ); + } + + DatePicker.setDate(_input!, date); +} + +/** + * Handles changes to the month select element. + */ +function changeMonth(event: Event): void { + const target = event.currentTarget as HTMLSelectElement; + renderGrid(undefined, +target.value); +} + +/** + * Handles changes to the year select element. + */ +function changeYear(event: Event): void { + const target = event.currentTarget as HTMLSelectElement; + renderGrid(undefined, undefined, +target.value); +} + +/** + * Handles clicks on an individual day. + */ +function click(event: MouseEvent): void { + event.preventDefault(); + + const target = event.currentTarget as HTMLAnchorElement; + if (target.classList.contains('otherMonth')) { + return; + } + + _input!.dataset.empty = 'false'; + + renderGrid(+target.textContent!); + + const data = _data.get(_input!) as DatePickerData; + if (!data.isDateTime) { + close(); + } +} + +/** + * Validates given element or id if it represents an active date picker. + */ +function getElement(element: InputElementOrString): HTMLInputElement { + if (typeof element === 'string') { + element = document.getElementById(element) as HTMLInputElement; + } + + if (!(element instanceof HTMLInputElement) || !element.classList.contains('inputDatePicker') || !_data.has(element)) { + throw new Error("Expected a valid date picker input element or id."); + } + + return element; +} + +function maintainFocus(event: FocusEvent): void { + if (_datePicker === null || !_datePicker.classList.contains('active')) { + return; + } + + if (!_datePicker.contains(event.target as HTMLElement)) { + if (_wasInsidePicker) { + const sibling = _input!.nextElementSibling as HTMLElement; + sibling.focus(); + _wasInsidePicker = false; + } else { + _datePicker!.querySelector('.previous')!.focus(); + } + } else { + _wasInsidePicker = true; + } +} + +const DatePicker = { + /** + * Initializes all date and datetime input fields. + */ + init(): void { + setup(); + + const now = new Date(); + document.querySelectorAll('input[type="date"]:not(.inputDatePicker), input[type="datetime"]:not(.inputDatePicker)').forEach(element => { + element.classList.add('inputDatePicker'); + element.readOnly = true; + + const isDateTime = (element.type === 'datetime'); + const isTimeOnly = isDateTime && Core.stringToBool(element.dataset.timeOnly || ''); + const disableClear = Core.stringToBool(element.dataset.disableClear || ''); + const ignoreTimezone = isDateTime && Core.stringToBool(element.dataset.ignoreTimezone || ''); + const isBirthday = element.classList.contains('birthday'); + + element.dataset.isDateTime = isDateTime ? 'true' : 'false'; + element.dataset.isTimeOnly = isTimeOnly ? 'true' : 'false'; + + // convert value + let date: Date | null = null; + let value = element.value; + + // ignore the timezone, if the value is only a date (YYYY-MM-DD) + const isDateOnly = /^\d+-\d+-\d+$/.test(value); + + if (value) { + if (isTimeOnly) { + date = new Date(); + const tmp = value.split(':'); + date.setHours(+tmp[0], +tmp[1]); + } else { + if (ignoreTimezone || isBirthday || isDateOnly) { + let timezoneOffset = new Date(value).getTimezoneOffset(); + let timezone = (timezoneOffset > 0) ? '-' : '+'; // -120 equals GMT+0200 + timezoneOffset = Math.abs(timezoneOffset); + + const hours = (Math.floor(timezoneOffset / 60)).toString(); + const minutes = (timezoneOffset % 60).toString(); + timezone += (hours.length === 2) ? hours : '0' + hours; + timezone += ':'; + timezone += (minutes.length === 2) ? minutes : '0' + minutes; + + if (isBirthday || isDateOnly) { + value += 'T00:00:00' + timezone; + } else { + value = value.replace(/[+-][0-9]{2}:[0-9]{2}$/, timezone); + } + } + + date = new Date(value); + } + + const time = date.getTime(); + + // check for invalid dates + if (isNaN(time)) { + value = ''; + } else { + element.dataset.value = time.toString(); + const format = (isTimeOnly) ? 'formatTime' : ('formatDate' + (isDateTime ? 'Time' : '')); + value = DateUtil[format](date); + } + } + + const isEmpty = (value.length === 0); + + // handle birthday input + if (isBirthday) { + element.dataset.minDate = '120'; + + // do not use 'now' here, all though it makes sense, it causes bad UX + element.dataset.maxDate = new Date().getFullYear() + '-12-31'; + } else { + if (element.min) { + element.dataset.minDate = element.min; + } + if (element.max) { + element.dataset.maxDate = element.max; + } + } + + initDateRange(element, now, true); + initDateRange(element, now, false); + + if ((element.dataset.minDate || '') === (element.dataset.maxDate || '')) { + throw new Error("Minimum and maximum date cannot be the same (element id '" + element.id + "')."); + } + + // change type to prevent browser's datepicker to trigger + element.type = 'text'; + element.value = value; + element.dataset.empty = isEmpty ? 'true' : 'false'; + + const placeholder = element.dataset.placeholder || ''; + if (placeholder) { + element.placeholder = placeholder; + } + + // add a hidden element to hold the actual date + const shadowElement = document.createElement('input'); + shadowElement.id = element.id + 'DatePicker'; + shadowElement.name = element.name; + shadowElement.type = 'hidden'; + + if (date !== null) { + if (isTimeOnly) { + shadowElement.value = DateUtil.format(date, 'H:i'); + } else if (ignoreTimezone) { + shadowElement.value = DateUtil.format(date, 'Y-m-dTH:i:s'); + } else { + shadowElement.value = DateUtil.format(date, (isDateTime) ? 'c' : 'Y-m-d'); + } + } + + element.parentNode!.insertBefore(shadowElement, element); + element.removeAttribute('name'); + + element.addEventListener('click', open); + + let clearButton: HTMLAnchorElement | null = null; + if (!element.disabled) { + // create input addon + const container = document.createElement('div'); + container.className = 'inputAddon'; + + clearButton = document.createElement('a'); + + clearButton.className = 'inputSuffix button jsTooltip'; + clearButton.href = '#'; + clearButton.setAttribute('role', 'button'); + clearButton.tabIndex = 0; + clearButton.title = Language.get('wcf.date.datePicker'); + clearButton.setAttribute('aria-label', Language.get('wcf.date.datePicker')); + clearButton.setAttribute('aria-haspopup', 'true'); + clearButton.setAttribute('aria-expanded', 'false'); + clearButton.addEventListener('click', open); + container.appendChild(clearButton); + + let icon = document.createElement('span'); + icon.className = 'icon icon16 fa-calendar'; + clearButton.appendChild(icon); + + element.parentNode!.insertBefore(container, element); + container.insertBefore(element, clearButton); + + if (!disableClear) { + const button = document.createElement('a'); + button.className = 'inputSuffix button'; + button.addEventListener('click', this.clear.bind(this, element)); + if (isEmpty) button.style.setProperty('visibility', 'hidden', ''); + + container.appendChild(button); + + icon = document.createElement('span'); + icon.className = 'icon icon16 fa-times'; + button.appendChild(icon); + } + } + + // check if the date input has one of the following classes set otherwise default to 'short' + const knownClasses = ['tiny', 'short', 'medium', 'long']; + let hasClass = false; + for (let j = 0; j < 4; j++) { + if (element.classList.contains(knownClasses[j])) { + hasClass = true; + } + } + + if (!hasClass) { + element.classList.add('short'); + } + + _data.set(element, { + clearButton, + shadow: shadowElement, + + disableClear, + isDateTime, + isEmpty, + isTimeOnly, + ignoreTimezone, + + onClose: null, + }); + }); + }, + + /** + * Shows the previous month. + */ + previousMonth(event: MouseEvent): void { + event.preventDefault(); + + if (_dateMonth.value === '0') { + _dateMonth.value = '11'; + _dateYear.value = (+_dateYear.value - 1).toString(); + } else { + _dateMonth.value = (+_dateMonth.value - 1).toString(); + } + + renderGrid(undefined, +_dateMonth.value, +_dateYear.value); + }, + + /** + * Shows the next month. + */ + nextMonth(event: MouseEvent): void { + event.preventDefault(); + + if (_dateMonth.value === '11') { + _dateMonth.value = '0'; + _dateYear.value = (+_dateYear.value + 1).toString(); + } else { + _dateMonth.value = (+_dateMonth.value + 1).toString(); + } + + renderGrid(undefined, +_dateMonth.value, +_dateYear.value); + }, + + /** + * Returns the current Date object or null. + */ + getDate(element: InputElementOrString): Date | null { + element = getElement(element); + + const value = element.dataset.value || ''; + if (value) { + return new Date(+value); + } + + return null; + }, + + /** + * Sets the date of given element. + * + * @param {(HTMLInputElement|string)} element input element or id + * @param {Date} date Date object + */ + setDate(element: InputElementOrString, date: Date): void { + element = getElement(element); + const data = _data.get(element) as DatePickerData; + + element.dataset.value = date.getTime().toString(); + + let format = ''; + let value: string; + if (data.isDateTime) { + if (data.isTimeOnly) { + value = DateUtil.formatTime(date); + format = 'H:i'; + } else if (data.ignoreTimezone) { + value = DateUtil.formatDateTime(date); + format = 'Y-m-dTH:i:s'; + } else { + value = DateUtil.formatDateTime(date); + format = 'c'; + } + } else { + value = DateUtil.formatDate(date); + format = 'Y-m-d'; + } + + element.value = value; + data.shadow.value = DateUtil.format(date, format); + + // show clear button + if (!data.disableClear) { + data.clearButton!.style.removeProperty('visibility'); + } + }, + + /** + * Returns the current value. + */ + getValue(element: InputElementOrString): string { + element = getElement(element); + const data = _data.get(element); + + if (data) { + return data.shadow.value; + } + + return ''; + }, + + /** + * Clears the date value of given element. + */ + clear(element: InputElementOrString): void { + element = getElement(element); + const data = _data.get(element) as DatePickerData; + + element.removeAttribute('data-value'); + element.value = ''; + + if (!data.disableClear) { + data.clearButton!.style.setProperty('visibility', 'hidden', ''); + } + + data.isEmpty = true; + data.shadow.value = ''; + }, + + /** + * Reverts the date picker into a normal input field. + */ + destroy(element: InputElementOrString): void { + element = getElement(element); + const data = _data.get(element) as DatePickerData; + + const container = element.parentNode as HTMLElement; + container.parentNode!.insertBefore(element, container); + container.remove(); + + element.type = 'date' + (data.isDateTime ? 'time' : ''); + element.name = data.shadow.name; + element.value = data.shadow.value; + + element.removeAttribute('data-value'); + element.removeEventListener('click', open); + data.shadow.remove(); + + element.classList.remove('inputDatePicker'); + element.readOnly = false; + _data.delete(element); + }, + + /** + * Sets the callback invoked on picker close. + */ + setCloseCallback(element: InputElementOrString, callback: Callback): void { + element = getElement(element); + _data.get(element)!.onClose = callback; + }, +}; + +// backward-compatibility for `$.ui.datepicker` shim +window.__wcf_bc_datePicker = DatePicker; + +export = DatePicker; + +type InputElementOrString = HTMLInputElement | string; + +type Callback = () => void; + +interface DatePickerData { + clearButton: HTMLAnchorElement | null; + shadow: HTMLInputElement; + + disableClear: boolean; + isDateTime: boolean; + isEmpty: boolean; + isTimeOnly: boolean; + ignoreTimezone: boolean; + + onClose: Callback | null; +} diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Dom/Change/Listener.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Dom/Change/Listener.ts index 230fbb4216..95dbaf2e08 100644 --- a/wcfsetup/install/files/ts/WoltLabSuite/Core/Dom/Change/Listener.ts +++ b/wcfsetup/install/files/ts/WoltLabSuite/Core/Dom/Change/Listener.ts @@ -14,7 +14,7 @@ import CallbackList from '../../CallbackList'; const _callbackList = new CallbackList(); let _hot = false; -export = { +const DomChangeListener = { /** * @see CallbackList.add */ @@ -41,4 +41,6 @@ export = { _hot = false; } }, -} +}; + +export = DomChangeListener diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Alignment.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Alignment.ts index 00d37cf38e..393d1cb5d4 100644 --- a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Alignment.ts +++ b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Alignment.ts @@ -139,7 +139,7 @@ function tryAlignmentVertical(alignment: VerticalAlignment, elDimensions, refDim * @param {Element} referenceElement reference element * @param {Object} options list of options to alter the behavior */ -export function set(element: HTMLElement, referenceElement: HTMLElement, options: AlignmentOptions): void { +export function set(element: HTMLElement, referenceElement: HTMLElement, options?: AlignmentOptions): void { options = Core.extend({ // offset to reference element verticalOffset: 0, @@ -154,7 +154,7 @@ export function set(element: HTMLElement, referenceElement: HTMLElement, options vertical: 'bottom', // allow flipping over axis, possible values: both, horizontal, vertical and none allowFlip: 'both', - }, options) as AlignmentOptions; + }, options || {}) as AlignmentOptions; if (!Array.isArray(options.pointerClassNames) || options.pointerClassNames.length !== (options.pointer ? 1 : 2)) { options.pointerClassNames = [];