@aquera/nile-elements 2.0.1 → 2.0.2

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 (53) hide show
  1. package/README.md +5 -0
  2. package/demo/nxtgen-dark.css +2 -2
  3. package/dist/index.cjs.js +1 -1
  4. package/dist/index.esm.js +1 -1
  5. package/dist/index.js +812 -465
  6. package/dist/nile-timestamp-builder/index.cjs.js +2 -0
  7. package/dist/nile-timestamp-builder/index.cjs.js.map +1 -0
  8. package/dist/nile-timestamp-builder/index.esm.js +1 -0
  9. package/dist/nile-timestamp-builder/nile-timestamp-builder-utils.cjs.js +2 -0
  10. package/dist/nile-timestamp-builder/nile-timestamp-builder-utils.cjs.js.map +1 -0
  11. package/dist/nile-timestamp-builder/nile-timestamp-builder-utils.esm.js +1 -0
  12. package/dist/nile-timestamp-builder/nile-timestamp-builder.cjs.js +2 -0
  13. package/dist/nile-timestamp-builder/nile-timestamp-builder.cjs.js.map +1 -0
  14. package/dist/nile-timestamp-builder/nile-timestamp-builder.css.cjs.js +2 -0
  15. package/dist/nile-timestamp-builder/nile-timestamp-builder.css.cjs.js.map +1 -0
  16. package/dist/nile-timestamp-builder/nile-timestamp-builder.css.esm.js +195 -0
  17. package/dist/nile-timestamp-builder/nile-timestamp-builder.esm.js +154 -0
  18. package/dist/nile-timestamp-builder/timezones.cjs.js +2 -0
  19. package/dist/nile-timestamp-builder/timezones.cjs.js.map +1 -0
  20. package/dist/nile-timestamp-builder/timezones.esm.js +1 -0
  21. package/dist/src/index.d.ts +1 -0
  22. package/dist/src/index.js +1 -0
  23. package/dist/src/index.js.map +1 -1
  24. package/dist/src/nile-timestamp-builder/index.d.ts +1 -0
  25. package/dist/src/nile-timestamp-builder/index.js +2 -0
  26. package/dist/src/nile-timestamp-builder/index.js.map +1 -0
  27. package/dist/src/nile-timestamp-builder/nile-timestamp-builder-utils.d.ts +54 -0
  28. package/dist/src/nile-timestamp-builder/nile-timestamp-builder-utils.js +254 -0
  29. package/dist/src/nile-timestamp-builder/nile-timestamp-builder-utils.js.map +1 -0
  30. package/dist/src/nile-timestamp-builder/nile-timestamp-builder.css.d.ts +7 -0
  31. package/dist/src/nile-timestamp-builder/nile-timestamp-builder.css.js +203 -0
  32. package/dist/src/nile-timestamp-builder/nile-timestamp-builder.css.js.map +1 -0
  33. package/dist/src/nile-timestamp-builder/nile-timestamp-builder.d.ts +94 -0
  34. package/dist/src/nile-timestamp-builder/nile-timestamp-builder.js +578 -0
  35. package/dist/src/nile-timestamp-builder/nile-timestamp-builder.js.map +1 -0
  36. package/dist/src/nile-timestamp-builder/nile-timestamp-builder.test.d.ts +1 -0
  37. package/dist/src/nile-timestamp-builder/nile-timestamp-builder.test.js +890 -0
  38. package/dist/src/nile-timestamp-builder/nile-timestamp-builder.test.js.map +1 -0
  39. package/dist/src/nile-timestamp-builder/timezones.d.ts +8 -0
  40. package/dist/src/nile-timestamp-builder/timezones.js +357 -0
  41. package/dist/src/nile-timestamp-builder/timezones.js.map +1 -0
  42. package/dist/src/version.js +2 -2
  43. package/dist/src/version.js.map +1 -1
  44. package/dist/tsconfig.tsbuildinfo +1 -1
  45. package/package.json +1 -1
  46. package/src/index.ts +1 -0
  47. package/src/nile-timestamp-builder/index.ts +1 -0
  48. package/src/nile-timestamp-builder/nile-timestamp-builder-utils.ts +321 -0
  49. package/src/nile-timestamp-builder/nile-timestamp-builder.css.ts +204 -0
  50. package/src/nile-timestamp-builder/nile-timestamp-builder.test.ts +1089 -0
  51. package/src/nile-timestamp-builder/nile-timestamp-builder.ts +625 -0
  52. package/src/nile-timestamp-builder/timezones.ts +356 -0
  53. package/vscode-html-custom-data.json +92 -0
