@aquera/nile-elements 2.0.18 → 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.
Files changed (138) hide show
  1. package/README.md +10 -0
  2. package/demo/index.js +0 -1
  3. package/dist/index.cjs.js +1 -1
  4. package/dist/index.esm.js +1 -1
  5. package/dist/index.js +1275 -490
  6. package/dist/nile-badge/index.cjs.js +1 -1
  7. package/dist/nile-badge/index.esm.js +1 -1
  8. package/dist/nile-badge/nile-badge.cjs.js +1 -1
  9. package/dist/nile-badge/nile-badge.cjs.js.map +1 -1
  10. package/dist/nile-badge/nile-badge.css.cjs.js +1 -1
  11. package/dist/nile-badge/nile-badge.css.cjs.js.map +1 -1
  12. package/dist/nile-badge/nile-badge.css.esm.js +24 -3
  13. package/dist/nile-badge/nile-badge.esm.js +5 -3
  14. package/dist/nile-breadcrumb/nile-breadcrumb.cjs.js +1 -1
  15. package/dist/nile-breadcrumb/nile-breadcrumb.cjs.js.map +1 -1
  16. package/dist/nile-breadcrumb/nile-breadcrumb.esm.js +2 -2
  17. package/dist/nile-breadcrumb-item/nile-breadcrumb-item.cjs.js +1 -1
  18. package/dist/nile-breadcrumb-item/nile-breadcrumb-item.cjs.js.map +1 -1
  19. package/dist/nile-breadcrumb-item/nile-breadcrumb-item.css.cjs.js +1 -1
  20. package/dist/nile-breadcrumb-item/nile-breadcrumb-item.css.cjs.js.map +1 -1
  21. package/dist/nile-breadcrumb-item/nile-breadcrumb-item.css.esm.js +14 -2
  22. package/dist/nile-breadcrumb-item/nile-breadcrumb-item.esm.js +4 -4
  23. package/dist/nile-calendar/index.cjs.js +1 -1
  24. package/dist/nile-calendar/index.esm.js +1 -1
  25. package/dist/nile-calendar/nile-calendar.cjs.js +2 -1
  26. package/dist/nile-calendar/nile-calendar.cjs.js.map +1 -1
  27. package/dist/nile-calendar/nile-calendar.css.cjs.js +1 -1
  28. package/dist/nile-calendar/nile-calendar.css.cjs.js.map +1 -1
  29. package/dist/nile-calendar/nile-calendar.css.esm.js +357 -2
  30. package/dist/nile-calendar/nile-calendar.esm.js +365 -41
  31. package/dist/nile-calendar/timezones.cjs.js +1 -1
  32. package/dist/nile-calendar/timezones.cjs.js.map +1 -1
  33. package/dist/nile-calendar/timezones.esm.js +1 -1
  34. package/dist/nile-date-time-picker/index.cjs.js +2 -0
  35. package/dist/nile-date-time-picker/index.cjs.js.map +1 -0
  36. package/dist/nile-date-time-picker/index.esm.js +1 -0
  37. package/dist/nile-date-time-picker/nile-date-time-picker.cjs.js +2 -0
  38. package/dist/nile-date-time-picker/nile-date-time-picker.cjs.js.map +1 -0
  39. package/dist/nile-date-time-picker/nile-date-time-picker.css.cjs.js +2 -0
  40. package/dist/nile-date-time-picker/nile-date-time-picker.css.cjs.js.map +1 -0
  41. package/dist/nile-date-time-picker/nile-date-time-picker.css.esm.js +13 -0
  42. package/dist/nile-date-time-picker/nile-date-time-picker.esm.js +53 -0
  43. package/dist/nile-select/index.cjs.js +1 -1
  44. package/dist/nile-select/index.esm.js +1 -1
  45. package/dist/nile-select/nile-select.cjs.js +2 -2
  46. package/dist/nile-select/nile-select.cjs.js.map +1 -1
  47. package/dist/nile-select/nile-select.esm.js +1 -1
  48. package/dist/nile-select/virtual-scroll-helper.cjs.js +1 -1
  49. package/dist/nile-select/virtual-scroll-helper.cjs.js.map +1 -1
  50. package/dist/nile-select/virtual-scroll-helper.esm.js +48 -47
  51. package/dist/nile-virtual-select/nile-virtual-select.cjs.js +4 -4
  52. package/dist/nile-virtual-select/nile-virtual-select.cjs.js.map +1 -1
  53. package/dist/nile-virtual-select/nile-virtual-select.esm.js +2 -2
  54. package/dist/src/index.d.ts +1 -0
  55. package/dist/src/index.js +1 -0
  56. package/dist/src/index.js.map +1 -1
  57. package/dist/src/nile-badge/nile-badge.css.js +24 -3
  58. package/dist/src/nile-badge/nile-badge.css.js.map +1 -1
  59. package/dist/src/nile-badge/nile-badge.d.ts +7 -4
  60. package/dist/src/nile-badge/nile-badge.js +22 -5
  61. package/dist/src/nile-badge/nile-badge.js.map +1 -1
  62. package/dist/src/nile-badge/nile-badge.test.js +88 -0
  63. package/dist/src/nile-badge/nile-badge.test.js.map +1 -1
  64. package/dist/src/nile-breadcrumb/nile-breadcrumb.d.ts +7 -0
  65. package/dist/src/nile-breadcrumb/nile-breadcrumb.js +24 -1
  66. package/dist/src/nile-breadcrumb/nile-breadcrumb.js.map +1 -1
  67. package/dist/src/nile-breadcrumb/nile-breadcrumb.test.js +51 -0
  68. package/dist/src/nile-breadcrumb/nile-breadcrumb.test.js.map +1 -1
  69. package/dist/src/nile-breadcrumb-item/nile-breadcrumb-item.css.js +12 -0
  70. package/dist/src/nile-breadcrumb-item/nile-breadcrumb-item.css.js.map +1 -1
  71. package/dist/src/nile-breadcrumb-item/nile-breadcrumb-item.d.ts +5 -0
  72. package/dist/src/nile-breadcrumb-item/nile-breadcrumb-item.js +9 -0
  73. package/dist/src/nile-breadcrumb-item/nile-breadcrumb-item.js.map +1 -1
  74. package/dist/src/nile-calendar/nile-calendar.css.js +355 -0
  75. package/dist/src/nile-calendar/nile-calendar.css.js.map +1 -1
  76. package/dist/src/nile-calendar/nile-calendar.d.ts +175 -0
  77. package/dist/src/nile-calendar/nile-calendar.js +1726 -26
  78. package/dist/src/nile-calendar/nile-calendar.js.map +1 -1
  79. package/dist/src/nile-calendar/nile-calendar.test.d.ts +1 -0
  80. package/dist/src/nile-calendar/nile-calendar.test.js +3580 -0
  81. package/dist/src/nile-calendar/nile-calendar.test.js.map +1 -1
  82. package/dist/src/nile-calendar/timezones.d.ts +21 -0
  83. package/dist/src/nile-calendar/timezones.js +49 -0
  84. package/dist/src/nile-calendar/timezones.js.map +1 -1
  85. package/dist/src/nile-date-time-picker/index.d.ts +1 -0
  86. package/dist/src/nile-date-time-picker/index.js +2 -0
  87. package/dist/src/nile-date-time-picker/index.js.map +1 -0
  88. package/dist/src/nile-date-time-picker/nile-date-time-picker.css.d.ts +11 -0
  89. package/dist/src/nile-date-time-picker/nile-date-time-picker.css.js +49 -0
  90. package/dist/src/nile-date-time-picker/nile-date-time-picker.css.js.map +1 -0
  91. package/dist/src/nile-date-time-picker/nile-date-time-picker.d.ts +173 -0
  92. package/dist/src/nile-date-time-picker/nile-date-time-picker.js +1270 -0
  93. package/dist/src/nile-date-time-picker/nile-date-time-picker.js.map +1 -0
  94. package/dist/src/nile-date-time-picker/nile-date-time-picker.test.d.ts +143 -0
  95. package/dist/src/nile-date-time-picker/nile-date-time-picker.test.js +7277 -0
  96. package/dist/src/nile-date-time-picker/nile-date-time-picker.test.js.map +1 -0
  97. package/dist/src/nile-select/nile-select.d.ts +1 -0
  98. package/dist/src/nile-select/nile-select.js +16 -1
  99. package/dist/src/nile-select/nile-select.js.map +1 -1
  100. package/dist/src/nile-select/nile-select.test.js +430 -5
  101. package/dist/src/nile-select/nile-select.test.js.map +1 -1
  102. package/dist/src/nile-select/virtual-scroll-helper.js +7 -4
  103. package/dist/src/nile-select/virtual-scroll-helper.js.map +1 -1
  104. package/dist/src/nile-virtual-select/nile-virtual-select.d.ts +3 -1
  105. package/dist/src/nile-virtual-select/nile-virtual-select.js +14 -6
  106. package/dist/src/nile-virtual-select/nile-virtual-select.js.map +1 -1
  107. package/dist/src/nile-virtual-select/nile-virtual-select.test.js +115 -1
  108. package/dist/src/nile-virtual-select/nile-virtual-select.test.js.map +1 -1
  109. package/dist/src/version.js +1 -1
  110. package/dist/src/version.js.map +1 -1
  111. package/dist/tsconfig.tsbuildinfo +1 -1
  112. package/dist/utilities/animation-registry.cjs.js +1 -1
  113. package/dist/utilities/animation-registry.cjs.js.map +1 -1
  114. package/dist/utilities/animation-registry.esm.js +1 -1
  115. package/package.json +3 -1
  116. package/scripts/coverage-report.mjs +96 -0
  117. package/src/index.ts +1 -0
  118. package/src/nile-badge/nile-badge.css.ts +24 -3
  119. package/src/nile-badge/nile-badge.test.ts +102 -0
  120. package/src/nile-badge/nile-badge.ts +28 -5
  121. package/src/nile-breadcrumb/nile-breadcrumb.test.ts +57 -0
  122. package/src/nile-breadcrumb/nile-breadcrumb.ts +19 -0
  123. package/src/nile-breadcrumb-item/nile-breadcrumb-item.css.ts +12 -0
  124. package/src/nile-breadcrumb-item/nile-breadcrumb-item.ts +7 -0
  125. package/src/nile-calendar/nile-calendar.css.ts +355 -0
  126. package/src/nile-calendar/nile-calendar.test.ts +3793 -0
  127. package/src/nile-calendar/nile-calendar.ts +1760 -28
  128. package/src/nile-calendar/timezones.ts +57 -0
  129. package/src/nile-date-time-picker/index.ts +1 -0
  130. package/src/nile-date-time-picker/nile-date-time-picker.css.ts +54 -0
  131. package/src/nile-date-time-picker/nile-date-time-picker.test.ts +7886 -0
  132. package/src/nile-date-time-picker/nile-date-time-picker.ts +1153 -0
  133. package/src/nile-select/nile-select.test.ts +560 -5
  134. package/src/nile-select/nile-select.ts +21 -1
  135. package/src/nile-select/virtual-scroll-helper.ts +7 -4
  136. package/src/nile-virtual-select/nile-virtual-select.test.ts +139 -1
  137. package/src/nile-virtual-select/nile-virtual-select.ts +17 -6
  138. package/vscode-html-custom-data.json +200 -20
