@aquera/nile-elements 2.0.19 → 2.0.20
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.
- package/README.md +5 -0
- package/demo/index.js +0 -1
- package/dist/index.cjs.js +1 -1
- package/dist/index.esm.js +1 -1
- package/dist/index.js +1254 -488
- package/dist/nile-badge/index.cjs.js +1 -1
- package/dist/nile-badge/index.esm.js +1 -1
- package/dist/nile-badge/nile-badge.cjs.js +1 -1
- package/dist/nile-badge/nile-badge.cjs.js.map +1 -1
- package/dist/nile-badge/nile-badge.css.cjs.js +1 -1
- package/dist/nile-badge/nile-badge.css.cjs.js.map +1 -1
- package/dist/nile-badge/nile-badge.css.esm.js +24 -3
- package/dist/nile-badge/nile-badge.esm.js +5 -3
- package/dist/nile-calendar/index.cjs.js +1 -1
- package/dist/nile-calendar/index.esm.js +1 -1
- package/dist/nile-calendar/nile-calendar.cjs.js +2 -1
- package/dist/nile-calendar/nile-calendar.cjs.js.map +1 -1
- package/dist/nile-calendar/nile-calendar.css.cjs.js +1 -1
- package/dist/nile-calendar/nile-calendar.css.cjs.js.map +1 -1
- package/dist/nile-calendar/nile-calendar.css.esm.js +357 -2
- package/dist/nile-calendar/nile-calendar.esm.js +365 -41
- package/dist/nile-calendar/timezones.cjs.js +1 -1
- package/dist/nile-calendar/timezones.cjs.js.map +1 -1
- package/dist/nile-calendar/timezones.esm.js +1 -1
- package/dist/nile-date-time-picker/index.cjs.js +2 -0
- package/dist/nile-date-time-picker/index.cjs.js.map +1 -0
- package/dist/nile-date-time-picker/index.esm.js +1 -0
- package/dist/nile-date-time-picker/nile-date-time-picker.cjs.js +2 -0
- package/dist/nile-date-time-picker/nile-date-time-picker.cjs.js.map +1 -0
- package/dist/nile-date-time-picker/nile-date-time-picker.css.cjs.js +2 -0
- package/dist/nile-date-time-picker/nile-date-time-picker.css.cjs.js.map +1 -0
- package/dist/nile-date-time-picker/nile-date-time-picker.css.esm.js +13 -0
- package/dist/nile-date-time-picker/nile-date-time-picker.esm.js +53 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/nile-badge/nile-badge.css.js +24 -3
- package/dist/src/nile-badge/nile-badge.css.js.map +1 -1
- package/dist/src/nile-badge/nile-badge.d.ts +7 -4
- package/dist/src/nile-badge/nile-badge.js +22 -5
- package/dist/src/nile-badge/nile-badge.js.map +1 -1
- package/dist/src/nile-badge/nile-badge.test.js +88 -0
- package/dist/src/nile-badge/nile-badge.test.js.map +1 -1
- package/dist/src/nile-calendar/nile-calendar.css.js +355 -0
- package/dist/src/nile-calendar/nile-calendar.css.js.map +1 -1
- package/dist/src/nile-calendar/nile-calendar.d.ts +175 -0
- package/dist/src/nile-calendar/nile-calendar.js +1726 -26
- package/dist/src/nile-calendar/nile-calendar.js.map +1 -1
- package/dist/src/nile-calendar/nile-calendar.test.d.ts +1 -0
- package/dist/src/nile-calendar/nile-calendar.test.js +3580 -0
- package/dist/src/nile-calendar/nile-calendar.test.js.map +1 -1
- package/dist/src/nile-calendar/timezones.d.ts +21 -0
- package/dist/src/nile-calendar/timezones.js +49 -0
- package/dist/src/nile-calendar/timezones.js.map +1 -1
- package/dist/src/nile-date-time-picker/index.d.ts +1 -0
- package/dist/src/nile-date-time-picker/index.js +2 -0
- package/dist/src/nile-date-time-picker/index.js.map +1 -0
- package/dist/src/nile-date-time-picker/nile-date-time-picker.css.d.ts +11 -0
- package/dist/src/nile-date-time-picker/nile-date-time-picker.css.js +49 -0
- package/dist/src/nile-date-time-picker/nile-date-time-picker.css.js.map +1 -0
- package/dist/src/nile-date-time-picker/nile-date-time-picker.d.ts +173 -0
- package/dist/src/nile-date-time-picker/nile-date-time-picker.js +1270 -0
- package/dist/src/nile-date-time-picker/nile-date-time-picker.js.map +1 -0
- package/dist/src/nile-date-time-picker/nile-date-time-picker.test.d.ts +143 -0
- package/dist/src/nile-date-time-picker/nile-date-time-picker.test.js +7277 -0
- package/dist/src/nile-date-time-picker/nile-date-time-picker.test.js.map +1 -0
- package/dist/src/version.js +1 -1
- package/dist/src/version.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/utilities/animation-registry.cjs.js +1 -1
- package/dist/utilities/animation-registry.cjs.js.map +1 -1
- package/dist/utilities/animation-registry.esm.js +1 -1
- package/package.json +3 -1
- package/scripts/coverage-report.mjs +96 -0
- package/src/index.ts +1 -0
- package/src/nile-badge/nile-badge.css.ts +24 -3
- package/src/nile-badge/nile-badge.test.ts +102 -0
- package/src/nile-badge/nile-badge.ts +28 -5
- package/src/nile-calendar/nile-calendar.css.ts +355 -0
- package/src/nile-calendar/nile-calendar.test.ts +3793 -0
- package/src/nile-calendar/nile-calendar.ts +1760 -28
- package/src/nile-calendar/timezones.ts +57 -0
- package/src/nile-date-time-picker/index.ts +1 -0
- package/src/nile-date-time-picker/nile-date-time-picker.css.ts +54 -0
- package/src/nile-date-time-picker/nile-date-time-picker.test.ts +7886 -0
- package/src/nile-date-time-picker/nile-date-time-picker.ts +1153 -0
- package/vscode-html-custom-data.json +161 -6
|
@@ -0,0 +1,1270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright Aquera Inc 2026
|
|
3
|
+
*
|
|
4
|
+
* This source code is licensed under the BSD-3-Clause license found in the
|
|
5
|
+
* LICENSE file in the root directory of this source tree.
|
|
6
|
+
*/
|
|
7
|
+
var NileDateTimePicker_1;
|
|
8
|
+
import { __decorate } from "tslib";
|
|
9
|
+
import { html } from 'lit';
|
|
10
|
+
import { customElement, property, query } from 'lit/decorators.js';
|
|
11
|
+
import { ifDefined } from 'lit/directives/if-defined.js';
|
|
12
|
+
import { createRef, ref } from 'lit/directives/ref.js';
|
|
13
|
+
import { styles } from './nile-date-time-picker.css';
|
|
14
|
+
import NileElement from '../internal/nile-element';
|
|
15
|
+
import '../nile-floating-panel';
|
|
16
|
+
import '../nile-calendar';
|
|
17
|
+
import '../nile-input';
|
|
18
|
+
import { isValidTimezone, utcFromFields, localDateFromFields, DEFAULT_START_YEAR, DEFAULT_END_YEAR } from '../nile-calendar/timezones';
|
|
19
|
+
import { instantFromValueString, wallClockFromOffsetLess, wallClockInZone } from '../nile-calendar/nile-calendar';
|
|
20
|
+
/** Accessible name for the trigger input when a consumer sets neither `label` nor `aria-label`. */
|
|
21
|
+
const DEFAULT_TRIGGER_LABEL = 'Date and time';
|
|
22
|
+
/**
|
|
23
|
+
* @tag nile-date-time-picker
|
|
24
|
+
* @event nile-change - Absolute `{ value, localTimezoneValue, unixEpoch, timezone }` (`unixEpoch` = ms since 1970, null when empty); relative `{ startDate, endDate, timezone }`.
|
|
25
|
+
* @event nile-clear
|
|
26
|
+
*/
|
|
27
|
+
let NileDateTimePicker = NileDateTimePicker_1 = class NileDateTimePicker extends NileElement {
|
|
28
|
+
constructor() {
|
|
29
|
+
super(...arguments);
|
|
30
|
+
this.showMonthDropdown = false;
|
|
31
|
+
this.showYearDropdown = false;
|
|
32
|
+
this.type = 'absolute';
|
|
33
|
+
/** Show the Absolute / Relative type switcher. Hidden by default; set to reveal it. */
|
|
34
|
+
this.showTypes = false;
|
|
35
|
+
this.hideDurationFields = [];
|
|
36
|
+
this.hideTimeFields = [];
|
|
37
|
+
/** Selected date/time for the inner `nile-calendar`: an instant, an offset-less wall clock read in `timezone`, the bracketed form, or date-only — all different. `unixEpoch` is the authoritative instant. */
|
|
38
|
+
this.value = null;
|
|
39
|
+
this.timezone = 'UTC';
|
|
40
|
+
this.allowedDates = JSON.stringify({});
|
|
41
|
+
this.dateFormat = 'DD/MM/YY';
|
|
42
|
+
this.placeholder = '';
|
|
43
|
+
this.label = '';
|
|
44
|
+
this.accessibleLabel = '';
|
|
45
|
+
this.doubleClickUnselect = false;
|
|
46
|
+
this.disabled = false;
|
|
47
|
+
this.open = false;
|
|
48
|
+
this.calendarRef = createRef();
|
|
49
|
+
this._openSyncFromPanel = false;
|
|
50
|
+
this._skipNextSeed = false;
|
|
51
|
+
/** true once the user has typed/pasted/deleted in the mask since the last seed, so only user edits are clamped to allowedDates */
|
|
52
|
+
this._maskEdited = false;
|
|
53
|
+
/** the zone the consumer configured (first render, or any later external change), restored on Clear instead of forcing UTC */
|
|
54
|
+
this._defaultTimezone = 'UTC';
|
|
55
|
+
/** the last zone this component assigned itself, so `updated` can tell it apart from a consumer change */
|
|
56
|
+
this._internalTimezone = null;
|
|
57
|
+
/** keep `open` in sync with the panel's own show/hide (click, outside-click, Escape) */
|
|
58
|
+
/** `performance.now()` of an Escape pressed while focus was inside the panel; 0 when there is none to honour */
|
|
59
|
+
this._escapeFromPanelAt = 0;
|
|
60
|
+
/**
|
|
61
|
+
* The panel hides on a document-level Escape and drops focus to <body> when the focused control disappears with it.
|
|
62
|
+
* Runs in the capture phase, before the panel's own handler, to note whether focus was inside the panel; onPanelHide
|
|
63
|
+
* then hands focus back to the trigger. Escape with focus anywhere else leaves focus where it is.
|
|
64
|
+
*/
|
|
65
|
+
this.onEscapeKeydown = (e) => {
|
|
66
|
+
if (e.key === 'Tab') {
|
|
67
|
+
this.onPanelTab(e);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (e.key !== 'Escape' || !this.open)
|
|
71
|
+
return;
|
|
72
|
+
this._escapeFromPanelAt = this.focusIsInPanel() ? performance.now() : 0;
|
|
73
|
+
};
|
|
74
|
+
/** ArrowDown / Alt+ArrowDown on the field opens the panel and moves focus into it. Enter, Space and typing are untouched. */
|
|
75
|
+
this.onTriggerKeydown = (e) => {
|
|
76
|
+
if (e.key !== 'ArrowDown' || e.defaultPrevented || e.ctrlKey || e.metaKey || e.shiftKey)
|
|
77
|
+
return;
|
|
78
|
+
if (this.disabled || this.open)
|
|
79
|
+
return;
|
|
80
|
+
e.preventDefault();
|
|
81
|
+
this.panel?.show?.()?.then(() => this.focusIntoPanel()).catch(() => { });
|
|
82
|
+
};
|
|
83
|
+
this.onPanelShow = (e) => {
|
|
84
|
+
e.stopPropagation();
|
|
85
|
+
this.applyDialogName();
|
|
86
|
+
document.addEventListener('keydown', this.onEscapeKeydown, true);
|
|
87
|
+
this.syncOpenFromPanel(true);
|
|
88
|
+
if (this._showCommittedOnOpen)
|
|
89
|
+
this.showSelectedMonth();
|
|
90
|
+
this._showCommittedOnOpen = false;
|
|
91
|
+
};
|
|
92
|
+
/** set by Apply: the next open lands on the committed date, not on whatever year/month was browsed before it */
|
|
93
|
+
this._showCommittedOnOpen = false;
|
|
94
|
+
this.onPanelHide = (e) => {
|
|
95
|
+
e.stopPropagation();
|
|
96
|
+
document.removeEventListener('keydown', this.onEscapeKeydown, true);
|
|
97
|
+
this.syncOpenFromPanel(false);
|
|
98
|
+
const fromPanel = this._escapeFromPanelAt && performance.now() - this._escapeFromPanelAt < 1000;
|
|
99
|
+
this._escapeFromPanelAt = 0;
|
|
100
|
+
if (fromPanel)
|
|
101
|
+
this.triggerInput?.focus?.();
|
|
102
|
+
};
|
|
103
|
+
this.maskValues = [];
|
|
104
|
+
this.maskSuffix = '';
|
|
105
|
+
/** the slot values as last seeded/committed from `value`; blur clamps only segments that differ from it (the user's edits) */
|
|
106
|
+
this.seedMask = null;
|
|
107
|
+
/** the seeded value's full year, set wherever `seedMask` is: a `YY` mask holds only its last two digits */
|
|
108
|
+
this.seedYear = null;
|
|
109
|
+
/** set when a YY year with no valid two-digit form was blanked; until a year is typed (or the mask is reseeded) the calendar keeps no selection built from day and month alone */
|
|
110
|
+
this._yearRejected = false;
|
|
111
|
+
this.onMaskBlur = () => {
|
|
112
|
+
if (this.type !== 'absolute')
|
|
113
|
+
return;
|
|
114
|
+
const nileInput = this.triggerInput;
|
|
115
|
+
const native = nileInput?.input;
|
|
116
|
+
if (!native)
|
|
117
|
+
return;
|
|
118
|
+
const layout = this.maskLayout();
|
|
119
|
+
if (this.maskValues.length !== layout.slots.length)
|
|
120
|
+
this.initMaskState(native.value);
|
|
121
|
+
if (!this.maskValues.some(v => v !== '')) {
|
|
122
|
+
native.value = '';
|
|
123
|
+
nileInput.value = '';
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const segStarts = [...new Set(layout.slots.map(s => s.segStart))];
|
|
127
|
+
for (const start of segStarts) {
|
|
128
|
+
const idxs = layout.slots.map((s, i) => (s.segStart === start ? i : -1)).filter(i => i !== -1);
|
|
129
|
+
const digits = idxs.map(i => this.maskValues[i]).filter(c => c !== '');
|
|
130
|
+
if (digits.length > 0 && digits.length < idxs.length) {
|
|
131
|
+
const padded = digits.join('').padStart(idxs.length, '0');
|
|
132
|
+
idxs.forEach((i, k) => { this.maskValues[i] = padded[k]; });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// a seeded value is not a user edit: clamping it to startYear/endYear, or re-reading a YY year as 20xx into the calendar, would diverge from `value`
|
|
136
|
+
const edited = this._maskEdited;
|
|
137
|
+
if (edited) {
|
|
138
|
+
// only segments the user changed since the seed: an untouched out-of-range year must survive an edit to another segment
|
|
139
|
+
const seed = this.seedMask?.length === this.maskValues.length ? this.seedMask : null;
|
|
140
|
+
segStarts.forEach(start => {
|
|
141
|
+
const touched = !seed
|
|
142
|
+
|| layout.slots.some((sl, i) => sl.segStart === start && this.maskValues[i] !== seed[i]);
|
|
143
|
+
if (touched)
|
|
144
|
+
this.clampSegment(this.maskValues, layout, start);
|
|
145
|
+
});
|
|
146
|
+
this.clampDay(this.maskValues, layout);
|
|
147
|
+
}
|
|
148
|
+
const { display } = this.computeDisplay(this.maskValues, this.maskSuffix, layout);
|
|
149
|
+
native.value = display;
|
|
150
|
+
nileInput.value = display;
|
|
151
|
+
if (edited)
|
|
152
|
+
this.syncCalendarFromMask();
|
|
153
|
+
this.seedMask = [...this.maskValues];
|
|
154
|
+
this._maskEdited = false;
|
|
155
|
+
};
|
|
156
|
+
this.onMaskPaste = (e) => {
|
|
157
|
+
if (this.type !== 'absolute' || this.disabled)
|
|
158
|
+
return;
|
|
159
|
+
const nileInput = this.triggerInput;
|
|
160
|
+
const native = nileInput?.input;
|
|
161
|
+
if (!native)
|
|
162
|
+
return;
|
|
163
|
+
const text = e.clipboardData?.getData('text') ?? '';
|
|
164
|
+
e.preventDefault();
|
|
165
|
+
if (!text)
|
|
166
|
+
return;
|
|
167
|
+
const selStart = native.selectionStart ?? native.value.length;
|
|
168
|
+
const selEnd = native.selectionEnd ?? selStart;
|
|
169
|
+
const layout = this.maskLayout();
|
|
170
|
+
if (this.maskValues.length !== layout.slots.length)
|
|
171
|
+
this.initMaskState(native.value);
|
|
172
|
+
const { slotPos } = this.computeDisplay(this.maskValues, this.maskSuffix, layout);
|
|
173
|
+
// slot index the caret/selection starts at
|
|
174
|
+
let first = slotPos.findIndex(pos => pos !== -1 && pos >= selStart);
|
|
175
|
+
if (first === -1)
|
|
176
|
+
first = this.maskValues.findIndex(v => v === '');
|
|
177
|
+
if (first === -1)
|
|
178
|
+
first = 0;
|
|
179
|
+
let last = first;
|
|
180
|
+
slotPos.forEach((pos, i) => { if (pos !== -1 && pos < selEnd)
|
|
181
|
+
last = Math.max(last, i + 1); });
|
|
182
|
+
const digits = text.replace(/\D/g, '');
|
|
183
|
+
if (!digits)
|
|
184
|
+
return;
|
|
185
|
+
this._maskEdited = true;
|
|
186
|
+
const values = [...this.maskValues];
|
|
187
|
+
if (selEnd > selStart)
|
|
188
|
+
for (let i = first; i < Math.min(last, values.length); i++)
|
|
189
|
+
values[i] = '';
|
|
190
|
+
for (let k = 0; k < digits.length && first + k < values.length; k++)
|
|
191
|
+
values[first + k] = digits[k];
|
|
192
|
+
this.maskValues = values;
|
|
193
|
+
const segStarts = [...new Set(layout.slots.map(s => s.segStart))];
|
|
194
|
+
segStarts.forEach(start => this.clampSegment(this.maskValues, layout, start));
|
|
195
|
+
this.clampDay(this.maskValues, layout);
|
|
196
|
+
const out = this.computeDisplay(this.maskValues, this.maskSuffix, layout).display;
|
|
197
|
+
this.writeMask(nileInput, native, out, out.length);
|
|
198
|
+
this.syncCalendarFromMask();
|
|
199
|
+
};
|
|
200
|
+
/** Covers cut, undo/redo, drag-drop, IME and soft-keyboard input, none of which arrive as a usable keydown. */
|
|
201
|
+
this.onMaskBeforeInput = (e) => {
|
|
202
|
+
if (this.type !== 'absolute' || this.disabled)
|
|
203
|
+
return;
|
|
204
|
+
// digits typed on a desktop keyboard are handled by onMaskKeydown (which preventDefaults them)
|
|
205
|
+
const t = e.inputType;
|
|
206
|
+
if (t === 'insertFromPaste')
|
|
207
|
+
return; // handled by onMaskPaste
|
|
208
|
+
const nileInput = this.triggerInput;
|
|
209
|
+
const native = nileInput?.input;
|
|
210
|
+
if (!native)
|
|
211
|
+
return;
|
|
212
|
+
const handled = ['insertText', 'insertCompositionText', 'insertReplacementText', 'insertFromDrop',
|
|
213
|
+
'deleteByCut', 'deleteByDrag', 'deleteContentBackward', 'deleteContentForward', 'historyUndo', 'historyRedo'];
|
|
214
|
+
if (!handled.includes(t))
|
|
215
|
+
return;
|
|
216
|
+
// let the browser apply the edit, then rebuild the slots from the resulting text
|
|
217
|
+
setTimeout(() => this.reflowMaskFromNative(nileInput, native), 0);
|
|
218
|
+
};
|
|
219
|
+
this.onMaskKeydown = (e) => {
|
|
220
|
+
if (this.type !== 'absolute')
|
|
221
|
+
return;
|
|
222
|
+
if (this.disabled || e.ctrlKey || e.metaKey || e.altKey)
|
|
223
|
+
return;
|
|
224
|
+
const nav = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End',
|
|
225
|
+
'Tab', 'Enter', 'Shift', 'Control', 'Meta', 'Alt', 'CapsLock'];
|
|
226
|
+
if (nav.includes(e.key))
|
|
227
|
+
return;
|
|
228
|
+
const nileInput = this.triggerInput;
|
|
229
|
+
const native = nileInput?.input;
|
|
230
|
+
if (!native)
|
|
231
|
+
return;
|
|
232
|
+
const layout = this.maskLayout();
|
|
233
|
+
if (this.maskValues.length !== layout.slots.length)
|
|
234
|
+
this.initMaskState(native.value);
|
|
235
|
+
const values = this.maskValues;
|
|
236
|
+
const { slotPos } = this.computeDisplay(values, this.maskSuffix, layout);
|
|
237
|
+
const selStart = native.selectionStart ?? 0;
|
|
238
|
+
const selEnd = native.selectionEnd ?? selStart;
|
|
239
|
+
// caret position to place after editing slot `target`
|
|
240
|
+
const caretAfter = (target) => {
|
|
241
|
+
const after = this.computeDisplay(values, this.maskSuffix, layout);
|
|
242
|
+
const np = after.slotPos[target + 1];
|
|
243
|
+
if (np !== undefined && np !== -1)
|
|
244
|
+
return np;
|
|
245
|
+
const suffixLen = after.lastFilled === layout.slots.length - 1 ? this.maskSuffix.length : 0;
|
|
246
|
+
return after.display.length - suffixLen;
|
|
247
|
+
};
|
|
248
|
+
const commit = (target, sync = true) => {
|
|
249
|
+
this.writeMask(nileInput, native, this.computeDisplay(values, this.maskSuffix, layout).display, caretAfter(target));
|
|
250
|
+
if (sync)
|
|
251
|
+
this.syncCalendarFromMask(); // reflect a complete manual edit back into the panel
|
|
252
|
+
};
|
|
253
|
+
const full = this.computeDisplay(values, this.maskSuffix, layout).display;
|
|
254
|
+
const timeEndPos = full.length - this.maskSuffix.length;
|
|
255
|
+
if ((e.key === 'Backspace' || e.key === 'Delete') && this.maskSuffix && selStart > timeEndPos) {
|
|
256
|
+
e.preventDefault();
|
|
257
|
+
this.maskSuffix = '';
|
|
258
|
+
const after = this.computeDisplay(values, this.maskSuffix, layout);
|
|
259
|
+
this.writeMask(nileInput, native, after.display, after.display.length);
|
|
260
|
+
this.syncCalendarFromMask();
|
|
261
|
+
this._skipNextSeed = true;
|
|
262
|
+
this.setTimezoneInternally('UTC');
|
|
263
|
+
if (this.value) {
|
|
264
|
+
this.value = this.value.replace(/\[GMT[+-]\d{2}:\d{2}\s+[^\]]+\]$/, '[GMT+00:00 UTC]');
|
|
265
|
+
}
|
|
266
|
+
this.updateComplete
|
|
267
|
+
.then(() => { this._skipNextSeed = false; })
|
|
268
|
+
.catch(() => { this._skipNextSeed = false; });
|
|
269
|
+
const cal = this.calendarRef.value;
|
|
270
|
+
if (cal) {
|
|
271
|
+
cal.timezone = 'UTC';
|
|
272
|
+
cal.updateComplete
|
|
273
|
+
?.then(() => cal.syncInnerCalendar?.())
|
|
274
|
+
.catch(() => { });
|
|
275
|
+
}
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if ((e.key >= '0' && e.key <= '9') || e.key === 'Backspace' || e.key === 'Delete')
|
|
279
|
+
this._maskEdited = true;
|
|
280
|
+
if (e.key >= '0' && e.key <= '9') {
|
|
281
|
+
e.preventDefault();
|
|
282
|
+
let target = slotPos.findIndex(p => p !== -1 && p >= selStart);
|
|
283
|
+
if (target === -1)
|
|
284
|
+
target = values.findIndex(v => v === ''); // append at the first gap
|
|
285
|
+
if (target === -1)
|
|
286
|
+
return; // every slot full and caret past the end
|
|
287
|
+
values[target] = e.key;
|
|
288
|
+
const typed = layout.slots[target];
|
|
289
|
+
if (typed.kind === 'year') {
|
|
290
|
+
// overtyping a seeded year leaves every slot filled while it is still half old, half new: the year is only
|
|
291
|
+
// complete once its last digit lands, so range-clamping it (or the day against it) earlier would act on a number nobody typed
|
|
292
|
+
const last = layout.slots.reduce((m, s, i) => (s.segStart === typed.segStart ? i : m), target);
|
|
293
|
+
if (target !== last) {
|
|
294
|
+
commit(target, false);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
this.settleYear(values, layout);
|
|
300
|
+
}
|
|
301
|
+
this.clampSegment(values, layout, target);
|
|
302
|
+
this.clampDay(values, layout);
|
|
303
|
+
commit(target);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (e.key === 'Backspace') {
|
|
307
|
+
e.preventDefault();
|
|
308
|
+
if (selStart !== selEnd) {
|
|
309
|
+
for (let i = 0; i < values.length; i++) {
|
|
310
|
+
if (slotPos[i] !== -1 && slotPos[i] >= selStart && slotPos[i] < selEnd)
|
|
311
|
+
values[i] = '';
|
|
312
|
+
}
|
|
313
|
+
this.writeMask(nileInput, native, this.computeDisplay(values, this.maskSuffix, layout).display, selStart);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
let target = -1;
|
|
317
|
+
for (let i = 0; i < values.length; i++) {
|
|
318
|
+
if (values[i] !== '' && slotPos[i] !== -1 && slotPos[i] < selStart)
|
|
319
|
+
target = i;
|
|
320
|
+
}
|
|
321
|
+
if (target === -1)
|
|
322
|
+
return;
|
|
323
|
+
values[target] = '';
|
|
324
|
+
const after = this.computeDisplay(values, this.maskSuffix, layout);
|
|
325
|
+
this.writeMask(nileInput, native, after.display, after.slotPos[target] !== -1 ? after.slotPos[target] : Math.max(0, selStart - 1));
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (e.key === 'Delete') {
|
|
329
|
+
e.preventDefault();
|
|
330
|
+
let target = -1;
|
|
331
|
+
for (let i = 0; i < values.length; i++) {
|
|
332
|
+
if (values[i] !== '' && slotPos[i] !== -1 && slotPos[i] >= selStart) {
|
|
333
|
+
target = i;
|
|
334
|
+
break;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
if (target === -1)
|
|
338
|
+
return;
|
|
339
|
+
values[target] = '';
|
|
340
|
+
const after = this.computeDisplay(values, this.maskSuffix, layout);
|
|
341
|
+
this.writeMask(nileInput, native, after.display, after.slotPos[target] !== -1 ? after.slotPos[target] : selStart);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (e.key.length === 1)
|
|
345
|
+
e.preventDefault();
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
static get styles() {
|
|
349
|
+
return [styles];
|
|
350
|
+
}
|
|
351
|
+
get structuredInput() { return true; }
|
|
352
|
+
setTimezoneInternally(tz) {
|
|
353
|
+
this._internalTimezone = tz;
|
|
354
|
+
this.timezone = tz;
|
|
355
|
+
}
|
|
356
|
+
get resolvedTimeFields() {
|
|
357
|
+
const hidden = Array.isArray(this.hideTimeFields) ? this.hideTimeFields : [];
|
|
358
|
+
const keys = ['hours', 'minutes', 'seconds', 'milliseconds', 'timezone'];
|
|
359
|
+
const out = {};
|
|
360
|
+
for (const k of keys)
|
|
361
|
+
out[k] = !hidden.includes(k);
|
|
362
|
+
return out;
|
|
363
|
+
}
|
|
364
|
+
get shownTimeFields() {
|
|
365
|
+
const f = this.resolvedTimeFields;
|
|
366
|
+
return NileDateTimePicker_1.TIME_SPEC.filter(t => f[t.key] !== false);
|
|
367
|
+
}
|
|
368
|
+
timeSep(kind) {
|
|
369
|
+
return kind === 'SSS' ? '.' : ':';
|
|
370
|
+
}
|
|
371
|
+
get showTimezone() {
|
|
372
|
+
return this.resolvedTimeFields.timezone !== false;
|
|
373
|
+
}
|
|
374
|
+
get defaultPlaceholder() {
|
|
375
|
+
const time = this.shownTimeFields;
|
|
376
|
+
let s = this.dateFormat || 'DD/MM/YY';
|
|
377
|
+
if (time.length) {
|
|
378
|
+
const timeStr = time
|
|
379
|
+
.map((t, i) => (i > 0 ? this.timeSep(t.kind) : '') + t.ph)
|
|
380
|
+
.join('');
|
|
381
|
+
s += ` - ${timeStr}`;
|
|
382
|
+
}
|
|
383
|
+
if (this.showTimezone)
|
|
384
|
+
s += ' - timezone';
|
|
385
|
+
return s;
|
|
386
|
+
}
|
|
387
|
+
get triggerInput() {
|
|
388
|
+
return this.shadowRoot?.querySelector('nile-input') ?? null;
|
|
389
|
+
}
|
|
390
|
+
get calendarElement() {
|
|
391
|
+
return this.calendarRef.value ?? null;
|
|
392
|
+
}
|
|
393
|
+
connectedCallback() {
|
|
394
|
+
super.connectedCallback();
|
|
395
|
+
// firstUpdated runs once; a re-attach (DOM move) must restore the mask listeners disconnectedCallback removed
|
|
396
|
+
if (this.hasUpdated)
|
|
397
|
+
this.bindStructuredInput();
|
|
398
|
+
this.emit('nile-init');
|
|
399
|
+
}
|
|
400
|
+
disconnectedCallback() {
|
|
401
|
+
super.disconnectedCallback();
|
|
402
|
+
document.removeEventListener('keydown', this.onEscapeKeydown, true);
|
|
403
|
+
const nileInput = this.triggerInput;
|
|
404
|
+
nileInput?.removeEventListener('keydown', this.onMaskKeydown, true);
|
|
405
|
+
nileInput?.removeEventListener('focusout', this.onMaskBlur);
|
|
406
|
+
nileInput?.removeEventListener('paste', this.onMaskPaste, true);
|
|
407
|
+
nileInput?.removeEventListener('beforeinput', this.onMaskBeforeInput, true);
|
|
408
|
+
nileInput?.removeEventListener('keydown', this.onTriggerKeydown);
|
|
409
|
+
this.emit('nile-destroy');
|
|
410
|
+
}
|
|
411
|
+
syncOpenFromPanel(open) {
|
|
412
|
+
if (this.open === open)
|
|
413
|
+
return;
|
|
414
|
+
this._openSyncFromPanel = true;
|
|
415
|
+
this.open = open;
|
|
416
|
+
this.updateComplete
|
|
417
|
+
.then(() => { this._openSyncFromPanel = false; })
|
|
418
|
+
.catch(() => { this._openSyncFromPanel = false; });
|
|
419
|
+
}
|
|
420
|
+
/** the element that really holds focus, however deep in shadow roots */
|
|
421
|
+
deepActiveElement() {
|
|
422
|
+
let active = document.activeElement;
|
|
423
|
+
while (active?.shadowRoot?.activeElement)
|
|
424
|
+
active = active.shadowRoot.activeElement;
|
|
425
|
+
return active;
|
|
426
|
+
}
|
|
427
|
+
/** tab stops inside the panel in DOM order, looking through shadow roots (the panel is portaled to <body>) */
|
|
428
|
+
panelTabbables() {
|
|
429
|
+
const cal = this.calendarRef.value;
|
|
430
|
+
if (!cal)
|
|
431
|
+
return [];
|
|
432
|
+
const out = [];
|
|
433
|
+
const walk = (node) => {
|
|
434
|
+
for (const child of Array.from(node.children)) {
|
|
435
|
+
if (child.hidden || child.hasAttribute('inert'))
|
|
436
|
+
continue;
|
|
437
|
+
if (child.tabIndex >= 0 && !child.matches(':disabled') && child.getClientRects().length > 0
|
|
438
|
+
&& getComputedStyle(child).visibility !== 'hidden')
|
|
439
|
+
out.push(child);
|
|
440
|
+
if (child.shadowRoot)
|
|
441
|
+
walk(child.shadowRoot);
|
|
442
|
+
walk(child);
|
|
443
|
+
}
|
|
444
|
+
};
|
|
445
|
+
if (cal.shadowRoot)
|
|
446
|
+
walk(cal.shadowRoot);
|
|
447
|
+
return out;
|
|
448
|
+
}
|
|
449
|
+
/** the panel's way in: the day that owns the grid's tab stop, else the first control */
|
|
450
|
+
focusIntoPanel() {
|
|
451
|
+
const tabbables = this.panelTabbables();
|
|
452
|
+
(tabbables.find(el => el.classList.contains('day_date')) ?? tabbables[0])?.focus();
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* The panel is portaled to the end of <body>, so the browser's own Tab order skips it (Chrome, Edge, Firefox) or
|
|
456
|
+
* lands in it from the wrong side. While it is open Tab from the field enters it at its first control, Shift+Tab from its first control
|
|
457
|
+
* returns to the field, and Tab from its last control hands focus back to the field so the browser's default move
|
|
458
|
+
* continues to the next page element (the panel then closes: it has been left). No trap: Shift+Tab from the field and
|
|
459
|
+
* every Tab outside the panel behave as usual.
|
|
460
|
+
*/
|
|
461
|
+
onPanelTab(e) {
|
|
462
|
+
if (!this.open || e.ctrlKey || e.metaKey || e.altKey)
|
|
463
|
+
return;
|
|
464
|
+
const native = this.triggerInput?.input;
|
|
465
|
+
const active = this.deepActiveElement();
|
|
466
|
+
if (!native || !active)
|
|
467
|
+
return;
|
|
468
|
+
if (active === native) {
|
|
469
|
+
if (e.shiftKey)
|
|
470
|
+
return;
|
|
471
|
+
const tabbables = this.panelTabbables();
|
|
472
|
+
if (!tabbables.length)
|
|
473
|
+
return;
|
|
474
|
+
e.preventDefault();
|
|
475
|
+
tabbables[0].focus();
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
const tabbables = this.panelTabbables();
|
|
479
|
+
const at = tabbables.indexOf(active);
|
|
480
|
+
if (at === -1)
|
|
481
|
+
return;
|
|
482
|
+
if (e.shiftKey && at === 0) {
|
|
483
|
+
e.preventDefault();
|
|
484
|
+
native.focus();
|
|
485
|
+
}
|
|
486
|
+
else if (!e.shiftKey && at === tabbables.length - 1) {
|
|
487
|
+
native.focus(); // no preventDefault: the default Tab now moves on from the field
|
|
488
|
+
this.panel?.hide?.();
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
focusIsInPanel() {
|
|
492
|
+
const cal = this.calendarRef.value;
|
|
493
|
+
const active = document.activeElement;
|
|
494
|
+
if (!cal || !active)
|
|
495
|
+
return false;
|
|
496
|
+
const popper = cal.closest('.nile-dtp-panel');
|
|
497
|
+
return active === cal || cal.contains(active) || (!!popper && (popper === active || popper.contains(active)));
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* nile-floating-panel creates the `role="dialog"` container itself, portals it out of this shadow root and gives it no
|
|
501
|
+
* name. Name it here after the field it belongs to, so the shared panel (and nile-context-menu) stays untouched.
|
|
502
|
+
*/
|
|
503
|
+
applyDialogName() {
|
|
504
|
+
const dialog = this.calendarRef.value?.closest('[role="dialog"]');
|
|
505
|
+
if (!dialog)
|
|
506
|
+
return;
|
|
507
|
+
const field = this.label.trim() || this.accessibleLabel.trim() || DEFAULT_TRIGGER_LABEL;
|
|
508
|
+
dialog.setAttribute('aria-label', `${field} picker`);
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* The month/year dropdowns only navigate, so after Apply the view can be left on a browsed month. Reopening then shows the
|
|
512
|
+
* committed date's month/year. Dismissing without Apply (Escape, outside click) never takes this path: the view and any
|
|
513
|
+
* pending selection stay as the user left them. With no selection the view is left alone.
|
|
514
|
+
*/
|
|
515
|
+
showSelectedMonth() {
|
|
516
|
+
const cal = this.calendarRef.value;
|
|
517
|
+
const selected = this.type === 'absolute' ? cal?.value : null;
|
|
518
|
+
if (!(selected instanceof Date) || isNaN(selected.getTime()))
|
|
519
|
+
return;
|
|
520
|
+
if (cal.currentYear === selected.getFullYear() && cal.currentMonth === selected.getMonth())
|
|
521
|
+
return;
|
|
522
|
+
cal.currentYear = selected.getFullYear();
|
|
523
|
+
cal.currentMonth = selected.getMonth();
|
|
524
|
+
cal.updateComplete
|
|
525
|
+
?.then(() => cal.syncInnerCalendar?.())
|
|
526
|
+
.catch(() => { });
|
|
527
|
+
}
|
|
528
|
+
willUpdate(changedProperties) {
|
|
529
|
+
super.willUpdate?.(changedProperties);
|
|
530
|
+
if (!this.hasUpdated)
|
|
531
|
+
this._defaultTimezone = this.timezone;
|
|
532
|
+
}
|
|
533
|
+
firstUpdated(changedProperties) {
|
|
534
|
+
super.firstUpdated?.(changedProperties);
|
|
535
|
+
this.bindStructuredInput();
|
|
536
|
+
this.seedTriggerFromValue();
|
|
537
|
+
this.applyTriggerAriaLabel();
|
|
538
|
+
}
|
|
539
|
+
updated(changedProperties) {
|
|
540
|
+
super.updated(changedProperties);
|
|
541
|
+
if (changedProperties.has('timezone')) {
|
|
542
|
+
if (this.timezone !== this._internalTimezone)
|
|
543
|
+
this._defaultTimezone = this.timezone;
|
|
544
|
+
this._internalTimezone = null;
|
|
545
|
+
}
|
|
546
|
+
// a live change to the display layout (not the first render) leaves the trigger text in the old layout; drop any draft and redraw from the committed value
|
|
547
|
+
const layoutChanged = ['dateFormat', 'hideTimeFields']
|
|
548
|
+
.some(p => changedProperties.has(p) && changedProperties.get(p) !== undefined);
|
|
549
|
+
// switching back to Absolute must not leave Relative text in the trigger; same redraw, from the retained committed absolute `value` (or empty)
|
|
550
|
+
const typeChanged = changedProperties.has('type') && changedProperties.get('type') !== undefined;
|
|
551
|
+
if ((layoutChanged || typeChanged) && this.type === 'absolute') {
|
|
552
|
+
this._skipNextSeed = false;
|
|
553
|
+
this._maskEdited = false;
|
|
554
|
+
if (this.value)
|
|
555
|
+
this.seedTriggerFromValue();
|
|
556
|
+
else
|
|
557
|
+
this.clearTriggerText(true);
|
|
558
|
+
}
|
|
559
|
+
else if (changedProperties.has('value') && !this.value && changedProperties.get('value')) {
|
|
560
|
+
// seedTriggerFromValue ignores an empty value, so a programmatic reset must clear the text itself
|
|
561
|
+
this.clearTriggerText();
|
|
562
|
+
}
|
|
563
|
+
else if (changedProperties.has('value') || changedProperties.has('timezone')) {
|
|
564
|
+
this.seedTriggerFromValue();
|
|
565
|
+
}
|
|
566
|
+
if (changedProperties.has('open') && !this._openSyncFromPanel) {
|
|
567
|
+
if (this.open)
|
|
568
|
+
this.panel?.show?.();
|
|
569
|
+
else
|
|
570
|
+
this.panel?.hide?.();
|
|
571
|
+
}
|
|
572
|
+
if (changedProperties.has('accessibleLabel') || changedProperties.has('label')) {
|
|
573
|
+
this.applyTriggerAriaLabel();
|
|
574
|
+
this.applyDialogName();
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
applyTriggerAriaLabel() {
|
|
578
|
+
const nileInput = this.triggerInput;
|
|
579
|
+
if (!nileInput) {
|
|
580
|
+
this.updateComplete
|
|
581
|
+
.then(() => { if (this.triggerInput)
|
|
582
|
+
this.applyTriggerAriaLabel(); })
|
|
583
|
+
.catch(() => { });
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
// A visible `label` names the field on its own, so `aria-label` stays off; with neither set, fall back to a built-in name (the placeholder describes the FORMAT, not the purpose).
|
|
587
|
+
const name = this.label
|
|
588
|
+
? ''
|
|
589
|
+
: this.accessibleLabel.trim() || DEFAULT_TRIGGER_LABEL;
|
|
590
|
+
const apply = () => {
|
|
591
|
+
const native = nileInput.input;
|
|
592
|
+
if (!native)
|
|
593
|
+
return;
|
|
594
|
+
if (name)
|
|
595
|
+
native.setAttribute('aria-label', name);
|
|
596
|
+
else
|
|
597
|
+
native.removeAttribute('aria-label');
|
|
598
|
+
};
|
|
599
|
+
apply();
|
|
600
|
+
nileInput.updateComplete
|
|
601
|
+
?.then(apply)
|
|
602
|
+
.catch(() => { });
|
|
603
|
+
}
|
|
604
|
+
seedTriggerFromValue() {
|
|
605
|
+
// a manual mask edit owns the trigger text; re-seeding would overwrite it
|
|
606
|
+
if (this._skipNextSeed)
|
|
607
|
+
return;
|
|
608
|
+
const triggerInput = this.triggerInput;
|
|
609
|
+
if (!triggerInput || this.type !== 'absolute')
|
|
610
|
+
return;
|
|
611
|
+
const raw = this.value;
|
|
612
|
+
if (!raw)
|
|
613
|
+
return;
|
|
614
|
+
const zoned = this.parseZonedValue(raw);
|
|
615
|
+
if (zoned && zoned.timezone !== this.timezone) {
|
|
616
|
+
this.setTimezoneInternally(zoned.timezone);
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
const wall = this.wallClockFromRaw(raw, zoned?.wall);
|
|
620
|
+
if (!wall) {
|
|
621
|
+
// unparseable: show nothing, as for an invalid initial value, instead of the previous selection
|
|
622
|
+
this.clearTriggerText();
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
const text = this.formatValue({ wall, timezone: this.timezone });
|
|
626
|
+
if (!text)
|
|
627
|
+
return;
|
|
628
|
+
this.initMaskState(text); // keep the editable slot values in sync
|
|
629
|
+
this.seedMask = [...this.maskValues];
|
|
630
|
+
this.seedYear = wall.year;
|
|
631
|
+
this._maskEdited = false;
|
|
632
|
+
triggerInput.value = text;
|
|
633
|
+
}
|
|
634
|
+
/** `silent` is for display-only refreshes (a dateFormat/hideTimeFields change): no `nile-input`, since the user did not type or clear */
|
|
635
|
+
clearTriggerText(silent = false) {
|
|
636
|
+
const triggerInput = this.triggerInput;
|
|
637
|
+
this.maskValues = [];
|
|
638
|
+
this.maskSuffix = '';
|
|
639
|
+
this.seedMask = null;
|
|
640
|
+
this.seedYear = null;
|
|
641
|
+
this._yearRejected = false;
|
|
642
|
+
if (!triggerInput || !triggerInput.value)
|
|
643
|
+
return;
|
|
644
|
+
triggerInput.value = '';
|
|
645
|
+
if (!silent)
|
|
646
|
+
triggerInput.emit('nile-input', { value: '' });
|
|
647
|
+
}
|
|
648
|
+
wallClockFromRaw(raw, zoned) {
|
|
649
|
+
if (zoned)
|
|
650
|
+
return zoned;
|
|
651
|
+
const epochMs = instantFromValueString(raw);
|
|
652
|
+
if (epochMs !== null) {
|
|
653
|
+
// an instant reads in this component's zone, never the browser's; an unusable zone falls back to the browser's, as in the calendar
|
|
654
|
+
return isValidTimezone(this.timezone)
|
|
655
|
+
? wallClockInZone(epochMs, this.timezone)
|
|
656
|
+
: this.wallClockFromDate(new Date(epochMs));
|
|
657
|
+
}
|
|
658
|
+
const trimmed = raw.trim();
|
|
659
|
+
// date-only is a calendar day: midnight of that day; anything outside the documented formats is null
|
|
660
|
+
return wallClockFromOffsetLess(/^\d{4}-\d{2}-\d{2}$/.test(trimmed) ? `${trimmed}T00:00` : trimmed);
|
|
661
|
+
}
|
|
662
|
+
bindStructuredInput() {
|
|
663
|
+
const nileInput = this.triggerInput;
|
|
664
|
+
if (!nileInput)
|
|
665
|
+
return;
|
|
666
|
+
nileInput.removeEventListener('keydown', this.onMaskKeydown, true);
|
|
667
|
+
nileInput.removeEventListener('focusout', this.onMaskBlur);
|
|
668
|
+
nileInput.removeEventListener('paste', this.onMaskPaste, true);
|
|
669
|
+
nileInput.removeEventListener('beforeinput', this.onMaskBeforeInput, true);
|
|
670
|
+
nileInput.removeEventListener('keydown', this.onTriggerKeydown);
|
|
671
|
+
nileInput.addEventListener('keydown', this.onTriggerKeydown);
|
|
672
|
+
if (this.structuredInput) {
|
|
673
|
+
nileInput.addEventListener('keydown', this.onMaskKeydown, true);
|
|
674
|
+
nileInput.addEventListener('focusout', this.onMaskBlur);
|
|
675
|
+
nileInput.addEventListener('paste', this.onMaskPaste, true);
|
|
676
|
+
nileInput.addEventListener('beforeinput', this.onMaskBeforeInput, true);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* The full year for a completed year segment. A `YY` year whose two digits equal the seeded value's keeps its century
|
|
681
|
+
* (editing the day of 1999-06-15, or retyping or restoring `99`, stays 1999); any other two digits read as 2000-2099.
|
|
682
|
+
*/
|
|
683
|
+
fullYearOf(yearText) {
|
|
684
|
+
const n = Number(yearText);
|
|
685
|
+
if (yearText.length !== 2)
|
|
686
|
+
return n;
|
|
687
|
+
if (this.seedYear !== null && Math.abs(this.seedYear) % 100 === n)
|
|
688
|
+
return this.seedYear;
|
|
689
|
+
return 2000 + n;
|
|
690
|
+
}
|
|
691
|
+
maskLayout() {
|
|
692
|
+
const segs = this.maskSegments();
|
|
693
|
+
const tokens = [];
|
|
694
|
+
const slots = [];
|
|
695
|
+
for (const seg of segs) {
|
|
696
|
+
const segStart = slots.length;
|
|
697
|
+
for (let k = 0; k < seg.len; k++) {
|
|
698
|
+
tokens.push({ slot: slots.length });
|
|
699
|
+
slots.push({ kind: seg.kind, max: seg.max, len: seg.len, segStart });
|
|
700
|
+
}
|
|
701
|
+
for (const ch of seg.after)
|
|
702
|
+
tokens.push({ lit: ch });
|
|
703
|
+
}
|
|
704
|
+
return { tokens, slots, length: tokens.length };
|
|
705
|
+
}
|
|
706
|
+
computeDisplay(values, suffix, layout) {
|
|
707
|
+
let lastFilled = -1;
|
|
708
|
+
values.forEach((v, i) => { if (v !== '')
|
|
709
|
+
lastFilled = i; });
|
|
710
|
+
let s = '';
|
|
711
|
+
const slotPos = new Array(layout.slots.length).fill(-1);
|
|
712
|
+
for (const t of layout.tokens) {
|
|
713
|
+
if ('slot' in t) {
|
|
714
|
+
if (t.slot > lastFilled)
|
|
715
|
+
break; // stop at the trailing empties
|
|
716
|
+
slotPos[t.slot] = s.length;
|
|
717
|
+
if (values[t.slot])
|
|
718
|
+
s += values[t.slot];
|
|
719
|
+
}
|
|
720
|
+
else {
|
|
721
|
+
s += t.lit;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
return { display: lastFilled === -1 ? '' : s + suffix, slotPos, lastFilled };
|
|
725
|
+
}
|
|
726
|
+
initMaskState(text) {
|
|
727
|
+
const layout = this.maskLayout();
|
|
728
|
+
const portion = (text || '').slice(0, layout.length);
|
|
729
|
+
this.maskSuffix = (text || '').slice(layout.length);
|
|
730
|
+
const digits = portion.replace(/\D/g, '');
|
|
731
|
+
this.maskValues = layout.slots.map((_s, i) => digits[i] ?? '');
|
|
732
|
+
this.seedMask = null; // text of unknown origin; callers that seed from `value` re-mark it
|
|
733
|
+
this.seedYear = null;
|
|
734
|
+
this._yearRejected = false;
|
|
735
|
+
}
|
|
736
|
+
clampSegment(values, layout, slotIndex) {
|
|
737
|
+
const seg = layout.slots[slotIndex];
|
|
738
|
+
const segSlots = [];
|
|
739
|
+
layout.slots.forEach((s, i) => { if (s.segStart === seg.segStart)
|
|
740
|
+
segSlots.push(i); });
|
|
741
|
+
const chars = segSlots.map(i => values[i]);
|
|
742
|
+
if (chars.some(c => c === ''))
|
|
743
|
+
return; // segment not full yet
|
|
744
|
+
const joined = chars.join('');
|
|
745
|
+
const n = Number(joined);
|
|
746
|
+
let corrected = null;
|
|
747
|
+
if (seg.kind === 'year') {
|
|
748
|
+
const fullYear = seg.len === 2 ? this.fullYearOf(joined) : n;
|
|
749
|
+
// an unset side falls back to 2000 / 2050, the same range the calendar's arrows and year dropdown use
|
|
750
|
+
const clamped = Math.min(this.endYear ?? DEFAULT_END_YEAR, Math.max(this.startYear ?? DEFAULT_START_YEAR, fullYear));
|
|
751
|
+
if (clamped !== fullYear) {
|
|
752
|
+
const back = seg.len === 2 ? clamped - 2000 : clamped;
|
|
753
|
+
if (seg.len === 2 && (back < 0 || back > 99)) {
|
|
754
|
+
// the range lies outside what a YY mask can write (2000-2099), so no typed YY year can be in it: put back the
|
|
755
|
+
// seeded digits, or blank the year when there are none, rather than commit a year the range rules out
|
|
756
|
+
const seed = this.seedMask?.length === values.length ? this.seedMask : null;
|
|
757
|
+
segSlots.forEach(s => { values[s] = seed ? seed[s] : ''; });
|
|
758
|
+
if (!seed)
|
|
759
|
+
this._yearRejected = true;
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
corrected = String(back).padStart(seg.len, '0').slice(-seg.len);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
else {
|
|
766
|
+
const min = seg.kind === 'month' || seg.kind === 'day' ? 1 : 0;
|
|
767
|
+
if (n > seg.max)
|
|
768
|
+
corrected = String(Number(joined[0])).padStart(seg.len, '0');
|
|
769
|
+
else if (n < min)
|
|
770
|
+
corrected = String(min).padStart(seg.len, '0');
|
|
771
|
+
}
|
|
772
|
+
if (corrected !== null)
|
|
773
|
+
segSlots.forEach((s, k) => { values[s] = corrected[k]; });
|
|
774
|
+
}
|
|
775
|
+
/** An abandoned year edit (digits typed over the old year, then the caret moved on) is normalised before another segment's edit builds on it. An untouched year, however far out of range, is left alone. */
|
|
776
|
+
settleYear(values, layout) {
|
|
777
|
+
const yearSlot = layout.slots.findIndex(s => s.kind === 'year');
|
|
778
|
+
if (yearSlot === -1)
|
|
779
|
+
return;
|
|
780
|
+
const seed = this.seedMask?.length === values.length ? this.seedMask : null;
|
|
781
|
+
const changed = !!seed && layout.slots.some((s, i) => s.kind === 'year' && values[i] !== seed[i]);
|
|
782
|
+
if (changed)
|
|
783
|
+
this.clampSegment(values, layout, yearSlot);
|
|
784
|
+
}
|
|
785
|
+
clampDay(values, layout) {
|
|
786
|
+
const collect = (kind) => layout.slots
|
|
787
|
+
.map((s, i) => (s.kind === kind ? i : -1)).filter(i => i !== -1);
|
|
788
|
+
const daySlots = collect('day');
|
|
789
|
+
if (!daySlots.length || daySlots.some(i => values[i] === ''))
|
|
790
|
+
return;
|
|
791
|
+
const monthSlots = collect('month');
|
|
792
|
+
const yearSlots = collect('year');
|
|
793
|
+
const monthStr = monthSlots.map(i => values[i]).join('');
|
|
794
|
+
const yearStr = yearSlots.map(i => values[i]).join('');
|
|
795
|
+
if (monthStr.length !== monthSlots.length)
|
|
796
|
+
return; // month not known yet
|
|
797
|
+
const month = Number(monthStr) > 12 ? String(Number(monthStr[0])).padStart(2, '0') : monthStr;
|
|
798
|
+
const fullYear = yearSlots.length && yearStr.length === yearSlots.length
|
|
799
|
+
? (yearSlots.length === 2 ? String(this.fullYearOf(yearStr)) : yearStr)
|
|
800
|
+
: '';
|
|
801
|
+
const dayMax = this.maxDayForMonth(month, fullYear);
|
|
802
|
+
const dayStr = daySlots.map(i => values[i]).join('');
|
|
803
|
+
if (Number(dayStr) > dayMax) {
|
|
804
|
+
const corrected = String(dayMax).padStart(daySlots.length, '0');
|
|
805
|
+
daySlots.forEach((s, k) => { values[s] = corrected[k]; });
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
writeMask(nileInput, native, display, caret) {
|
|
809
|
+
native.value = display;
|
|
810
|
+
nileInput.value = display;
|
|
811
|
+
try {
|
|
812
|
+
native.setSelectionRange(caret, caret);
|
|
813
|
+
}
|
|
814
|
+
catch { /* noop */ }
|
|
815
|
+
nileInput.updateComplete
|
|
816
|
+
?.then(() => { try {
|
|
817
|
+
native.setSelectionRange(caret, caret);
|
|
818
|
+
}
|
|
819
|
+
catch { /* noop */ } })
|
|
820
|
+
.catch(() => { });
|
|
821
|
+
}
|
|
822
|
+
completedSegment(layout, kind) {
|
|
823
|
+
const idxs = layout.slots
|
|
824
|
+
.map((s, i) => (s.kind === kind ? i : -1))
|
|
825
|
+
.filter(i => i !== -1);
|
|
826
|
+
if (!idxs.length || idxs.some(i => this.maskValues[i] === ''))
|
|
827
|
+
return null;
|
|
828
|
+
return idxs.map(i => this.maskValues[i]).join('');
|
|
829
|
+
}
|
|
830
|
+
/**
|
|
831
|
+
* A typed date outside the calendar's allowedDates range snaps to the nearest boundary (the calendar disables
|
|
832
|
+
* those day cells but typing bypasses it). Only the date slots change; time and timezone are untouched.
|
|
833
|
+
*/
|
|
834
|
+
clampMaskToAllowedDates(cal, layout) {
|
|
835
|
+
const range = cal.allowedDatesLocal;
|
|
836
|
+
if (!range)
|
|
837
|
+
return;
|
|
838
|
+
const dayStr = this.completedSegment(layout, 'day');
|
|
839
|
+
const monthStr = this.completedSegment(layout, 'month');
|
|
840
|
+
const yearStr = this.completedSegment(layout, 'year');
|
|
841
|
+
if (dayStr === null || monthStr === null || yearStr === null)
|
|
842
|
+
return;
|
|
843
|
+
const yearLen = layout.slots.filter(s => s.kind === 'year').length;
|
|
844
|
+
const year = yearLen === 2 ? this.fullYearOf(yearStr) : Number(yearStr);
|
|
845
|
+
const month0 = Number(monthStr) - 1;
|
|
846
|
+
const day = Number(dayStr);
|
|
847
|
+
if (month0 < 0 || month0 > 11 || day < 1 || cal.isAllowedDate(day, month0, year))
|
|
848
|
+
return;
|
|
849
|
+
const typed = utcFromFields(year, month0, day);
|
|
850
|
+
const bound = typed < cal.getUTCDate(range.startDate).getTime()
|
|
851
|
+
? cal.getUTCDate(range.startDate)
|
|
852
|
+
: cal.getUTCDate(range.endDate);
|
|
853
|
+
const boundYear = bound.getUTCFullYear();
|
|
854
|
+
if (yearLen === 2 && (boundYear < 2000 || boundYear > 2099))
|
|
855
|
+
return; // not representable in a YY mask
|
|
856
|
+
const fill = (kind, text) => {
|
|
857
|
+
let k = 0;
|
|
858
|
+
layout.slots.forEach((s, i) => { if (s.kind === kind)
|
|
859
|
+
this.maskValues[i] = text[k++]; });
|
|
860
|
+
};
|
|
861
|
+
fill('year', yearLen === 2 ? String(boundYear - 2000).padStart(2, '0') : String(boundYear).padStart(4, '0'));
|
|
862
|
+
if (yearLen === 2)
|
|
863
|
+
this.seedYear = boundYear; // the year text now holds the boundary's year
|
|
864
|
+
fill('month', String(bound.getUTCMonth() + 1).padStart(2, '0'));
|
|
865
|
+
fill('day', String(bound.getUTCDate()).padStart(2, '0'));
|
|
866
|
+
const nileInput = this.triggerInput;
|
|
867
|
+
const native = nileInput?.input;
|
|
868
|
+
if (!native)
|
|
869
|
+
return;
|
|
870
|
+
const { display } = this.computeDisplay(this.maskValues, this.maskSuffix, layout);
|
|
871
|
+
this.writeMask(nileInput, native, display, native.selectionStart ?? display.length);
|
|
872
|
+
}
|
|
873
|
+
syncCalendarFromMask() {
|
|
874
|
+
const layout = this.maskLayout();
|
|
875
|
+
if (this.maskValues.length !== layout.slots.length)
|
|
876
|
+
return;
|
|
877
|
+
const cal = this.calendarRef.value;
|
|
878
|
+
if (!cal)
|
|
879
|
+
return;
|
|
880
|
+
if (this._yearRejected) {
|
|
881
|
+
if (this.completedSegment(layout, 'year') !== null) {
|
|
882
|
+
this._yearRejected = false;
|
|
883
|
+
}
|
|
884
|
+
else {
|
|
885
|
+
// day and month alone must not leave a committable date (carrying the calendar's own year) behind
|
|
886
|
+
cal.value = null;
|
|
887
|
+
cal.updateComplete?.then(() => cal.syncInnerCalendar?.()).catch(() => { });
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
if (this._maskEdited)
|
|
892
|
+
this.clampMaskToAllowedDates(cal, layout);
|
|
893
|
+
const monthStr = this.completedSegment(layout, 'month');
|
|
894
|
+
const dayStr = this.completedSegment(layout, 'day');
|
|
895
|
+
const yearStr = this.completedSegment(layout, 'year');
|
|
896
|
+
const yearSlots = layout.slots.filter(s => s.kind === 'year').length;
|
|
897
|
+
const typedMonth = monthStr === null ? null : Number(monthStr) - 1;
|
|
898
|
+
const typedYear = yearStr === null ? null : (yearSlots === 2 ? this.fullYearOf(yearStr) : Number(yearStr));
|
|
899
|
+
let changed = false;
|
|
900
|
+
if (typedMonth !== null && typedMonth >= 0 && typedMonth <= 11) {
|
|
901
|
+
cal.currentMonth = typedMonth;
|
|
902
|
+
changed = true;
|
|
903
|
+
}
|
|
904
|
+
if (typedYear !== null) {
|
|
905
|
+
cal.currentYear = typedYear;
|
|
906
|
+
changed = true;
|
|
907
|
+
}
|
|
908
|
+
if (dayStr !== null) {
|
|
909
|
+
const month = typedMonth ?? cal.currentMonth;
|
|
910
|
+
const year = typedYear ?? cal.currentYear;
|
|
911
|
+
const lastDay = localDateFromFields(year, month + 1, 0).getDate();
|
|
912
|
+
const date = localDateFromFields(year, month, Math.min(Number(dayStr), lastDay));
|
|
913
|
+
if (!isNaN(date.getTime())) {
|
|
914
|
+
cal.value = date;
|
|
915
|
+
cal.currentMonth = date.getMonth();
|
|
916
|
+
cal.currentYear = date.getFullYear();
|
|
917
|
+
changed = true;
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
const setTime = (kind, prop) => {
|
|
921
|
+
const v = this.completedSegment(layout, kind);
|
|
922
|
+
if (v !== null) {
|
|
923
|
+
cal[prop] = Number(v);
|
|
924
|
+
changed = true;
|
|
925
|
+
}
|
|
926
|
+
};
|
|
927
|
+
setTime('HH', 'tpHours');
|
|
928
|
+
setTime('mm', 'tpMinutes');
|
|
929
|
+
setTime('ss', 'tpSeconds');
|
|
930
|
+
setTime('SSS', 'tpMilliseconds');
|
|
931
|
+
const tz = this.maskSuffix.replace(/^\s*-\s*/, '').trim();
|
|
932
|
+
if (tz) {
|
|
933
|
+
cal.timezone = tz;
|
|
934
|
+
changed = true;
|
|
935
|
+
}
|
|
936
|
+
if (changed) {
|
|
937
|
+
cal.updateComplete
|
|
938
|
+
?.then(() => cal.syncInnerCalendar?.())
|
|
939
|
+
.catch(() => { });
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
/** Re-derive slot state from whatever the native input now holds, then redisplay it masked. */
|
|
943
|
+
reflowMaskFromNative(nileInput, native) {
|
|
944
|
+
const layout = this.maskLayout();
|
|
945
|
+
// initMaskState forgets the seed's century; the year digits themselves decide whether it still applies
|
|
946
|
+
const { seedYear } = this;
|
|
947
|
+
this.initMaskState(native.value);
|
|
948
|
+
this._maskEdited = true;
|
|
949
|
+
this.seedYear = seedYear;
|
|
950
|
+
const segStarts = [...new Set(layout.slots.map(s => s.segStart))];
|
|
951
|
+
segStarts.forEach(start => this.clampSegment(this.maskValues, layout, start));
|
|
952
|
+
this.clampDay(this.maskValues, layout);
|
|
953
|
+
const { display } = this.computeDisplay(this.maskValues, this.maskSuffix, layout);
|
|
954
|
+
this.writeMask(nileInput, native, display, display.length - (this.maskSuffix && this.maskValues.every(v => v !== '') ? this.maskSuffix.length : 0));
|
|
955
|
+
this.syncCalendarFromMask();
|
|
956
|
+
}
|
|
957
|
+
maskSegments() {
|
|
958
|
+
const spec = (token) => {
|
|
959
|
+
switch (token) {
|
|
960
|
+
case 'YYYY': return { kind: 'year', len: 4, max: 9999 };
|
|
961
|
+
case 'YY': return { kind: 'year', len: 2, max: 99 };
|
|
962
|
+
case 'MM': return { kind: 'month', len: 2, max: 12 };
|
|
963
|
+
default: return { kind: 'day', len: 2, max: 31 }; // DD
|
|
964
|
+
}
|
|
965
|
+
};
|
|
966
|
+
const fmt = this.dateFormat || 'DD/MM/YY';
|
|
967
|
+
const re = /YYYY|YY|MM|DD/g;
|
|
968
|
+
const found = [];
|
|
969
|
+
let m;
|
|
970
|
+
while ((m = re.exec(fmt)) !== null)
|
|
971
|
+
found.push({ token: m[0], index: m.index });
|
|
972
|
+
const time = this.shownTimeFields;
|
|
973
|
+
const segments = found.map((f, i) => {
|
|
974
|
+
const sep = fmt.slice(f.index + f.token.length, i + 1 < found.length ? found[i + 1].index : fmt.length);
|
|
975
|
+
return { ...spec(f.token), after: i < found.length - 1 ? sep : (time.length ? ' - ' : '') };
|
|
976
|
+
});
|
|
977
|
+
time.forEach((t, i) => {
|
|
978
|
+
const next = time[i + 1];
|
|
979
|
+
segments.push({ kind: t.kind, len: t.len, max: t.max, after: next ? this.timeSep(next.kind) : '' });
|
|
980
|
+
});
|
|
981
|
+
return segments;
|
|
982
|
+
}
|
|
983
|
+
maxDayForMonth(month, fullYear) {
|
|
984
|
+
if (month.length < 2)
|
|
985
|
+
return 31;
|
|
986
|
+
const m = Number(month);
|
|
987
|
+
if (m < 1 || m > 12)
|
|
988
|
+
return 31;
|
|
989
|
+
const days = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
990
|
+
if (m !== 2)
|
|
991
|
+
return days[m - 1];
|
|
992
|
+
if (!fullYear)
|
|
993
|
+
return 29;
|
|
994
|
+
const y = Number(fullYear);
|
|
995
|
+
const leap = (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0;
|
|
996
|
+
return leap ? 29 : 28;
|
|
997
|
+
}
|
|
998
|
+
render() {
|
|
999
|
+
return html `
|
|
1000
|
+
<nile-floating-panel
|
|
1001
|
+
part="panel"
|
|
1002
|
+
exportparts="base"
|
|
1003
|
+
panelClass="nile-dtp-panel"
|
|
1004
|
+
trigger="click"
|
|
1005
|
+
placement="bottom-start"
|
|
1006
|
+
arrow="none"
|
|
1007
|
+
distance="6"
|
|
1008
|
+
?disabled="${this.disabled}"
|
|
1009
|
+
@nile-init="${(e) => e.stopPropagation()}"
|
|
1010
|
+
@nile-destroy="${(e) => e.stopPropagation()}"
|
|
1011
|
+
@nile-visibility-change="${(e) => e.stopPropagation()}"
|
|
1012
|
+
@nile-show="${this.onPanelShow}"
|
|
1013
|
+
@nile-hide="${this.onPanelHide}"
|
|
1014
|
+
>
|
|
1015
|
+
<nile-input
|
|
1016
|
+
part="trigger"
|
|
1017
|
+
exportparts="base"
|
|
1018
|
+
label=${ifDefined(this.label || undefined)}
|
|
1019
|
+
placeholder="${this.placeholder || this.defaultPlaceholder}"
|
|
1020
|
+
?disabled="${this.disabled}"
|
|
1021
|
+
>
|
|
1022
|
+
<nile-glyph slot="suffix" name="ng-calendar-clock" size="16" method="stroke" color=${ifDefined(this.disabled ? 'var(--nile-colors-dark-500, var(--ng-colors-fg-disabled))' : undefined)}></nile-glyph>
|
|
1023
|
+
</nile-input>
|
|
1024
|
+
|
|
1025
|
+
<nile-calendar
|
|
1026
|
+
${ref(this.calendarRef)}
|
|
1027
|
+
variant="date-time-picker"
|
|
1028
|
+
value="${ifDefined(this.value ?? undefined)}"
|
|
1029
|
+
allowedDates="${this.allowedDates}"
|
|
1030
|
+
?showTypes="${this.showTypes}"
|
|
1031
|
+
?doubleClickUnselect="${this.doubleClickUnselect}"
|
|
1032
|
+
type="${this.type}"
|
|
1033
|
+
selectedUnit="${ifDefined(this.selectedUnit)}"
|
|
1034
|
+
selectedValue="${ifDefined(this.selectedValue)}"
|
|
1035
|
+
.hideDurationFields="${this.hideDurationFields}"
|
|
1036
|
+
startYear="${ifDefined(this.startYear)}"
|
|
1037
|
+
endYear="${ifDefined(this.endYear)}"
|
|
1038
|
+
?showMonthDropdown="${this.showMonthDropdown}"
|
|
1039
|
+
?showYearDropdown="${this.showYearDropdown}"
|
|
1040
|
+
?disabled="${this.disabled}"
|
|
1041
|
+
timezone="${this.timezone}"
|
|
1042
|
+
dateFormat="${this.dateFormat}"
|
|
1043
|
+
.hideTimeFields="${this.hideTimeFields}"
|
|
1044
|
+
@nile-init="${(e) => e.stopPropagation()}"
|
|
1045
|
+
@nile-destroy="${(e) => e.stopPropagation()}"
|
|
1046
|
+
@nile-change="${this.handleChanged}"
|
|
1047
|
+
@nile-clear="${(e) => { e.stopPropagation(); this.handleClear(); }}"
|
|
1048
|
+
@nile-type-change="${(e) => { e.stopPropagation(); this.handleTypeChange(e); }}"
|
|
1049
|
+
></nile-calendar>
|
|
1050
|
+
</nile-floating-panel>
|
|
1051
|
+
`;
|
|
1052
|
+
}
|
|
1053
|
+
handleTypeChange(e) {
|
|
1054
|
+
const next = e.detail?.value;
|
|
1055
|
+
if (next === 'absolute' || next === 'relative')
|
|
1056
|
+
this.type = next;
|
|
1057
|
+
// slot state belongs to the absolute layout; drop it so a stale mask can't rewrite relative text
|
|
1058
|
+
this.maskValues = [];
|
|
1059
|
+
this.maskSuffix = '';
|
|
1060
|
+
this._yearRejected = false;
|
|
1061
|
+
this.emit('nile-type-change', e.detail);
|
|
1062
|
+
}
|
|
1063
|
+
handleChanged(event) {
|
|
1064
|
+
event.stopPropagation();
|
|
1065
|
+
if (this.disabled)
|
|
1066
|
+
return;
|
|
1067
|
+
const detail = event.detail;
|
|
1068
|
+
const triggerInput = this.triggerInput;
|
|
1069
|
+
if (triggerInput && detail) {
|
|
1070
|
+
const text = this.formatValue(detail);
|
|
1071
|
+
if (this.type === 'absolute') {
|
|
1072
|
+
this.initMaskState(text); // keep the editable slot values in sync
|
|
1073
|
+
this.seedMask = [...this.maskValues];
|
|
1074
|
+
// the applied selection's own year, so a YY mask keeps its century on the next edit
|
|
1075
|
+
this.seedYear = detail.wall?.year
|
|
1076
|
+
?? (detail.value instanceof Date ? detail.value.getFullYear()
|
|
1077
|
+
: typeof detail.value === 'string' ? this.parseWallClock(detail.value)?.year ?? null : null);
|
|
1078
|
+
this._maskEdited = false;
|
|
1079
|
+
this._showCommittedOnOpen = true;
|
|
1080
|
+
}
|
|
1081
|
+
else {
|
|
1082
|
+
// relative range text isn't in the absolute mask layout; drop stale slot state
|
|
1083
|
+
this.maskValues = [];
|
|
1084
|
+
this.maskSuffix = '';
|
|
1085
|
+
this._yearRejected = false;
|
|
1086
|
+
}
|
|
1087
|
+
triggerInput.value = text;
|
|
1088
|
+
triggerInput.emit('nile-input', { value: text });
|
|
1089
|
+
}
|
|
1090
|
+
// keep the public value in step with the applied selection, so a later re-seed (e.g. a timezone change) can't restore the old one
|
|
1091
|
+
if (this.type === 'absolute' && typeof detail?.value === 'string' && detail.value) {
|
|
1092
|
+
this.value = detail.value;
|
|
1093
|
+
}
|
|
1094
|
+
this.emit('nile-change', detail);
|
|
1095
|
+
triggerInput?.input?.focus?.();
|
|
1096
|
+
this.panel?.hide();
|
|
1097
|
+
}
|
|
1098
|
+
handleClear() {
|
|
1099
|
+
const triggerInput = this.triggerInput;
|
|
1100
|
+
if (triggerInput) {
|
|
1101
|
+
triggerInput.value = '';
|
|
1102
|
+
triggerInput.emit('nile-input', { value: '' });
|
|
1103
|
+
}
|
|
1104
|
+
this.maskValues = [];
|
|
1105
|
+
this.maskSuffix = '';
|
|
1106
|
+
this._yearRejected = false;
|
|
1107
|
+
this.value = null;
|
|
1108
|
+
this.setTimezoneInternally(this._defaultTimezone);
|
|
1109
|
+
// the calendar may have drifted (zone typed in the mask, picked in its dropdown) while our own `timezone` did not change, so the template binding won't re-apply it
|
|
1110
|
+
const cal = this.calendarRef.value;
|
|
1111
|
+
if (cal)
|
|
1112
|
+
cal.timezone = this._defaultTimezone;
|
|
1113
|
+
this.emit('nile-clear');
|
|
1114
|
+
}
|
|
1115
|
+
pad(value, length = 2) {
|
|
1116
|
+
return String(value).padStart(length, '0');
|
|
1117
|
+
}
|
|
1118
|
+
/** Read an instant's fields in the host zone. Lossless in this direction. */
|
|
1119
|
+
wallClockFromDate(date) {
|
|
1120
|
+
return {
|
|
1121
|
+
year: date.getFullYear(), month: date.getMonth() + 1, day: date.getDate(),
|
|
1122
|
+
hours: date.getHours(), minutes: date.getMinutes(),
|
|
1123
|
+
seconds: date.getSeconds(), ms: date.getMilliseconds(),
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1126
|
+
formatDatePartFields(wall) {
|
|
1127
|
+
const yyyy = String(wall.year);
|
|
1128
|
+
// the structured mask has exactly four year slots, so years 0-999 must print four digits (a bare `100` would be re-read as `1000` on blur)
|
|
1129
|
+
const year4 = wall.year >= 0 && wall.year < 1000 ? yyyy.padStart(4, '0') : yyyy;
|
|
1130
|
+
return (this.dateFormat || 'DD/MM/YY')
|
|
1131
|
+
.replace(/YYYY/g, year4)
|
|
1132
|
+
.replace(/YY/g, yyyy.padStart(2, '0').slice(-2))
|
|
1133
|
+
.replace(/MM/g, this.pad(wall.month))
|
|
1134
|
+
.replace(/DD/g, this.pad(wall.day));
|
|
1135
|
+
}
|
|
1136
|
+
formatWallClock(wall) {
|
|
1137
|
+
const valueFor = {
|
|
1138
|
+
HH: this.pad(wall.hours),
|
|
1139
|
+
mm: this.pad(wall.minutes),
|
|
1140
|
+
ss: this.pad(wall.seconds),
|
|
1141
|
+
SSS: this.pad(wall.ms, 3),
|
|
1142
|
+
};
|
|
1143
|
+
const time = this.shownTimeFields;
|
|
1144
|
+
if (!time.length)
|
|
1145
|
+
return this.formatDatePartFields(wall);
|
|
1146
|
+
const timeStr = time
|
|
1147
|
+
.map((t, i) => (i > 0 ? this.timeSep(t.kind) : '') + valueFor[t.kind])
|
|
1148
|
+
.join('');
|
|
1149
|
+
return `${this.formatDatePartFields(wall)} - ${timeStr}`;
|
|
1150
|
+
}
|
|
1151
|
+
formatDateTime(date) {
|
|
1152
|
+
return this.formatWallClock(this.wallClockFromDate(date));
|
|
1153
|
+
}
|
|
1154
|
+
/** A value's digits as written: bracketed `…[GMT+05:30 Zone]` or offset-less `YYYY-MM-DDTHH:mm[:ss[.SSS]]`; anything else is null. */
|
|
1155
|
+
parseWallClock(iso) {
|
|
1156
|
+
return this.parseZonedValue(iso)?.wall ?? wallClockFromOffsetLess(iso);
|
|
1157
|
+
}
|
|
1158
|
+
parseZonedValue(raw) {
|
|
1159
|
+
const m = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?)\[GMT[+-]\d{2}:\d{2}\s+([^\]]+)\]$/.exec(raw.trim());
|
|
1160
|
+
if (!m)
|
|
1161
|
+
return null;
|
|
1162
|
+
const wall = wallClockFromOffsetLess(m[1]);
|
|
1163
|
+
if (!wall)
|
|
1164
|
+
return null;
|
|
1165
|
+
const zone = m[2].trim();
|
|
1166
|
+
const timezone = isValidTimezone(zone) ? zone : 'UTC';
|
|
1167
|
+
return { wall, timezone };
|
|
1168
|
+
}
|
|
1169
|
+
formatValue(detail) {
|
|
1170
|
+
// `detail` is an untrusted event payload: optional chaining alone would render a literal " - undefined" when `timezone` is omitted, so fall back to the component's own zone.
|
|
1171
|
+
const zone = detail?.timezone ?? this.timezone;
|
|
1172
|
+
const tz = this.showTimezone ? ` - ${zone}` : '';
|
|
1173
|
+
if (detail?.wall) {
|
|
1174
|
+
return `${this.formatWallClock(detail.wall)}${tz}`;
|
|
1175
|
+
}
|
|
1176
|
+
if (detail?.value instanceof Date) {
|
|
1177
|
+
return `${this.formatDateTime(detail.value)}${tz}`;
|
|
1178
|
+
}
|
|
1179
|
+
if (typeof detail?.value === 'string' && detail.value) {
|
|
1180
|
+
const wall = this.parseWallClock(detail.value);
|
|
1181
|
+
if (wall)
|
|
1182
|
+
return `${this.formatWallClock(wall)}${tz}`;
|
|
1183
|
+
}
|
|
1184
|
+
if (detail?.startDate && detail?.endDate) {
|
|
1185
|
+
// a labelled range must show its instants in the labelled zone, not the host's
|
|
1186
|
+
const inZone = (d) => this.showTimezone && isValidTimezone(zone)
|
|
1187
|
+
? this.formatWallClock(wallClockInZone(d.getTime(), zone))
|
|
1188
|
+
: this.formatDateTime(d);
|
|
1189
|
+
const start = inZone(new Date(detail.startDate));
|
|
1190
|
+
const end = inZone(new Date(detail.endDate));
|
|
1191
|
+
return `${start} - ${end}${tz}`;
|
|
1192
|
+
}
|
|
1193
|
+
return '';
|
|
1194
|
+
}
|
|
1195
|
+
};
|
|
1196
|
+
NileDateTimePicker.TIME_SPEC = [
|
|
1197
|
+
{ key: 'hours', kind: 'HH', len: 2, max: 23, ph: 'hh' },
|
|
1198
|
+
{ key: 'minutes', kind: 'mm', len: 2, max: 59, ph: 'mm' },
|
|
1199
|
+
{ key: 'seconds', kind: 'ss', len: 2, max: 59, ph: 'ss' },
|
|
1200
|
+
{ key: 'milliseconds', kind: 'SSS', len: 3, max: 999, ph: 'SSS' },
|
|
1201
|
+
];
|
|
1202
|
+
__decorate([
|
|
1203
|
+
property({ type: Boolean, attribute: true, reflect: true })
|
|
1204
|
+
], NileDateTimePicker.prototype, "showMonthDropdown", void 0);
|
|
1205
|
+
__decorate([
|
|
1206
|
+
property({ type: Boolean, attribute: true, reflect: true })
|
|
1207
|
+
], NileDateTimePicker.prototype, "showYearDropdown", void 0);
|
|
1208
|
+
__decorate([
|
|
1209
|
+
property({ type: String, reflect: true })
|
|
1210
|
+
], NileDateTimePicker.prototype, "type", void 0);
|
|
1211
|
+
__decorate([
|
|
1212
|
+
property({ type: Boolean, attribute: true, reflect: true })
|
|
1213
|
+
], NileDateTimePicker.prototype, "showTypes", void 0);
|
|
1214
|
+
__decorate([
|
|
1215
|
+
property({ type: Array, attribute: true })
|
|
1216
|
+
], NileDateTimePicker.prototype, "hideDurationFields", void 0);
|
|
1217
|
+
__decorate([
|
|
1218
|
+
property({ type: Array, attribute: 'hide-time-fields' })
|
|
1219
|
+
], NileDateTimePicker.prototype, "hideTimeFields", void 0);
|
|
1220
|
+
__decorate([
|
|
1221
|
+
property({ type: String })
|
|
1222
|
+
], NileDateTimePicker.prototype, "value", void 0);
|
|
1223
|
+
__decorate([
|
|
1224
|
+
property({ type: String, reflect: true })
|
|
1225
|
+
], NileDateTimePicker.prototype, "timezone", void 0);
|
|
1226
|
+
__decorate([
|
|
1227
|
+
property({ type: String })
|
|
1228
|
+
], NileDateTimePicker.prototype, "selectedUnit", void 0);
|
|
1229
|
+
__decorate([
|
|
1230
|
+
property({ type: Number })
|
|
1231
|
+
], NileDateTimePicker.prototype, "selectedValue", void 0);
|
|
1232
|
+
__decorate([
|
|
1233
|
+
property({ type: Number, attribute: true, reflect: true })
|
|
1234
|
+
], NileDateTimePicker.prototype, "startYear", void 0);
|
|
1235
|
+
__decorate([
|
|
1236
|
+
property({ type: Number, attribute: true, reflect: true })
|
|
1237
|
+
], NileDateTimePicker.prototype, "endYear", void 0);
|
|
1238
|
+
__decorate([
|
|
1239
|
+
property({ type: String, attribute: true, reflect: true })
|
|
1240
|
+
], NileDateTimePicker.prototype, "allowedDates", void 0);
|
|
1241
|
+
__decorate([
|
|
1242
|
+
property({ type: String, reflect: true })
|
|
1243
|
+
], NileDateTimePicker.prototype, "dateFormat", void 0);
|
|
1244
|
+
__decorate([
|
|
1245
|
+
property({ type: String })
|
|
1246
|
+
], NileDateTimePicker.prototype, "placeholder", void 0);
|
|
1247
|
+
__decorate([
|
|
1248
|
+
property({ type: String })
|
|
1249
|
+
], NileDateTimePicker.prototype, "label", void 0);
|
|
1250
|
+
__decorate([
|
|
1251
|
+
property({ type: String, attribute: 'aria-label' })
|
|
1252
|
+
], NileDateTimePicker.prototype, "accessibleLabel", void 0);
|
|
1253
|
+
__decorate([
|
|
1254
|
+
property({ type: Boolean, reflect: true })
|
|
1255
|
+
], NileDateTimePicker.prototype, "doubleClickUnselect", void 0);
|
|
1256
|
+
__decorate([
|
|
1257
|
+
property({ type: Boolean, attribute: true, reflect: true })
|
|
1258
|
+
], NileDateTimePicker.prototype, "disabled", void 0);
|
|
1259
|
+
__decorate([
|
|
1260
|
+
property({ type: Boolean, reflect: true })
|
|
1261
|
+
], NileDateTimePicker.prototype, "open", void 0);
|
|
1262
|
+
__decorate([
|
|
1263
|
+
query('nile-floating-panel')
|
|
1264
|
+
], NileDateTimePicker.prototype, "panel", void 0);
|
|
1265
|
+
NileDateTimePicker = NileDateTimePicker_1 = __decorate([
|
|
1266
|
+
customElement('nile-date-time-picker')
|
|
1267
|
+
], NileDateTimePicker);
|
|
1268
|
+
export { NileDateTimePicker };
|
|
1269
|
+
export default NileDateTimePicker;
|
|
1270
|
+
//# sourceMappingURL=nile-date-time-picker.js.map
|