UNPKG

@atlassian/aui

Version:

Atlassian User Interface library

544 lines (449 loc) 18.9 kB
import $ from './jquery'; import datepickerUI from 'jquery-ui/ui/widgets/datepicker'; import * as logger from './internal/log'; import { supportsDateField } from './internal/browser'; import globalize from './internal/globalize'; import keyCode from './key-code'; import { I18n } from './i18n'; import InlineDialogEl from './inline-dialog2'; import generateUniqueId from './unique-id'; const makePopup = ({ horizontalAlignment, datePickerUUID }) => { const popupInlineDialogElement = new InlineDialogEl(); popupInlineDialogElement.id = datePickerUUID; const popup = $(popupInlineDialogElement); popup.attr('persistent', ''); popup.attr('data-aui-focus', 'false'); popup.attr('alignment', `bottom ${horizontalAlignment}`); popup.addClass('aui-datepicker-dialog'); return popup; }; const makeConfig = ({ minDate, maxDate, dateFormat, $field, onSelect, hide, onChangeMonthYear, }) => ({ dateFormat, defaultDate: $field.val(), maxDate: maxDate || $field.attr('max'), minDate: minDate || $field.attr('min'), nextText: '>', onSelect: function (dateText) { $field.val(dateText); $field.trigger('change'); hide(); // TODO make sure the docs are explicit about the fact that onSelect cannot be an arrow function onSelect && onSelect.call(this, dateText); }, onChangeMonthYear, prevText: '<', }); const initCalendar = ({ config, popupContents, getCalendarNode, hint }) => { const calendar = $(getCalendarNode()); calendar.datepicker(config); let $hint; if (hint) { $hint = $('<div/>').addClass('aui-datepicker-hint'); $hint.append('<span/>').text(hint); popupContents.append($hint); } return calendar; }; const makeDefaultPopupController = ($field, datePickerUUID) => { let popup; let popupContents; let parentPopup; let isTrackingDatePickerFocus = false; // used to prevent multiple bindings of handleDatePickerFocus within handleFieldBlur const $body = $('body'); const handleDatePickerFocus = (event) => { let $eventTarget = $(event.target); let isTargetInput = $eventTarget.closest(popupContents).length || $eventTarget.is($field); let isTargetPopup = $eventTarget.closest('.ui-datepicker-header').length; // Hide if we're clicking anywhere else but the input or popup OR if esc is pressed. if ((!isTargetInput && !isTargetPopup) || event.keyCode === keyCode.ESCAPE) { hideDatePicker(); isTrackingDatePickerFocus = false; return; } if ($eventTarget.get(0) !== $field.get(0)) { event.preventDefault(); } }; const handleFieldBlur = () => { // Trigger blur if event is keydown and esc OR is focusout. if (!isTrackingDatePickerFocus) { $body.on('focus blur click mousedown', '*', handleDatePickerFocus); isTrackingDatePickerFocus = true; } }; const createPolyfill = function () { // bind additional field processing events $body.on('keydown', handleDatePickerFocus); $field.on('focusout keydown', handleFieldBlur); }; const getPopupContents = ({ $field }) => { const calculateHorizontalAlignment = ($field) => { let inLeftHalf = $field.offset().left < window.innerWidth / 2; return inLeftHalf ? 'left' : 'right'; }; popup = makePopup({ horizontalAlignment: calculateHorizontalAlignment($field), datePickerUUID, }); parentPopup = $field.closest('aui-inline-dialog').get(0); if (parentPopup) { parentPopup._datePickerPopup = popup; // AUI-2696 - hackish coupling to control inline-dialog close behaviour. $(parentPopup).on('aui-hide', (e) => { if (isTrackingDatePickerFocus) { e.preventDefault(); } $body.off('focus blur', '*', handleDatePickerFocus); if (parentPopup && parentPopup._datePickerPopup) { delete parentPopup._datePickerPopup; } }); } $body.append(popup); popupContents = popup; return popup; }; const handleFieldFocus = () => { if (!popup.get(0).open) { showDatePicker(); } }; const showDatePicker = () => { popup.get(0).open = true; }; const hideDatePicker = () => { popup.get(0).open = false; }; const handleChangeMonthYear = () => { // defer refresh call until current stack has cleared (after month has rendered) setTimeout(popup.refresh, 0); }; const getCalendarNode = () => popup.get(0).childNodes[0]; const destroyPolyfill = () => { // goodbye, cruel world! hideDatePicker(); $field.off('focus click', handleFieldFocus); $field.off('focusout keydown', handleFieldBlur); $body.off('keydown', handleFieldBlur); $body.off('focus blur click mousedown keydown', handleDatePickerFocus); }; return { calendarContainerSelector: null, getPopupContents, handleFieldFocus, showDatePicker, hideDatePicker, handleChangeMonthYear, getCalendarNode, destroyPolyfill, createPolyfill, }; }; const initPolyfill = function (datePicker) { const $field = datePicker.getField(); const options = datePicker.getOptions(); const datePickerUUID = datePicker.getUUID(); let calendar; const { getPopupContents, handleFieldFocus, showDatePicker, hideDatePicker, handleChangeMonthYear, getCalendarNode, destroyPolyfill, createPolyfill, } = makeDefaultPopupController($field, datePickerUUID); const handleFieldUpdate = (event) => { let val = $(event.currentTarget).val(); // IE10/11 fire the 'input' event when internally showing and hiding // the placeholder of an input. This was cancelling the initial click // event and preventing the selection of the first date. The val check here // is a workaround to assure we have legitimate user input that should update // the calendar if (val) { calendar.datepicker('setDate', $field.val()); } }; // keep track of things we mutate within `initPolyfill`. // these should all be restored in the `destroyPolyfill` function. const originalPlaceholder = $field.attr('placeholder'); const originalType = $field.prop('type'); let attributeHandler; // ----------------------------------------------------------------- // mutate datePicker public API // ----------------------------------------------------------------- { const withCalendar = (callback) => (value) => { if (typeof calendar !== 'undefined') { return callback(value); } }; const destroyCalendar = withCalendar(() => { calendar.datepicker('destroy'); }); datePicker.show = showDatePicker; datePicker.hide = hideDatePicker; // un-does everything the `initPolyfill` function constructed or mutated. datePicker.destroyPolyfill = () => { destroyPolyfill(); $field.off('propertychange keyup input paste', handleFieldUpdate); if (attributeHandler) { attributeHandler.disconnect(); attributeHandler = null; } if (originalPlaceholder) { $field.attr('placeholder', originalPlaceholder); } if (originalType) { $field.prop('type', originalType); } $field.removeAttr('data-aui-dp-uuid'); destroyCalendar(); // TODO: figure out a way to tear down the popup (if necessary) delete datePicker.destroyPolyfill; delete datePicker.show; delete datePicker.hide; }; datePicker.setDate = withCalendar((value) => { calendar.datepicker('setDate', value); }); datePicker.getDate = withCalendar(() => calendar.datepicker('getDate')); datePicker.setMin = withCalendar((value) => calendar.datepicker('option', 'minDate', value) ); datePicker.setMax = withCalendar((value) => calendar.datepicker('option', 'maxDate', value) ); } // ----------------------------------------------------------------- // polyfill bootstrap ---------------------------------------------- // ----------------------------------------------------------------- if (!(options.languageCode in DatePicker.prototype.localisations)) { options.languageCode = ''; } const i18nConfig = DatePicker.prototype.localisations; $field.attr('aria-controls', datePickerUUID); if (typeof calendar === 'undefined') { if (typeof $field.attr('step') !== 'undefined') { logger.log( 'WARNING: The date picker polyfill currently does not support the step attribute!' ); } const baseConfig = makeConfig({ dateFormat: options.dateFormat, minDate: options.minDate, maxDate: options.maxDate, $field, onSelect: options.onSelect, hide: datePicker.hide, onChangeMonthYear: handleChangeMonthYear, }); const config = $.extend(undefined, baseConfig, i18nConfig); // If this is a string value, it will be coerced to numeric. // Since nothing only numbers can be larger than a negative number, this works as a defense. if (options.firstDay > -1) { config.firstDay = options.firstDay; } calendar = initCalendar({ config, popupContents: getPopupContents({ $field }), getCalendarNode, hint: options.hint, }); createPolyfill(); $field.on('propertychange keyup input paste', handleFieldUpdate); // bind attribute handlers to account for html5 attributes attributeHandler = new MutationObserver(function (mutationsList) { mutationsList.forEach(function (mutation) { if (mutation.attributeName === 'min') { datePicker.setMin(mutation.target.getAttribute('min')); } else if (mutation.attributeName === 'max') { datePicker.setMax(mutation.target.getAttribute('max')); } }); }); attributeHandler.observe($field.get(0), { attributes: true }); } // bind what we need to start off with $field.on('focus click', handleFieldFocus); // the click is for fucking opera... Y U NO FIRE FOCUS EVENTS PROPERLY??? // give users a hint that this is a date field; note that placeholder isn't technically a valid attribute // according to the spec... $field.attr('placeholder', options.placeholder); if ($field.prop('type') !== 'text') { // Override the browser's default date field implementation. // There used to be a fix for AUI-3681 here, too, but my testing of Edge 15 // shows that changing the `type` of input does not erase its value. // see https://codepen.io/chrisdarroch/pen/YzwgjyJ $field.prop('type', 'text'); // Set default value on initialization to handle all date formats. // It is possible, because of changing type to text on the line above. $field.val($field.attr('value')); } // Trigger change to update calendar popup value. $field.trigger('propertychange'); // demonstrate the polyfill is initialised $field.attr('data-aui-dp-uuid', datePickerUUID); }; function DatePicker(field, baseOptions) { let options = {}; const datePickerUUID = generateUniqueId('date-picker'); const $field = $(field); const datePicker = { getUUID: () => datePickerUUID, getField: () => $field, getOptions: () => options, destroy: () => { if (typeof datePicker.destroyPolyfill === 'function') { datePicker.destroyPolyfill(); } }, reset: () => { datePicker.destroy(); const browserDoesNotSupportDateField = !DatePicker.prototype.browserSupportsDateField; const shouldOverrideBrowserDefault = options.overrideBrowserDefault !== false; if (browserDoesNotSupportDateField || shouldOverrideBrowserDefault) { initPolyfill(datePicker); } }, reconfigure: (newOptions) => { options = $.extend(undefined, DatePicker.prototype.defaultOptions, newOptions); datePicker.reset(); }, }; datePicker.reconfigure(baseOptions); return datePicker; } // ------------------------------------------------------------------------- // things that should be common -------------------------------------------- // ------------------------------------------------------------------------- DatePicker.prototype.browserSupportsDateField = supportsDateField(); DatePicker.prototype.defaultOptions = { overrideBrowserDefault: false, firstDay: -1, languageCode: $('html').attr('lang') || 'en-AU', dateFormat: datepickerUI.W3C, // same as $.datepicker.ISO_8601 }; function CalendarWidget(calendarNode, baseOptions) { const options = $.extend( { nextText: '>', prevText: '<', }, baseOptions ); const $calendarNode = $(calendarNode); const $result = $calendarNode .addClass('aui-datepicker-dialog') .addClass('aui-calendar-widget') .datepicker(options); if (options.hint) { const $hint = $('<div/>').addClass('aui-datepicker-hint'); $hint.append('<span/>').text(options.hint); $result.append($hint); } $result.reconfigure = (options) => { $result.datepicker('destroy'); $result.datepicker(options); }; $result.destroy = () => { $result.datepicker('destroy'); }; return $result; } // adapted from the jQuery UI Datepicker widget (v1.8.16), with the following changes: // - dayNamesShort -> dayNamesMin // - unnecessary attributes omitted /* CODE to extract codes out: var langCode, langs, out; langs = jQuery.datepicker.regional; out = {}; for (langCode in langs) { if (langs.hasOwnProperty(langCode)) { out[langCode] = { 'dayNames': langs[langCode].dayNames, 'dayNamesMin': langs[langCode].dayNamesShort, // this is deliberate 'firstDay': langs[langCode].firstDay, 'isRTL': langs[langCode].isRTL, 'monthNames': langs[langCode].monthNames, 'showMonthAfterYear': langs[langCode].showMonthAfterYear, 'yearSuffix': langs[langCode].yearSuffix }; } } */ DatePicker.prototype.localisations = { dayNames: [ I18n.getText('ajs.datepicker.localisations.day-names.sunday'), I18n.getText('ajs.datepicker.localisations.day-names.monday'), I18n.getText('ajs.datepicker.localisations.day-names.tuesday'), I18n.getText('ajs.datepicker.localisations.day-names.wednesday'), I18n.getText('ajs.datepicker.localisations.day-names.thursday'), I18n.getText('ajs.datepicker.localisations.day-names.friday'), I18n.getText('ajs.datepicker.localisations.day-names.saturday'), ], dayNamesMin: [ I18n.getText('ajs.datepicker.localisations.day-names-min.sunday'), I18n.getText('ajs.datepicker.localisations.day-names-min.monday'), I18n.getText('ajs.datepicker.localisations.day-names-min.tuesday'), I18n.getText('ajs.datepicker.localisations.day-names-min.wednesday'), I18n.getText('ajs.datepicker.localisations.day-names-min.thursday'), I18n.getText('ajs.datepicker.localisations.day-names-min.friday'), I18n.getText('ajs.datepicker.localisations.day-names-min.saturday'), ], firstDay: I18n.getText('ajs.datepicker.localisations.first-day'), isRTL: I18n.getText('ajs.datepicker.localisations.is-RTL') === 'true', monthNames: [ I18n.getText('ajs.datepicker.localisations.month-names.january'), I18n.getText('ajs.datepicker.localisations.month-names.february'), I18n.getText('ajs.datepicker.localisations.month-names.march'), I18n.getText('ajs.datepicker.localisations.month-names.april'), I18n.getText('ajs.datepicker.localisations.month-names.may'), I18n.getText('ajs.datepicker.localisations.month-names.june'), I18n.getText('ajs.datepicker.localisations.month-names.july'), I18n.getText('ajs.datepicker.localisations.month-names.august'), I18n.getText('ajs.datepicker.localisations.month-names.september'), I18n.getText('ajs.datepicker.localisations.month-names.october'), I18n.getText('ajs.datepicker.localisations.month-names.november'), I18n.getText('ajs.datepicker.localisations.month-names.december'), ], showMonthAfterYear: I18n.getText('ajs.datepicker.localisations.show-month-after-year') === 'true', yearSuffix: I18n.getText('ajs.datepicker.localisations.year-suffix'), }; // TODO Workaround a localisation issue in WRM related to empty strings not being considered as valid localisation values // TODO Remove once this WRM PR is pulled in via platform (most probably) 8: https://bitbucket.org/atlassian/atlassian-plugins-webresource/pull-requests/1761/overview if (DatePicker.prototype.localisations.yearSuffix === 'ajs.datepicker.localisations.year-suffix') { DatePicker.prototype.localisations.yearSuffix = ''; } // ------------------------------------------------------------------------- // finally, integrate with jQuery for convenience -------------------------- // ------------------------------------------------------------------------- const key = 'aui-datepicker'; const makePlugin = (WidgetConstructor) => function (options) { let picker = this.data(key); if (!picker) { picker = new WidgetConstructor(this, options); this.data(key, picker); } else if (typeof options === 'object') { picker.reconfigure(options); } else if (options === 'destroy') { picker.destroy(); } return picker; }; $.fn.datePicker = makePlugin(DatePicker); globalize('DatePicker', DatePicker); $.fn.calendarWidget = makePlugin(CalendarWidget); globalize('CalendarWidget', CalendarWidget); export default DatePicker; export { CalendarWidget };