@vaadin/time-picker 24.6.0-alpha4 → 24.6.0-alpha5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,605 @@
1
+ /**
2
+ * @license
3
+ * Copyright (c) 2018 - 2024 Vaadin Ltd.
4
+ * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
5
+ */
6
+ import { InputControlMixin } from '@vaadin/field-base/src/input-control-mixin.js';
7
+ import { PatternMixin } from '@vaadin/field-base/src/pattern-mixin.js';
8
+ import { formatISOTime, parseISOTime, validateTime } from './vaadin-time-picker-helper.js';
9
+
10
+ const MIN_ALLOWED_TIME = '00:00:00.000';
11
+ const MAX_ALLOWED_TIME = '23:59:59.999';
12
+
13
+ /**
14
+ * A mixin providing common time-picker functionality.
15
+ *
16
+ * @polymerMixin
17
+ * @mixes InputControlMixin
18
+ * @mixes PatternMixin
19
+ */
20
+ export const TimePickerMixin = (superClass) =>
21
+ class TimePickerMixinClass extends PatternMixin(InputControlMixin(superClass)) {
22
+ static get properties() {
23
+ return {
24
+ /**
25
+ * The time value for this element.
26
+ *
27
+ * Supported time formats are in ISO 8601:
28
+ * - `hh:mm` (default)
29
+ * - `hh:mm:ss`
30
+ * - `hh:mm:ss.fff`
31
+ * @type {string}
32
+ */
33
+ value: {
34
+ type: String,
35
+ notify: true,
36
+ value: '',
37
+ sync: true,
38
+ },
39
+
40
+ /**
41
+ * True if the dropdown is open, false otherwise.
42
+ */
43
+ opened: {
44
+ type: Boolean,
45
+ notify: true,
46
+ value: false,
47
+ reflectToAttribute: true,
48
+ sync: true,
49
+ },
50
+
51
+ /**
52
+ * Minimum time allowed.
53
+ *
54
+ * Supported time formats are in ISO 8601:
55
+ * - `hh:mm`
56
+ * - `hh:mm:ss`
57
+ * - `hh:mm:ss.fff`
58
+ * @type {string}
59
+ */
60
+ min: {
61
+ type: String,
62
+ value: '',
63
+ sync: true,
64
+ },
65
+
66
+ /**
67
+ * Maximum time allowed.
68
+ *
69
+ * Supported time formats are in ISO 8601:
70
+ * - `hh:mm`
71
+ * - `hh:mm:ss`
72
+ * - `hh:mm:ss.fff`
73
+ * @type {string}
74
+ */
75
+ max: {
76
+ type: String,
77
+ value: '',
78
+ sync: true,
79
+ },
80
+
81
+ /**
82
+ * Defines the time interval (in seconds) between the items displayed
83
+ * in the time selection box. The default is 1 hour (i.e. `3600`).
84
+ *
85
+ * It also configures the precision of the value string. By default
86
+ * the component formats values as `hh:mm` but setting a step value
87
+ * lower than one minute or one second, format resolution changes to
88
+ * `hh:mm:ss` and `hh:mm:ss.fff` respectively.
89
+ *
90
+ * Unit must be set in seconds, and for correctly configuring intervals
91
+ * in the dropdown, it need to evenly divide a day.
92
+ *
93
+ * Note: it is possible to define step that is dividing an hour in inexact
94
+ * fragments (i.e. 5760 seconds which equals 1 hour 36 minutes), but it is
95
+ * not recommended to use it for better UX experience.
96
+ */
97
+ step: {
98
+ type: Number,
99
+ sync: true,
100
+ },
101
+
102
+ /**
103
+ * Set true to prevent the overlay from opening automatically.
104
+ * @attr {boolean} auto-open-disabled
105
+ */
106
+ autoOpenDisabled: {
107
+ type: Boolean,
108
+ sync: true,
109
+ },
110
+
111
+ /**
112
+ * A space-delimited list of CSS class names to set on the overlay element.
113
+ *
114
+ * @attr {string} overlay-class
115
+ */
116
+ overlayClass: {
117
+ type: String,
118
+ },
119
+
120
+ /**
121
+ * The object used to localize this component.
122
+ * To change the default localization, replace the entire
123
+ * _i18n_ object or just the property you want to modify.
124
+ *
125
+ * The object has the following JSON structure:
126
+ *
127
+ * ```
128
+ * {
129
+ * // A function to format given `Object` as
130
+ * // time string. Object is in the format `{ hours: ..., minutes: ..., seconds: ..., milliseconds: ... }`
131
+ * formatTime: (time) => {
132
+ * // returns a string representation of the given
133
+ * // object in `hh` / 'hh:mm' / 'hh:mm:ss' / 'hh:mm:ss.fff' - formats
134
+ * },
135
+ *
136
+ * // A function to parse the given text to an `Object` in the format
137
+ * // `{ hours: ..., minutes: ..., seconds: ..., milliseconds: ... }`.
138
+ * // Must properly parse (at least) text
139
+ * // formatted by `formatTime`.
140
+ * parseTime: text => {
141
+ * // Parses a string in object/string that can be formatted by`formatTime`.
142
+ * }
143
+ * }
144
+ * ```
145
+ *
146
+ * Both `formatTime` and `parseTime` need to be implemented
147
+ * to ensure the component works properly.
148
+ *
149
+ * @type {!TimePickerI18n}
150
+ */
151
+ i18n: {
152
+ type: Object,
153
+ sync: true,
154
+ value: () => {
155
+ return {
156
+ formatTime: formatISOTime,
157
+ parseTime: parseISOTime,
158
+ };
159
+ },
160
+ },
161
+
162
+ /** @private */
163
+ _comboBoxValue: {
164
+ type: String,
165
+ sync: true,
166
+ observer: '__comboBoxValueChanged',
167
+ },
168
+
169
+ /** @private */
170
+ __dropdownItems: {
171
+ type: Array,
172
+ sync: true,
173
+ },
174
+ };
175
+ }
176
+
177
+ static get observers() {
178
+ return [
179
+ '__updateAriaAttributes(__dropdownItems, opened, inputElement)',
180
+ '__updateDropdownItems(i18n, min, max, step)',
181
+ ];
182
+ }
183
+
184
+ static get constraints() {
185
+ return [...super.constraints, 'min', 'max'];
186
+ }
187
+
188
+ /**
189
+ * Used by `ClearButtonMixin` as a reference to the clear button element.
190
+ * @protected
191
+ * @return {!HTMLElement}
192
+ */
193
+ get clearElement() {
194
+ return this.$.clearButton;
195
+ }
196
+
197
+ /**
198
+ * The input element's value when it cannot be parsed as a time, and an empty string otherwise.
199
+ *
200
+ * @private
201
+ * @return {string}
202
+ */
203
+ get __unparsableValue() {
204
+ if (this._inputElementValue && !this.i18n.parseTime(this._inputElementValue)) {
205
+ return this._inputElementValue;
206
+ }
207
+
208
+ return '';
209
+ }
210
+
211
+ /**
212
+ * Override method inherited from `InputMixin` to forward the input to combo-box.
213
+ * @protected
214
+ * @override
215
+ */
216
+ _inputElementChanged(input) {
217
+ super._inputElementChanged(input);
218
+
219
+ if (input) {
220
+ this.$.comboBox._setInputElement(input);
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Opens the dropdown list.
226
+ */
227
+ open() {
228
+ if (!this.disabled && !this.readonly) {
229
+ this.opened = true;
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Closes the dropdown list.
235
+ */
236
+ close() {
237
+ this.opened = false;
238
+ }
239
+
240
+ /**
241
+ * Returns true if the current input value satisfies all constraints (if any).
242
+ * You can override this method for custom validations.
243
+ *
244
+ * @return {boolean} True if the value is valid
245
+ */
246
+ checkValidity() {
247
+ return !!(
248
+ this.inputElement.checkValidity() &&
249
+ (!this.value || this._timeAllowed(this.i18n.parseTime(this.value))) &&
250
+ (!this._comboBoxValue || this.i18n.parseTime(this._comboBoxValue))
251
+ );
252
+ }
253
+
254
+ /**
255
+ * @param {boolean} focused
256
+ * @override
257
+ * @protected
258
+ */
259
+ _setFocused(focused) {
260
+ super._setFocused(focused);
261
+
262
+ if (!focused) {
263
+ // Do not validate when focusout is caused by document
264
+ // losing focus, which happens on browser tab switch.
265
+ if (document.hasFocus()) {
266
+ this.validate();
267
+ }
268
+ }
269
+ }
270
+
271
+ /** @private */
272
+ __validDayDivisor(step) {
273
+ // Valid if undefined, or exact divides a day, or has millisecond resolution
274
+ return !step || (24 * 3600) % step === 0 || (step < 1 && ((step % 1) * 1000) % 1 === 0);
275
+ }
276
+
277
+ /**
278
+ * Override an event listener from `KeyboardMixin`.
279
+ * @param {!KeyboardEvent} e
280
+ * @protected
281
+ */
282
+ _onKeyDown(e) {
283
+ super._onKeyDown(e);
284
+
285
+ if (this.readonly || this.disabled || this.__dropdownItems.length) {
286
+ return;
287
+ }
288
+
289
+ const stepResolution = (this.__validDayDivisor(this.step) && this.step) || 60;
290
+
291
+ if (e.keyCode === 40) {
292
+ this.__onArrowPressWithStep(-stepResolution);
293
+ } else if (e.keyCode === 38) {
294
+ this.__onArrowPressWithStep(stepResolution);
295
+ }
296
+ }
297
+
298
+ /**
299
+ * Override an event listener from `KeyboardMixin`.
300
+ * Do not call `super` in order to override clear
301
+ * button logic defined in `InputControlMixin`.
302
+ * @param {Event} event
303
+ * @protected
304
+ */
305
+ _onEscape() {
306
+ // Do nothing, the internal combo-box handles Escape.
307
+ }
308
+
309
+ /** @private */
310
+ __onArrowPressWithStep(step) {
311
+ const objWithStep = this.__addStep(this.__getMsec(this.__memoValue), step, true);
312
+ this.__memoValue = objWithStep;
313
+
314
+ // Setting `_comboBoxValue` property triggers the synchronous
315
+ // observer where the value can be parsed again, so we set
316
+ // this flag to ensure it does not alter the value.
317
+ this.__useMemo = true;
318
+ this._comboBoxValue = this.i18n.formatTime(objWithStep);
319
+ this.__useMemo = false;
320
+
321
+ this.__commitValueChange();
322
+ }
323
+
324
+ /**
325
+ * Depending on the nature of the value change that has occurred since
326
+ * the last commit attempt, triggers validation and fires an event:
327
+ *
328
+ * Value change | Event
329
+ * -------------------------|-------------------
330
+ * empty => parsable | change
331
+ * empty => unparsable | unparsable-change
332
+ * parsable => empty | change
333
+ * parsable => parsable | change
334
+ * parsable => unparsable | change
335
+ * unparsable => empty | unparsable-change
336
+ * unparsable => parsable | change
337
+ * unparsable => unparsable | unparsable-change
338
+ *
339
+ * @private
340
+ */
341
+ __commitValueChange() {
342
+ const unparsableValue = this.__unparsableValue;
343
+
344
+ if (this.__committedValue !== this.value) {
345
+ this.validate();
346
+ this.dispatchEvent(new CustomEvent('change', { bubbles: true }));
347
+ } else if (this.__committedUnparsableValue !== unparsableValue) {
348
+ this.validate();
349
+ this.dispatchEvent(new CustomEvent('unparsable-change'));
350
+ }
351
+
352
+ this.__committedValue = this.value;
353
+ this.__committedUnparsableValue = unparsableValue;
354
+ }
355
+
356
+ /**
357
+ * Returning milliseconds from Object in the format `{ hours: ..., minutes: ..., seconds: ..., milliseconds: ... }`
358
+ * @private
359
+ */
360
+ __getMsec(obj) {
361
+ let result = ((obj && obj.hours) || 0) * 60 * 60 * 1000;
362
+ result += ((obj && obj.minutes) || 0) * 60 * 1000;
363
+ result += ((obj && obj.seconds) || 0) * 1000;
364
+ result += (obj && parseInt(obj.milliseconds)) || 0;
365
+
366
+ return result;
367
+ }
368
+
369
+ /**
370
+ * Returning seconds from Object in the format `{ hours: ..., minutes: ..., seconds: ..., milliseconds: ... }`
371
+ * @private
372
+ */
373
+ __getSec(obj) {
374
+ let result = ((obj && obj.hours) || 0) * 60 * 60;
375
+ result += ((obj && obj.minutes) || 0) * 60;
376
+ result += (obj && obj.seconds) || 0;
377
+ result += (obj && obj.milliseconds / 1000) || 0;
378
+
379
+ return result;
380
+ }
381
+
382
+ /**
383
+ * Returning Object in the format `{ hours: ..., minutes: ..., seconds: ..., milliseconds: ... }`
384
+ * from the result of adding step value in milliseconds to the milliseconds amount.
385
+ * With `precision` parameter rounding the value to the closest step valid interval.
386
+ * @private
387
+ */
388
+ __addStep(msec, step, precision) {
389
+ // If the time is `00:00` and step changes value downwards, it should be considered as `24:00`
390
+ if (msec === 0 && step < 0) {
391
+ msec = 24 * 60 * 60 * 1000;
392
+ }
393
+
394
+ const stepMsec = step * 1000;
395
+ const diffToNext = msec % stepMsec;
396
+ if (stepMsec < 0 && diffToNext && precision) {
397
+ msec -= diffToNext;
398
+ } else if (stepMsec > 0 && diffToNext && precision) {
399
+ msec -= diffToNext - stepMsec;
400
+ } else {
401
+ msec += stepMsec;
402
+ }
403
+
404
+ const hh = Math.floor(msec / 1000 / 60 / 60);
405
+ msec -= hh * 1000 * 60 * 60;
406
+ const mm = Math.floor(msec / 1000 / 60);
407
+ msec -= mm * 1000 * 60;
408
+ const ss = Math.floor(msec / 1000);
409
+ msec -= ss * 1000;
410
+
411
+ return { hours: hh < 24 ? hh : 0, minutes: mm, seconds: ss, milliseconds: msec };
412
+ }
413
+
414
+ /** @private */
415
+ __updateDropdownItems(i18n, min, max, step) {
416
+ const minTimeObj = validateTime(parseISOTime(min || MIN_ALLOWED_TIME), step);
417
+ const minSec = this.__getSec(minTimeObj);
418
+
419
+ const maxTimeObj = validateTime(parseISOTime(max || MAX_ALLOWED_TIME), step);
420
+ const maxSec = this.__getSec(maxTimeObj);
421
+
422
+ this.__dropdownItems = this.__generateDropdownList(minSec, maxSec, step);
423
+
424
+ if (step !== this.__oldStep) {
425
+ this.__oldStep = step;
426
+ const parsedObj = validateTime(parseISOTime(this.value), step);
427
+ this.__updateValue(parsedObj);
428
+ }
429
+
430
+ if (this.value) {
431
+ this._comboBoxValue = i18n.formatTime(i18n.parseTime(this.value));
432
+ }
433
+ }
434
+
435
+ /** @private */
436
+ __updateAriaAttributes(items, opened, input) {
437
+ if (items === undefined || input === undefined) {
438
+ return;
439
+ }
440
+
441
+ if (items.length === 0) {
442
+ input.removeAttribute('role');
443
+ input.removeAttribute('aria-expanded');
444
+ } else {
445
+ input.setAttribute('role', 'combobox');
446
+ input.setAttribute('aria-expanded', !!opened);
447
+ }
448
+ }
449
+
450
+ /** @private */
451
+ __generateDropdownList(minSec, maxSec, step) {
452
+ if (step < 15 * 60 || !this.__validDayDivisor(step)) {
453
+ return [];
454
+ }
455
+
456
+ const generatedList = [];
457
+
458
+ // Default step in overlay items is 1 hour
459
+ if (!step) {
460
+ step = 3600;
461
+ }
462
+
463
+ let time = -step + minSec;
464
+ while (time + step >= minSec && time + step <= maxSec) {
465
+ const timeObj = validateTime(this.__addStep(time * 1000, step), step);
466
+ time += step;
467
+ const formatted = this.i18n.formatTime(timeObj);
468
+ generatedList.push({ label: formatted, value: formatted });
469
+ }
470
+
471
+ return generatedList;
472
+ }
473
+
474
+ /**
475
+ * Override an observer from `InputMixin`.
476
+ * @protected
477
+ * @override
478
+ */
479
+ _valueChanged(value, oldValue) {
480
+ const parsedObj = (this.__memoValue = parseISOTime(value));
481
+ const newValue = formatISOTime(parsedObj) || '';
482
+
483
+ // Mark value set programmatically by the user
484
+ // as committed for the change event detection.
485
+ if (!this.__keepCommittedValue) {
486
+ this.__committedValue = value;
487
+ this.__committedUnparsableValue = '';
488
+ }
489
+
490
+ if (value !== '' && value !== null && !parsedObj) {
491
+ // Value can not be parsed, reset to the old one.
492
+ this.value = oldValue === undefined ? '' : oldValue;
493
+ } else if (value !== newValue) {
494
+ // Value can be parsed (e.g. 12 -> 12:00), adjust.
495
+ this.value = newValue;
496
+ } else if (this.__keepInvalidInput) {
497
+ // User input could not be parsed and was reset
498
+ // to empty string, do not update input value.
499
+ delete this.__keepInvalidInput;
500
+ } else {
501
+ this.__updateInputValue(parsedObj);
502
+ }
503
+
504
+ this._toggleHasValue(this._hasValue);
505
+ }
506
+
507
+ /** @private */
508
+ __comboBoxValueChanged(value, oldValue) {
509
+ if (value === '' && oldValue === undefined) {
510
+ return;
511
+ }
512
+
513
+ const parsedObj = this.__useMemo ? this.__memoValue : this.i18n.parseTime(value);
514
+ const newValue = this.i18n.formatTime(parsedObj) || '';
515
+
516
+ if (parsedObj) {
517
+ if (value !== newValue) {
518
+ this._comboBoxValue = newValue;
519
+ } else {
520
+ this.__keepCommittedValue = true;
521
+ this.__updateValue(parsedObj);
522
+ this.__keepCommittedValue = false;
523
+ }
524
+ } else {
525
+ // If the user input can not be parsed, set a flag
526
+ // that prevents `__valueChanged` from removing the input
527
+ // after setting the value property to an empty string below.
528
+ if (this.value !== '' && value !== '') {
529
+ this.__keepInvalidInput = true;
530
+ }
531
+
532
+ this.__keepCommittedValue = true;
533
+ this.value = '';
534
+ this.__keepCommittedValue = false;
535
+ }
536
+ }
537
+
538
+ /** @private */
539
+ __onComboBoxChange(event) {
540
+ event.stopPropagation();
541
+ this.__commitValueChange();
542
+ }
543
+
544
+ /**
545
+ * Synchronizes the `_hasInputValue` property with the internal combo-box's one.
546
+ *
547
+ * @private
548
+ */
549
+ __onComboBoxHasInputValueChanged() {
550
+ this._hasInputValue = this.$.comboBox._hasInputValue;
551
+ }
552
+
553
+ /** @private */
554
+ __updateValue(obj) {
555
+ const timeString = formatISOTime(validateTime(obj, this.step)) || '';
556
+ this.value = timeString;
557
+ }
558
+
559
+ /** @private */
560
+ __updateInputValue(obj) {
561
+ const timeString = this.i18n.formatTime(validateTime(obj, this.step)) || '';
562
+ this._comboBoxValue = timeString;
563
+ }
564
+
565
+ /**
566
+ * Returns true if `time` satisfies the `min` and `max` constraints (if any).
567
+ *
568
+ * @param {!TimePickerTime} time Value to check against constraints
569
+ * @return {boolean} True if `time` satisfies the constraints
570
+ * @protected
571
+ */
572
+ _timeAllowed(time) {
573
+ const parsedMin = this.i18n.parseTime(this.min || MIN_ALLOWED_TIME);
574
+ const parsedMax = this.i18n.parseTime(this.max || MAX_ALLOWED_TIME);
575
+
576
+ return (
577
+ (!this.__getMsec(parsedMin) || this.__getMsec(time) >= this.__getMsec(parsedMin)) &&
578
+ (!this.__getMsec(parsedMax) || this.__getMsec(time) <= this.__getMsec(parsedMax))
579
+ );
580
+ }
581
+
582
+ /**
583
+ * Override method inherited from `InputControlMixin`.
584
+ * @protected
585
+ */
586
+ _onClearButtonClick() {}
587
+
588
+ /**
589
+ * Override method inherited from `InputConstraintsMixin`.
590
+ * @protected
591
+ */
592
+ _onChange() {}
593
+
594
+ /**
595
+ * Override method inherited from `InputMixin`.
596
+ * @protected
597
+ */
598
+ _onInput() {}
599
+
600
+ /**
601
+ * Fired when the user commits a value change.
602
+ *
603
+ * @event change
604
+ */
605
+ };