UNPKG

14.2 kBJavaScriptView Raw
1/**
2 * Copyright IBM Corp. 2016, 2018
3 *
4 * This source code is licensed under the Apache-2.0 license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7
8import Flatpickr from 'flatpickr';
9import settings from '../../globals/js/settings';
10import mixin from '../../globals/js/misc/mixin';
11import createComponent from '../../globals/js/mixins/create-component';
12import initComponentBySearch from '../../globals/js/mixins/init-component-by-search';
13import handles from '../../globals/js/mixins/handles';
14import on from '../../globals/js/misc/on';
15import { componentsX } from '../../globals/js/feature-flags';
16
17/* eslint no-underscore-dangle: [2, { "allow": ["_input", "_updateClassNames", "_updateInputFields"], "allowAfterThis": true }] */
18
19// `this.options` create-component mix-in creates prototype chain
20// so that `options` given in constructor argument wins over the one defined in static `options` property
21// 'Flatpickr' wants flat structure of object instead
22
23function flattenOptions(options) {
24 const o = {};
25 // eslint-disable-next-line guard-for-in, no-restricted-syntax
26 for (const key in options) {
27 o[key] = options[key];
28 }
29 return o;
30}
31
32// Weekdays shorthand for english locale
33Flatpickr.l10ns.en.weekdays.shorthand.forEach((day, index) => {
34 const currentDay = Flatpickr.l10ns.en.weekdays.shorthand;
35 if (currentDay[index] === 'Thu' || currentDay[index] === 'Th') {
36 currentDay[index] = 'Th';
37 } else {
38 currentDay[index] = currentDay[index].charAt(0);
39 }
40});
41
42const toArray = arrayLike => Array.prototype.slice.call(arrayLike);
43
44class DatePicker extends mixin(createComponent, initComponentBySearch, handles) {
45 /**
46 * DatePicker.
47 * @extends CreateComponent
48 * @extends InitComponentBySearch
49 * @extends Handles
50 * @param {HTMLElement} element The element working as an date picker.
51 */
52 constructor(element, options) {
53 super(element, options);
54 const type = this.element.getAttribute(this.options.attribType);
55 this.calendar = this._initDatePicker(type);
56 if (this.calendar.calendarContainer) {
57 this.manage(
58 on(this.element, 'keydown', e => {
59 if (e.which === 40) {
60 this.calendar.calendarContainer.focus();
61 }
62 })
63 );
64 this.manage(
65 on(this.calendar.calendarContainer, 'keydown', e => {
66 if (e.which === 9 && type === 'range') {
67 this._updateClassNames(this.calendar);
68 this.element.querySelector(this.options.selectorDatePickerInputFrom).focus();
69 }
70 })
71 );
72 }
73 }
74
75 /**
76 * Opens the date picker dropdown when this component gets focus.
77 * Used only for range mode for now.
78 * @private
79 */
80 _handleFocus = () => {
81 if (this.calendar) {
82 this.calendar.open();
83 }
84 };
85
86 /**
87 * Closes the date picker dropdown when this component loses focus.
88 * Used only for range mode for now.
89 * @private
90 */
91 _handleBlur = event => {
92 if (this.calendar) {
93 const focusTo = event.relatedTarget;
94 if (
95 !focusTo ||
96 (!this.element.contains(focusTo) &&
97 (!this.calendar.calendarContainer || !this.calendar.calendarContainer.contains(focusTo)))
98 ) {
99 this.calendar.close();
100 }
101 }
102 };
103
104 _initDatePicker = type => {
105 if (type === 'range') {
106 // Given FlatPickr assumes one `<input>` even in range mode,
107 // use a hidden `<input>` for such purpose, separate from our from/to `<input>`s
108 const doc = this.element.ownerDocument;
109 const rangeInput = doc.createElement('input');
110 rangeInput.className = this.options.classVisuallyHidden;
111 rangeInput.setAttribute('aria-hidden', 'true');
112 this.element.appendChild(rangeInput);
113 this._rangeInput = rangeInput;
114
115 // An attempt to open the date picker dropdown when this component gets focus,
116 // and close the date picker dropdown when this component loses focus
117 const w = doc.defaultView;
118 const hasFocusin = 'onfocusin' in w;
119 const hasFocusout = 'onfocusout' in w;
120 const focusinEventName = hasFocusin ? 'focusin' : 'focus';
121 const focusoutEventName = hasFocusout ? 'focusout' : 'blur';
122 this.manage(on(this.element, focusinEventName, this._handleFocus, !hasFocusin));
123 this.manage(on(this.element, focusoutEventName, this._handleBlur, !hasFocusout));
124 this.manage(
125 on(this.element.querySelector(this.options.selectorDatePickerIcon), focusoutEventName, this._handleBlur, !hasFocusout)
126 );
127 }
128 const self = this;
129 const date = type === 'range' ? this._rangeInput : this.element.querySelector(this.options.selectorDatePickerInput);
130 const { onClose, onChange, onMonthChange, onYearChange, onOpen, onValueUpdate } = this.options;
131 const calendar = new Flatpickr(
132 date,
133 Object.assign(flattenOptions(this.options), {
134 allowInput: true,
135 mode: type,
136 positionElement: type === 'range' && this.element.querySelector(this.options.selectorDatePickerInputFrom),
137 onClose(selectedDates, ...remainder) {
138 // An attempt to disable Flatpickr's focus tracking system,
139 // which has adverse effect with our old set up with two `<input>`s or our latest setup with a hidden `<input>`
140 if (self.shouldForceOpen) {
141 if (self.calendar.calendarContainer) {
142 self.calendar.calendarContainer.classList.add('open');
143 }
144 self.calendar.isOpen = true;
145 }
146 if (!onClose || onClose.call(this, selectedDates, ...remainder) !== false) {
147 self._updateClassNames(calendar);
148 self._updateInputFields(selectedDates, type);
149 }
150 },
151 onChange(...args) {
152 if (!onChange || onChange.call(this, ...args) !== false) {
153 self._updateClassNames(calendar);
154 if (type === 'range') {
155 if (calendar.selectedDates.length === 1 && calendar.isOpen) {
156 self.element.querySelector(self.options.selectorDatePickerInputTo).classList.add(self.options.classFocused);
157 } else {
158 self.element.querySelector(self.options.selectorDatePickerInputTo).classList.remove(self.options.classFocused);
159 }
160 }
161 }
162 },
163 onMonthChange(...args) {
164 if (!onMonthChange || onMonthChange.call(this, ...args) !== false) {
165 self._updateClassNames(calendar);
166 }
167 },
168 onYearChange(...args) {
169 if (!onYearChange || onYearChange.call(this, ...args) !== false) {
170 self._updateClassNames(calendar);
171 }
172 },
173 onOpen(...args) {
174 // An attempt to disable Flatpickr's focus tracking system,
175 // which has adverse effect with our old set up with two `<input>`s or our latest setup with a hidden `<input>`
176 self.shouldForceOpen = true;
177 setTimeout(() => {
178 self.shouldForceOpen = false;
179 }, 0);
180 if (!onOpen || onOpen.call(this, ...args) !== false) {
181 self._updateClassNames(calendar);
182 }
183 },
184 onValueUpdate(...args) {
185 if ((!onValueUpdate || onValueUpdate.call(this, ...args) !== false) && type === 'range') {
186 self._updateInputFields(self.calendar.selectedDates, type);
187 }
188 },
189 nextArrow: this._rightArrowHTML(),
190 prevArrow: this._leftArrowHTML(),
191 })
192 );
193 if (type === 'range') {
194 this._addInputLogic(this.element.querySelector(this.options.selectorDatePickerInputFrom), 0);
195 this._addInputLogic(this.element.querySelector(this.options.selectorDatePickerInputTo), 1);
196 }
197 this.manage(
198 on(this.element.querySelector(this.options.selectorDatePickerIcon), 'click', () => {
199 calendar.open();
200 })
201 );
202 this._updateClassNames(calendar);
203 if (type !== 'range') {
204 this._addInputLogic(date);
205 }
206 return calendar;
207 };
208
209 _rightArrowHTML() {
210 return componentsX
211 ? `
212 <svg
213 focusable="false"
214 preserveAspectRatio="xMidYMid meet"
215 style="will-change: transform;"
216 xmlns="http://www.w3.org/2000/svg"
217 width="16"
218 height="16"
219 viewBox="0 0 16 16"
220 aria-hidden="true">
221 <path d="M11 8l-5 5-.7-.7L9.6 8 5.3 3.7 6 3z"></path>
222 </svg>`
223 : `
224 <svg width="8" height="12" viewBox="0 0 8 12" fill-rule="evenodd">
225 <path d="M0 10.6L4.7 6 0 1.4 1.4 0l6.1 6-6.1 6z"></path>
226 </svg>`;
227 }
228
229 _leftArrowHTML() {
230 return componentsX
231 ? `
232 <svg
233 focusable="false"
234 preserveAspectRatio="xMidYMid meet"
235 style="will-change: transform;"
236 xmlns="http://www.w3.org/2000/svg"
237 width="16"
238 height="16"
239 viewBox="0 0 16 16"
240 aria-hidden="true"
241 >
242 <path d="M5 8l5-5 .7.7L6.4 8l4.3 4.3-.7.7z"></path>
243 </svg>`
244 : `
245 <svg width="8" height="12" viewBox="0 0 8 12" fill-rule="evenodd">
246 <path d="M7.5 10.6L2.8 6l4.7-4.6L6.1 0 0 6l6.1 6z"></path>
247 </svg>`;
248 }
249
250 _addInputLogic = (input, index) => {
251 if (!isNaN(index) && (index < 0 || index > 1)) {
252 throw new RangeError(`The index of <input> (${index}) is out of range.`);
253 }
254 const inputField = input;
255 this.manage(
256 on(inputField, 'change', evt => {
257 if (evt.isTrusted || (evt.detail && evt.detail.isNotFromFlatpickr)) {
258 const inputDate = this.calendar.parseDate(inputField.value);
259 if (inputDate && !isNaN(inputDate.valueOf())) {
260 if (isNaN(index)) {
261 this.calendar.setDate(inputDate);
262 } else {
263 const { selectedDates } = this.calendar;
264 selectedDates[index] = inputDate;
265 this.calendar.setDate(selectedDates);
266 }
267 }
268 }
269 this._updateClassNames(this.calendar);
270 })
271 );
272 // An attempt to temporarily set the `<input>` being edited as the one FlatPicker manages,
273 // as FlatPicker attempts to take over `keydown` event handler on `document` to run on the date picker dropdown.
274 this.manage(
275 on(inputField, 'keydown', evt => {
276 const origInput = this.calendar._input;
277 this.calendar._input = evt.target;
278 setTimeout(() => {
279 this.calendar._input = origInput;
280 });
281 })
282 );
283 };
284
285 _updateClassNames = ({ calendarContainer, selectedDates }) => {
286 if (calendarContainer) {
287 calendarContainer.classList.add(this.options.classCalendarContainer);
288 calendarContainer.querySelector('.flatpickr-month').classList.add(this.options.classMonth);
289 calendarContainer.querySelector('.flatpickr-weekdays').classList.add(this.options.classWeekdays);
290 calendarContainer.querySelector('.flatpickr-days').classList.add(this.options.classDays);
291 toArray(calendarContainer.querySelectorAll('.flatpickr-weekday')).forEach(item => {
292 const currentItem = item;
293 currentItem.innerHTML = currentItem.innerHTML.replace(/\s+/g, '');
294 currentItem.classList.add(this.options.classWeekday);
295 });
296 toArray(calendarContainer.querySelectorAll('.flatpickr-day')).forEach(item => {
297 item.classList.add(this.options.classDay);
298 if (item.classList.contains('today') && selectedDates.length > 0) {
299 item.classList.add('no-border');
300 } else if (item.classList.contains('today') && selectedDates.length === 0) {
301 item.classList.remove('no-border');
302 }
303 });
304 }
305 };
306
307 _updateInputFields = (selectedDates, type) => {
308 if (type === 'range') {
309 if (selectedDates.length === 2) {
310 this.element.querySelector(this.options.selectorDatePickerInputFrom).value = this._formatDate(selectedDates[0]);
311 this.element.querySelector(this.options.selectorDatePickerInputTo).value = this._formatDate(selectedDates[1]);
312 } else if (selectedDates.length === 1) {
313 this.element.querySelector(this.options.selectorDatePickerInputFrom).value = this._formatDate(selectedDates[0]);
314 }
315 } else if (selectedDates.length === 1) {
316 this.element.querySelector(this.options.selectorDatePickerInput).value = this._formatDate(selectedDates[0]);
317 }
318 this._updateClassNames(this.calendar);
319 };
320
321 _formatDate = date => this.calendar.formatDate(date, this.calendar.config.dateFormat);
322
323 release() {
324 if (this._rangeInput && this._rangeInput.parentNode) {
325 this._rangeInput.parentNode.removeChild(this._rangeInput);
326 }
327 if (this.calendar) {
328 try {
329 this.calendar.destroy();
330 } catch (err) {} // eslint-disable-line no-empty
331 this.calendar = null;
332 }
333 return super.release();
334 }
335
336 /**
337 * The component options.
338 * If `options` is specified in the constructor,
339 * {@linkcode DatePicker.create .create()}, or {@linkcode DatePicker.init .init()},
340 * properties in this object are overriden for the instance being create and how {@linkcode DatePicker.init .init()} works.
341 * @property {string} selectorInit The CSS selector to find date picker UIs.
342 */
343 static get options() {
344 const { prefix } = settings;
345 return {
346 selectorInit: '[data-date-picker]',
347 selectorDatePickerInput: '[data-date-picker-input]',
348 selectorDatePickerInputFrom: '[data-date-picker-input-from]',
349 selectorDatePickerInputTo: '[data-date-picker-input-to]',
350 selectorDatePickerIcon: '[data-date-picker-icon]',
351 classCalendarContainer: `${prefix}--date-picker__calendar`,
352 classMonth: `${prefix}--date-picker__month`,
353 classWeekdays: `${prefix}--date-picker__weekdays`,
354 classDays: `${prefix}--date-picker__days`,
355 classWeekday: `${prefix}--date-picker__weekday`,
356 classDay: `${prefix}--date-picker__day`,
357 classFocused: `${prefix}--focused`,
358 classVisuallyHidden: `${prefix}--visually-hidden`,
359 attribType: 'data-date-picker-type',
360 dateFormat: 'm/d/Y',
361 };
362 }
363
364 /**
365 * The map associating DOM element and date picker UI instance.
366 * @type {WeakMap}
367 */
368 static components /* #__PURE_CLASS_PROPERTY__ */ = new WeakMap();
369}
370
371export default DatePicker;