@@ -0,0 +1,890 @@
1
+ import { expect, fixture, html, oneEvent } from '@open-wc/testing';
2
+ import './nile-timestamp-builder';
3
+ import { clampField, clampWallClock, daysInMonth, epochToFields, fieldsToEpoch, formatWallClock, getLocalTimeZone, getTimezoneList, hasTemporal, isValidTimeZone, normalizeValueInput, parseZonedValue, } from './nile-timestamp-builder-utils';
4
+ const make = (attrs = '') => fixture(html `<nile-timestamp-builder ${attrs}></nile-timestamp-builder>`);
5
+ /** Dispatch a nile-change carrying a STRING value on the nth inner nile-input. */
6
+ function changeField(el, index, value) {
7
+ const inputs = el.shadowRoot.querySelectorAll('nile-input');
8
+ const input = inputs[index];
9
+ input.dispatchEvent(new CustomEvent('nile-change', {
10
+ detail: { value: String(value) },
11
+ bubbles: true,
12
+ composed: true,
13
+ }));
14
+ }
15
+ // Field order in the expanded view.
16
+ const FIELD = {
17
+ year: 0,
18
+ month: 1,
19
+ day: 2,
20
+ hour: 3,
21
+ minute: 4,
22
+ second: 5,
23
+ millisecond: 6,
24
+ };
25
+ describe('NileTimestampBuilder', () => {
26
+ describe('rendering & defaults', () => {
27
+ it('renders', async () => {
28
+ const el = await make();
29
+ expect(el).to.exist;
30
+ expect(el.shadowRoot).to.not.be.null;
31
+ });
32
+ it('is defined', () => {
33
+ expect(customElements.get('nile-timestamp-builder')).to.exist;
34
+ });
35
+ it('tag name', async () => {
36
+ const el = await make();
37
+ expect(el.tagName.toLowerCase()).to.equal('nile-timestamp-builder');
38
+ });
39
+ it('has styles', async () => {
40
+ const mod = await import('./nile-timestamp-builder');
41
+ expect(mod.NileTimestampBuilder.styles).to.exist;
42
+ });
43
+ it('defaults', async () => {
44
+ const el = await make();
45
+ expect(el.value).to.equal(null);
46
+ expect(el.open).to.equal(false);
47
+ expect(el.minYear).to.equal(1);
48
+ expect(el.maxYear).to.equal(9999);
49
+ expect(el.timezone).to.be.a('string');
50
+ });
51
+ it('resolves the timezone list once on connect', async () => {
52
+ const el = await make();
53
+ expect(el.timezoneList.length).to.be.greaterThan(0);
54
+ });
55
+ it('collapsed view shows a placeholder when empty', async () => {
56
+ const el = await make();
57
+ const value = el.shadowRoot.querySelector('[part="value"]');
58
+ expect(value?.textContent?.trim()).to.equal('No timestamp set');
59
+ });
60
+ });
61
+ describe('view toggling', () => {
62
+ it('Edit expands, Done collapses', async () => {
63
+ const el = await make();
64
+ el.handleEdit();
65
+ await el.updateComplete;
66
+ expect(el.open).to.be.true;
67
+ expect(el.shadowRoot.querySelector('[part="editor"]')).to.exist;
68
+ el.handleDone();
69
+ await el.updateComplete;
70
+ expect(el.open).to.be.false;
71
+ expect(el.shadowRoot.querySelector('[part="display"]')).to.exist;
72
+ });
73
+ it('readonly never expands', async () => {
74
+ const el = await make();
75
+ el.readonly = true;
76
+ await el.updateComplete;
77
+ el.handleEdit();
78
+ await el.updateComplete;
79
+ expect(el.open).to.be.false;
80
+ expect(el.shadowRoot.querySelector('[part="editor"]')).to.not.exist;
81
+ });
82
+ describe('copyable', () => {
83
+ let written;
84
+ let original;
85
+ beforeEach(() => {
86
+ written = [];
87
+ original = navigator.clipboard;
88
+ Object.defineProperty(navigator, 'clipboard', {
89
+ value: {
90
+ writeText: (t) => {
91
+ written.push(t);
92
+ return Promise.resolve();
93
+ },
94
+ },
95
+ configurable: true,
96
+ });
97
+ });
98
+ afterEach(() => {
99
+ Object.defineProperty(navigator, 'clipboard', {
100
+ value: original,
101
+ configurable: true,
102
+ });
103
+ });
104
+ it('stays hidden unless copyable is passed', async () => {
105
+ const el = await make();
106
+ el.readonly = true;
107
+ await el.updateComplete;
108
+ expect(el.shadowRoot.querySelector('[part="actions"]')).to.not.exist;
109
+ });
110
+ it('shows a single Copy action when readonly and copyable', async () => {
111
+ const el = await make();
112
+ el.readonly = true;
113
+ el.copyable = true;
114
+ await el.updateComplete;
115
+ const buttons = el.shadowRoot.querySelectorAll('nile-button');
116
+ expect(buttons.length).to.equal(1);
117
+ // Icon only — the accessible name carries the meaning, not text.
118
+ expect(buttons[0].textContent.trim()).to.equal('');
119
+ expect(buttons[0].getAttribute('aria-label')).to.equal('Copy timestamp');
120
+ expect(buttons[0].querySelector('nile-glyph').getAttribute('name')).to.equal('ng-copy');
121
+ });
122
+ it('does not show Copy when disabled', async () => {
123
+ const el = await make();
124
+ el.value = new Date();
125
+ el.disabled = true;
126
+ el.copyable = true;
127
+ await el.updateComplete;
128
+ expect(el.shadowRoot.querySelector('[part="actions"]')).to.not.exist;
129
+ });
130
+ it('does not show Copy when disabled and readonly together', async () => {
131
+ const el = await make();
132
+ el.value = new Date();
133
+ el.disabled = true;
134
+ el.readonly = true;
135
+ el.copyable = true;
136
+ await el.updateComplete;
137
+ expect(el.shadowRoot.querySelector('[part="actions"]')).to.not.exist;
138
+ });
139
+ it('does not show Copy while the value is editable', async () => {
140
+ const el = await make();
141
+ el.copyable = true;
142
+ await el.updateComplete;
143
+ expect(el.shadowRoot.querySelectorAll('nile-button').length).to.equal(2);
144
+ });
145
+ it('writes the canonical zoned value to the clipboard', async () => {
146
+ const el = await make();
147
+ el.value = new Date(Date.UTC(2026, 2, 14, 4, 11, 0));
148
+ el.timezone = 'UTC';
149
+ el.readonly = true;
150
+ el.copyable = true;
151
+ await el.updateComplete;
152
+ await el.handleCopy();
153
+ expect(written.length).to.equal(1);
154
+ expect(written[0]).to.contain('2026-03-14');
155
+ });
156
+ it('emits nile-copy with the copied value', async () => {
157
+ const el = await make();
158
+ el.value = new Date(Date.UTC(2026, 2, 14, 4, 11, 0));
159
+ el.readonly = true;
160
+ el.copyable = true;
161
+ await el.updateComplete;
162
+ setTimeout(() => el.handleCopy());
163
+ const evt = await oneEvent(el, 'nile-copy');
164
+ expect(evt.detail.value).to.be.a('string');
165
+ });
166
+ it('confirms with a checked icon and an updated accessible name', async () => {
167
+ const el = await make();
168
+ el.value = new Date();
169
+ el.readonly = true;
170
+ el.copyable = true;
171
+ await el.updateComplete;
172
+ await el.handleCopy();
173
+ await el.updateComplete;
174
+ expect(el.justCopied).to.be.true;
175
+ const button = el.shadowRoot.querySelector('nile-button');
176
+ expect(button.getAttribute('aria-label')).to.equal('Copied');
177
+ expect(button.querySelector('nile-glyph').getAttribute('name')).to.equal('ng-copy-check');
178
+ });
179
+ it('disables Copy when there is no value to copy', async () => {
180
+ const el = await make();
181
+ el.readonly = true;
182
+ el.copyable = true;
183
+ await el.updateComplete;
184
+ expect(el.hasValue).to.be.false;
185
+ const button = el.shadowRoot.querySelector('nile-button');
186
+ expect(button.hasAttribute('disabled')).to.be.true;
187
+ await el.handleCopy();
188
+ expect(written.length).to.equal(0);
189
+ });
190
+ it('emits nile-copy-error when the clipboard rejects', async () => {
191
+ Object.defineProperty(navigator, 'clipboard', {
192
+ value: { writeText: () => Promise.reject(new Error('denied')) },
193
+ configurable: true,
194
+ });
195
+ const el = await make();
196
+ el.value = new Date();
197
+ el.readonly = true;
198
+ el.copyable = true;
199
+ await el.updateComplete;
200
+ setTimeout(() => el.handleCopy());
201
+ const evt = await oneEvent(el, 'nile-copy-error');
202
+ expect(evt.detail.value).to.be.a('string');
203
+ expect(el.justCopied).to.be.false;
204
+ });
205
+ });
206
+ it('keeps the collapsed row the same height with and without actions', async () => {
207
+ // The token stylesheet is not loaded under test, so the one length this
208
+ // assertion depends on is supplied explicitly. It inherits into the
209
+ // shadow root like a custom property normally would.
210
+ const withHeightToken = async (mutate) => {
211
+ const el = await make();
212
+ el.style.setProperty('--ng-height-28px', '28px');
213
+ el.value = new Date();
214
+ mutate(el);
215
+ await el.updateComplete;
216
+ return el.shadowRoot.querySelector('.tsb__collapsed');
217
+ };
218
+ const withActions = await withHeightToken(() => { });
219
+ const readonlyRow = await withHeightToken(el => {
220
+ el.readonly = true;
221
+ });
222
+ const disabledRow = await withHeightToken(el => {
223
+ el.disabled = true;
224
+ });
225
+ expect(withActions.offsetHeight).to.equal(readonlyRow.offsetHeight);
226
+ expect(withActions.offsetHeight).to.equal(disabledRow.offsetHeight);
227
+ });
228
+ it('renders no actions when readonly', async () => {
229
+ const el = await make();
230
+ el.readonly = true;
231
+ await el.updateComplete;
232
+ expect(el.shadowRoot.querySelector('[part="actions"]')).to.not.exist;
233
+ expect(el.shadowRoot.querySelectorAll('nile-button').length).to.equal(0);
234
+ });
235
+ it('renders no actions when disabled', async () => {
236
+ const el = await make();
237
+ el.disabled = true;
238
+ await el.updateComplete;
239
+ expect(el.shadowRoot.querySelector('[part="actions"]')).to.not.exist;
240
+ });
241
+ it('renders Now and Edit when editable', async () => {
242
+ const el = await make();
243
+ await el.updateComplete;
244
+ expect(el.shadowRoot.querySelector('[part="actions"]')).to.exist;
245
+ expect(el.shadowRoot.querySelectorAll('nile-button').length).to.equal(2);
246
+ });
247
+ it('marks the readonly display as focusable and aria-readonly', async () => {
248
+ const el = await make();
249
+ el.readonly = true;
250
+ await el.updateComplete;
251
+ const display = el.shadowRoot.querySelector('.tsb__display');
252
+ expect(display.getAttribute('tabindex')).to.equal('0');
253
+ expect(display.getAttribute('aria-readonly')).to.equal('true');
254
+ });
255
+ it('drops the display from the tab order when disabled', async () => {
256
+ const el = await make();
257
+ el.disabled = true;
258
+ await el.updateComplete;
259
+ const display = el.shadowRoot.querySelector('.tsb__display');
260
+ expect(display.hasAttribute('tabindex')).to.be.false;
261
+ });
262
+ it('emits nile-expand / nile-collapse', async () => {
263
+ const el = await make();
264
+ setTimeout(() => el.handleEdit());
265
+ const expandEvt = await oneEvent(el, 'nile-expand');
266
+ expect(expandEvt.detail.open).to.be.true;
267
+ setTimeout(() => el.handleDone());
268
+ const collapseEvt = await oneEvent(el, 'nile-collapse');
269
+ expect(collapseEvt.detail.open).to.be.false;
270
+ });
271
+ const display = (el) => el.shadowRoot.querySelector('.tsb__display');
272
+ const tap = (el) => display(el).dispatchEvent(new PointerEvent('pointerup', {
273
+ pointerType: 'touch',
274
+ bubbles: true,
275
+ composed: true,
276
+ }));
277
+ it('double-click on the display expands', async () => {
278
+ const el = await make();
279
+ display(el).dispatchEvent(new MouseEvent('dblclick', { bubbles: true, composed: true }));
280
+ await el.updateComplete;
281
+ expect(el.open).to.be.true;
282
+ });
283
+ it('double-tap on the display expands', async () => {
284
+ const el = await make();
285
+ tap(el);
286
+ tap(el);
287
+ await el.updateComplete;
288
+ expect(el.open).to.be.true;
289
+ });
290
+ it('a single tap does not expand', async () => {
291
+ const el = await make();
292
+ tap(el);
293
+ await el.updateComplete;
294
+ expect(el.open).to.be.false;
295
+ });
296
+ it('two taps further apart than the double-tap window do not expand', async () => {
297
+ const el = await make();
298
+ tap(el);
299
+ el.lastTapAt -= 1000;
300
+ tap(el);
301
+ await el.updateComplete;
302
+ expect(el.open).to.be.false;
303
+ });
304
+ it('readonly and disabled ignore double-click and double-tap', async () => {
305
+ for (const flag of ['readonly', 'disabled']) {
306
+ const el = await make();
307
+ el[flag] = true;
308
+ await el.updateComplete;
309
+ display(el).dispatchEvent(new MouseEvent('dblclick', { bubbles: true, composed: true }));
310
+ tap(el);
311
+ tap(el);
312
+ await el.updateComplete;
313
+ expect(el.open, flag).to.be.false;
314
+ }
315
+ });
316
+ });
317
+ describe('fields <-> epoch', () => {
318
+ it('derives fields from an external value', async () => {
319
+ const el = await make();
320
+ el.timezone = 'UTC';
321
+ el.value = Date.UTC(2022, 4, 15, 13, 45, 30, 250);
322
+ await el.updateComplete;
323
+ expect(el.wallClock.year).to.equal(2022);
324
+ expect(el.wallClock.month).to.equal(5);
325
+ expect(el.wallClock.day).to.equal(15);
326
+ expect(el.wallClock.hour).to.equal(13);
327
+ expect(el.wallClock.minute).to.equal(45);
328
+ expect(el.wallClock.second).to.equal(30);
329
+ expect(el.wallClock.millisecond).to.equal(250);
330
+ expect(el.hasValue).to.be.true;
331
+ });
332
+ it('field change payload is coerced to a Number', async () => {
333
+ const el = await make();
334
+ el.timezone = 'UTC';
335
+ el.handleEdit();
336
+ await el.updateComplete;
337
+ changeField(el, FIELD.hour, 9);
338
+ await el.updateComplete;
339
+ expect(el.wallClock.hour).to.equal(9);
340
+ expect(el.wallClock.hour).to.be.a('number');
341
+ // Field edits stage only; commit via Done before checking the value.
342
+ el.handleDone();
343
+ await el.updateComplete;
344
+ expect(el.value).to.be.an.instanceOf(Date);
345
+ });
346
+ it('round trips fields to epoch and back', async () => {
347
+ const el = await make();
348
+ el.timezone = 'UTC';
349
+ const epoch = Date.UTC(2021, 5, 15, 12, 30, 45, 123);
350
+ el.value = epoch;
351
+ await el.updateComplete;
352
+ el.handleEdit();
353
+ await el.updateComplete;
354
+ // Re-stage the same field, then commit; epoch must be preserved.
355
+ changeField(el, FIELD.second, 45);
356
+ await el.updateComplete;
357
+ el.handleDone();
358
+ await el.updateComplete;
359
+ expect(el.value?.getTime()).to.equal(epoch);
360
+ });
361
+ });
362
+ describe('clamping', () => {
363
+ async function seeded() {
364
+ const el = await make();
365
+ el.timezone = 'UTC';
366
+ el.value = Date.UTC(2023, 0, 15, 10, 0, 0, 0);
367
+ await el.updateComplete;
368
+ el.handleEdit();
369
+ await el.updateComplete;
370
+ return el;
371
+ }
372
+ it('clamps month to 1-12', async () => {
373
+ const el = await seeded();
374
+ changeField(el, FIELD.month, 13);
375
+ await el.updateComplete;
376
+ expect(el.wallClock.month).to.equal(12);
377
+ });
378
+ it('clamps day to leap-aware month length', async () => {
379
+ const el = await seeded();
380
+ el.value = Date.UTC(2024, 1, 10, 0, 0, 0, 0); // Feb 2024 (leap)
381
+ await el.updateComplete;
382
+ changeField(el, FIELD.day, 30);
383
+ await el.updateComplete;
384
+ expect(el.wallClock.day).to.equal(29);
385
+ });
386
+ it('clamps day for a non-leap February', async () => {
387
+ const el = await seeded();
388
+ el.value = Date.UTC(2023, 1, 10, 0, 0, 0, 0); // Feb 2023
389
+ await el.updateComplete;
390
+ changeField(el, FIELD.day, 31);
391
+ await el.updateComplete;
392
+ expect(el.wallClock.day).to.equal(28);
393
+ });
394
+ it('clamps hour/minute/second/ms', async () => {
395
+ const el = await seeded();
396
+ changeField(el, FIELD.hour, 30);
397
+ changeField(el, FIELD.minute, 99);
398
+ changeField(el, FIELD.second, 61);
399
+ changeField(el, FIELD.millisecond, 1500);
400
+ await el.updateComplete;
401
+ expect(el.wallClock.hour).to.equal(23);
402
+ expect(el.wallClock.minute).to.equal(59);
403
+ expect(el.wallClock.second).to.equal(59);
404
+ expect(el.wallClock.millisecond).to.equal(999);
405
+ });
406
+ it('clamps year to min/max', async () => {
407
+ const el = await seeded();
408
+ el.minYear = 2000;
409
+ el.maxYear = 2030;
410
+ await el.updateComplete;
411
+ changeField(el, FIELD.year, 3000);
412
+ await el.updateComplete;
413
+ expect(el.wallClock.year).to.equal(2030);
414
+ changeField(el, FIELD.year, 1000);
415
+ await el.updateComplete;
416
+ expect(el.wallClock.year).to.equal(2000);
417
+ });
418
+ it('recaps day when switching to a shorter month', async () => {
419
+ const el = await seeded();
420
+ changeField(el, FIELD.day, 31); // January has 31 days
421
+ await el.updateComplete;
422
+ expect(el.wallClock.day).to.equal(31);
423
+ changeField(el, FIELD.month, 4); // April has 30
424
+ await el.updateComplete;
425
+ expect(el.wallClock.day).to.equal(30);
426
+ });
427
+ });
428
+ describe('timezone behaviour', () => {
429
+ it('switching timezone keeps fields and changes epoch', async () => {
430
+ const el = await make();
431
+ el.timezone = 'UTC';
432
+ el.value = Date.UTC(2022, 0, 1, 12, 0, 0, 0);
433
+ await el.updateComplete;
434
+ const before = {
435
+ y: el.wallClock.year,
436
+ mo: el.wallClock.month,
437
+ d: el.wallClock.day,
438
+ h: el.wallClock.hour,
439
+ epoch: el.value?.getTime(),
440
+ };
441
+ const select = el.shadowRoot.querySelector('nile-select');
442
+ // ensure select exists (expanded not required for handler)
443
+ el.open = true;
444
+ await el.updateComplete;
445
+ const tzSelect = el.shadowRoot.querySelector('nile-select');
446
+ tzSelect.dispatchEvent(new CustomEvent('nile-change', {
447
+ detail: { value: 'Asia/Kolkata' },
448
+ bubbles: true,
449
+ composed: true,
450
+ }));
451
+ await el.updateComplete;
452
+ // Timezone selection stages only — commit via Done to see the new epoch.
453
+ el.handleDone();
454
+ await el.updateComplete;
455
+ expect(select || tzSelect).to.exist;
456
+ expect(el.wallClock.year).to.equal(before.y);
457
+ expect(el.wallClock.month).to.equal(before.mo);
458
+ expect(el.wallClock.day).to.equal(before.d);
459
+ expect(el.wallClock.hour).to.equal(before.h);
460
+ expect(el.value?.getTime()).to.not.equal(before.epoch);
461
+ });
462
+ it('Use UTC sets timezone to UTC and keeps fields', async () => {
463
+ const el = await make();
464
+ el.timezone = 'Asia/Kolkata';
465
+ el.value = Date.now();
466
+ await el.updateComplete;
467
+ const y = el.wallClock.year;
468
+ el.handleUseUtc();
469
+ await el.updateComplete;
470
+ expect(el.timezone).to.equal('UTC');
471
+ expect(el.wallClock.year).to.equal(y);
472
+ });
473
+ it('Use Local Timezone sets a valid tz', async () => {
474
+ const el = await make();
475
+ el.timezone = 'UTC';
476
+ el.value = Date.now();
477
+ await el.updateComplete;
478
+ el.handleUseLocalTz();
479
+ await el.updateComplete;
480
+ expect(isValidTimeZone(el.timezone)).to.be.true;
481
+ });
482
+ });
483
+ describe('set to now', () => {
484
+ it('sets value near Date.now() and emits nile-change', async () => {
485
+ const el = await make();
486
+ setTimeout(() => el.handleSetNow());
487
+ const evt = await oneEvent(el, 'nile-change');
488
+ // Emitted detail matches the date-time picker contract: a zone-encoded
489
+ // string value plus the absolute instant as a Date.
490
+ expect(evt.detail.value).to.be.a('string');
491
+ expect(evt.detail.value).to.match(/^\d{4}-\d{2}-\d{2}T.*\[GMT[+-]\d{2}:\d{2} .+\]$/);
492
+ expect(evt.detail.localTimezoneValue).to.be.an.instanceOf(Date);
493
+ expect(evt.detail.timezone).to.be.a('string');
494
+ expect(Math.abs(el.value.getTime() - Date.now())).to.be.lessThan(5000);
495
+ expect(el.hasValue).to.be.true;
496
+ });
497
+ });
498
+ describe('emit contract (date-time picker parity)', () => {
499
+ it('emits a zone-encoded value string carrying the selected offset + zone, only on Done', async () => {
500
+ const el = await make();
501
+ el.timezone = 'Asia/Yerevan'; // UTC+4, no DST
502
+ el.value = Date.UTC(2030, 3, 7, 17, 8, 56, 892); // 21:08:56.892 wall-clock
503
+ el.open = true; // fields only render in the expanded view
504
+ await el.updateComplete;
505
+ // Editing a field only stages it — no nile-change until Done.
506
+ let changeFired = false;
507
+ el.addEventListener('nile-change', () => (changeFired = true));
508
+ changeField(el, FIELD.second, 56); // same value; re-stages the field
509
+ await el.updateComplete;
510
+ expect(changeFired).to.be.false;
511
+ const listener = oneEvent(el, 'nile-change');
512
+ el.handleDone();
513
+ const evt = await listener;
514
+ expect(evt.detail.value).to.equal('2030-04-07T21:08:56.892[GMT+04:00 Asia/Yerevan]');
515
+ expect(evt.detail.localTimezoneValue).to.be.an.instanceOf(Date);
516
+ // localTimezoneValue is the absolute instant behind the wall-clock, and it
517
+ // must agree exactly with the epoch — no roundtrip drift.
518
+ expect(evt.detail.localTimezoneValue.getTime()).to.equal(Date.UTC(2030, 3, 7, 17, 8, 56, 892));
519
+ expect(el.value?.getTime()).to.equal(Date.UTC(2030, 3, 7, 17, 8, 56, 892));
520
+ expect(evt.detail.timezone).to.equal('Asia/Yerevan');
521
+ });
522
+ it('labels a UTC value as [GMT+00:00 UTC]', async () => {
523
+ const el = await make();
524
+ el.timezone = 'UTC';
525
+ el.value = Date.UTC(2030, 3, 7, 17, 8, 56, 892);
526
+ el.open = true;
527
+ await el.updateComplete;
528
+ changeField(el, FIELD.second, 56);
529
+ await el.updateComplete;
530
+ const listener = oneEvent(el, 'nile-change');
531
+ el.handleDone();
532
+ const evt = await listener;
533
+ expect(evt.detail.value).to.equal('2030-04-07T17:08:56.892[GMT+00:00 UTC]');
534
+ });
535
+ it('selecting a timezone stages only — no nile-change until Done', async () => {
536
+ const el = await make();
537
+ el.timezone = 'UTC';
538
+ el.value = Date.UTC(2022, 0, 1, 12, 0, 0, 0);
539
+ el.open = true;
540
+ await el.updateComplete;
541
+ let changeFired = false;
542
+ el.addEventListener('nile-change', () => (changeFired = true));
543
+ const tzSelect = el.shadowRoot.querySelector('nile-select');
544
+ tzSelect.dispatchEvent(new CustomEvent('nile-change', {
545
+ detail: { value: 'Asia/Kolkata' },
546
+ bubbles: true,
547
+ composed: true,
548
+ }));
549
+ await el.updateComplete;
550
+ expect(el.timezone).to.equal('Asia/Kolkata');
551
+ expect(changeFired).to.be.false;
552
+ expect(el.value?.getTime()).to.equal(Date.UTC(2022, 0, 1, 12, 0, 0, 0)); // unchanged
553
+ const listener = oneEvent(el, 'nile-change');
554
+ el.handleDone();
555
+ const evt = await listener;
556
+ expect(evt.detail.timezone).to.equal('Asia/Kolkata');
557
+ expect(el.value?.getTime()).to.not.equal(Date.UTC(2022, 0, 1, 12, 0, 0, 0));
558
+ });
559
+ it('field edits emit live nile-input without committing', async () => {
560
+ const el = await make();
561
+ el.timezone = 'UTC';
562
+ el.value = Date.UTC(2022, 0, 1, 12, 0, 0, 0);
563
+ el.open = true;
564
+ await el.updateComplete;
565
+ const listener = oneEvent(el, 'nile-input');
566
+ changeField(el, FIELD.hour, 9);
567
+ const evt = await listener;
568
+ expect(evt.detail.value).to.be.a('string');
569
+ expect(evt.detail.localTimezoneValue).to.be.an.instanceOf(Date);
570
+ // The stored value has NOT committed yet.
571
+ expect(el.value?.getTime()).to.equal(Date.UTC(2022, 0, 1, 12, 0, 0, 0));
572
+ });
573
+ });
574
+ describe('value accepts Date, number, or string, stores a Date', () => {
575
+ it('accepts an ISO string via property assignment', async () => {
576
+ const el = await make();
577
+ el.value = '2026-07-15T00:00:00.000Z';
578
+ await el.updateComplete;
579
+ expect(el.value?.getTime()).to.equal(Date.UTC(2026, 6, 15, 0, 0, 0, 0));
580
+ });
581
+ it('accepts a numeric string via property assignment', async () => {
582
+ const el = await make();
583
+ const epoch = Date.UTC(2026, 6, 15, 0, 0, 0, 0);
584
+ el.value = String(epoch);
585
+ await el.updateComplete;
586
+ expect(el.value?.getTime()).to.equal(epoch);
587
+ });
588
+ it('accepts a Date via property assignment', async () => {
589
+ const el = await make();
590
+ const date = new Date(Date.UTC(2026, 6, 15, 0, 0, 0, 0));
591
+ el.value = date;
592
+ await el.updateComplete;
593
+ expect(el.value).to.be.an.instanceOf(Date);
594
+ expect(el.value?.getTime()).to.equal(date.getTime());
595
+ });
596
+ it('accepts an ISO string via the value attribute', async () => {
597
+ const el = await fixture(html `<nile-timestamp-builder
598
+ value="2026-07-15T00:00:00.000Z"
599
+ ></nile-timestamp-builder>`);
600
+ expect(el.value?.getTime()).to.equal(Date.UTC(2026, 6, 15, 0, 0, 0, 0));
601
+ });
602
+ it('collapses an unparseable string to null', async () => {
603
+ const el = await make();
604
+ el.value = 'not-a-date';
605
+ await el.updateComplete;
606
+ expect(el.value).to.equal(null);
607
+ });
608
+ it('collapses an empty string to null', async () => {
609
+ const el = await make();
610
+ el.value = Date.now();
611
+ await el.updateComplete;
612
+ el.value = '';
613
+ await el.updateComplete;
614
+ expect(el.value).to.equal(null);
615
+ });
616
+ });
617
+ describe('empty-expand seeding', () => {
618
+ it('seeds fields on Edit but does NOT emit until Done', async () => {
619
+ const el = await make();
620
+ let emitted = false;
621
+ el.addEventListener('nile-change', () => (emitted = true));
622
+ el.handleEdit();
623
+ await el.updateComplete;
624
+ expect(el.value).to.equal(null);
625
+ expect(el.hasValue).to.be.false;
626
+ expect(el.wallClock.year).to.be.greaterThan(1970);
627
+ expect(emitted).to.be.false;
628
+ el.handleDone();
629
+ await el.updateComplete;
630
+ expect(emitted).to.be.true;
631
+ expect(el.value).to.be.an.instanceOf(Date);
632
+ expect(el.hasValue).to.be.true;
633
+ });
634
+ });
635
+ describe('external value = null clears', () => {
636
+ it('clears state when value set back to null', async () => {
637
+ const el = await make();
638
+ el.timezone = 'UTC';
639
+ el.value = Date.now();
640
+ await el.updateComplete;
641
+ expect(el.hasValue).to.be.true;
642
+ el.value = null;
643
+ await el.updateComplete;
644
+ expect(el.hasValue).to.be.false;
645
+ const hidden = el.shadowRoot.querySelector('.value-input');
646
+ expect(hidden.value).to.equal('');
647
+ });
648
+ });
649
+ describe('form association', () => {
650
+ it('FormData contains name -> ISO string', async () => {
651
+ const epoch = Date.UTC(2020, 0, 1, 0, 0, 0, 0);
652
+ const form = await fixture(html `
653
+ <form>
654
+ <nile-timestamp-builder
655
+ name="ts"
656
+ .value=${epoch}
657
+ ></nile-timestamp-builder>
658
+ </form>
659
+ `);
660
+ const el = form.querySelector('nile-timestamp-builder');
661
+ await el.updateComplete;
662
+ const data = new FormData(form);
663
+ expect(data.get('ts')).to.equal(new Date(epoch).toISOString());
664
+ });
665
+ it('required + null is invalid', async () => {
666
+ const el = await make();
667
+ el.required = true;
668
+ el.value = null;
669
+ await el.updateComplete;
670
+ expect(el.checkValidity()).to.be.false;
671
+ });
672
+ it('required + value is valid', async () => {
673
+ const el = await make();
674
+ el.required = true;
675
+ el.value = Date.now();
676
+ await el.updateComplete;
677
+ expect(el.checkValidity()).to.be.true;
678
+ });
679
+ it('disabled control is excluded from FormData', async () => {
680
+ const epoch = Date.UTC(2020, 0, 1, 0, 0, 0, 0);
681
+ const form = await fixture(html `
682
+ <form>
683
+ <nile-timestamp-builder
684
+ name="ts"
685
+ disabled
686
+ .value=${epoch}
687
+ ></nile-timestamp-builder>
688
+ </form>
689
+ `);
690
+ const el = form.querySelector('nile-timestamp-builder');
691
+ await el.updateComplete;
692
+ const data = new FormData(form);
693
+ expect(data.get('ts')).to.equal(null);
694
+ });
695
+ it('reset restores the default value', async () => {
696
+ const form = await fixture(html `
697
+ <form>
698
+ <nile-timestamp-builder
699
+ name="ts"
700
+ value="1000"
701
+ ></nile-timestamp-builder>
702
+ </form>
703
+ `);
704
+ const el = form.querySelector('nile-timestamp-builder');
705
+ await el.updateComplete;
706
+ el.value = 5000;
707
+ await el.updateComplete;
708
+ form.reset();
709
+ await el.updateComplete;
710
+ expect(el.value?.getTime()).to.equal(1000);
711
+ });
712
+ });
713
+ describe('robustness', () => {
714
+ it('falls back to a valid tz when given junk', async () => {
715
+ const el = await fixture(html `<nile-timestamp-builder
716
+ timezone="Not/ARealZone"
717
+ ></nile-timestamp-builder>`);
718
+ await el.updateComplete;
719
+ expect(isValidTimeZone(el.timezone)).to.be.true;
720
+ });
721
+ });
722
+ });
723
+ describe('nile-timestamp-builder utils', () => {
724
+ it('daysInMonth is leap-aware', () => {
725
+ expect(daysInMonth(2024, 2)).to.equal(29);
726
+ expect(daysInMonth(2023, 2)).to.equal(28);
727
+ expect(daysInMonth(2024, 4)).to.equal(30);
728
+ expect(daysInMonth(2024, 1)).to.equal(31);
729
+ expect(daysInMonth(2024, 12)).to.equal(31);
730
+ });
731
+ it('clampField collapses NaN to min', () => {
732
+ expect(clampField(NaN, 0, 10)).to.equal(0);
733
+ expect(clampField(5, 0, 10)).to.equal(5);
734
+ expect(clampField(-3, 0, 10)).to.equal(0);
735
+ expect(clampField(99, 0, 10)).to.equal(10);
736
+ });
737
+ it('clampWallClock caps day using year+month', () => {
738
+ const clamped = clampWallClock({
739
+ year: 2023,
740
+ month: 2,
741
+ day: 31,
742
+ hour: 25,
743
+ minute: 61,
744
+ second: 61,
745
+ millisecond: 1001,
746
+ }, 1, 9999);
747
+ expect(clamped.day).to.equal(28);
748
+ expect(clamped.hour).to.equal(23);
749
+ expect(clamped.minute).to.equal(59);
750
+ expect(clamped.second).to.equal(59);
751
+ expect(clamped.millisecond).to.equal(999);
752
+ });
753
+ it('getTimezoneList is non-empty', () => {
754
+ expect(getTimezoneList().length).to.be.greaterThan(0);
755
+ });
756
+ it('getLocalTimeZone is valid', () => {
757
+ expect(isValidTimeZone(getLocalTimeZone())).to.be.true;
758
+ });
759
+ it('isValidTimeZone', () => {
760
+ expect(isValidTimeZone('UTC')).to.be.true;
761
+ expect(isValidTimeZone('America/New_York')).to.be.true;
762
+ expect(isValidTimeZone('Not/AZone')).to.be.false;
763
+ expect(isValidTimeZone('')).to.be.false;
764
+ });
765
+ it('hasTemporal returns a boolean', () => {
766
+ expect(hasTemporal()).to.be.a('boolean');
767
+ });
768
+ describe('normalizeValueInput', () => {
769
+ it('converts a number to a Date at that instant', () => {
770
+ const result = normalizeValueInput(1712500000000);
771
+ expect(result).to.be.an.instanceOf(Date);
772
+ expect(result?.getTime()).to.equal(1712500000000);
773
+ });
774
+ it('parses a numeric string as a Date at that epoch ms', () => {
775
+ expect(normalizeValueInput('1712500000000')?.getTime()).to.equal(1712500000000);
776
+ });
777
+ it('parses an ISO string as a Date', () => {
778
+ expect(normalizeValueInput('2026-07-15T00:00:00.000Z')?.getTime()).to.equal(Date.UTC(2026, 6, 15, 0, 0, 0, 0));
779
+ });
780
+ it('passes a Date through unchanged', () => {
781
+ const d = new Date(1712500000000);
782
+ expect(normalizeValueInput(d)).to.equal(d);
783
+ });
784
+ it('collapses null, undefined, empty string, and NaN to null', () => {
785
+ expect(normalizeValueInput(null)).to.equal(null);
786
+ expect(normalizeValueInput(undefined)).to.equal(null);
787
+ expect(normalizeValueInput('')).to.equal(null);
788
+ expect(normalizeValueInput(' ')).to.equal(null);
789
+ expect(normalizeValueInput(NaN)).to.equal(null);
790
+ });
791
+ it('collapses an unparseable string to null', () => {
792
+ expect(normalizeValueInput('not-a-date')).to.equal(null);
793
+ });
794
+ });
795
+ describe('parseZonedValue offset/timezone consistency', () => {
796
+ it('accepts an offset that matches the zone at that instant', () => {
797
+ const parsed = parseZonedValue('2026-08-04T12:00:00[GMT+02:00 Europe/Paris]');
798
+ expect(parsed).to.not.equal(null);
799
+ expect(parsed.timezone).to.equal('Europe/Paris');
800
+ // 12:00 at +02:00 is 10:00Z — the wall-clock the string displays.
801
+ expect(parsed.date.getTime()).to.equal(Date.UTC(2026, 7, 4, 10, 0, 0, 0));
802
+ });
803
+ it('rejects a stale offset that contradicts the zone', () => {
804
+ // Paris is +02:00 in August. Accepting +01:00 would resolve to 11:00Z and
805
+ // silently render as 13:00 Paris — an hour away from the 12:00 shown.
806
+ expect(parseZonedValue('2026-08-04T12:00:00[GMT+01:00 Europe/Paris]')).to.equal(null);
807
+ });
808
+ it('accepts both offsets of a DST fall-back, preserving the distinct instants', () => {
809
+ // 2026-11-01 01:30 occurs twice in New York; only the offset separates them.
810
+ const first = parseZonedValue('2026-11-01T01:30:00[GMT-04:00 America/New_York]');
811
+ const second = parseZonedValue('2026-11-01T01:30:00[GMT-05:00 America/New_York]');
812
+ expect(first).to.not.equal(null);
813
+ expect(second).to.not.equal(null);
814
+ expect(first.date.getTime()).to.equal(Date.UTC(2026, 10, 1, 5, 30, 0, 0));
815
+ expect(second.date.getTime()).to.equal(Date.UTC(2026, 10, 1, 6, 30, 0, 0));
816
+ expect(first.date.getTime()).to.not.equal(second.date.getTime());
817
+ });
818
+ it('rejects a nonexistent spring-forward local time under either offset', () => {
819
+ // New York jumps 02:00 -> 03:00 on 2026-03-08, so 02:30 never occurs.
820
+ expect(parseZonedValue('2026-03-08T02:30:00[GMT-05:00 America/New_York]')).to.equal(null);
821
+ expect(parseZonedValue('2026-03-08T02:30:00[GMT-04:00 America/New_York]')).to.equal(null);
822
+ });
823
+ it('rejects an unknown timezone identifier without throwing', () => {
824
+ expect(parseZonedValue('2026-08-04T12:00:00[GMT+02:00 Not/AZone]')).to.equal(null);
825
+ });
826
+ it('round-trips the values emitted by formatZonedValue', () => {
827
+ const yerevan = parseZonedValue('2030-04-07T21:08:56.892[GMT+04:00 Asia/Yerevan]');
828
+ expect(yerevan?.date.getTime()).to.equal(Date.UTC(2030, 3, 7, 17, 8, 56, 892));
829
+ const utc = parseZonedValue('2030-04-07T17:08:56.892[GMT+00:00 UTC]');
830
+ expect(utc?.date.getTime()).to.equal(Date.UTC(2030, 3, 7, 17, 8, 56, 892));
831
+ });
832
+ });
833
+ it('formatWallClock pads correctly', () => {
834
+ expect(formatWallClock({
835
+ year: 5,
836
+ month: 3,
837
+ day: 7,
838
+ hour: 8,
839
+ minute: 9,
840
+ second: 1,
841
+ millisecond: 42,
842
+ })).to.equal('0005-03-07 08:09:01.042');
843
+ });
844
+ it('round-trips across multiple zones', () => {
845
+ const epoch = Date.UTC(2021, 5, 15, 12, 30, 45, 123);
846
+ for (const tz of [
847
+ 'UTC',
848
+ 'America/New_York',
849
+ 'Asia/Kolkata',
850
+ 'Asia/Kathmandu',
851
+ ]) {
852
+ const w = epochToFields(epoch, tz);
853
+ expect(fieldsToEpoch(w, tz)).to.equal(epoch);
854
+ }
855
+ });
856
+ it('resolves DST gaps/overlaps deterministically', () => {
857
+ // Spring-forward gap in US Eastern (2021-03-14 02:30 does not exist).
858
+ const gap = {
859
+ year: 2021,
860
+ month: 3,
861
+ day: 14,
862
+ hour: 2,
863
+ minute: 30,
864
+ second: 0,
865
+ millisecond: 0,
866
+ };
867
+ const a = fieldsToEpoch(gap, 'America/New_York');
868
+ const b = fieldsToEpoch(gap, 'America/New_York');
869
+ expect(a).to.equal(b);
870
+ expect(Number.isFinite(a)).to.be.true;
871
+ // Fall-back overlap (2021-11-07 01:30 occurs twice).
872
+ const overlap = { ...gap, month: 11, day: 7, hour: 1 };
873
+ const c = fieldsToEpoch(overlap, 'America/New_York');
874
+ expect(fieldsToEpoch(overlap, 'America/New_York')).to.equal(c);
875
+ expect(Number.isFinite(c)).to.be.true;
876
+ });
877
+ it('epochToFields agrees with a known UTC instant', () => {
878
+ const w = epochToFields(Date.UTC(1999, 11, 31, 23, 59, 59, 999), 'UTC');
879
+ expect(w).to.deep.equal({
880
+ year: 1999,
881
+ month: 12,
882
+ day: 31,
883
+ hour: 23,
884
+ minute: 59,
885
+ second: 59,
886
+ millisecond: 999,
887
+ });
888
+ });
889
+ });
890
+ //# sourceMappingURL=nile-timestamp-builder.test.js.map