@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
|
@@ -18,14 +18,164 @@ import { query } from 'lit/decorators.js';
|
|
|
18
18
|
import { watch } from '../internal/watch';
|
|
19
19
|
import NileElement from '../internal/nile-element';
|
|
20
20
|
import { ClearMode } from './nile-calendar.enum';
|
|
21
|
+
import { TIMEZONES, isValidTimezone, isClockTime, utcFromFields, localDateFromFields, formatYear4, DEFAULT_START_YEAR, DEFAULT_END_YEAR } from './timezones';
|
|
22
|
+
import { setAnimation } from '../utilities/animation-registry';
|
|
21
23
|
import type { PropertyValueMap } from 'lit';
|
|
22
24
|
type NileCalendarDateRange={ startDate: string, endDate: string }
|
|
23
25
|
type TimeUnits= 'minutes'|'hours'|'days'|'weeks'|'months';
|
|
26
|
+
|
|
27
|
+
/** A wall clock — digits on a clock face. Local time*/
|
|
28
|
+
export type CalendarWallClock = {
|
|
29
|
+
year: number;
|
|
30
|
+
/** 1–12 (January = 1), NOT the 0–11 month of JavaScript `Date`; subtract 1 before `new Date(...)`. */
|
|
31
|
+
month: number;
|
|
32
|
+
day: number;
|
|
33
|
+
hours: number; minutes: number; seconds: number; ms: number;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/* one exact moment on the timeline Eg: 2026-03-01T10:00:00Z, …+05:30 */
|
|
37
|
+
const INSTANT_VALUE_RE =
|
|
38
|
+
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?(?:\.\d{1,3})?(?:Z|[+-]\d{2}:?\d{2})$/;
|
|
39
|
+
/** an instant plus the zone it was picked in. Eg: 2026-03-01T10:00:00.000[GMT+05:30 Asia/Kolkata] */
|
|
40
|
+
const BRACKETED_VALUE_RE =
|
|
41
|
+
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{3}))?\[GMT([+-])(\d{2}):(\d{2})\s+([^\]]+)\]$/;
|
|
42
|
+
export function instantFromValueString(raw: string | null | undefined): number | null {
|
|
43
|
+
if (typeof raw !== 'string') return null;
|
|
44
|
+
const trimmed = raw.trim();
|
|
45
|
+
if (!INSTANT_VALUE_RE.test(trimmed)) return null;
|
|
46
|
+
const ms = new Date(trimmed).getTime();
|
|
47
|
+
return isNaN(ms) ? null : ms;
|
|
48
|
+
}
|
|
49
|
+
/** digits on a clock face, 2026-03-01T10:00:00 */
|
|
50
|
+
const OFFSETLESS_VALUE_RE =
|
|
51
|
+
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?(?:\.(\d{1,3}))?$/;
|
|
52
|
+
/** Digits as written, with no zone conversion. */
|
|
53
|
+
export function wallClockFromOffsetLess(
|
|
54
|
+
raw: string | null | undefined
|
|
55
|
+
): CalendarWallClock | null {
|
|
56
|
+
if (typeof raw !== 'string') return null;
|
|
57
|
+
const m = OFFSETLESS_VALUE_RE.exec(raw.trim());
|
|
58
|
+
if (!m) return null;
|
|
59
|
+
const year = Number(m[1]);
|
|
60
|
+
const month0 = Number(m[2]) - 1;
|
|
61
|
+
const day = Number(m[3]);
|
|
62
|
+
if (month0 < 0 || month0 > 11 || day < 1) return null;
|
|
63
|
+
if (!isClockTime(Number(m[4]), Number(m[5]), Number(m[6] ?? 0), Number(m[7] ?? 0))) return null;
|
|
64
|
+
const lastDay = new Date(utcFromFields(year, month0 + 1, 0)).getUTCDate();
|
|
65
|
+
const t = utcFromFields(
|
|
66
|
+
year, month0, Math.min(day, lastDay),
|
|
67
|
+
Number(m[4]), Number(m[5]), Number(m[6] ?? 0), Number(m[7] ?? 0)
|
|
68
|
+
);
|
|
69
|
+
if (isNaN(t)) return null;
|
|
70
|
+
const u = new Date(t);
|
|
71
|
+
return {
|
|
72
|
+
year: u.getUTCFullYear(),
|
|
73
|
+
month: u.getUTCMonth() + 1,
|
|
74
|
+
day: u.getUTCDate(),
|
|
75
|
+
hours: u.getUTCHours(),
|
|
76
|
+
minutes: u.getUTCMinutes(),
|
|
77
|
+
seconds: u.getUTCSeconds(),
|
|
78
|
+
ms: u.getUTCMilliseconds(),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function wallClockInZone(epochMs: number, zone: string): CalendarWallClock {
|
|
83
|
+
const formatter = new Intl.DateTimeFormat('en-US', {
|
|
84
|
+
timeZone: zone,
|
|
85
|
+
hourCycle: 'h23',
|
|
86
|
+
year: 'numeric', month: '2-digit', day: '2-digit',
|
|
87
|
+
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
|
88
|
+
});
|
|
89
|
+
const parts: Record<string, number> = {};
|
|
90
|
+
for (const part of formatter.formatToParts(new Date(epochMs))) {
|
|
91
|
+
if (part.type !== 'literal') parts[part.type] = Number(part.value);
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
year: parts.year,
|
|
95
|
+
month: parts.month,
|
|
96
|
+
day: parts.day,
|
|
97
|
+
hours: parts.hour === 24 ? 0 : parts.hour,
|
|
98
|
+
minutes: parts.minute,
|
|
99
|
+
seconds: parts.second,
|
|
100
|
+
ms: ((epochMs % 1000) + 1000) % 1000,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function instantFromBracketedValue(
|
|
105
|
+
raw: string | null | undefined
|
|
106
|
+
): { epochMs: number; timezone: string } | null {
|
|
107
|
+
if (typeof raw !== 'string') return null;
|
|
108
|
+
const m = BRACKETED_VALUE_RE.exec(raw.trim());
|
|
109
|
+
if (!m) return null;
|
|
110
|
+
const [, y, mo, d, h, mi, s, ms, sign, oh, om, rawZone] = m;
|
|
111
|
+
const zone = rawZone.trim();
|
|
112
|
+
if (!isValidTimezone(zone)) return null;
|
|
113
|
+
|
|
114
|
+
const wall = {
|
|
115
|
+
year: Number(y), month: Number(mo), day: Number(d),
|
|
116
|
+
hours: Number(h), minutes: Number(mi), seconds: Number(s), ms: Number(ms ?? 0),
|
|
117
|
+
};
|
|
118
|
+
// ISO end-of-day: exactly 24:00:00(.000) is the next day's 00:00:00; any other hour 24 or above is not a time
|
|
119
|
+
const endOfDay = wall.hours === 24;
|
|
120
|
+
if (wall.hours > 24 || (endOfDay && (wall.minutes !== 0 || wall.seconds !== 0 || wall.ms !== 0))) return null;
|
|
121
|
+
const offsetMs =
|
|
122
|
+
(sign === '-' ? -1 : 1) * (Number(oh) * 3600000 + Number(om) * 60000);
|
|
123
|
+
const wallMs = utcFromFields(wall.year, wall.month - 1, wall.day, wall.hours, wall.minutes, wall.seconds, wall.ms);
|
|
124
|
+
const candidate = wallMs - offsetMs;
|
|
125
|
+
|
|
126
|
+
const readBack = wallClockInZone(candidate, zone);
|
|
127
|
+
// the digits the zone must read back: the written ones, or the rolled-over next day for 24:00:00
|
|
128
|
+
const expected = endOfDay ? wallClockInZone(wallMs, 'UTC') : wall;
|
|
129
|
+
const digitsAgree =
|
|
130
|
+
readBack.year === expected.year && readBack.month === expected.month &&
|
|
131
|
+
readBack.day === expected.day && readBack.hours === expected.hours &&
|
|
132
|
+
readBack.minutes === expected.minutes && readBack.seconds === expected.seconds &&
|
|
133
|
+
readBack.ms === expected.ms;
|
|
134
|
+
if (!digitsAgree) return null;
|
|
135
|
+
return { epochMs: candidate, timezone: zone };
|
|
136
|
+
}
|
|
24
137
|
/**
|
|
25
138
|
* Nile icon component.
|
|
26
139
|
*
|
|
27
140
|
* @tag nile-calendar
|
|
28
141
|
*
|
|
142
|
+
* @csspart calendar-container - The outer container.
|
|
143
|
+
* @csspart calendar-switcher - The Absolute / Relative tab switcher.
|
|
144
|
+
* @csspart button-container - The default variant's button row.
|
|
145
|
+
* @csspart calendar-wrapper - Wraps the month grid.
|
|
146
|
+
* @csspart trigger - The month and year dropdown triggers.
|
|
147
|
+
* @csspart calendar-day-N - A day cell, where N is the day of the month.
|
|
148
|
+
* @csspart current-date-dot - The marker on today's date.
|
|
149
|
+
*
|
|
150
|
+
* Parts that exist only in `variant="date-time-picker"`:
|
|
151
|
+
* @csspart calendar-config - The row holding the type switcher.
|
|
152
|
+
* @csspart calendar - One month's header and grid.
|
|
153
|
+
* @csspart calendar-header - The month header row.
|
|
154
|
+
* @csspart calendar-header-prev - The previous-month icon.
|
|
155
|
+
* @csspart calendar-header-next - The next-month icon.
|
|
156
|
+
* @csspart calendar-header-center - The month and year area.
|
|
157
|
+
* @csspart days-container - The grid of day cells.
|
|
158
|
+
* @csspart day-name - A weekday label (Sun, Mon, ...).
|
|
159
|
+
* @csspart calendar-day-label - The number inside a day cell.
|
|
160
|
+
* @csspart tp-chevron - The expand / collapse control.
|
|
161
|
+
* @csspart tp-scroll-area - The scrolling area under the grid.
|
|
162
|
+
* @csspart tp-scroll-content - The content inside the scrolling area.
|
|
163
|
+
* @csspart tp-time-fields - The row of time inputs.
|
|
164
|
+
* @csspart tp-time-field - One time input (hours, minutes, seconds, milliseconds).
|
|
165
|
+
* @csspart tp-stepper - The up/down stepper inside a time input.
|
|
166
|
+
* @csspart tp-stepper-btn - Either stepper button.
|
|
167
|
+
* @csspart tp-stepper-btn-up - The increase button.
|
|
168
|
+
* @csspart tp-stepper-btn-down - The decrease button.
|
|
169
|
+
* @csspart tp-timezone - The timezone section.
|
|
170
|
+
* @csspart tp-timezone-header - The timezone label row.
|
|
171
|
+
* @csspart tp-timezone-label - The "Timezone" label.
|
|
172
|
+
* @csspart tp-timezone-local-link - The "Use local timezone" link.
|
|
173
|
+
* @csspart tp-timezone-select - The timezone select.
|
|
174
|
+
* @csspart tp-footer - The footer.
|
|
175
|
+
* @csspart tp-footer-actions - The Reset and Apply group.
|
|
176
|
+
* @csspart tp-set-to-now-button - The "Set to now" button.
|
|
177
|
+
* @csspart clear-button - The Reset button.
|
|
178
|
+
* @csspart apply-button - The Apply button.
|
|
29
179
|
*/
|
|
30
180
|
@customElement('nile-calendar')
|
|
31
181
|
export class NileCalendar extends NileElement {
|
|
@@ -47,6 +197,8 @@ export class NileCalendar extends NileElement {
|
|
|
47
197
|
|
|
48
198
|
@property({ type: Boolean, attribute: true, reflect:true }) hideTypes = false;
|
|
49
199
|
|
|
200
|
+
@property({ type: Boolean, attribute: true, reflect: true }) showTypes = false;
|
|
201
|
+
|
|
50
202
|
@property({ type: Boolean, reflect: true }) doubleClickUnselect = false;
|
|
51
203
|
|
|
52
204
|
@property({ type: Boolean, reflect: true }) allowClear = false;
|
|
@@ -71,6 +223,7 @@ export class NileCalendar extends NileElement {
|
|
|
71
223
|
|
|
72
224
|
@property({ type: String, reflect: true, attribute: true}) dateFormat: string = 'MM/DD/YYYY';
|
|
73
225
|
|
|
226
|
+
@property({ type: Boolean, reflect: true }) structuredInput = false;
|
|
74
227
|
|
|
75
228
|
@state() startDate: Date | null = null;
|
|
76
229
|
|
|
@@ -89,35 +242,215 @@ export class NileCalendar extends NileElement {
|
|
|
89
242
|
@state() allowedDatesLocal: NileCalendarDateRange | any = null;
|
|
90
243
|
|
|
91
244
|
@property({ type: Boolean, attribute: true, reflect: true }) disabled = false;
|
|
245
|
+
|
|
246
|
+
@property({ type: String, attribute: true }) variant: 'default' | 'date-time-picker' = 'default';
|
|
247
|
+
|
|
248
|
+
@property({ type: String, attribute: true }) timezone: string = 'UTC';
|
|
249
|
+
|
|
250
|
+
/** the timezone the consumer configured; Reset restores this (internal changes never overwrite it) */
|
|
251
|
+
private configuredTimezone = 'UTC';
|
|
252
|
+
|
|
253
|
+
/** the zone the component itself is assigning, so updated() can tell it from a consumer change */
|
|
254
|
+
private internalTimezone: string | null = null;
|
|
255
|
+
|
|
256
|
+
private setTimezoneInternally(tz: string): void {
|
|
257
|
+
if (tz !== this.timezone) this.internalTimezone = tz;
|
|
258
|
+
this.timezone = tz;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
@property({ type: Array, attribute: 'hide-time-fields' }) hideTimeFields: string[] = [];
|
|
262
|
+
|
|
263
|
+
/** every field shown, minus the keys in hideTimeFields (a non-array value counts as an empty list) */
|
|
264
|
+
private get resolvedTimeFields(): Record<string, boolean> {
|
|
265
|
+
const merged: Record<string, boolean> = {
|
|
266
|
+
hours: true,
|
|
267
|
+
minutes: true,
|
|
268
|
+
seconds: true,
|
|
269
|
+
milliseconds: true,
|
|
270
|
+
timezone: true,
|
|
271
|
+
};
|
|
272
|
+
const hidden = Array.isArray(this.hideTimeFields) ? this.hideTimeFields : [];
|
|
273
|
+
for (const key of hidden) merged[key] = false;
|
|
274
|
+
return merged;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private get tpFewFieldsNoTz(): boolean {
|
|
278
|
+
const f = this.resolvedTimeFields;
|
|
279
|
+
if (f.timezone !== false) return false;
|
|
280
|
+
const timeCount = ['hours', 'minutes', 'seconds', 'milliseconds'].filter(
|
|
281
|
+
k => f[k] !== false
|
|
282
|
+
).length;
|
|
283
|
+
return timeCount <= 2;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
@state() expanded = false;
|
|
287
|
+
@state() tpLockedHeight: string | null = null;
|
|
288
|
+
@state() tpHours: number | null = null;
|
|
289
|
+
@state() tpMinutes: number | null = null;
|
|
290
|
+
@state() tpSeconds: number | null = null;
|
|
291
|
+
@state() tpMilliseconds: number | null = null;
|
|
292
|
+
@state() tpFocusedTimeField: string | null = null;
|
|
293
|
+
/** the day the keyboard last moved to; owns the grid's single tab stop (roving tabindex) */
|
|
294
|
+
@state() tpFocusDate: { year: number; month: number; day: number } | null = null;
|
|
295
|
+
@state() tpEditingText = '';
|
|
296
|
+
|
|
297
|
+
private seedInstant: number | null = null;
|
|
298
|
+
|
|
299
|
+
private seedFieldsKey: string | null = null;
|
|
300
|
+
|
|
301
|
+
private seedZone: string | null = null;
|
|
302
|
+
|
|
92
303
|
connectedCallback() {
|
|
93
304
|
super.connectedCallback();
|
|
305
|
+
// before initializeValue: a zoned/bracketed value seeds the zone internally and must not become the configured one
|
|
306
|
+
if (!this.hasUpdated) this.configuredTimezone = this.timezone;
|
|
94
307
|
this.initializeValue();
|
|
308
|
+
// firstUpdated runs once; a re-attach (DOM move) must restore the structuredInput listeners disconnectedCallback removed (no-op unless structuredInput)
|
|
309
|
+
if (this.hasUpdated) this.bindStructuredInput();
|
|
310
|
+
// disconnectedCallback dropped the observer; an already-rendered picker panel needs it back (updated() won't run on a bare re-attach)
|
|
311
|
+
if (this.hasUpdated && this.variant === 'date-time-picker') this.observeCollapsedBase();
|
|
95
312
|
this.emit('nile-init');
|
|
96
313
|
}
|
|
97
314
|
|
|
98
315
|
disconnectedCallback() {
|
|
99
316
|
super.disconnectedCallback();
|
|
317
|
+
this.releasePanelObserver();
|
|
318
|
+
this.unbindStructuredInput();
|
|
100
319
|
this.emit('nile-destroy');
|
|
101
320
|
}
|
|
102
321
|
|
|
322
|
+
protected firstUpdated(): void {
|
|
323
|
+
this.bindStructuredInput();
|
|
324
|
+
}
|
|
325
|
+
|
|
103
326
|
protected updated(changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>): void {
|
|
104
327
|
super.updated(changedProperties);
|
|
328
|
+
if (changedProperties.has('timezone')) {
|
|
329
|
+
if (this.timezone !== this.internalTimezone) this.configuredTimezone = this.timezone;
|
|
330
|
+
this.internalTimezone = null;
|
|
331
|
+
}
|
|
332
|
+
if (
|
|
333
|
+
changedProperties.has('timezone') &&
|
|
334
|
+
!changedProperties.has('valueAttribute') &&
|
|
335
|
+
this.seedInstant !== null
|
|
336
|
+
) {
|
|
337
|
+
if (this.seedZone !== null) {
|
|
338
|
+
if (this.timezone !== this.seedZone) this.clearInstantSeed();
|
|
339
|
+
} else if (this.seedFieldsKey === this.instantSeedFieldsKey()) {
|
|
340
|
+
if (isValidTimezone(this.timezone)) {
|
|
341
|
+
this.seedFromInstant(this.seedInstant, this.timezone);
|
|
342
|
+
} else {
|
|
343
|
+
this.clearInstantSeed();
|
|
344
|
+
this.initializeValue();
|
|
345
|
+
}
|
|
346
|
+
} else {
|
|
347
|
+
this.clearInstantSeed();
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if (
|
|
351
|
+
changedProperties.has('structuredInput') ||
|
|
352
|
+
changedProperties.has('variant') ||
|
|
353
|
+
changedProperties.has('range') ||
|
|
354
|
+
changedProperties.has('showManualInputs') ||
|
|
355
|
+
changedProperties.has('type') ||
|
|
356
|
+
changedProperties.has('dateFormat')
|
|
357
|
+
) {
|
|
358
|
+
this.bindStructuredInput();
|
|
359
|
+
}
|
|
105
360
|
if (changedProperties.has('valueAttribute')) {
|
|
106
361
|
if(!this.valueAttribute){
|
|
107
362
|
this.value=null;
|
|
108
363
|
this.startDate=null;
|
|
109
364
|
this.endDate=null;
|
|
110
365
|
this.isSelectingStart=true;
|
|
366
|
+
if (this.variant === 'date-time-picker') this.resetTimeUnits();
|
|
111
367
|
}
|
|
112
|
-
const date =
|
|
368
|
+
const date = this.dateFromValueString(this.valueAttribute || '');
|
|
113
369
|
if (!isNaN(date.getTime())) {
|
|
114
|
-
|
|
115
|
-
this.value = new Date(date.getTime() - offset * 60 * 1000);
|
|
370
|
+
this.value = date;
|
|
116
371
|
this.currentMonth = this.value.getMonth();
|
|
117
372
|
this.currentYear = this.value.getFullYear();
|
|
118
373
|
}
|
|
119
374
|
this.initializeValue();
|
|
120
375
|
}
|
|
376
|
+
if (this.variant === 'date-time-picker') {
|
|
377
|
+
this.applyMonthYearDropdownAnimation();
|
|
378
|
+
this.captureCollapsedHeight();
|
|
379
|
+
} else {
|
|
380
|
+
this.releasePanelObserver(); // the default variant has no picker panel to watch (a variant switch removed it)
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
private tpResizeObserver?: ResizeObserver;
|
|
385
|
+
|
|
386
|
+
/** the `.base--date-time-picker` element the observer currently watches */
|
|
387
|
+
private tpObservedBase: HTMLElement | null = null;
|
|
388
|
+
|
|
389
|
+
private releasePanelObserver(): void {
|
|
390
|
+
this.tpResizeObserver?.disconnect();
|
|
391
|
+
this.tpResizeObserver = undefined;
|
|
392
|
+
this.tpObservedBase = null;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** (re)points the observer at the rendered panel; a no-op while it already watches that element */
|
|
396
|
+
private observeCollapsedBase(): HTMLElement | null {
|
|
397
|
+
const base = this.shadowRoot?.querySelector(
|
|
398
|
+
'.base--date-time-picker'
|
|
399
|
+
) as HTMLElement | null;
|
|
400
|
+
if (!base) {
|
|
401
|
+
this.releasePanelObserver();
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
if (this.tpObservedBase !== base) {
|
|
405
|
+
this.tpResizeObserver?.disconnect();
|
|
406
|
+
this.tpResizeObserver = new ResizeObserver(() => this.measurePanel(base));
|
|
407
|
+
this.tpResizeObserver.observe(base);
|
|
408
|
+
this.tpObservedBase = base;
|
|
409
|
+
}
|
|
410
|
+
return base;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
private captureCollapsedHeight(): void {
|
|
414
|
+
const base = this.observeCollapsedBase();
|
|
415
|
+
if (base) this.measurePanel(base);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private measurePanel(base: HTMLElement): void {
|
|
419
|
+
if (this.expanded) return;
|
|
420
|
+
const h = `${base.offsetHeight}px`;
|
|
421
|
+
if (h !== this.tpLockedHeight) this.tpLockedHeight = h;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
private applyMonthYearDropdownAnimation(): void {
|
|
425
|
+
const apply = (root: ShadowRoot | null | undefined) => {
|
|
426
|
+
root?.querySelectorAll('.month-dropdown, .year-dropdown').forEach(dd => {
|
|
427
|
+
setAnimation(dd, 'dropdown.show', {
|
|
428
|
+
keyframes: [
|
|
429
|
+
{ opacity: 0, scale: 0.9, translate: '0 -14px' },
|
|
430
|
+
{ opacity: 1, scale: 1, translate: '0 0' },
|
|
431
|
+
],
|
|
432
|
+
options: { duration: 280, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' },
|
|
433
|
+
});
|
|
434
|
+
setAnimation(dd, 'dropdown.hide', {
|
|
435
|
+
keyframes: [
|
|
436
|
+
{ opacity: 1, scale: 1, translate: '0 0' },
|
|
437
|
+
{ opacity: 0, scale: 0.9, translate: '0 -14px' },
|
|
438
|
+
],
|
|
439
|
+
options: { duration: 160, easing: 'ease' },
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
apply(this.shadowRoot);
|
|
445
|
+
|
|
446
|
+
const inner = this.shadowRoot?.querySelector(
|
|
447
|
+
'.tp-inner-calendar'
|
|
448
|
+
) as (HTMLElement & { updateComplete?: Promise<unknown>; shadowRoot: ShadowRoot | null }) | null;
|
|
449
|
+
if (inner) {
|
|
450
|
+
const innerApply = () => apply(inner.shadowRoot);
|
|
451
|
+
if (inner.updateComplete) inner.updateComplete.then(innerApply);
|
|
452
|
+
else innerApply();
|
|
453
|
+
}
|
|
121
454
|
}
|
|
122
455
|
|
|
123
456
|
private get monthNames() {
|
|
@@ -127,11 +460,8 @@ export class NileCalendar extends NileElement {
|
|
|
127
460
|
}
|
|
128
461
|
|
|
129
462
|
private get yearOptions(): number[] {
|
|
130
|
-
const
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
const start = this.startYear ?? fallbackStart;
|
|
134
|
-
const end = this.endYear ?? fallbackEnd;
|
|
463
|
+
const start = this.startYear ?? DEFAULT_START_YEAR;
|
|
464
|
+
const end = this.endYear ?? DEFAULT_END_YEAR;
|
|
135
465
|
|
|
136
466
|
if (start > end) return [];
|
|
137
467
|
|
|
@@ -148,6 +478,47 @@ export class NileCalendar extends NileElement {
|
|
|
148
478
|
this.emit('nile-year-change', { year });
|
|
149
479
|
}
|
|
150
480
|
|
|
481
|
+
/** brings the currently-selected month/year option into view when a dropdown opens */
|
|
482
|
+
private scrollSelectedIntoView(selector: string) {
|
|
483
|
+
const selected = this.shadowRoot?.querySelector(selector) as HTMLElement | null;
|
|
484
|
+
if (!selected) return;
|
|
485
|
+
const container = selected.closest(
|
|
486
|
+
'.month-scroll-wrapper, .year-scroll-wrapper'
|
|
487
|
+
) as HTMLElement | null;
|
|
488
|
+
if (!container) {
|
|
489
|
+
selected.scrollIntoView({ block: 'center' });
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
const target =
|
|
493
|
+
container.scrollTop +
|
|
494
|
+
(selected.getBoundingClientRect().top -
|
|
495
|
+
container.getBoundingClientRect().top) -
|
|
496
|
+
(container.clientHeight - selected.clientHeight) / 2;
|
|
497
|
+
const prefersReducedMotion = window.matchMedia(
|
|
498
|
+
'(prefers-reduced-motion: reduce)'
|
|
499
|
+
).matches;
|
|
500
|
+
if (prefersReducedMotion) {
|
|
501
|
+
container.scrollTop = target;
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
this.smoothScrollTo(container, target, 450);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
private smoothScrollTo(el: HTMLElement, to: number, duration: number) {
|
|
508
|
+
const start = el.scrollTop;
|
|
509
|
+
const change = to - start;
|
|
510
|
+
if (change === 0) return;
|
|
511
|
+
const startTime = performance.now();
|
|
512
|
+
const ease = (t: number) =>
|
|
513
|
+
t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
|
|
514
|
+
const step = (now: number) => {
|
|
515
|
+
const t = Math.min(1, (now - startTime) / duration);
|
|
516
|
+
el.scrollTop = start + change * ease(t);
|
|
517
|
+
if (t < 1) requestAnimationFrame(step);
|
|
518
|
+
};
|
|
519
|
+
requestAnimationFrame(step);
|
|
520
|
+
}
|
|
521
|
+
|
|
151
522
|
@watch('allowedDates')
|
|
152
523
|
checkValidAllowedDate() {
|
|
153
524
|
let newDateRange: NileCalendarDateRange | null;
|
|
@@ -173,19 +544,31 @@ export class NileCalendar extends NileElement {
|
|
|
173
544
|
}
|
|
174
545
|
}
|
|
175
546
|
|
|
547
|
+
/**
|
|
548
|
+
* The year range month navigation enforces. An explicit `startYear` / `endYear` always wins; the date-time-picker
|
|
549
|
+
* variant falls back to the dropdown's 2000-2050 for an unset side, while the default calendar leaves it open as published.
|
|
550
|
+
*/
|
|
551
|
+
private get minYear(): number | undefined {
|
|
552
|
+
return this.variant === 'date-time-picker' ? this.startYear ?? DEFAULT_START_YEAR : this.startYear;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
private get maxYear(): number | undefined {
|
|
556
|
+
return this.variant === 'date-time-picker' ? this.endYear ?? DEFAULT_END_YEAR : this.endYear;
|
|
557
|
+
}
|
|
558
|
+
|
|
176
559
|
private isPrevDisabled(): boolean {
|
|
177
560
|
return (
|
|
178
561
|
this.currentMonth === 0 &&
|
|
179
|
-
this.
|
|
180
|
-
this.currentYear <= this.
|
|
562
|
+
this.minYear !== undefined &&
|
|
563
|
+
this.currentYear <= this.minYear
|
|
181
564
|
);
|
|
182
565
|
}
|
|
183
566
|
|
|
184
567
|
private isNextDisabled(): boolean {
|
|
185
568
|
return (
|
|
186
569
|
this.currentMonth === 11 &&
|
|
187
|
-
this.
|
|
188
|
-
this.currentYear >= this.
|
|
570
|
+
this.maxYear !== undefined &&
|
|
571
|
+
this.currentYear >= this.maxYear
|
|
189
572
|
);
|
|
190
573
|
}
|
|
191
574
|
|
|
@@ -197,7 +580,8 @@ export class NileCalendar extends NileElement {
|
|
|
197
580
|
const dd = String(date.getDate()).padStart(2, '0');
|
|
198
581
|
|
|
199
582
|
let formatted = this.dateFormat;
|
|
200
|
-
|
|
583
|
+
// the date-time-picker variant pads years 0-99 to four digits; the default calendar prints the year as published
|
|
584
|
+
formatted = formatted.replace(/YYYY/g, this.variant === 'date-time-picker' ? formatYear4(yyyy) : String(yyyy));
|
|
201
585
|
formatted = formatted.replace(/MM/g, mm);
|
|
202
586
|
formatted = formatted.replace(/DD/g, dd);
|
|
203
587
|
formatted = formatted.replace(/YY/g, String(yyyy).slice(-2));
|
|
@@ -218,11 +602,18 @@ export class NileCalendar extends NileElement {
|
|
|
218
602
|
const match = input.match(regex);
|
|
219
603
|
if (!match || !match.groups) return null;
|
|
220
604
|
|
|
221
|
-
const
|
|
605
|
+
const dtp = this.variant === 'date-time-picker';
|
|
606
|
+
const typedYear = match.groups.year;
|
|
607
|
+
let year = typedYear.length === 2 ? Number(`20${typedYear}`) : Number(typedYear);
|
|
608
|
+
// date-time-picker only: an existing bound keeps its century while the visible two digits are unchanged; with no existing bound a typed YY is 2000-2099 (the default calendar reads it as published)
|
|
609
|
+
if (dtp && typedYear.length === 2 && existingDate && Math.abs(existingDate.getFullYear()) % 100 === Number(typedYear)) {
|
|
610
|
+
year = existingDate.getFullYear();
|
|
611
|
+
}
|
|
222
612
|
const month = Number(match.groups.month) - 1;
|
|
223
613
|
const day = Number(match.groups.day);
|
|
224
614
|
|
|
225
|
-
|
|
615
|
+
// new Date(y, ...) remaps years 0-99 to 1900-1999 as published; only the variant keeps the literal year
|
|
616
|
+
const date = dtp ? localDateFromFields(year, month, day) : new Date(year, month, day);
|
|
226
617
|
if (existingDate) {
|
|
227
618
|
date.setHours(
|
|
228
619
|
existingDate.getHours(),
|
|
@@ -259,11 +650,253 @@ private handleEndDateInput(event: CustomEvent): void {
|
|
|
259
650
|
}
|
|
260
651
|
|
|
261
652
|
|
|
653
|
+
private maskValuesMap = new WeakMap<HTMLInputElement, string[]>();
|
|
654
|
+
|
|
655
|
+
private boundMaskInputs: Element[] = [];
|
|
656
|
+
|
|
657
|
+
private unbindStructuredInput(): void {
|
|
658
|
+
for (const inp of this.boundMaskInputs) {
|
|
659
|
+
inp.removeEventListener('keydown', this.onMaskKeydown as EventListener, true);
|
|
660
|
+
inp.removeEventListener('focusout', this.onMaskBlur as EventListener);
|
|
661
|
+
}
|
|
662
|
+
this.boundMaskInputs = [];
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
private bindStructuredInput(): void {
|
|
666
|
+
this.unbindStructuredInput();
|
|
667
|
+
if (!this.structuredInput) return;
|
|
668
|
+
const inputs = this.shadowRoot?.querySelectorAll('.calender-input nile-input');
|
|
669
|
+
inputs?.forEach(inp => {
|
|
670
|
+
inp.addEventListener('keydown', this.onMaskKeydown as EventListener, true);
|
|
671
|
+
inp.addEventListener('focusout', this.onMaskBlur as EventListener);
|
|
672
|
+
this.boundMaskInputs.push(inp);
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
private dateMaskSegments(): Array<{ kind: string; len: number; min: number; max: number; after: string }> {
|
|
677
|
+
const spec = (token: string): { kind: string; len: number; min: number; max: number } => {
|
|
678
|
+
switch (token) {
|
|
679
|
+
case 'YYYY': return { kind: 'year', len: 4, min: 0, max: 9999 };
|
|
680
|
+
case 'YY': return { kind: 'year', len: 2, min: 0, max: 99 };
|
|
681
|
+
case 'MM': return { kind: 'month', len: 2, min: 1, max: 12 };
|
|
682
|
+
default: return { kind: 'day', len: 2, min: 1, max: 31 }; // DD
|
|
683
|
+
}
|
|
684
|
+
};
|
|
685
|
+
const fmt = this.dateFormat || 'MM/DD/YYYY';
|
|
686
|
+
const re = /YYYY|YY|MM|DD/g;
|
|
687
|
+
const found: Array<{ token: string; index: number }> = [];
|
|
688
|
+
let m: RegExpExecArray | null;
|
|
689
|
+
while ((m = re.exec(fmt)) !== null) found.push({ token: m[0], index: m.index });
|
|
690
|
+
return found.map((f, i) => {
|
|
691
|
+
const sep = fmt.slice(
|
|
692
|
+
f.index + f.token.length,
|
|
693
|
+
i + 1 < found.length ? found[i + 1].index : fmt.length
|
|
694
|
+
);
|
|
695
|
+
return { ...spec(f.token), after: i < found.length - 1 ? sep : '' };
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
private timeMaskSegments(): Array<{ kind: string; len: number; min: number; max: number; after: string }> {
|
|
700
|
+
return [
|
|
701
|
+
{ kind: 'hour', len: 2, min: 0, max: 23, after: ':' },
|
|
702
|
+
{ kind: 'minute', len: 2, min: 0, max: 59, after: ':' },
|
|
703
|
+
{ kind: 'second', len: 2, min: 0, max: 59, after: '' },
|
|
704
|
+
];
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
private buildMaskLayout(
|
|
708
|
+
segs: Array<{ kind: string; len: number; min: number; max: number; after: string }>
|
|
709
|
+
) {
|
|
710
|
+
const tokens: Array<{ slot: number } | { lit: string }> = [];
|
|
711
|
+
const slots: Array<{ kind: string; min: number; max: number; len: number; segStart: number }> = [];
|
|
712
|
+
for (const seg of segs) {
|
|
713
|
+
const segStart = slots.length;
|
|
714
|
+
for (let k = 0; k < seg.len; k++) {
|
|
715
|
+
tokens.push({ slot: slots.length });
|
|
716
|
+
slots.push({ kind: seg.kind, min: seg.min, max: seg.max, len: seg.len, segStart });
|
|
717
|
+
}
|
|
718
|
+
for (const ch of seg.after) tokens.push({ lit: ch });
|
|
719
|
+
}
|
|
720
|
+
return { tokens, slots, length: tokens.length };
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
private maskLayoutFor(kind: 'date' | 'time') {
|
|
724
|
+
return this.buildMaskLayout(kind === 'time' ? this.timeMaskSegments() : this.dateMaskSegments());
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
private computeMaskDisplay(values: string[], layout: ReturnType<typeof this.buildMaskLayout>) {
|
|
728
|
+
let lastFilled = -1;
|
|
729
|
+
values.forEach((v, i) => { if (v !== '') lastFilled = i; });
|
|
730
|
+
let s = '';
|
|
731
|
+
const slotPos = new Array(layout.slots.length).fill(-1);
|
|
732
|
+
for (const t of layout.tokens) {
|
|
733
|
+
if ('slot' in t) {
|
|
734
|
+
if (t.slot > lastFilled) break; // stop at the trailing empties
|
|
735
|
+
slotPos[t.slot] = s.length;
|
|
736
|
+
if (values[t.slot]) s += values[t.slot];
|
|
737
|
+
} else {
|
|
738
|
+
s += t.lit;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
return { display: lastFilled === -1 ? '' : s, slotPos, lastFilled };
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
private initMaskValues(text: string, layout: ReturnType<typeof this.buildMaskLayout>): string[] {
|
|
745
|
+
const digits = (text || '').replace(/\D/g, '').slice(0, layout.slots.length);
|
|
746
|
+
return layout.slots.map((_s, i) => digits[i] ?? '');
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/** clamp a filled segment into range (year is left unclamped) */
|
|
750
|
+
private clampMaskSegment(
|
|
751
|
+
values: string[],
|
|
752
|
+
layout: ReturnType<typeof this.buildMaskLayout>,
|
|
753
|
+
slotIndex: number
|
|
754
|
+
): void {
|
|
755
|
+
const seg = layout.slots[slotIndex];
|
|
756
|
+
if (seg.kind === 'year') return;
|
|
757
|
+
const segSlots: number[] = [];
|
|
758
|
+
layout.slots.forEach((s, i) => { if (s.segStart === seg.segStart) segSlots.push(i); });
|
|
759
|
+
const chars = segSlots.map(i => values[i]);
|
|
760
|
+
if (chars.some(c => c === '')) return; // segment not full yet
|
|
761
|
+
const joined = chars.join('');
|
|
762
|
+
const n = Number(joined);
|
|
763
|
+
let corrected: string | null = null;
|
|
764
|
+
if (n > seg.max) corrected = String(Number(joined[0])).padStart(seg.len, '0');
|
|
765
|
+
else if (n < seg.min) corrected = String(seg.min).padStart(seg.len, '0');
|
|
766
|
+
if (corrected !== null) segSlots.forEach((s, k) => { values[s] = corrected![k]; });
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
private maskValuesFor(
|
|
770
|
+
native: HTMLInputElement,
|
|
771
|
+
layout: ReturnType<typeof this.buildMaskLayout>
|
|
772
|
+
): string[] {
|
|
773
|
+
let values = this.maskValuesMap.get(native);
|
|
774
|
+
if (!values || values.length !== layout.slots.length) {
|
|
775
|
+
values = this.initMaskValues(native.value, layout);
|
|
776
|
+
} else if (this.computeMaskDisplay(values, layout).display !== native.value) {
|
|
777
|
+
values = this.initMaskValues(native.value, layout);
|
|
778
|
+
}
|
|
779
|
+
this.maskValuesMap.set(native, values);
|
|
780
|
+
return values;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
private onMaskKeydown = (e: KeyboardEvent): void => {
|
|
784
|
+
if (this.disabled || e.ctrlKey || e.metaKey || e.altKey) return;
|
|
785
|
+
const nav = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End',
|
|
786
|
+
'Tab', 'Enter', 'Shift', 'Control', 'Meta', 'Alt', 'CapsLock'];
|
|
787
|
+
if (nav.includes(e.key)) return;
|
|
788
|
+
|
|
789
|
+
const nileInput = e.currentTarget as (HTMLElement & { input?: HTMLInputElement; value?: string }) | null;
|
|
790
|
+
const native = nileInput?.input;
|
|
791
|
+
if (!nileInput || !native) return;
|
|
792
|
+
|
|
793
|
+
const kind: 'date' | 'time' = nileInput.classList.contains('time-input') ? 'time' : 'date';
|
|
794
|
+
const layout = this.maskLayoutFor(kind);
|
|
795
|
+
const values = this.maskValuesFor(native, layout);
|
|
796
|
+
const { slotPos } = this.computeMaskDisplay(values, layout);
|
|
797
|
+
const selStart = native.selectionStart ?? 0;
|
|
798
|
+
const selEnd = native.selectionEnd ?? selStart;
|
|
799
|
+
|
|
800
|
+
const writeMask = (display: string, caret: number) => {
|
|
801
|
+
native.value = display;
|
|
802
|
+
nileInput.value = display;
|
|
803
|
+
try { native.setSelectionRange(caret, caret); } catch { /* noop */ }
|
|
804
|
+
};
|
|
805
|
+
const caretAfter = (target: number) => {
|
|
806
|
+
const after = this.computeMaskDisplay(values, layout);
|
|
807
|
+
const np = after.slotPos[target + 1];
|
|
808
|
+
if (np !== undefined && np !== -1) return np;
|
|
809
|
+
return after.display.length;
|
|
810
|
+
};
|
|
811
|
+
|
|
812
|
+
if (e.key >= '0' && e.key <= '9') {
|
|
813
|
+
e.preventDefault();
|
|
814
|
+
let target = slotPos.findIndex(p => p !== -1 && p >= selStart);
|
|
815
|
+
if (target === -1) target = values.findIndex(v => v === ''); // append at the first gap
|
|
816
|
+
if (target === -1) return; // every slot full and caret past the end
|
|
817
|
+
values[target] = e.key;
|
|
818
|
+
this.clampMaskSegment(values, layout, target);
|
|
819
|
+
writeMask(this.computeMaskDisplay(values, layout).display, caretAfter(target));
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
if (e.key === 'Backspace') {
|
|
824
|
+
e.preventDefault();
|
|
825
|
+
if (selStart !== selEnd) {
|
|
826
|
+
for (let i = 0; i < values.length; i++) {
|
|
827
|
+
if (slotPos[i] !== -1 && slotPos[i] >= selStart && slotPos[i] < selEnd) values[i] = '';
|
|
828
|
+
}
|
|
829
|
+
writeMask(this.computeMaskDisplay(values, layout).display, selStart);
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
let target = -1;
|
|
833
|
+
for (let i = 0; i < values.length; i++) {
|
|
834
|
+
if (values[i] !== '' && slotPos[i] !== -1 && slotPos[i] < selStart) target = i;
|
|
835
|
+
}
|
|
836
|
+
if (target === -1) return;
|
|
837
|
+
values[target] = '';
|
|
838
|
+
const after = this.computeMaskDisplay(values, layout);
|
|
839
|
+
writeMask(after.display, after.slotPos[target] !== -1 ? after.slotPos[target] : Math.max(0, selStart - 1));
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
if (e.key === 'Delete') {
|
|
844
|
+
e.preventDefault();
|
|
845
|
+
let target = -1;
|
|
846
|
+
for (let i = 0; i < values.length; i++) {
|
|
847
|
+
if (values[i] !== '' && slotPos[i] !== -1 && slotPos[i] >= selStart) { target = i; break; }
|
|
848
|
+
}
|
|
849
|
+
if (target === -1) return;
|
|
850
|
+
values[target] = '';
|
|
851
|
+
const after = this.computeMaskDisplay(values, layout);
|
|
852
|
+
writeMask(after.display, after.slotPos[target] !== -1 ? after.slotPos[target] : selStart);
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
if (e.key.length === 1) e.preventDefault(); // block any other printable key
|
|
857
|
+
};
|
|
858
|
+
|
|
859
|
+
private onMaskBlur = (e: FocusEvent): void => {
|
|
860
|
+
const nileInput = e.currentTarget as (HTMLElement & { input?: HTMLInputElement; value?: string; emit?: Function }) | null;
|
|
861
|
+
const native = nileInput?.input;
|
|
862
|
+
if (!nileInput || !native) return;
|
|
863
|
+
|
|
864
|
+
const kind: 'date' | 'time' = nileInput.classList.contains('time-input') ? 'time' : 'date';
|
|
865
|
+
const layout = this.maskLayoutFor(kind);
|
|
866
|
+
const values = this.maskValuesFor(native, layout);
|
|
867
|
+
|
|
868
|
+
if (!values.some(v => v !== '')) {
|
|
869
|
+
native.value = '';
|
|
870
|
+
nileInput.value = '';
|
|
871
|
+
nileInput.emit?.('nile-change', { value: '' });
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// left-pad any partially-typed segment, then clamp each into range
|
|
876
|
+
const segStarts = [...new Set(layout.slots.map(s => s.segStart))];
|
|
877
|
+
for (const start of segStarts) {
|
|
878
|
+
const idxs = layout.slots.map((s, i) => (s.segStart === start ? i : -1)).filter(i => i !== -1);
|
|
879
|
+
const digits = idxs.map(i => values[i]).filter(c => c !== '');
|
|
880
|
+
if (digits.length > 0 && digits.length < idxs.length) {
|
|
881
|
+
const padded = digits.join('').padStart(idxs.length, '0');
|
|
882
|
+
idxs.forEach((i, k) => { values[i] = padded[k]; });
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
segStarts.forEach(start => this.clampMaskSegment(values, layout, start));
|
|
886
|
+
|
|
887
|
+
const { display } = this.computeMaskDisplay(values, layout);
|
|
888
|
+
native.value = display;
|
|
889
|
+
nileInput.value = display;
|
|
890
|
+
nileInput.emit?.('nile-change', { value: display });
|
|
891
|
+
};
|
|
262
892
|
|
|
263
893
|
/**
|
|
264
894
|
* Render method
|
|
265
895
|
*/
|
|
266
896
|
render(): TemplateResult {
|
|
897
|
+
if (this.variant === 'date-time-picker') {
|
|
898
|
+
return this.renderDateTimePicker();
|
|
899
|
+
}
|
|
267
900
|
return html`
|
|
268
901
|
<div
|
|
269
902
|
class=${classMap({
|
|
@@ -434,7 +1067,37 @@ private handleEndDateInput(event: CustomEvent): void {
|
|
|
434
1067
|
</div>
|
|
435
1068
|
</div>
|
|
436
1069
|
|
|
437
|
-
${this.
|
|
1070
|
+
${this.variant === 'date-time-picker' ? html`
|
|
1071
|
+
<div>
|
|
1072
|
+
<div class="unit-input-container">
|
|
1073
|
+
<nile-input
|
|
1074
|
+
class="manual-input duration-input"
|
|
1075
|
+
?disabled="${this.disabled}"
|
|
1076
|
+
label="Duration"
|
|
1077
|
+
inputmode="numeric"
|
|
1078
|
+
type="text"
|
|
1079
|
+
value="${this.selectedValue}"
|
|
1080
|
+
@nile-input="${this.handleDurationChange}"
|
|
1081
|
+
@nile-change="${this.handleDurationChange}"
|
|
1082
|
+
placeholder="Enter Value"
|
|
1083
|
+
></nile-input>
|
|
1084
|
+
|
|
1085
|
+
<nile-select
|
|
1086
|
+
class="manual-input time-input"
|
|
1087
|
+
?disabled="${this.disabled}"
|
|
1088
|
+
label="Unit of time"
|
|
1089
|
+
value="${this.selectedUnit}"
|
|
1090
|
+
@nile-change="${this.handleUnitChange}"
|
|
1091
|
+
>
|
|
1092
|
+
${this.hideDurationFields?.includes('minutes') ? '' : html`<nile-option value="minutes">Minutes</nile-option>`}
|
|
1093
|
+
${this.hideDurationFields?.includes('hours') ? '' : html`<nile-option value="hours">Hours</nile-option>`}
|
|
1094
|
+
${this.hideDurationFields?.includes('days') ? '' : html`<nile-option value="days">Days</nile-option>`}
|
|
1095
|
+
${this.hideDurationFields?.includes('weeks') ? '' : html`<nile-option value="weeks">Weeks</nile-option>`}
|
|
1096
|
+
${this.hideDurationFields?.includes('months') ? '' : html`<nile-option value="months">Months</nile-option>`}
|
|
1097
|
+
</nile-select>
|
|
1098
|
+
</div>
|
|
1099
|
+
</div>
|
|
1100
|
+
` : this.range ? html`
|
|
438
1101
|
<div>
|
|
439
1102
|
<div class="unit-input-container">
|
|
440
1103
|
<nile-input
|
|
@@ -714,7 +1377,8 @@ ${this.showMonthDropdown || this.showYearDropdown ? html`
|
|
|
714
1377
|
if (!this.allowedDatesLocal) {
|
|
715
1378
|
return true;
|
|
716
1379
|
}
|
|
717
|
-
|
|
1380
|
+
// Date.UTC remaps years 0-99 to 1900-1999 as published; only the date-time-picker variant keeps the literal year
|
|
1381
|
+
const dateToCheck = new Date(this.variant === 'date-time-picker' ? utcFromFields(year, month, day) : Date.UTC(year, month, day));
|
|
718
1382
|
const startDate = this.getUTCDate(this.allowedDatesLocal.startDate)
|
|
719
1383
|
const endDate = this.getUTCDate(this.allowedDatesLocal.endDate)
|
|
720
1384
|
endDate.setUTCHours(23, 59, 59, 999);
|
|
@@ -722,6 +1386,12 @@ ${this.showMonthDropdown || this.showYearDropdown ? html`
|
|
|
722
1386
|
return isWithinRange;
|
|
723
1387
|
}
|
|
724
1388
|
|
|
1389
|
+
private dayCellLabel(day: number, month: number, year: number): string {
|
|
1390
|
+
return localDateFromFields(year, month, day, 12).toLocaleDateString('default', {
|
|
1391
|
+
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric',
|
|
1392
|
+
});
|
|
1393
|
+
}
|
|
1394
|
+
|
|
725
1395
|
clearDate(mode?: ClearMode) {
|
|
726
1396
|
const clearType =
|
|
727
1397
|
mode ??
|
|
@@ -731,6 +1401,7 @@ ${this.showMonthDropdown || this.showYearDropdown ? html`
|
|
|
731
1401
|
: ClearMode.Range
|
|
732
1402
|
: ClearMode.Single);
|
|
733
1403
|
|
|
1404
|
+
this.clearInstantSeed();
|
|
734
1405
|
if (clearType === ClearMode.Single) {
|
|
735
1406
|
this.value = null;
|
|
736
1407
|
} else {
|
|
@@ -755,7 +1426,8 @@ ${this.showMonthDropdown || this.showYearDropdown ? html`
|
|
|
755
1426
|
*/
|
|
756
1427
|
selectDate(day: number, month: number, year: number): void {
|
|
757
1428
|
if (this.disabled) return;
|
|
758
|
-
|
|
1429
|
+
// new Date(y, ...) remaps years 0-99 to 1900-1999 as published; only the date-time-picker variant keeps the literal year
|
|
1430
|
+
const selectedDate = this.variant === 'date-time-picker' ? localDateFromFields(year, month, day) : new Date(year, month, day);
|
|
759
1431
|
|
|
760
1432
|
if (this.range) {
|
|
761
1433
|
if (this.startDate && this.endDate) {
|
|
@@ -796,25 +1468,80 @@ ${this.showMonthDropdown || this.showYearDropdown ? html`
|
|
|
796
1468
|
* Function to be called on initialization to set all other properties
|
|
797
1469
|
*/
|
|
798
1470
|
initializeValue() {
|
|
1471
|
+
this.clearInstantSeed();
|
|
799
1472
|
if (this.range) {
|
|
800
1473
|
try {
|
|
801
1474
|
const rangeValue = JSON.parse(this.valueAttribute || '');
|
|
802
|
-
this.startDate =
|
|
803
|
-
this.endDate =
|
|
1475
|
+
this.startDate = this.rangeBoundFromValue(rangeValue.startDate, false);
|
|
1476
|
+
this.endDate = this.rangeBoundFromValue(rangeValue.endDate, true);
|
|
804
1477
|
|
|
805
1478
|
// Convert to local time
|
|
806
1479
|
this.startDate = new Date(this.startDate.getTime());
|
|
807
1480
|
this.endDate = new Date(this.endDate.getTime());
|
|
808
1481
|
|
|
1482
|
+
// date-time-picker variant only: open on the seeded range's start month (the default variant keeps its month)
|
|
1483
|
+
if (this.variant === 'date-time-picker' && this.startDate && !isNaN(this.startDate.getTime())) {
|
|
1484
|
+
this.currentMonth = this.startDate.getMonth();
|
|
1485
|
+
this.currentYear = this.startDate.getFullYear();
|
|
1486
|
+
}
|
|
1487
|
+
|
|
809
1488
|
this.value = null;
|
|
810
1489
|
} catch (e) {
|
|
811
1490
|
// console.error('Invalid range value');
|
|
812
1491
|
}
|
|
813
1492
|
} else {
|
|
1493
|
+
if (this.variant === 'date-time-picker' && this.trySeedFromInstantValue()) {
|
|
1494
|
+
return;
|
|
1495
|
+
}
|
|
1496
|
+
const zoned =
|
|
1497
|
+
this.variant === 'date-time-picker'
|
|
1498
|
+
? this.parseZonedValue(this.valueAttribute)
|
|
1499
|
+
: null;
|
|
1500
|
+
if (zoned) {
|
|
1501
|
+
this.setTimezoneInternally(zoned.timezone);
|
|
1502
|
+
const w = zoned.wall;
|
|
1503
|
+
this.value = localDateFromFields(w.year, w.month - 1, w.day, 12, 0, 0, 0);
|
|
1504
|
+
this.currentMonth = w.month - 1;
|
|
1505
|
+
this.currentYear = w.year;
|
|
1506
|
+
this.tpHours = w.hours;
|
|
1507
|
+
this.tpMinutes = w.minutes;
|
|
1508
|
+
this.tpSeconds = w.seconds;
|
|
1509
|
+
this.tpMilliseconds = w.ms;
|
|
1510
|
+
return;
|
|
1511
|
+
}
|
|
1512
|
+
if (this.variant === 'date-time-picker') {
|
|
1513
|
+
const wall = wallClockFromOffsetLess(this.valueAttribute);
|
|
1514
|
+
if (wall) {
|
|
1515
|
+
this.value = localDateFromFields(wall.year, wall.month - 1, wall.day, 12, 0, 0, 0);
|
|
1516
|
+
this.currentMonth = wall.month - 1;
|
|
1517
|
+
this.currentYear = wall.year;
|
|
1518
|
+
this.tpHours = wall.hours;
|
|
1519
|
+
this.tpMinutes = wall.minutes;
|
|
1520
|
+
this.tpSeconds = wall.seconds;
|
|
1521
|
+
this.tpMilliseconds = wall.ms;
|
|
1522
|
+
return;
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
if (this.variant === 'date-time-picker') {
|
|
1526
|
+
const preset = this.parsePresetValue(this.valueAttribute);
|
|
1527
|
+
if (preset) {
|
|
1528
|
+
this.value = preset;
|
|
1529
|
+
this.currentMonth = preset.getMonth();
|
|
1530
|
+
this.currentYear = preset.getFullYear();
|
|
1531
|
+
// seed the time-picker spinners from the preset value's wall-clock time
|
|
1532
|
+
this.tpHours = preset.getHours();
|
|
1533
|
+
this.tpMinutes = preset.getMinutes();
|
|
1534
|
+
this.tpSeconds = preset.getSeconds();
|
|
1535
|
+
this.tpMilliseconds = preset.getMilliseconds();
|
|
1536
|
+
} else if (this.valueAttribute) {
|
|
1537
|
+
// an unparseable value must not leave the previous selection showing (the empty-value path above already clears it)
|
|
1538
|
+
this.value = null;
|
|
1539
|
+
this.resetTimeUnits();
|
|
1540
|
+
}
|
|
1541
|
+
return;
|
|
1542
|
+
}
|
|
814
1543
|
if (this.valueAttribute) {
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
date = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
|
|
1544
|
+
const date = this.dateFromValueString(this.valueAttribute);
|
|
818
1545
|
|
|
819
1546
|
if (!isNaN(date.getTime())) {
|
|
820
1547
|
this.value = date;
|
|
@@ -825,11 +1552,155 @@ ${this.showMonthDropdown || this.showYearDropdown ? html`
|
|
|
825
1552
|
}
|
|
826
1553
|
}
|
|
827
1554
|
|
|
1555
|
+
/** Forget any retained instant, in every category. */
|
|
1556
|
+
private clearInstantSeed(): void {
|
|
1557
|
+
this.seedInstant = null;
|
|
1558
|
+
this.seedFieldsKey = null;
|
|
1559
|
+
this.seedZone = null;
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
private instantSeedFieldsKey(): string | null {
|
|
1563
|
+
const v = this.value;
|
|
1564
|
+
if (!v) return null;
|
|
1565
|
+
return (
|
|
1566
|
+
`${v.getFullYear()}-${v.getMonth()}-${v.getDate()}|` +
|
|
1567
|
+
`${this.tpHours}:${this.tpMinutes}:${this.tpSeconds}.${this.tpMilliseconds}`
|
|
1568
|
+
);
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
private seedFromInstant(epochMs: number, zone: string): void {
|
|
1572
|
+
const w = wallClockInZone(epochMs, zone);
|
|
1573
|
+
this.value = localDateFromFields(w.year, w.month - 1, w.day, 12, 0, 0, 0);
|
|
1574
|
+
this.currentMonth = w.month - 1;
|
|
1575
|
+
this.currentYear = w.year;
|
|
1576
|
+
this.tpHours = w.hours;
|
|
1577
|
+
this.tpMinutes = w.minutes;
|
|
1578
|
+
this.tpSeconds = w.seconds;
|
|
1579
|
+
this.tpMilliseconds = w.ms;
|
|
1580
|
+
this.seedInstant = epochMs;
|
|
1581
|
+
this.seedFieldsKey = this.instantSeedFieldsKey();
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
private trySeedFromInstantValue(): boolean {
|
|
1585
|
+
const epochMs = instantFromValueString(this.valueAttribute);
|
|
1586
|
+
if (epochMs !== null) {
|
|
1587
|
+
if (!isValidTimezone(this.timezone)) return false;
|
|
1588
|
+
this.seedFromInstant(epochMs, this.timezone);
|
|
1589
|
+
this.seedZone = null;
|
|
1590
|
+
return true;
|
|
1591
|
+
}
|
|
1592
|
+
const bracketed = instantFromBracketedValue(this.valueAttribute);
|
|
1593
|
+
if (bracketed) {
|
|
1594
|
+
this.setTimezoneInternally(bracketed.timezone);
|
|
1595
|
+
this.seedFromInstant(bracketed.epochMs, bracketed.timezone);
|
|
1596
|
+
this.seedZone = bracketed.timezone;
|
|
1597
|
+
return true;
|
|
1598
|
+
}
|
|
1599
|
+
return false;
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
/**
|
|
1603
|
+
* Seeds `value` from a string with the published conversion: `new Date(raw)` shifted by the host offset. Every variant
|
|
1604
|
+
* keeps it, because the default calendar must behave exactly as the published component; the date-time-picker variant
|
|
1605
|
+
* re-derives its value from its own parsers in `initializeValue`.
|
|
1606
|
+
*/
|
|
1607
|
+
private dateFromValueString(raw: string): Date {
|
|
1608
|
+
const parsed = new Date(raw);
|
|
1609
|
+
return new Date(parsed.getTime() - parsed.getTimezoneOffset() * 60000);
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
/**
|
|
1613
|
+
* One bound of a `range` value. In the date-time-picker variant `YYYY-MM-DD` is a calendar day, not a UTC instant: it
|
|
1614
|
+
* lands on that local day, at 00:00:00.000 for the start and 23:59:59.999 for the end (what clicking the day produces).
|
|
1615
|
+
* Every other string, and every bound of the default calendar, goes through `new Date(raw)` exactly as published.
|
|
1616
|
+
*/
|
|
1617
|
+
private rangeBoundFromValue(raw: unknown, isEnd: boolean): Date {
|
|
1618
|
+
if (this.variant === 'date-time-picker' && typeof raw === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(raw)) {
|
|
1619
|
+
const day = this.parsePresetValue(raw);
|
|
1620
|
+
if (day) {
|
|
1621
|
+
if (isEnd) day.setHours(23, 59, 59, 999);
|
|
1622
|
+
return day;
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
return new Date(raw as string);
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
private parsePresetValue(raw: string | null): Date | null {
|
|
1629
|
+
if (!raw) return null;
|
|
1630
|
+
const dateOnly = /^\d{4}-\d{2}-\d{2}$/.exec(raw);
|
|
1631
|
+
if (dateOnly) {
|
|
1632
|
+
const year = Number(raw.slice(0, 4));
|
|
1633
|
+
const month = Number(raw.slice(5, 7)) - 1;
|
|
1634
|
+
const day = Number(raw.slice(8, 10));
|
|
1635
|
+
if (month < 0 || month > 11 || day < 1) return null;
|
|
1636
|
+
const lastDay = localDateFromFields(year, month + 1, 0).getDate();
|
|
1637
|
+
const date = localDateFromFields(year, month, Math.min(day, lastDay));
|
|
1638
|
+
return isNaN(date.getTime()) ? null : date;
|
|
1639
|
+
}
|
|
1640
|
+
// only an instant with an unusable zone or an undocumented string gets here; the documented formats are parsed before this
|
|
1641
|
+
const date = new Date(raw);
|
|
1642
|
+
return isNaN(date.getTime()) ? null : date;
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
private parseZonedValue(
|
|
1646
|
+
raw: string | null
|
|
1647
|
+
): { wall: CalendarWallClock; timezone: string } | null {
|
|
1648
|
+
if (!raw) return null;
|
|
1649
|
+
const m =
|
|
1650
|
+
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{3}))?\[GMT[+-]\d{2}:\d{2}\s+([^\]]+)\]$/.exec(
|
|
1651
|
+
raw.trim()
|
|
1652
|
+
);
|
|
1653
|
+
if (!m) return null;
|
|
1654
|
+
const [, y, mo, d, h, mi, s, ms, rawZone] = m;
|
|
1655
|
+
const month0 = Number(mo) - 1;
|
|
1656
|
+
const hours = Number(h);
|
|
1657
|
+
const minutes = Number(mi);
|
|
1658
|
+
const seconds = Number(s);
|
|
1659
|
+
if (month0 < 0 || month0 > 11 || hours > 23 || minutes > 59 || seconds > 59) {
|
|
1660
|
+
return null;
|
|
1661
|
+
}
|
|
1662
|
+
const day = Number(d);
|
|
1663
|
+
if (day < 1) return null;
|
|
1664
|
+
// clamp instead of letting an out-of-range day roll into the next month
|
|
1665
|
+
const lastDay = localDateFromFields(Number(y), month0 + 1, 0).getDate();
|
|
1666
|
+
const wall: CalendarWallClock = {
|
|
1667
|
+
year: Number(y),
|
|
1668
|
+
month: month0 + 1,
|
|
1669
|
+
day: Math.min(day, lastDay),
|
|
1670
|
+
hours,
|
|
1671
|
+
minutes,
|
|
1672
|
+
seconds,
|
|
1673
|
+
ms: Number(ms ?? 0),
|
|
1674
|
+
};
|
|
1675
|
+
const zone = rawZone.trim();
|
|
1676
|
+
const timezone = isValidTimezone(zone) ? zone : 'UTC';
|
|
1677
|
+
return { wall, timezone };
|
|
1678
|
+
}
|
|
1679
|
+
|
|
828
1680
|
/**
|
|
829
1681
|
* Function to handle relative date selection
|
|
830
1682
|
*/
|
|
831
1683
|
handleDurationChange(event: CustomEvent) {
|
|
832
1684
|
event.stopPropagation()
|
|
1685
|
+
if (this.variant === 'date-time-picker') {
|
|
1686
|
+
const input = event.target as (HTMLElement & { value: string }) | null;
|
|
1687
|
+
const cleaned = String(event.detail?.value ?? input?.value ?? '').replace(/\D/g, '');
|
|
1688
|
+
if (input && input.value !== cleaned) input.value = cleaned;
|
|
1689
|
+
const duration = Number(cleaned);
|
|
1690
|
+
if (!duration) {
|
|
1691
|
+
|
|
1692
|
+
this.selectedValue = undefined!;
|
|
1693
|
+
this.startDate = null;
|
|
1694
|
+
this.endDate = null;
|
|
1695
|
+
this.requestUpdate();
|
|
1696
|
+
return;
|
|
1697
|
+
}
|
|
1698
|
+
this.selectedValue = duration;
|
|
1699
|
+
if (this.selectedUnit && this.selectedValue) {
|
|
1700
|
+
this.handleTimeValueClick(this.selectedUnit, this.selectedValue, event);
|
|
1701
|
+
}
|
|
1702
|
+
return;
|
|
1703
|
+
}
|
|
833
1704
|
event.detail.value = event.detail.value.replace(/e/gi, "");
|
|
834
1705
|
const duration=Number(event.detail.value);
|
|
835
1706
|
if(!duration) {
|
|
@@ -858,13 +1729,19 @@ ${this.showMonthDropdown || this.showYearDropdown ? html`
|
|
|
858
1729
|
createRelativePeriod(unit: String, value: number) {
|
|
859
1730
|
const endTime = new Date();
|
|
860
1731
|
const startTime = new Date();
|
|
1732
|
+
// only the date-time-picker variant uses exact elapsed time, month-end clamping and the too-large-duration guard; the
|
|
1733
|
+
// default calendar keeps the published wall-clock arithmetic (and its behaviour for an overflowing duration)
|
|
1734
|
+
const dtp = this.variant === 'date-time-picker';
|
|
861
1735
|
|
|
862
1736
|
switch (unit) {
|
|
863
1737
|
case 'minutes':
|
|
864
|
-
|
|
1738
|
+
// elapsed duration: wall-clock arithmetic (setMinutes) is off by an hour across a host DST change
|
|
1739
|
+
if (dtp) startTime.setTime(endTime.getTime() - value * 60000);
|
|
1740
|
+
else startTime.setMinutes(startTime.getMinutes() - value);
|
|
865
1741
|
break;
|
|
866
1742
|
case 'hours':
|
|
867
|
-
startTime.
|
|
1743
|
+
if (dtp) startTime.setTime(endTime.getTime() - value * 3600000);
|
|
1744
|
+
else startTime.setHours(startTime.getHours() - value);
|
|
868
1745
|
break;
|
|
869
1746
|
case 'days':
|
|
870
1747
|
startTime.setDate(startTime.getDate() - value);
|
|
@@ -873,10 +1750,26 @@ ${this.showMonthDropdown || this.showYearDropdown ? html`
|
|
|
873
1750
|
startTime.setDate(startTime.getDate() - 7 * value); // Subtract weeks as days
|
|
874
1751
|
break;
|
|
875
1752
|
case 'months':
|
|
876
|
-
|
|
1753
|
+
if (dtp) {
|
|
1754
|
+
// clamp to the target month's last day: setMonth alone rolls 31 Mar - 1 month into 3 Mar
|
|
1755
|
+
const day = startTime.getDate();
|
|
1756
|
+
startTime.setDate(1);
|
|
1757
|
+
startTime.setMonth(startTime.getMonth() - value);
|
|
1758
|
+
const lastDay = localDateFromFields(startTime.getFullYear(), startTime.getMonth() + 1, 0).getDate();
|
|
1759
|
+
startTime.setDate(Math.min(day, lastDay));
|
|
1760
|
+
} else {
|
|
1761
|
+
startTime.setMonth(startTime.getMonth() - value);
|
|
1762
|
+
}
|
|
877
1763
|
break;
|
|
878
1764
|
}
|
|
879
1765
|
|
|
1766
|
+
// a duration too large for a Date leaves an Invalid Date; storing it would keep Apply enabled and make consumers throw (RangeError) when they format it, so in the variant it is no selection
|
|
1767
|
+
if (dtp && (isNaN(startTime.getTime()) || isNaN(endTime.getTime()))) {
|
|
1768
|
+
this.startDate = null;
|
|
1769
|
+
this.endDate = null;
|
|
1770
|
+
return { startDate: null, endDate: null };
|
|
1771
|
+
}
|
|
1772
|
+
|
|
880
1773
|
this.startDate = new Date(startTime.getTime());
|
|
881
1774
|
this.endDate = new Date(endTime.getTime());
|
|
882
1775
|
|
|
@@ -951,7 +1844,7 @@ ${this.showMonthDropdown || this.showYearDropdown ? html`
|
|
|
951
1844
|
if (this.disabled) return;
|
|
952
1845
|
if (this.currentMonth === 11) {
|
|
953
1846
|
const newYear = this.currentYear + 1;
|
|
954
|
-
if(this.
|
|
1847
|
+
if(this.maxYear !== undefined && newYear > this.maxYear) return;
|
|
955
1848
|
this.currentMonth = 0;
|
|
956
1849
|
this.currentYear = newYear;
|
|
957
1850
|
} else {
|
|
@@ -963,7 +1856,7 @@ ${this.showMonthDropdown || this.showYearDropdown ? html`
|
|
|
963
1856
|
if (this.disabled) return;
|
|
964
1857
|
if (this.currentMonth === 0) {
|
|
965
1858
|
const newYear = this.currentYear - 1;
|
|
966
|
-
if(this.
|
|
1859
|
+
if(this.minYear !== undefined && newYear < this.minYear) return;
|
|
967
1860
|
this.currentMonth = 11;
|
|
968
1861
|
this.currentYear = newYear;
|
|
969
1862
|
} else {
|
|
@@ -982,9 +1875,20 @@ ${this.showMonthDropdown || this.showYearDropdown ? html`
|
|
|
982
1875
|
onTypeChange(event: CustomEvent) {
|
|
983
1876
|
if (this.disabled) return;
|
|
984
1877
|
this.type = event.detail.value;
|
|
1878
|
+
if (this.variant === 'date-time-picker' && this.type !== 'absolute') this.expanded = false;
|
|
985
1879
|
this.emit('nile-type-change', { value: this.type })
|
|
986
1880
|
}
|
|
987
1881
|
getUTCDate(dateStr:any){
|
|
1882
|
+
// Date.UTC remaps years 0-99 to 1900-1999 as published; only the date-time-picker variant keeps the literal year
|
|
1883
|
+
if (this.variant === 'date-time-picker') {
|
|
1884
|
+
return new Date(
|
|
1885
|
+
utcFromFields(
|
|
1886
|
+
Number(dateStr.slice(0, 4)),
|
|
1887
|
+
Number(dateStr.slice(5, 7)) - 1,
|
|
1888
|
+
Number(dateStr.slice(8, 10))
|
|
1889
|
+
)
|
|
1890
|
+
)
|
|
1891
|
+
}
|
|
988
1892
|
return new Date(
|
|
989
1893
|
Date.UTC(
|
|
990
1894
|
dateStr.slice(0, 4),
|
|
@@ -1003,6 +1907,834 @@ ${this.showMonthDropdown || this.showYearDropdown ? html`
|
|
|
1003
1907
|
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
|
1004
1908
|
return Array.from({ length: daysInMonth }, (_, i) => i + 1);
|
|
1005
1909
|
}
|
|
1910
|
+
|
|
1911
|
+
private renderDateTimePicker(): TemplateResult {
|
|
1912
|
+
// the collapsed height is held while the month grid is expanded (the measured value reaches the stylesheet as a custom property)
|
|
1913
|
+
const heightLocked = this.type === 'absolute' && this.expanded && !!this.tpLockedHeight;
|
|
1914
|
+
return html`
|
|
1915
|
+
<div
|
|
1916
|
+
class=${classMap({
|
|
1917
|
+
base: true,
|
|
1918
|
+
'base--date-time-picker': true,
|
|
1919
|
+
'base--tp-expanded': this.expanded,
|
|
1920
|
+
'base--tp-fewfields': this.tpFewFieldsNoTz,
|
|
1921
|
+
'base--tp-height-locked': heightLocked,
|
|
1922
|
+
})}
|
|
1923
|
+
style=${heightLocked ? `--nile-calendar-tp-locked-height:${this.tpLockedHeight}` : nothing}
|
|
1924
|
+
part="calendar-container"
|
|
1925
|
+
>
|
|
1926
|
+
<div class=${classMap({
|
|
1927
|
+
'calendar-config': true,
|
|
1928
|
+
hidden: !this.showTypes,
|
|
1929
|
+
})} part="calendar-config">
|
|
1930
|
+
<div class="calendar-switcher" part="calendar-switcher">
|
|
1931
|
+
<nile-tab-group centered @nile-tab-show="${this.onTypeChange}" value="${this.type}">
|
|
1932
|
+
<nile-tab slot="nav" panel="absolute" tabindex="${this.disabled ? '-1' : '0'}">Absolute</nile-tab>
|
|
1933
|
+
<nile-tab slot="nav" panel="relative" tabindex="${this.disabled ? '-1' : '0'}">Relative</nile-tab>
|
|
1934
|
+
</nile-tab-group>
|
|
1935
|
+
</div>
|
|
1936
|
+
</div>
|
|
1937
|
+
|
|
1938
|
+
${this.type === 'relative' ? this.renderRelativeCalendar() : this.renderDateTimePickerAbsolute()}
|
|
1939
|
+
|
|
1940
|
+
<div
|
|
1941
|
+
class=${classMap({
|
|
1942
|
+
'tp-footer': true,
|
|
1943
|
+
'tp-footer--relative': this.type === 'relative',
|
|
1944
|
+
})}
|
|
1945
|
+
part="tp-footer"
|
|
1946
|
+
>
|
|
1947
|
+
${this.type === 'absolute' ? html`
|
|
1948
|
+
<nile-button
|
|
1949
|
+
id="tp-set-to-now"
|
|
1950
|
+
class="tp-set-to-now-button"
|
|
1951
|
+
part="tp-set-to-now-button"
|
|
1952
|
+
variant="secondary"
|
|
1953
|
+
?disabled="${this.disabled}"
|
|
1954
|
+
@click="${this.handleSetToNow}"
|
|
1955
|
+
@keydown="${(e: KeyboardEvent) => {if (e.key === 'Enter' || e.key === ' ') this.handleSetToNow();}}"
|
|
1956
|
+
>Set to now <nile-glyph name="ng-clock" size="16" method="stroke" color=${this.disabled ? 'var(--nile-colors-dark-500, var(--ng-colors-fg-disabled))' : nothing}></nile-glyph></nile-button>
|
|
1957
|
+
<nile-lite-tooltip
|
|
1958
|
+
for="tp-set-to-now"
|
|
1959
|
+
content="Sets the time to the current time"
|
|
1960
|
+
size="small"
|
|
1961
|
+
></nile-lite-tooltip>
|
|
1962
|
+
` : nothing}
|
|
1963
|
+
<div class="tp-footer-actions" part="tp-footer-actions">
|
|
1964
|
+
<nile-button
|
|
1965
|
+
class="clear-button"
|
|
1966
|
+
part="clear-button"
|
|
1967
|
+
variant="secondary"
|
|
1968
|
+
?disabled="${this.disabled || !this.hasSelection()}"
|
|
1969
|
+
@click="${this.handleReset}"
|
|
1970
|
+
@keydown="${(e: KeyboardEvent) => {if (e.key === 'Enter' || e.key === ' ') this.handleReset();}}"
|
|
1971
|
+
>Reset</nile-button>
|
|
1972
|
+
<nile-button
|
|
1973
|
+
class="apply-button"
|
|
1974
|
+
part="apply-button"
|
|
1975
|
+
?disabled="${this.disabled || !this.hasSelection()}"
|
|
1976
|
+
@click="${this.handleApply}"
|
|
1977
|
+
@keydown="${(e: KeyboardEvent) => {if (e.key === 'Enter' || e.key === ' ') this.handleApply();}}"
|
|
1978
|
+
>Apply</nile-button>
|
|
1979
|
+
</div>
|
|
1980
|
+
</div>
|
|
1981
|
+
</div>
|
|
1982
|
+
`;
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
private renderDateTimePickerAbsolute(): TemplateResult {
|
|
1986
|
+
return html`
|
|
1987
|
+
<div class="calendar-wrapper" part="calendar-wrapper">
|
|
1988
|
+
<div class="calendar-container">
|
|
1989
|
+
${this.expanded
|
|
1990
|
+
? this.renderExpandedCalendar()
|
|
1991
|
+
: this.renderCollapsedMonth(this.currentYear, this.currentMonth)}
|
|
1992
|
+
</div>
|
|
1993
|
+
</div>
|
|
1994
|
+
|
|
1995
|
+
<div
|
|
1996
|
+
class=${classMap({
|
|
1997
|
+
'tp-chevron': true,
|
|
1998
|
+
'tp-chevron--expanded': this.expanded,
|
|
1999
|
+
})}
|
|
2000
|
+
part="tp-chevron"
|
|
2001
|
+
role="button"
|
|
2002
|
+
tabindex="${this.disabled ? '-1' : '0'}"
|
|
2003
|
+
aria-expanded="${this.expanded}"
|
|
2004
|
+
aria-label="${this.expanded ? 'Collapse calendar' : 'Expand calendar'}"
|
|
2005
|
+
@click="${this.toggleExpanded}"
|
|
2006
|
+
@keydown="${(e: KeyboardEvent) => {if (e.key === 'Enter' || e.key === ' ') this.toggleExpanded();}}"
|
|
2007
|
+
>
|
|
2008
|
+
<nile-icon name="arrowdropdown" color=${this.disabled ? 'var(--nile-colors-dark-500, var(--ng-colors-fg-disabled))' : 'var(--nile-colors-dark-900, var(--ng-colors-text-primary-900))'}></nile-icon>
|
|
2009
|
+
</div>
|
|
2010
|
+
|
|
2011
|
+
<div class="tp-scroll-area" part="tp-scroll-area">
|
|
2012
|
+
<div class="tp-scroll-content" part="tp-scroll-content">
|
|
2013
|
+
${this.renderTimeFields()}
|
|
2014
|
+
${this.resolvedTimeFields.timezone ? this.renderTimezone() : ''}
|
|
2015
|
+
</div>
|
|
2016
|
+
</div>
|
|
2017
|
+
`;
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
private renderExpandedCalendar(): TemplateResult {
|
|
2021
|
+
return this.renderVariantMonth(
|
|
2022
|
+
this.currentYear,
|
|
2023
|
+
this.currentMonth,
|
|
2024
|
+
this.getFullMonthDays(this.currentYear, this.currentMonth)
|
|
2025
|
+
);
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
private renderCollapsedMonth(year: number, month: number): TemplateResult {
|
|
2029
|
+
return this.renderVariantMonth(
|
|
2030
|
+
year,
|
|
2031
|
+
month,
|
|
2032
|
+
this.getCollapsedWindowDays(year, month)
|
|
2033
|
+
);
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
private renderVariantMonth(
|
|
2037
|
+
year: number,
|
|
2038
|
+
month: number,
|
|
2039
|
+
cells: Array<{ day: number; month: number; year: number; inMonth: boolean }>
|
|
2040
|
+
): TemplateResult {
|
|
2041
|
+
const isSelectedDate = (
|
|
2042
|
+
day: number,
|
|
2043
|
+
month: number,
|
|
2044
|
+
year: number,
|
|
2045
|
+
isCurrentMonth: boolean
|
|
2046
|
+
) => {
|
|
2047
|
+
if (!isCurrentMonth) return '';
|
|
2048
|
+
if (this.value) {
|
|
2049
|
+
const isSelected =
|
|
2050
|
+
day === this.value.getDate() &&
|
|
2051
|
+
month === this.value.getMonth() &&
|
|
2052
|
+
year === this.value.getFullYear();
|
|
2053
|
+
if (isSelected) return 'selected-date';
|
|
2054
|
+
}
|
|
2055
|
+
return '';
|
|
2056
|
+
};
|
|
2057
|
+
|
|
2058
|
+
const isCurrentDate = (day: number, month: number, year: number) => {
|
|
2059
|
+
const today = new Date();
|
|
2060
|
+
return (
|
|
2061
|
+
day === today.getDate() &&
|
|
2062
|
+
month + 1 === today.getMonth() + 1 &&
|
|
2063
|
+
year === today.getFullYear()
|
|
2064
|
+
);
|
|
2065
|
+
};
|
|
2066
|
+
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
2067
|
+
// roving tabindex: one reachable day (the keyboard's last day, else the selection, else today, else the first allowed day)
|
|
2068
|
+
const key = (c: { day: number; month: number; year: number }) => `${c.year}-${c.month}-${c.day}`;
|
|
2069
|
+
const reachable = cells.filter(c => c.inMonth && this.isAllowedDate(c.day, c.month, c.year));
|
|
2070
|
+
const wanted = [
|
|
2071
|
+
this.tpFocusDate && this.tpFocusDate,
|
|
2072
|
+
this.value && { year: this.value.getFullYear(), month: this.value.getMonth(), day: this.value.getDate() },
|
|
2073
|
+
(() => { const t = new Date(); return { year: t.getFullYear(), month: t.getMonth(), day: t.getDate() }; })(),
|
|
2074
|
+
];
|
|
2075
|
+
let tabStop: string | null = null;
|
|
2076
|
+
for (const w of wanted) {
|
|
2077
|
+
if (w && reachable.some(c => key(c) === key(w))) { tabStop = key(w); break; }
|
|
2078
|
+
}
|
|
2079
|
+
if (tabStop === null && reachable.length) tabStop = key(reachable[0]);
|
|
2080
|
+
return html`
|
|
2081
|
+
<div class="calendar" part="calendar">
|
|
2082
|
+
<div class="calendar-header" part="calendar-header">
|
|
2083
|
+
<nile-icon
|
|
2084
|
+
class="calendar-header__month-navigation"
|
|
2085
|
+
part="calendar-header-prev"
|
|
2086
|
+
name="var(--nile-icon-arrow-left, var(--ng-icon-chevron-left))"
|
|
2087
|
+
method="var(--nile-svg-method-fill, var(--ng-svg-method-stroke))"
|
|
2088
|
+
color=${this.disabled ? 'var(--nile-colors-dark-500, var(--ng-colors-fg-disabled))' : 'var(--nile-colors-dark-900, var(--ng-colors-text-primary-900))'}
|
|
2089
|
+
role="button"
|
|
2090
|
+
aria-label="Previous month"
|
|
2091
|
+
aria-disabled="${this.disabled || this.isPrevDisabled()}"
|
|
2092
|
+
tabindex="${this.disabled || this.isPrevDisabled() ? '-1' : '0'}"
|
|
2093
|
+
@keydown="${(e: KeyboardEvent) => {if (e.key === 'Enter' || e.key === ' ') this.prevMonth();}}"
|
|
2094
|
+
@click="${this.prevMonth}"
|
|
2095
|
+
>
|
|
2096
|
+
</nile-icon>
|
|
2097
|
+
<div class="calendar-header__center" part="calendar-header-center">
|
|
2098
|
+
|
|
2099
|
+
${this.showMonthDropdown || this.showYearDropdown ? html`
|
|
2100
|
+
${this.showMonthDropdown ? html`
|
|
2101
|
+
<nile-dropdown ?disabled="${this.disabled}" class="month-dropdown"
|
|
2102
|
+
@nile-after-show="${() => this.scrollSelectedIntoView('.month-menu-item[selected]')}">
|
|
2103
|
+
<span
|
|
2104
|
+
slot="trigger"
|
|
2105
|
+
class="calendar-header__trigger month-trigger"
|
|
2106
|
+
role="button"
|
|
2107
|
+
part="trigger"
|
|
2108
|
+
tabindex="${this.disabled ? '-1' : '0'}"
|
|
2109
|
+
@keydown="${(e: KeyboardEvent) => {
|
|
2110
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
2111
|
+
e.preventDefault();
|
|
2112
|
+
(e.currentTarget as HTMLElement).click();
|
|
2113
|
+
}
|
|
2114
|
+
}}"
|
|
2115
|
+
>
|
|
2116
|
+
${new Date(year, month).toLocaleString('default', { month: 'long' })}
|
|
2117
|
+
<nile-icon name="arrowdropdown" color=${this.disabled ? 'var(--nile-colors-dark-500, var(--ng-colors-fg-disabled))' : 'var(--nile-colors-dark-900, var(--ng-colors-text-primary-900))'} class="calendar-header__caret"></nile-icon>
|
|
2118
|
+
</span>
|
|
2119
|
+
<nile-menu>
|
|
2120
|
+
<div class="month-scroll-wrapper">
|
|
2121
|
+
${this.monthNames.map((m, idx) => html`
|
|
2122
|
+
<nile-menu-item
|
|
2123
|
+
class="month-menu-item"
|
|
2124
|
+
?selected="${idx === month}"
|
|
2125
|
+
@click="${() => this.onMonthSelected(idx)}"
|
|
2126
|
+
>
|
|
2127
|
+
${m}
|
|
2128
|
+
</nile-menu-item>
|
|
2129
|
+
`)}
|
|
2130
|
+
</div>
|
|
2131
|
+
</nile-menu>
|
|
2132
|
+
</nile-dropdown>
|
|
2133
|
+
` : html`
|
|
2134
|
+
<span
|
|
2135
|
+
>${new Date(year, month).toLocaleString('default', { month: 'long' })}</span
|
|
2136
|
+
>
|
|
2137
|
+
`}
|
|
2138
|
+
${this.showYearDropdown ? html`
|
|
2139
|
+
<nile-dropdown ?disabled="${this.disabled}" class="year-dropdown"
|
|
2140
|
+
@nile-after-show="${() => this.scrollSelectedIntoView('.year-menu-item[selected]')}">
|
|
2141
|
+
<span
|
|
2142
|
+
slot="trigger"
|
|
2143
|
+
class="calendar-header__trigger"
|
|
2144
|
+
role="button"
|
|
2145
|
+
part="trigger"
|
|
2146
|
+
tabindex="${this.disabled ? '-1' : '0'}"
|
|
2147
|
+
@keydown="${(e: KeyboardEvent) => {
|
|
2148
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
2149
|
+
e.preventDefault();
|
|
2150
|
+
(e.currentTarget as HTMLElement).click();
|
|
2151
|
+
}
|
|
2152
|
+
}}"
|
|
2153
|
+
>
|
|
2154
|
+
${year}
|
|
2155
|
+
<nile-icon name="arrowdropdown" color=${this.disabled ? 'var(--nile-colors-dark-500, var(--ng-colors-fg-disabled))' : 'var(--nile-colors-dark-900, var(--ng-colors-text-primary-900))'} class="calendar-header__caret"></nile-icon>
|
|
2156
|
+
</span>
|
|
2157
|
+
<nile-menu>
|
|
2158
|
+
<div class="year-scroll-wrapper">
|
|
2159
|
+
${this.yearOptions.map(
|
|
2160
|
+
y => html`
|
|
2161
|
+
<nile-menu-item
|
|
2162
|
+
class="year-menu-item"
|
|
2163
|
+
?selected="${y === year}"
|
|
2164
|
+
@click="${() => this.onYearSelected(y)}"
|
|
2165
|
+
>
|
|
2166
|
+
${y}
|
|
2167
|
+
</nile-menu-item>
|
|
2168
|
+
`
|
|
2169
|
+
)}
|
|
2170
|
+
</div>
|
|
2171
|
+
</nile-menu>
|
|
2172
|
+
</nile-dropdown>
|
|
2173
|
+
` : html`
|
|
2174
|
+
<span>${year}</span>
|
|
2175
|
+
`}
|
|
2176
|
+
` : html`
|
|
2177
|
+
<span
|
|
2178
|
+
>${new Date(year, month).toLocaleString('default', {
|
|
2179
|
+
month: 'long',
|
|
2180
|
+
})}
|
|
2181
|
+
${year}</span
|
|
2182
|
+
>
|
|
2183
|
+
`}
|
|
2184
|
+
</div>
|
|
2185
|
+
|
|
2186
|
+
<nile-icon
|
|
2187
|
+
class="calendar-header__month-navigation"
|
|
2188
|
+
part="calendar-header-next"
|
|
2189
|
+
name="var(--nile-icon-arrow-right, var(--ng-icon-chevron-right))"
|
|
2190
|
+
method="var(--nile-svg-method-fill, var(--ng-svg-method-stroke))"
|
|
2191
|
+
color=${this.disabled ? 'var(--nile-colors-dark-500, var(--ng-colors-fg-disabled))' : 'var(--nile-colors-dark-900, var(--ng-colors-text-primary-900))'}
|
|
2192
|
+
role="button"
|
|
2193
|
+
aria-label="Next month"
|
|
2194
|
+
aria-disabled="${this.disabled || this.isNextDisabled()}"
|
|
2195
|
+
tabindex="${this.disabled || this.isNextDisabled() ? '-1' : '0'}"
|
|
2196
|
+
@keydown="${(e: KeyboardEvent) => {if (e.key === 'Enter' || e.key === ' ') this.nextMonth();}}"
|
|
2197
|
+
@click="${this.nextMonth}"
|
|
2198
|
+
>
|
|
2199
|
+
</nile-icon>
|
|
2200
|
+
</div>
|
|
2201
|
+
|
|
2202
|
+
<div class="days_container" part="days-container">
|
|
2203
|
+
${dayNames.map(day => html`<div class="day_name" part="day-name">${day}</div>`)}
|
|
2204
|
+
${cells.map(cell => {
|
|
2205
|
+
const isCurrentMonth = cell.inMonth;
|
|
2206
|
+
const datePlacement:''|'selected-date' = isSelectedDate(cell.day, cell.month, cell.year, isCurrentMonth)
|
|
2207
|
+
const classMapObj:any={
|
|
2208
|
+
'day_date' : true,
|
|
2209
|
+
'not-allowed' : !this.isAllowedDate(cell.day, cell.month, cell.year),
|
|
2210
|
+
'filler' : !isCurrentMonth,
|
|
2211
|
+
}
|
|
2212
|
+
if(datePlacement) classMapObj[datePlacement]=true
|
|
2213
|
+
|
|
2214
|
+
const cellAllowed = this.isAllowedDate(cell.day, cell.month, cell.year);
|
|
2215
|
+
const cellToday = isCurrentDate(cell.day, cell.month, cell.year) && isCurrentMonth;
|
|
2216
|
+
|
|
2217
|
+
return html`
|
|
2218
|
+
<div
|
|
2219
|
+
part="calendar-day-${cell.day}"
|
|
2220
|
+
class=${classMap(classMapObj)}
|
|
2221
|
+
role=${isCurrentMonth ? 'button' : nothing}
|
|
2222
|
+
aria-hidden=${isCurrentMonth ? nothing : 'true'}
|
|
2223
|
+
aria-label=${isCurrentMonth ? this.dayCellLabel(cell.day, cell.month, cell.year) : nothing}
|
|
2224
|
+
aria-disabled=${isCurrentMonth && (!cellAllowed || this.disabled) ? 'true' : nothing}
|
|
2225
|
+
aria-pressed=${isCurrentMonth ? String(!!datePlacement) : nothing}
|
|
2226
|
+
aria-current=${cellToday ? 'date' : nothing}
|
|
2227
|
+
data-tp-date="${isCurrentMonth ? key(cell) : nothing}"
|
|
2228
|
+
tabindex="${isCurrentMonth && !this.disabled && key(cell) === tabStop ? '0' : '-1'}"
|
|
2229
|
+
@keydown="${(e: KeyboardEvent) => {
|
|
2230
|
+
if (!isCurrentMonth) return;
|
|
2231
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
2232
|
+
e.preventDefault(); // Space on a role=button div would otherwise scroll the page
|
|
2233
|
+
this.tpFocusDate = { year: cell.year, month: cell.month, day: cell.day };
|
|
2234
|
+
this.tpSelectDate(cell.day, cell.month, cell.year);
|
|
2235
|
+
return;
|
|
2236
|
+
}
|
|
2237
|
+
this.handleDayNavigationKey(e, cell);
|
|
2238
|
+
}}"
|
|
2239
|
+
@click="${() => { if (isCurrentMonth) { this.tpFocusDate = { year: cell.year, month: cell.month, day: cell.day }; this.tpSelectDate(cell.day, cell.month, cell.year); } }}"
|
|
2240
|
+
>
|
|
2241
|
+
<span class="day-label" part="calendar-day-label">
|
|
2242
|
+
${cell.day}
|
|
2243
|
+
${isCurrentDate(cell.day, cell.month, cell.year) && isCurrentMonth?html`<div class="current__date__dot" part="current-date-dot"></div>`:nothing}
|
|
2244
|
+
</span>
|
|
2245
|
+
</div>`;
|
|
2246
|
+
})}
|
|
2247
|
+
</div>
|
|
2248
|
+
</div>
|
|
2249
|
+
`;
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
private getCollapsedWindowDays(
|
|
2253
|
+
year: number,
|
|
2254
|
+
month: number
|
|
2255
|
+
): Array<{ day: number; month: number; year: number; inMonth: boolean }> {
|
|
2256
|
+
const today = new Date();
|
|
2257
|
+
const isCurrentMonth =
|
|
2258
|
+
today.getFullYear() === year && today.getMonth() === month;
|
|
2259
|
+
const selectionInMonth =
|
|
2260
|
+
this.value instanceof Date &&
|
|
2261
|
+
this.value.getFullYear() === year &&
|
|
2262
|
+
this.value.getMonth() === month;
|
|
2263
|
+
const centerOn = (dayOfMonth: number) => {
|
|
2264
|
+
const anchor = localDateFromFields(year, month, dayOfMonth);
|
|
2265
|
+
const s = new Date(anchor);
|
|
2266
|
+
s.setDate(anchor.getDate() - anchor.getDay() - 7);
|
|
2267
|
+
return s;
|
|
2268
|
+
};
|
|
2269
|
+
const start = selectionInMonth
|
|
2270
|
+
? centerOn(this.value!.getDate())
|
|
2271
|
+
: isCurrentMonth
|
|
2272
|
+
? centerOn(today.getDate())
|
|
2273
|
+
: (() => {
|
|
2274
|
+
const first = localDateFromFields(year, month, 1);
|
|
2275
|
+
const s = new Date(first);
|
|
2276
|
+
s.setDate(1 - first.getDay());
|
|
2277
|
+
return s;
|
|
2278
|
+
})();
|
|
2279
|
+
return Array.from({ length: 21 }, (_, i) => {
|
|
2280
|
+
const d = new Date(start);
|
|
2281
|
+
d.setDate(start.getDate() + i);
|
|
2282
|
+
return {
|
|
2283
|
+
day: d.getDate(),
|
|
2284
|
+
month: d.getMonth(),
|
|
2285
|
+
year: d.getFullYear(),
|
|
2286
|
+
inMonth: d.getMonth() === month && d.getFullYear() === year,
|
|
2287
|
+
};
|
|
2288
|
+
});
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
private getFullMonthDays(
|
|
2292
|
+
year: number,
|
|
2293
|
+
month: number
|
|
2294
|
+
): Array<{ day: number; month: number; year: number; inMonth: boolean }> {
|
|
2295
|
+
const first = localDateFromFields(year, month, 1);
|
|
2296
|
+
const leading = first.getDay();
|
|
2297
|
+
const daysInMonth = localDateFromFields(year, month + 1, 0).getDate();
|
|
2298
|
+
const totalCells = Math.ceil((leading + daysInMonth) / 7) * 7;
|
|
2299
|
+
const start = localDateFromFields(year, month, 1 - leading);
|
|
2300
|
+
return Array.from({ length: totalCells }, (_, i) => {
|
|
2301
|
+
const d = new Date(start);
|
|
2302
|
+
d.setDate(start.getDate() + i);
|
|
2303
|
+
return {
|
|
2304
|
+
day: d.getDate(),
|
|
2305
|
+
month: d.getMonth(),
|
|
2306
|
+
year: d.getFullYear(),
|
|
2307
|
+
inMonth: d.getMonth() === month && d.getFullYear() === year,
|
|
2308
|
+
};
|
|
2309
|
+
});
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2312
|
+
/**
|
|
2313
|
+
* Arrow keys move by a day / week, Home and End to the week's first and last day, PageUp and PageDown by a month
|
|
2314
|
+
* (Shift: a year). The target is clamped to the month's length and to the year range; a day that the collapsed
|
|
2315
|
+
* three-week window does not show expands the calendar to reveal it.
|
|
2316
|
+
*/
|
|
2317
|
+
private handleDayNavigationKey(e: KeyboardEvent, cell: { day: number; month: number; year: number }): void {
|
|
2318
|
+
if (this.disabled || e.ctrlKey || e.metaKey || e.altKey) return;
|
|
2319
|
+
const from = localDateFromFields(cell.year, cell.month, cell.day);
|
|
2320
|
+
let target: Date | null = null;
|
|
2321
|
+
switch (e.key) {
|
|
2322
|
+
case 'ArrowLeft': target = localDateFromFields(cell.year, cell.month, cell.day - 1); break;
|
|
2323
|
+
case 'ArrowRight': target = localDateFromFields(cell.year, cell.month, cell.day + 1); break;
|
|
2324
|
+
case 'ArrowUp': target = localDateFromFields(cell.year, cell.month, cell.day - 7); break;
|
|
2325
|
+
case 'ArrowDown': target = localDateFromFields(cell.year, cell.month, cell.day + 7); break;
|
|
2326
|
+
case 'Home': target = localDateFromFields(cell.year, cell.month, cell.day - from.getDay()); break;
|
|
2327
|
+
case 'End': target = localDateFromFields(cell.year, cell.month, cell.day + (6 - from.getDay())); break;
|
|
2328
|
+
case 'PageUp':
|
|
2329
|
+
case 'PageDown': {
|
|
2330
|
+
const step = (e.key === 'PageUp' ? -1 : 1) * (e.shiftKey ? 12 : 1);
|
|
2331
|
+
const month = cell.month + step;
|
|
2332
|
+
const last = localDateFromFields(cell.year, month + 1, 0).getDate();
|
|
2333
|
+
target = localDateFromFields(cell.year, month, Math.min(cell.day, last));
|
|
2334
|
+
break;
|
|
2335
|
+
}
|
|
2336
|
+
default: return;
|
|
2337
|
+
}
|
|
2338
|
+
e.preventDefault();
|
|
2339
|
+
this.moveDayFocus(target);
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
private moveDayFocus(target: Date): void {
|
|
2343
|
+
const year = target.getFullYear();
|
|
2344
|
+
const month = target.getMonth();
|
|
2345
|
+
const lo = this.minYear;
|
|
2346
|
+
const hi = this.maxYear;
|
|
2347
|
+
if ((lo !== undefined && year < lo) || (hi !== undefined && year > hi)) return;
|
|
2348
|
+
this.tpFocusDate = { year, month, day: target.getDate() };
|
|
2349
|
+
this.currentYear = year;
|
|
2350
|
+
this.currentMonth = month;
|
|
2351
|
+
const sel = `[data-tp-date="${year}-${month}-${target.getDate()}"]`;
|
|
2352
|
+
const focusCell = () => this.shadowRoot?.querySelector<HTMLElement>(sel)?.focus();
|
|
2353
|
+
this.updateComplete.then(() => {
|
|
2354
|
+
if (this.shadowRoot?.querySelector(sel)) { focusCell(); return; }
|
|
2355
|
+
// not in the collapsed window: reveal the whole month
|
|
2356
|
+
this.expanded = true;
|
|
2357
|
+
this.updateComplete.then(focusCell).catch(() => { /* focus is best-effort */ });
|
|
2358
|
+
}).catch(() => { /* focus is best-effort */ });
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
private tpSelectDate(day: number, month: number, year: number): void {
|
|
2362
|
+
if (this.disabled) return;
|
|
2363
|
+
if (!this.isAllowedDate(day, month, year)) return;
|
|
2364
|
+
const selectedDate = localDateFromFields(year, month, day);
|
|
2365
|
+
// compare the calendar day only: seeded values are stored at noon, a clicked day at midnight
|
|
2366
|
+
const sameDay = !!this.value &&
|
|
2367
|
+
this.value.getFullYear() === year &&
|
|
2368
|
+
this.value.getMonth() === month &&
|
|
2369
|
+
this.value.getDate() === day;
|
|
2370
|
+
if (sameDay && this.doubleClickUnselect) {
|
|
2371
|
+
this.handleReset();
|
|
2372
|
+
return;
|
|
2373
|
+
}
|
|
2374
|
+
if (!sameDay) {
|
|
2375
|
+
this.resetTimeUnits();
|
|
2376
|
+
}
|
|
2377
|
+
this.value = selectedDate;
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
private resetTimeUnits(): void {
|
|
2381
|
+
this.tpHours = null;
|
|
2382
|
+
this.tpMinutes = null;
|
|
2383
|
+
this.tpSeconds = null;
|
|
2384
|
+
this.tpMilliseconds = null;
|
|
2385
|
+
this.tpEditingText = '';
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
private renderTimeFields(): TemplateResult {
|
|
2389
|
+
const fields = this.resolvedTimeFields;
|
|
2390
|
+
return html`
|
|
2391
|
+
<div class=${classMap({
|
|
2392
|
+
'tp-time-fields': true,
|
|
2393
|
+
'tp-time-fields--stacked': this.tpFewFieldsNoTz,
|
|
2394
|
+
})} part="tp-time-fields">
|
|
2395
|
+
${fields.hours ? this.renderTimeField('Hours (24H)', 'HH', 23, this.tpHours, v => { this.tpHours = v; }) : ''}
|
|
2396
|
+
${fields.minutes ? this.renderTimeField('Minutes', 'MM', 59, this.tpMinutes, v => { this.tpMinutes = v; }) : ''}
|
|
2397
|
+
${fields.seconds ? this.renderTimeField('Seconds', 'SS', 59, this.tpSeconds, v => { this.tpSeconds = v; }) : ''}
|
|
2398
|
+
${fields.milliseconds ? this.renderTimeField('Milliseconds', 'MS', 999, this.tpMilliseconds, v => { this.tpMilliseconds = v; }) : ''}
|
|
2399
|
+
</div>
|
|
2400
|
+
`;
|
|
2401
|
+
}
|
|
2402
|
+
|
|
2403
|
+
private renderTimeField(
|
|
2404
|
+
label: string,
|
|
2405
|
+
placeholder: string,
|
|
2406
|
+
max: number,
|
|
2407
|
+
value: number | null,
|
|
2408
|
+
setter: (v: number | null) => void
|
|
2409
|
+
): TemplateResult {
|
|
2410
|
+
const maxDigits = String(max).length;
|
|
2411
|
+
const normalize = (raw: string): { text: string; value: number | null } => {
|
|
2412
|
+
let digits = (raw || '').replace(/\D/g, '');
|
|
2413
|
+
while (digits.length > maxDigits && digits[0] === '0') digits = digits.slice(1);
|
|
2414
|
+
if (digits.length > maxDigits) digits = digits.slice(0, maxDigits);
|
|
2415
|
+
if (digits === '') return { text: '', value: null };
|
|
2416
|
+
const num = Number(digits);
|
|
2417
|
+
if (num > max) {
|
|
2418
|
+
const first = Number(digits[0]);
|
|
2419
|
+
return { text: String(first).padStart(maxDigits, '0'), value: first };
|
|
2420
|
+
}
|
|
2421
|
+
return { text: digits, value: num };
|
|
2422
|
+
};
|
|
2423
|
+
const apply = (e: CustomEvent) => {
|
|
2424
|
+
e.stopPropagation();
|
|
2425
|
+
const input = e.target as any;
|
|
2426
|
+
const { text, value: next } = normalize(input.value);
|
|
2427
|
+
setter(next);
|
|
2428
|
+
this.tpEditingText = text;
|
|
2429
|
+
if (input.value !== text) input.value = text;
|
|
2430
|
+
};
|
|
2431
|
+
const step = (delta: 1 | -1, focusEl?: HTMLElement | null) => {
|
|
2432
|
+
if (this.disabled) return;
|
|
2433
|
+
// A stepper click keeps DOM focus on whichever field was focused before, so move it here — the
|
|
2434
|
+
// shared editing text must be attributed to the right input.
|
|
2435
|
+
if (focusEl) {
|
|
2436
|
+
const input = focusEl.closest('nile-input') as any;
|
|
2437
|
+
// `nile-input.focus()` dereferences its inner <input>, which does not exist until the input has rendered
|
|
2438
|
+
// (a calendar in a closed, not-yet-attached panel never renders it). Nothing to focus then; still step.
|
|
2439
|
+
if (input?.input) input.focus();
|
|
2440
|
+
}
|
|
2441
|
+
const span = max + 1;
|
|
2442
|
+
const next =
|
|
2443
|
+
value === null
|
|
2444
|
+
? (delta === 1 ? 0 : max)
|
|
2445
|
+
: ((value + delta) % span + span) % span;
|
|
2446
|
+
setter(next);
|
|
2447
|
+
this.tpFocusedTimeField = label;
|
|
2448
|
+
this.tpEditingText = String(next).padStart(maxDigits, '0');
|
|
2449
|
+
};
|
|
2450
|
+
const focused = this.tpFocusedTimeField === label;
|
|
2451
|
+
const displayValue = focused
|
|
2452
|
+
? this.tpEditingText
|
|
2453
|
+
: value !== null || this.value
|
|
2454
|
+
? String(value ?? 0).padStart(maxDigits, '0')
|
|
2455
|
+
: '';
|
|
2456
|
+
return html`
|
|
2457
|
+
<nile-input
|
|
2458
|
+
class="tp-time-field"
|
|
2459
|
+
part="tp-time-field"
|
|
2460
|
+
type="text"
|
|
2461
|
+
inputmode="numeric"
|
|
2462
|
+
label="${label}"
|
|
2463
|
+
placeholder="${placeholder}"
|
|
2464
|
+
?disabled="${this.disabled}"
|
|
2465
|
+
value="${displayValue}"
|
|
2466
|
+
@nile-change="${apply}"
|
|
2467
|
+
@nile-input="${apply}"
|
|
2468
|
+
@keydown="${(e: KeyboardEvent) => {
|
|
2469
|
+
if (e.key === 'ArrowUp') { e.preventDefault(); step(1); }
|
|
2470
|
+
else if (e.key === 'ArrowDown') { e.preventDefault(); step(-1); }
|
|
2471
|
+
}}"
|
|
2472
|
+
@nile-focus="${(e: CustomEvent) => {
|
|
2473
|
+
e.stopPropagation();
|
|
2474
|
+
this.tpFocusedTimeField = label;
|
|
2475
|
+
this.tpEditingText = '';
|
|
2476
|
+
}}"
|
|
2477
|
+
@nile-blur="${(e: CustomEvent) => { e.stopPropagation(); if (this.tpFocusedTimeField === label) this.tpFocusedTimeField = null; }}"
|
|
2478
|
+
>
|
|
2479
|
+
<div
|
|
2480
|
+
slot="suffix"
|
|
2481
|
+
class="tp-stepper"
|
|
2482
|
+
part="tp-stepper"
|
|
2483
|
+
@mousedown="${(e: MouseEvent) => e.preventDefault()}"
|
|
2484
|
+
>
|
|
2485
|
+
<div
|
|
2486
|
+
class="tp-stepper-btn tp-stepper-btn--up"
|
|
2487
|
+
part="tp-stepper-btn tp-stepper-btn-up"
|
|
2488
|
+
role="button"
|
|
2489
|
+
tabindex="${this.disabled ? '-1' : '0'}"
|
|
2490
|
+
aria-label="Increase ${label}"
|
|
2491
|
+
@click="${(e: MouseEvent) => step(1, e.currentTarget as HTMLElement)}"
|
|
2492
|
+
@keydown="${(e: KeyboardEvent) => {if (e.key === 'Enter' || e.key === ' ') step(1, e.currentTarget as HTMLElement);}}"
|
|
2493
|
+
>
|
|
2494
|
+
<nile-icon name="arrowdropdown" size="16" color=${this.disabled ? 'var(--nile-colors-dark-500, var(--ng-colors-fg-disabled))' : nothing}></nile-icon>
|
|
2495
|
+
</div>
|
|
2496
|
+
<div
|
|
2497
|
+
class="tp-stepper-btn"
|
|
2498
|
+
part="tp-stepper-btn tp-stepper-btn-down"
|
|
2499
|
+
role="button"
|
|
2500
|
+
tabindex="${this.disabled ? '-1' : '0'}"
|
|
2501
|
+
aria-label="Decrease ${label}"
|
|
2502
|
+
@click="${(e: MouseEvent) => step(-1, e.currentTarget as HTMLElement)}"
|
|
2503
|
+
@keydown="${(e: KeyboardEvent) => {if (e.key === 'Enter' || e.key === ' ') step(-1, e.currentTarget as HTMLElement);}}"
|
|
2504
|
+
>
|
|
2505
|
+
<nile-icon name="arrowdropdown" size="16" color=${this.disabled ? 'var(--nile-colors-dark-500, var(--ng-colors-fg-disabled))' : nothing}></nile-icon>
|
|
2506
|
+
</div>
|
|
2507
|
+
</div>
|
|
2508
|
+
</nile-input>
|
|
2509
|
+
`;
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
private get timezoneOptions(): string[] {
|
|
2513
|
+
const zones: string[] =
|
|
2514
|
+
typeof (Intl as any).supportedValuesOf === 'function'
|
|
2515
|
+
? (Intl as any).supportedValuesOf('timeZone')
|
|
2516
|
+
: TIMEZONES;
|
|
2517
|
+
return zones.includes('UTC') ? zones : ['UTC', ...zones];
|
|
2518
|
+
}
|
|
2519
|
+
|
|
2520
|
+
private renderTimezone(): TemplateResult {
|
|
2521
|
+
return html`
|
|
2522
|
+
<div class="tp-timezone" part="tp-timezone">
|
|
2523
|
+
<div class="tp-timezone-header" part="tp-timezone-header">
|
|
2524
|
+
<span class="tp-timezone-label" part="tp-timezone-label">Timezone</span>
|
|
2525
|
+
<span
|
|
2526
|
+
class="tp-timezone-local-link"
|
|
2527
|
+
part="tp-timezone-local-link"
|
|
2528
|
+
role="button"
|
|
2529
|
+
tabindex="${this.disabled ? '-1' : '0'}"
|
|
2530
|
+
@click="${this.handleUseLocalTimezone}"
|
|
2531
|
+
@keydown="${(e: KeyboardEvent) => {if (e.key === 'Enter' || e.key === ' ') {e.preventDefault(); this.handleUseLocalTimezone();}}}"
|
|
2532
|
+
>Use local timezone</span>
|
|
2533
|
+
</div>
|
|
2534
|
+
<nile-select
|
|
2535
|
+
class="tp-timezone-select"
|
|
2536
|
+
part="tp-timezone-select"
|
|
2537
|
+
hoist
|
|
2538
|
+
placement="top"
|
|
2539
|
+
?disabled="${this.disabled}"
|
|
2540
|
+
searchEnabled
|
|
2541
|
+
placeholder="Select timezone"
|
|
2542
|
+
.value="${this.timezone}"
|
|
2543
|
+
@nile-change="${this.handleTimezoneChange}"
|
|
2544
|
+
>
|
|
2545
|
+
${this.timezoneOptions.map(
|
|
2546
|
+
tz => html`<nile-option value="${tz}">${tz}</nile-option>`
|
|
2547
|
+
)}
|
|
2548
|
+
</nile-select>
|
|
2549
|
+
</div>
|
|
2550
|
+
`;
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
private handleTimezoneChange(event: CustomEvent): void {
|
|
2554
|
+
event.stopPropagation();
|
|
2555
|
+
const selected =
|
|
2556
|
+
(event.detail && (event.detail.value ?? event.detail)) ||
|
|
2557
|
+
(event.target as { value?: unknown } | null)?.value;
|
|
2558
|
+
if (typeof selected === 'string' && selected) {
|
|
2559
|
+
this.setTimezoneInternally(selected);
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
|
|
2563
|
+
private handleUseLocalTimezone(): void {
|
|
2564
|
+
if (this.disabled) return;
|
|
2565
|
+
const local = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
2566
|
+
if (local) {
|
|
2567
|
+
this.setTimezoneInternally(local);
|
|
2568
|
+
}
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2571
|
+
private toggleExpanded(): void {
|
|
2572
|
+
if (this.disabled) return;
|
|
2573
|
+
this.expanded = !this.expanded;
|
|
2574
|
+
}
|
|
2575
|
+
|
|
2576
|
+
|
|
2577
|
+
private composeLocalTimezoneValue(): Date | null {
|
|
2578
|
+
if (!this.value) return null;
|
|
2579
|
+
if (
|
|
2580
|
+
this.seedInstant !== null &&
|
|
2581
|
+
this.seedFieldsKey === this.instantSeedFieldsKey() &&
|
|
2582
|
+
(this.seedZone === null || this.seedZone === this.timezone)
|
|
2583
|
+
) {
|
|
2584
|
+
return new Date(this.seedInstant);
|
|
2585
|
+
}
|
|
2586
|
+
return this.wallClockToInstant(
|
|
2587
|
+
this.value.getFullYear(),
|
|
2588
|
+
this.value.getMonth(),
|
|
2589
|
+
this.value.getDate(),
|
|
2590
|
+
this.tpHours ?? 0,
|
|
2591
|
+
this.tpMinutes ?? 0,
|
|
2592
|
+
this.tpSeconds ?? 0,
|
|
2593
|
+
this.tpMilliseconds ?? 0
|
|
2594
|
+
);
|
|
2595
|
+
}
|
|
2596
|
+
|
|
2597
|
+
private composeValueISO(epochMs: number): string {
|
|
2598
|
+
const zone = this.timezone || 'UTC';
|
|
2599
|
+
const p = (n: number, len = 2) => String(n).padStart(len, '0');
|
|
2600
|
+
|
|
2601
|
+
const ms = ((epochMs % 1000) + 1000) % 1000;
|
|
2602
|
+
|
|
2603
|
+
let y: number, mo: number, d: number, h: number, mi: number, s: number;
|
|
2604
|
+
let offMs: number;
|
|
2605
|
+
try {
|
|
2606
|
+
const dtf = new Intl.DateTimeFormat('en-US', {
|
|
2607
|
+
timeZone: zone,
|
|
2608
|
+
hourCycle: 'h23',
|
|
2609
|
+
year: 'numeric', month: '2-digit', day: '2-digit',
|
|
2610
|
+
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
|
2611
|
+
});
|
|
2612
|
+
const map: Record<string, number> = {};
|
|
2613
|
+
for (const part of dtf.formatToParts(new Date(epochMs))) {
|
|
2614
|
+
if (part.type !== 'literal') map[part.type] = Number(part.value);
|
|
2615
|
+
}
|
|
2616
|
+
y = map.year; mo = map.month; d = map.day;
|
|
2617
|
+
h = map.hour; mi = map.minute; s = map.second;
|
|
2618
|
+
offMs = this.zoneOffsetMs(epochMs - ms);
|
|
2619
|
+
} catch {
|
|
2620
|
+
const local = new Date(epochMs);
|
|
2621
|
+
y = local.getFullYear(); mo = local.getMonth() + 1; d = local.getDate();
|
|
2622
|
+
h = local.getHours(); mi = local.getMinutes(); s = local.getSeconds();
|
|
2623
|
+
offMs = 0;
|
|
2624
|
+
}
|
|
2625
|
+
|
|
2626
|
+
// the bracket only carries HH:mm; historical sub-minute offsets (LMT) round
|
|
2627
|
+
offMs = Math.round(offMs / 60000) * 60000;
|
|
2628
|
+
const sign = offMs >= 0 ? '+' : '-';
|
|
2629
|
+
const abs = Math.abs(offMs);
|
|
2630
|
+
const off = `${sign}${p(Math.floor(abs / 3600000))}:${p(Math.floor((abs % 3600000) / 60000))}`;
|
|
2631
|
+
|
|
2632
|
+
return (
|
|
2633
|
+
`${p(y, 4)}-${p(mo)}-${p(d)}T${p(h)}:${p(mi)}:${p(s)}.${p(ms, 3)}` +
|
|
2634
|
+
`[GMT${off} ${zone}]`
|
|
2635
|
+
);
|
|
2636
|
+
}
|
|
2637
|
+
|
|
2638
|
+
private wallClockToInstant(
|
|
2639
|
+
y: number, monthIndex: number, day: number,
|
|
2640
|
+
h: number, m: number, s: number, ms: number
|
|
2641
|
+
): Date {
|
|
2642
|
+
const guess = utcFromFields(y, monthIndex, day, h, m, s, 0);
|
|
2643
|
+
try {
|
|
2644
|
+
const offset1 = this.zoneOffsetMs(guess);
|
|
2645
|
+
let instant = guess - offset1;
|
|
2646
|
+
const offset2 = this.zoneOffsetMs(instant);
|
|
2647
|
+
if (offset2 !== offset1) instant = guess - offset2;
|
|
2648
|
+
return new Date(instant + ms);
|
|
2649
|
+
} catch {
|
|
2650
|
+
const composed = new Date(this.value!.getTime());
|
|
2651
|
+
composed.setHours(h, m, s, ms);
|
|
2652
|
+
return composed;
|
|
2653
|
+
}
|
|
2654
|
+
}
|
|
2655
|
+
|
|
2656
|
+
private zoneOffsetMs(utcMs: number): number {
|
|
2657
|
+
const dtf = new Intl.DateTimeFormat('en-US', {
|
|
2658
|
+
timeZone: this.timezone,
|
|
2659
|
+
hourCycle: 'h23',
|
|
2660
|
+
year: 'numeric', month: '2-digit', day: '2-digit',
|
|
2661
|
+
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
|
2662
|
+
});
|
|
2663
|
+
const map: Record<string, number> = {};
|
|
2664
|
+
for (const p of dtf.formatToParts(new Date(utcMs))) {
|
|
2665
|
+
if (p.type !== 'literal') map[p.type] = Number(p.value);
|
|
2666
|
+
}
|
|
2667
|
+
const asUTC = utcFromFields(
|
|
2668
|
+
map.year, map.month - 1, map.day, map.hour, map.minute, map.second
|
|
2669
|
+
);
|
|
2670
|
+
return asUTC - utcMs;
|
|
2671
|
+
}
|
|
2672
|
+
|
|
2673
|
+
private hasSelection(): boolean {
|
|
2674
|
+
if (this.type === 'relative') {
|
|
2675
|
+
return Boolean(this.startDate && this.endDate);
|
|
2676
|
+
}
|
|
2677
|
+
return Boolean(this.value);
|
|
2678
|
+
}
|
|
2679
|
+
|
|
2680
|
+
private handleSetToNow(): void {
|
|
2681
|
+
if (this.disabled) return;
|
|
2682
|
+
const now = new Date();
|
|
2683
|
+
this.tpHours = now.getHours();
|
|
2684
|
+
this.tpMinutes = now.getMinutes();
|
|
2685
|
+
this.tpSeconds = now.getSeconds();
|
|
2686
|
+
this.tpMilliseconds = now.getMilliseconds();
|
|
2687
|
+
this.value = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
2688
|
+
this.currentMonth = now.getMonth();
|
|
2689
|
+
this.currentYear = now.getFullYear();
|
|
2690
|
+
const local = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
2691
|
+
if (local) {
|
|
2692
|
+
this.setTimezoneInternally(local);
|
|
2693
|
+
}
|
|
2694
|
+
}
|
|
2695
|
+
|
|
2696
|
+
private handleApply(): void {
|
|
2697
|
+
if (this.disabled || !this.hasSelection()) return;
|
|
2698
|
+
if (this.type === 'relative') {
|
|
2699
|
+
this.emitDateTimePickerChange({
|
|
2700
|
+
startDate: this.startDate!,
|
|
2701
|
+
endDate: this.endDate!,
|
|
2702
|
+
timezone: 'UTC',
|
|
2703
|
+
});
|
|
2704
|
+
return;
|
|
2705
|
+
}
|
|
2706
|
+
const localTimezoneValue = this.composeLocalTimezoneValue();
|
|
2707
|
+
const unixEpoch = localTimezoneValue ? localTimezoneValue.getTime() : null;
|
|
2708
|
+
this.emitDateTimePickerChange({
|
|
2709
|
+
value: unixEpoch === null ? null : this.composeValueISO(unixEpoch),
|
|
2710
|
+
localTimezoneValue,
|
|
2711
|
+
unixEpoch,
|
|
2712
|
+
timezone: this.timezone,
|
|
2713
|
+
});
|
|
2714
|
+
}
|
|
2715
|
+
|
|
2716
|
+
private handleReset(): void {
|
|
2717
|
+
if (this.disabled) return;
|
|
2718
|
+
this.clearInstantSeed();
|
|
2719
|
+
this.value = null;
|
|
2720
|
+
this.startDate = null;
|
|
2721
|
+
this.endDate = null;
|
|
2722
|
+
this.selectedUnit = undefined!;
|
|
2723
|
+
this.selectedValue = undefined!;
|
|
2724
|
+
this.resetTimeUnits();
|
|
2725
|
+
this.setTimezoneInternally(this.configuredTimezone);
|
|
2726
|
+
this.requestUpdate();
|
|
2727
|
+
this.emit('nile-clear');
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
private emitDateTimePickerChange(
|
|
2731
|
+
data:
|
|
2732
|
+
| { startDate: Date; endDate: Date; timezone: string }
|
|
2733
|
+
| { value: string | null; localTimezoneValue: Date | null; unixEpoch: number | null; timezone: string }
|
|
2734
|
+
| null
|
|
2735
|
+
) {
|
|
2736
|
+
this.emit('nile-change', data);
|
|
2737
|
+
}
|
|
1006
2738
|
}
|
|
1007
2739
|
|
|
1008
2740
|
export default NileCalendar;
|