@@ -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 = new Date(this.valueAttribute || '');
368
+ const date = this.dateFromValueString(this.valueAttribute || '');
113
369
  if (!isNaN(date.getTime())) {
114
- const offset = date.getTimezoneOffset();
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 fallbackStart = 2000;
131
- const fallbackEnd = 2050;
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.startYear !== undefined &&
180
- this.currentYear <= this.startYear
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.endYear !== undefined &&
188
- this.currentYear >= this.endYear
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
- formatted = formatted.replace(/YYYY/g, String(yyyy));
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 year = match.groups.year.length === 2 ? Number(`20${match.groups.year}`) : Number(match.groups.year);
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
- const date = new Date(year, month, day);
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.range ? html`
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
- const dateToCheck = new Date(Date.UTC(year, month, day));
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
- const selectedDate = new Date(year, month, day);
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 = new Date(rangeValue.startDate);
803
- this.endDate = new Date(rangeValue.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
- let date: Date = new Date(this.valueAttribute);
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
- startTime.setMinutes(startTime.getMinutes() - value);
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.setHours(startTime.getHours() - value);
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
- startTime.setMonth(startTime.getMonth() - value);
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.endYear !== undefined && newYear > this.endYear) return;
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.startYear !== undefined && newYear < this.startYear) return;
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;