@atlassian/aui
Version:
Atlassian User Interface library
544 lines (449 loc) • 18.9 kB
JavaScript
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 };