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